From a801a19581f801fa21348a05e4d16aeb17683fbb Mon Sep 17 00:00:00 2001 From: Rahul Barwal Date: Fri, 20 Jun 2025 09:39:51 +0530 Subject: [PATCH] feat: Implement empty meta widget generation for handling empty data scenarios (#40886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Problem Errors appeared in the console when the List widget in edit mode received an empty array as data. Root cause In edit mode, the List widget uses template widgets to create meta widgets. When the list becomes empty, meta widgets are not generated, but template widgets with `currentItem` bindings remain. Since `currentItem` requires meta widgets for evaluation, this results in errors. Solution This PR handles the generation of empty meta widgets when the List widget receives empty data, ensuring `currentItem` bindings don’t break. It introduces a `generateEmptyMetaWidgets` method in `MetaWidgetGenerator`, enhances the List widget to track and respond to empty data states, and resets the meta widget cache when transitioning between empty and non-empty states. Fixes #`Issue Number` _or_ Fixes https://github.com/appsmithorg/appsmith/issues/31925 > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.List" ### :mag: Cypress test results > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: 58818e09dd83faaa92ee0dad53aab93dcf5f55a5 > Cypress dashboard. > Tags: `@tag.List` > Spec: >
Thu, 19 Jun 2025 12:11:58 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **Bug Fixes** - Improved handling of empty list data on the first page, ensuring widgets display correctly when no data is present. - Enhanced consistency in detecting and rendering empty lists across the List Widget. - **New Features** - Added a utility function to accurately identify fully empty lists, enabling more predictable widget behavior. - Introduced an option to handle empty list cases by injecting placeholder data for consistent widget generation. - **Tests** - Added a test to verify the display of "No data to display" message when the list is empty and confirm no errors occur. --- .../ListV2/Listv2_BasicClientSideData_spec.js | 12 +++++++ .../ListWidgetV2/MetaWidgetGenerator.test.ts | 4 +++ .../ListWidgetV2/MetaWidgetGenerator.ts | 36 +++++++++++++------ .../src/widgets/ListWidgetV2/widget/helper.ts | 14 ++++++++ .../src/widgets/ListWidgetV2/widget/index.tsx | 19 +++++++--- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/client/cypress/e2e/Regression/ClientSide/Widgets/ListV2/Listv2_BasicClientSideData_spec.js b/app/client/cypress/e2e/Regression/ClientSide/Widgets/ListV2/Listv2_BasicClientSideData_spec.js index 0728fa37cc..2dc8613884 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/Widgets/ListV2/Listv2_BasicClientSideData_spec.js +++ b/app/client/cypress/e2e/Regression/ClientSide/Widgets/ListV2/Listv2_BasicClientSideData_spec.js @@ -162,5 +162,17 @@ describe( false, ); }); + + it("7. For the empty list, there should be no errors in appsmith console(as empty meta widgets are generated)", () => { + cy.openPropertyPane("listwidgetv2"); + + _.propPane.UpdatePropertyFieldValue("Items", "[]"); + + _.agHelper.AssertElementVisibility( + _.locators._visibleTextDiv("No data to display"), + ); + + _.debuggerHelper.AssertErrorCount(0); + }); }, ); diff --git a/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.test.ts b/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.test.ts index e05586955a..2e4053948e 100644 --- a/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.test.ts +++ b/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.test.ts @@ -167,6 +167,7 @@ const init = ({ const options = klona({ ...DEFAULT_OPTIONS, ...optionsProps, + isEmptyListWidgetCase: false, }); const cache = passedCache || new Cache(); @@ -850,6 +851,7 @@ describe("#generate", () => { levelData, pageSize: 3, widgetName: "List6", + isEmptyListWidgetCase: false, }) .generate(); @@ -897,6 +899,7 @@ describe("#generate", () => { levelData, pageSize: 3, widgetName: "List6", + isEmptyListWidgetCase: false, }) .generate(); @@ -957,6 +960,7 @@ describe("#generate", () => { levelData, pageSize: 3, widgetName: listWidgetName, + isEmptyListWidgetCase: false, }) .generate(); diff --git a/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.ts b/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.ts index 7d85d3dee2..0ec98567a8 100644 --- a/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.ts +++ b/app/client/src/widgets/ListWidgetV2/MetaWidgetGenerator.ts @@ -92,6 +92,7 @@ export interface GeneratorOptions { serverSidePagination: boolean; templateHeight: number; widgetName: string; + isEmptyListWidgetCase: boolean; } export interface ConstructorProps { @@ -252,6 +253,7 @@ class MetaWidgetGenerator { private templateWidgetStatus: TemplateWidgetStatus; private virtualizer?: VirtualizerInstance; private widgetName: GeneratorOptions["widgetName"]; + private isEmptyListWidgetCase: boolean; constructor(props: ConstructorProps) { this.siblings = {}; @@ -296,6 +298,7 @@ class MetaWidgetGenerator { unchanged: new Set(), }; this.widgetName = ""; + this.isEmptyListWidgetCase = false; } withOptions = (options: GeneratorOptions) => { @@ -325,6 +328,7 @@ class MetaWidgetGenerator { this.level = options.level ?? 1; this.prevPrimaryKeys = this.primaryKeys; this.primaryKeys = this.generatePrimaryKeys(options); + this.isEmptyListWidgetCase = options.isEmptyListWidgetCase; this.updateModificationsQueue(this.prevOptions); @@ -1106,14 +1110,28 @@ class MetaWidgetGenerator { ) => { if (metaWidget.currentItem) return; - const shouldAddDataCacheToBinding = this.shouldAddDataCacheToBinding( - metaWidgetId, - key, - ); + /** + * Check if we're in the empty data case (when original listData was empty, we add [{}]) + * The page condition is included because there may be instances when a user uses navigation controls and lands on a specific page number, + * such as page 3, which returns an empty response. + * In this case, the list data will be empty, and we want to avoid generating empty meta widgets for that scenario. + */ - const dataBinding = shouldAddDataCacheToBinding - ? `{{${JSON.stringify(this.cachedKeyDataMap[key])}}}` - : `{{${this.widgetName}.listData[${metaWidgetName}.currentIndex]}}`; + let dataBinding: string; + + if (this.isEmptyListWidgetCase) { + // For empty data case, set currentItem to empty object + dataBinding = "{{{}}}"; + } else { + const shouldAddDataCacheToBinding = this.shouldAddDataCacheToBinding( + metaWidgetId, + key, + ); + + dataBinding = shouldAddDataCacheToBinding + ? `{{${JSON.stringify(this.cachedKeyDataMap[key])}}}` + : `{{${this.widgetName}.listData[${metaWidgetName}.currentIndex]}}`; + } metaWidget.currentItem = dataBinding; metaWidget.dynamicBindingPathList = [ @@ -1854,10 +1872,6 @@ class MetaWidgetGenerator { })?.metaWidgetName; }; - private resetCache = () => { - this.setWidgetCache({}); - }; - private initVirtualizer = () => { const options = this.virtualizerOptions(); diff --git a/app/client/src/widgets/ListWidgetV2/widget/helper.ts b/app/client/src/widgets/ListWidgetV2/widget/helper.ts index 2c297c77ca..33719b98b5 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/helper.ts +++ b/app/client/src/widgets/ListWidgetV2/widget/helper.ts @@ -129,3 +129,17 @@ export const isTargetElementClickable = (e: React.MouseEvent) => { return isInput || hasControl || parentHasControl || hasLink || hasOnClick; }; + +export const isListFullyEmpty = ( + listData: unknown[] | undefined, + pageNo: number, +) => { + if (!listData) return false; + + /** + * This empty check is included because there may be instances when a user uses navigation controls and lands on a specific page number, + * such as page 3, which returns an empty response. + * In this case, the list data will be empty, and we want to avoid generating empty meta widgets for that scenario. + */ + return listData.length === 0 && pageNo === 1; +}; diff --git a/app/client/src/widgets/ListWidgetV2/widget/index.tsx b/app/client/src/widgets/ListWidgetV2/widget/index.tsx index 05cea0fd3e..f14fb87f16 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/ListWidgetV2/widget/index.tsx @@ -43,7 +43,11 @@ import type { TabContainerWidgetProps, TabsWidgetProps, } from "widgets/TabsWidget/constants"; -import { getMetaFlexLayers, isTargetElementClickable } from "./helper"; +import { + getMetaFlexLayers, + isListFullyEmpty, + isTargetElementClickable, +} from "./helper"; import { DefaultAutocompleteDefinitions } from "widgets/WidgetUtils"; import type { ExtraDef } from "utils/autocomplete/defCreatorUtils"; import { LayoutSystemTypes } from "layoutSystems/types"; @@ -558,12 +562,16 @@ class ListWidget extends BaseWidget< serverSidePagination = false, } = this.props; const pageSize = this.pageSize; + const data = this.props.listData; + + const isEmptyListWidgetCase = + (data && isListFullyEmpty(data, pageNo)) ?? false; return { containerParentId: mainCanvasId, containerWidgetId: mainContainerId, currTemplateWidgets: this.currFlattenedChildCanvasWidgets, - data: listData, + data: isEmptyListWidgetCase ? [{}] : listData, itemSpacing: this.props.itemSpacing || 0, infiniteScroll: this.props.infiniteScroll ?? false, level: this.props.level ?? 1, @@ -580,6 +588,7 @@ class ListWidget extends BaseWidget< hooks: { afterMetaWidgetGenerate: this.afterMetaWidgetGenerate, }, + isEmptyListWidgetCase, }; }; @@ -602,6 +611,9 @@ class ListWidget extends BaseWidget< }; generateMetaWidgets = () => { + // The metaWidgetGeneratorOptions method already handles empty data + // by providing [{}] when listData is empty, so we can use normal generation flow + const generatorOptions = this.metaWidgetGeneratorOptions(); const { metaWidgets, propertyUpdates, removedMetaWidgetIds } = @@ -1458,8 +1470,7 @@ class ListWidget extends BaseWidget< if ( Array.isArray(this.props.listData) && - this.props.listData.filter((item) => !isEmpty(item)).length === 0 && - this.props.renderMode === RenderModes.PAGE + this.props.listData.filter((item) => !isEmpty(item)).length === 0 ) { return ( <>