From 1e6a0e8e9248cb918f8580407475adb2a286928b Mon Sep 17 00:00:00 2001 From: "vicky-primathon.in" Date: Tue, 13 Apr 2021 16:55:50 +0530 Subject: [PATCH 01/35] Fix filepicker widget delete file not working Added cypress test case to test selected file deletion works --- app/client/cypress/fixtures/testFile2.mov | Bin 0 -> 256000 bytes .../FormWidgets/FilePicker_spec.js | 23 ++++++++++++++++++ app/client/src/widgets/FilepickerWidget.tsx | 3 +-- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 app/client/cypress/fixtures/testFile2.mov diff --git a/app/client/cypress/fixtures/testFile2.mov b/app/client/cypress/fixtures/testFile2.mov new file mode 100644 index 0000000000000000000000000000000000000000..af5b36c70550177e32f0f81a6ee70e065e9caf8f GIT binary patch literal 256000 zcmeIuF#!Mo0K%a4Pi+Wah(KY$fB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd v0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM82APT=q&&N literal 0 HcmV?d00001 diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/FormWidgets/FilePicker_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/FormWidgets/FilePicker_spec.js index 1a71d138c1..3330821242 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/FormWidgets/FilePicker_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/FormWidgets/FilePicker_spec.js @@ -43,6 +43,29 @@ describe("FilePicker Widget Functionality", function() { cy.get("button").contains("1 files selected"); }); + it("It checks the deletion of filepicker works as expected", function() { + cy.get(commonlocators.filePickerButton).click(); + cy.get(commonlocators.filePickerInput) + .first() + .attachFile("testFile.mov"); + cy.get(commonlocators.filePickerUploadButton).click(); + //eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get("button").contains("1 files selected"); + cy.get(commonlocators.filePickerButton).click(); + //eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + cy.get("button.uppy-Dashboard-Item-action--remove").click(); + cy.get("button.uppy-Dashboard-browse").click(); + cy.get(commonlocators.filePickerInput) + .first() + .attachFile("testFile2.mov"); + cy.get(commonlocators.filePickerUploadButton).click(); + //eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get("button").contains("1 files selected"); + }); + afterEach(() => { // put your clean up code if any }); diff --git a/app/client/src/widgets/FilepickerWidget.tsx b/app/client/src/widgets/FilepickerWidget.tsx index 2fbc092f94..e3ed4694b3 100644 --- a/app/client/src/widgets/FilepickerWidget.tsx +++ b/app/client/src/widgets/FilepickerWidget.tsx @@ -326,14 +326,13 @@ class FilePickerWidget extends BaseWidget< return file.id !== dslFile.id; }) : []; - this.props.updateWidgetMetaProperty("files", updatedFiles); + this.props.updateWidgetMetaProperty("selectedFiles", updatedFiles); }); this.state.uppy.on("files-added", (files: any[]) => { const dslFiles = this.props.selectedFiles ? [...this.props.selectedFiles] : []; - const fileReaderPromises = files.map((file) => { const reader = new FileReader(); return new Promise((resolve) => { From a13fe4369e66bba6659d1e5cef65eb4847886ce6 Mon Sep 17 00:00:00 2001 From: Nikhil Nandagopal Date: Wed, 21 Apr 2021 16:10:45 +0530 Subject: [PATCH 02/35] updated the default table data to guide users to connect a datasource --- .../mockResponses/WidgetConfigResponse.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/app/client/src/mockResponses/WidgetConfigResponse.tsx b/app/client/src/mockResponses/WidgetConfigResponse.tsx index 03cf9ab4b5..f9e50de0a9 100644 --- a/app/client/src/mockResponses/WidgetConfigResponse.tsx +++ b/app/client/src/mockResponses/WidgetConfigResponse.tsx @@ -138,28 +138,36 @@ const WidgetConfigResponse: WidgetConfigReducerState = { horizontalAlignment: "LEFT", verticalAlignment: "CENTER", primaryColumns: {}, - derivedColumns: {}, + derivedColumns: { + action: { + id: "1", + label: "Action", + columnType: "button", + isVisible: true, + isDerived: true, + index: 3, + buttonLabel: "Start", + width: 50, + computedValue: "Do It", + onClick: + "{{currentRow.step === '#1' ? showAlert('Done', 'success') : currentRow.step === '#2' ? navigateTo('https://docs.appsmith.com/core-concepts/connecting-to-data-sources/connecting-to-databases/querying-a-database',undefined,'NEW_WINDOW') : navigateTo('https://docs.appsmith.com/core-concepts/displaying-data-read/display-data-tables',undefined,'NEW_WINDOW')}}", + }, + }, tableData: [ { - id: 2381224, - email: "michael.lawson@reqres.in", - userName: "Michael Lawson", - productName: "Chicken Sandwich", - orderAmount: 4.99, + step: "#1", + task: "Drag a Table", + status: "βœ…", }, { - id: 2736212, - email: "lindsay.ferguson@reqres.in", - userName: "Lindsay Ferguson", - productName: "Tuna Salad", - orderAmount: 9.99, + step: "#2", + task: "Create a Query fetch_users with the Mock DB", + status: "--", }, { - id: 6788734, - email: "tobias.funke@reqres.in", - userName: "Tobias Funke", - productName: "Beef steak", - orderAmount: 19.99, + step: "#3", + task: "Bind the query to the table {{fetch_users.data}}", + status: "--", }, ], version: 1, From 2eec2fba28a15b026802aa2d029ad526d4f9f027 Mon Sep 17 00:00:00 2001 From: Nidhi Date: Thu, 22 Apr 2021 16:44:03 +0530 Subject: [PATCH 03/35] Modified error text, added comments for regex (#4103) --- .../src/main/java/com/external/config/AppendMethod.java | 2 +- .../main/java/com/external/config/BulkAppendMethod.java | 2 +- .../main/java/com/external/config/BulkUpdateMethod.java | 2 +- .../src/main/java/com/external/config/ClearMethod.java | 2 +- .../src/main/java/com/external/config/CopyMethod.java | 2 +- .../main/java/com/external/config/DeleteRowMethod.java | 2 +- .../main/java/com/external/config/DeleteSheetMethod.java | 2 +- .../main/java/com/external/config/GetValuesMethod.java | 9 ++++++++- .../src/main/java/com/external/config/InfoMethod.java | 2 +- .../src/main/java/com/external/config/UpdateMethod.java | 2 +- 10 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/AppendMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/AppendMethod.java index d5ee54eed9..50f5f84535 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/AppendMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/AppendMethod.java @@ -37,7 +37,7 @@ public class AppendMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkAppendMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkAppendMethod.java index 0b898f18fa..66c2d30842 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkAppendMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkAppendMethod.java @@ -38,7 +38,7 @@ public class BulkAppendMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkUpdateMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkUpdateMethod.java index e0e8737e79..1d1830e26e 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkUpdateMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/BulkUpdateMethod.java @@ -38,7 +38,7 @@ public class BulkUpdateMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/ClearMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/ClearMethod.java index 069c1afa0f..a232cceb5b 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/ClearMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/ClearMethod.java @@ -22,7 +22,7 @@ public class ClearMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSpreadsheetRange() == null || methodConfig.getSpreadsheetRange().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Data Range"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/CopyMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/CopyMethod.java index 1c90da56d4..4b60e3f012 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/CopyMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/CopyMethod.java @@ -22,7 +22,7 @@ public class CopyMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetId() == null || methodConfig.getSheetId().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet Id"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteRowMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteRowMethod.java index d3f51c4735..003bee6b8d 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteRowMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteRowMethod.java @@ -30,7 +30,7 @@ public class DeleteRowMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteSheetMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteSheetMethod.java index 5651ef5a20..ef2ab8ba42 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteSheetMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/DeleteSheetMethod.java @@ -32,7 +32,7 @@ public class DeleteSheetMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (GoogleSheets.SHEET.equalsIgnoreCase(methodConfig.getDeleteFormat())) { if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/GetValuesMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/GetValuesMethod.java index c0aa5f51cb..992ec87379 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/GetValuesMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/GetValuesMethod.java @@ -33,14 +33,21 @@ public class GetValuesMethod implements Method { this.objectMapper = objectMapper; } + // Used to capture the range of columns in this request. The handling for this regex makes sure that + // all possible combinations of A1 notation for a range map to a common format Pattern findAllRowsPattern = Pattern.compile("([a-zA-Z]*)\\d*:([a-zA-Z]*)\\d*"); + + // The starting row for a range is captured using this pattern to find its relative index from table heading Pattern findOffsetRowPattern = Pattern.compile("(\\d+):"); + + // Since the value for this pattern is coming from an API response, it also contains the sheet name + // We use this pattern to retrieve only range information Pattern sheetRangePattern = Pattern.compile(".*!([a-zA-Z]*)\\d*:([a-zA-Z]*)\\d*"); @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/InfoMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/InfoMethod.java index 14fd0f25b5..ec85c1ebe6 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/InfoMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/InfoMethod.java @@ -23,7 +23,7 @@ public class InfoMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } return true; diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/UpdateMethod.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/UpdateMethod.java index 2c125b9f75..9bb5104a0d 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/UpdateMethod.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/config/UpdateMethod.java @@ -35,7 +35,7 @@ public class UpdateMethod implements Method { @Override public boolean validateMethodRequest(MethodConfig methodConfig) { if (methodConfig.getSpreadsheetId() == null || methodConfig.getSpreadsheetId().isBlank()) { - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Id"); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Spreadsheet Url"); } if (methodConfig.getSheetName() == null || methodConfig.getSheetName().isBlank()) { throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Missing required field Sheet name"); From 1717b0e3922882dc55eb48302a655b99b4386564 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Fri, 23 Apr 2021 11:13:13 +0530 Subject: [PATCH 04/35] [Feature] Grid Widget (#2389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated test * updated assertions * Resizing image to take full width of table cell * updated assertion * Stop updating dynamicBindingPathList directly from widget * Fix selectedRow and selectedRows computations * Fix primaryColumns computations * Updated test for derived column * Added tests for computed value * Added check clear data * Reordering of test * updated common method * Made image size as 100% of table cell size * add templating logic * Updated flow and dsl * Clear old primary columns * Updated testname * updated assertion * use evaluated values for children * Fix primary columns update on component mount and component update * add isArray check * remove property pane enhancement reducer * add property pane enhancement reducer * disable items other than template + fix running property enchancment on drop of list widget * disbled drag, resize, settingsControl, drag for items other than template * add grid options * uncomment the widget operation for add child for grid children * handle delete scenario for child widget in list widget * WIP: Use the new delete and update property features * add listdsl.json for testcases * add test cases for correct no. of items being rendered * add test cases currentItem binding in list widget * change dragEnabled to dragDisabled * change resizeEnabled to resizeDisabled * change settingsControlEnabled to settingsControlDisabled * change dropEnabled to dropDisabled * update settingsControlDisabled default value * Use deleteProperties in propertyControls * Fix unsetting of array indices when deleting widget properties * remove old TableWidget.tsx file * Fix derived column property update on primary column property update * Handle undefined primary columns * Fix filepicker immutable prop issue * Fix object.freeze issue when adding ids to the property pane configuration * fix widget issue in grid * Fix column actions dynamicBindingPathList inclusion issue * remove consoles + fix typo around batch update * Remove redundant tests * js binding test for date picker * hydate enhancement map on copy list widget * check for dynamicleaf * fixes * improve check * fix getNextWidgetName * update template in list widget when copying * updating template copy logic when copying widget * update dynamicBindingPathList in copied widget * Add path parameter to hidden functions in property pane configs * fix copy bug when copying list widget * add computed list property control * Remove time column type Fix editor prompt for currentRow Fix undefined derivedColumns scenario Remove validations for primaryColums and derivedColumns Fix section toggle for video, image and button column types * Fix table widget actions and custom column migrations * Add logs for cyclical dependency map :recycle: * Process array differences * add property control for list widget * Fix onClick migrations * Property pane config parity * binding and trigger paths from the property pane config (#2920) * try react virtualized library * Fix unit test * Fix unit test :white_check_mark: * Fix minor issues in table widget * Add default meta props to binding paths to ensure eval and validation * Dummy commit :tada: * Remove unnecessary datepicker test Fix chart data as string issue * Achieve table column sorting and resizing parity with release * handle scenario where last column isn't available to access * Fix for panel config path not existing in the widget * Fix bindings in currentRow (default) Add dummy property pane config for canvas widget * Update canvas widgets with dynamicPathLists on delete of property paths * Add all diffs to change paths and trim later * Add back default properties πŸšΆπŸ»β€β™‚οΈ * Use object based paths instead of arrays for primaryColumns and derivedColumns * Fix issue in reordered columns * Fix inccorect update order * add virtualized list * Fix failing property pane tests * minor change * minor list widget change * Remove .vscode from git * Rename ads to alloy Fix isVisible in list widget * move grid component to widget folder * fix import in widget registry * add sticky row in virtualized list * add sticky container * Fix Height of grid widget items container * fix dragging of items in children other than template children * update list widget * update list widget * Fix padding in list widget * hide scrollbar in list widget list * fix copy bug in list widget * regenrate enhancement map on undo delete widget * Use enhancementmap for autocomplete in list widget Basic styles for list widget scrollbar * add custom control in widget config * minor commit * update scrollbar styles * remove unused variable * fix typo in custom control * comment out test cases * remove unused imports * remove unused imports * add JSON stringify in interweave * add noPad styling in dragLayer for noPad prop * implement grid gap * add list item background color prop * add white color in color picker control * fix gap in last list item * remove onBeforeParse in textcomponent * remove virtualization in grid widget * allow overflow-y * add onListItemClick action * add beta label * add pagination * fix actions in pagination in list widget * add list widget icon * add list background color default value * remove extra div * fix pagination issue * fix list widget crashing on perpage change * extract child operation function to widgetblueprint saga * refactor enhancements * add enhancement hook * refactor propertyUpdate hook enhancment * remove enhacement map * revert renaming ads to alloy * add autopagination * Cleanup unused vars Re-write loop using map Fix binding with external input widget * update default background color * remove unnessary scrol + fix pagination per page * remove console.log * use grid gap in pixel instead of snap * fix list widget tests for binding * add tests for on click action and pagination * remove unnecessary imports * remove overflow hidden in list component * Add feature to enable template actions * update property pane help text for list widget * disable pagination in editor view * update property pane options * add test case for action * uncomment tests * fix grid gap validation * update test cases * fix property pane opening issue for list tempalte * Disable form widgets in list widget * fix template issue for actions * add validation tests for list data * update starting template * add selectedRow + enable pagination in edit mode * remove extra padding in list widget + popper fix on settingDisabled * add stop propagation for button click * fix click event in edit mode * disallow filepicker widget for list widget * add test for list widget entity definition for selectItem * remove unused imports * fix test * remove evaluated value for list child widgets * add comment * remove log * fix copying bug in list widget * add check for not allowing template to copy * fix test * add test for property pane actions * remove unused import * add draglayercomponent test * add test for draggable component * add test for evaluatedvalue popup * add test for messages.ts * add test for widgeticons * add test for property pane selector * add test for widget config response * start testing widget configresponse * add test for enhancements in widget config * add test for codeeditor * add test for base widget + list widget * add test for executeWidgetBlueprintChildOperations * remove unused import * add test for widget operation utils * remove unused import * add test for handleSpecificCasesWhilePasting * remove unused function * remove unused import * add empty list styling * resolve all review comments * fix message test * add test for widget operation utils * fix merge conflicts * move validations in property config Co-authored-by: Abhinav Jha Co-authored-by: nandan.anantharamu Co-authored-by: vicky-primathon.in Co-authored-by: Pawan Kumar Co-authored-by: Piyush Co-authored-by: hetunandu Co-authored-by: Hetu Nandu Co-authored-by: root --- .gitignore | 3 +- app/client/.gitignore | 2 + app/client/cypress/.eslintrc.json | 3 + app/client/cypress/fixtures/listdsl.json | 161 +++++ .../LayoutWidgets/List_spec.js | 89 +++ .../cypress/locators/commonlocators.json | 2 + .../manual_TestSuite/new_Table_Spec.js | 2 - app/client/package.json | 10 +- app/client/src/actions/controlActions.tsx | 1 + app/client/src/actions/pageActions.tsx | 6 +- .../src/actions/propertyPaneActions.test.ts | 11 + app/client/src/actions/propertyPaneActions.ts | 6 + app/client/src/assets/icons/widget/list.svg | 3 + .../appsmith/ContainerComponent.tsx | 4 + .../appsmith/PositionedContainer.tsx | 42 +- .../CodeEditor/CodeEditor.test.tsx | 77 +++ .../CodeEditor/EvaluatedValuePopup.test.tsx | 50 ++ .../CodeEditor/EvaluatedValuePopup.tsx | 19 +- .../editorComponents/CodeEditor/index.tsx | 3 + .../DragLayerComponent.test.tsx | 54 ++ .../editorComponents/DragLayerComponent.tsx | 19 +- .../DraggableComponent.test.tsx | 10 + .../editorComponents/DraggableComponent.tsx | 18 +- .../editorComponents/DropTargetComponent.tsx | 17 +- .../editorComponents/ResizableComponent.tsx | 2 +- .../WidgetNameComponent/index.tsx | 2 +- .../propertyControls/BaseControl.tsx | 1 + .../propertyControls/DropDownControl.tsx | 2 +- .../propertyControls/InputTextControl.tsx | 9 + .../src/constants/FieldExpectedValue.ts | 5 + app/client/src/constants/HelpConstants.ts | 4 + app/client/src/constants/WidgetConstants.tsx | 1 + app/client/src/constants/WidgetValidation.ts | 1 + app/client/src/constants/messages.test.ts | 9 + app/client/src/constants/messages.ts | 2 + app/client/src/icons/WidgetIcons.test.tsx | 20 + app/client/src/icons/WidgetIcons.tsx | 6 + .../WidgetConfigResponse.test.tsx | 77 +++ .../mockResponses/WidgetConfigResponse.tsx | 330 +++++++++- .../mockResponses/WidgetSidebarResponse.tsx | 6 + .../Editor/PropertyPane/PropertyControl.tsx | 111 +++- .../PropertyPane/PropertyPaneGenerator.tsx | 66 ++ .../src/pages/Editor/PropertyPane/index.tsx | 21 +- .../src/pages/Editor/PropertyPaneTitle.tsx | 5 - app/client/src/pages/Editor/WidgetCard.tsx | 13 + .../entityReducers/widgetConfigReducer.tsx | 2 + app/client/src/reducers/uiReducers/index.tsx | 1 + app/client/src/sagas/PageSagas.tsx | 6 +- .../src/sagas/WidgetBlueprintSagas.test.ts | 51 ++ app/client/src/sagas/WidgetBlueprintSagas.ts | 149 ++++- .../src/sagas/WidgetBlueprintSagasEnums.ts | 5 + .../src/sagas/WidgetEnhancementHelpers.ts | 221 +++++++ app/client/src/sagas/WidgetOperationSagas.tsx | 122 +++- .../src/sagas/WidgetOperationUtils.test.ts | 207 +++++++ app/client/src/sagas/WidgetOperationUtils.ts | 177 ++++++ .../src/selectors/propertyPaneSelectors.tsx | 17 +- .../src/utils/PropertyControlFactory.tsx | 3 + app/client/src/utils/WidgetPropsUtils.tsx | 1 + app/client/src/utils/WidgetRegistry.tsx | 19 +- .../autocomplete/EntityDefinitions.test.ts | 47 ++ .../utils/autocomplete/EntityDefinitions.ts | 11 + app/client/src/utils/helpers.tsx | 10 + app/client/src/widgets/BaseWidget.tsx | 60 +- app/client/src/widgets/ButtonWidget.tsx | 4 +- app/client/src/widgets/CanvasWidget.tsx | 22 +- app/client/src/widgets/ContainerWidget.tsx | 52 +- .../src/widgets/ListWidget/ListComponent.tsx | 67 +++ .../src/widgets/ListWidget/ListPagination.tsx | 326 ++++++++++ .../ListWidget/ListPropertyPaneConfig.ts | 86 +++ .../widgets/ListWidget/ListWidget.test.tsx | 76 +++ .../src/widgets/ListWidget/ListWidget.tsx | 569 ++++++++++++++++++ app/client/src/widgets/ListWidget/index.tsx | 1 + app/client/src/workers/validations.test.ts | 63 ++ app/client/src/workers/validations.ts | 40 +- app/client/yarn.lock | 106 +++- 75 files changed, 3677 insertions(+), 149 deletions(-) create mode 100644 app/client/cypress/fixtures/listdsl.json create mode 100644 app/client/cypress/integration/Smoke_TestSuite/LayoutWidgets/List_spec.js create mode 100644 app/client/src/actions/propertyPaneActions.test.ts create mode 100644 app/client/src/assets/icons/widget/list.svg create mode 100644 app/client/src/components/editorComponents/CodeEditor/CodeEditor.test.tsx create mode 100644 app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.test.tsx create mode 100644 app/client/src/components/editorComponents/DragLayerComponent.test.tsx create mode 100644 app/client/src/components/editorComponents/DraggableComponent.test.tsx create mode 100644 app/client/src/constants/messages.test.ts create mode 100644 app/client/src/icons/WidgetIcons.test.tsx create mode 100644 app/client/src/mockResponses/WidgetConfigResponse.test.tsx create mode 100644 app/client/src/pages/Editor/PropertyPane/PropertyPaneGenerator.tsx create mode 100644 app/client/src/sagas/WidgetBlueprintSagas.test.ts create mode 100644 app/client/src/sagas/WidgetBlueprintSagasEnums.ts create mode 100644 app/client/src/sagas/WidgetEnhancementHelpers.ts create mode 100644 app/client/src/sagas/WidgetOperationUtils.test.ts create mode 100644 app/client/src/sagas/WidgetOperationUtils.ts create mode 100644 app/client/src/utils/autocomplete/EntityDefinitions.test.ts create mode 100644 app/client/src/widgets/ListWidget/ListComponent.tsx create mode 100644 app/client/src/widgets/ListWidget/ListPagination.tsx create mode 100644 app/client/src/widgets/ListWidget/ListPropertyPaneConfig.ts create mode 100644 app/client/src/widgets/ListWidget/ListWidget.test.tsx create mode 100644 app/client/src/widgets/ListWidget/ListWidget.tsx create mode 100644 app/client/src/widgets/ListWidget/index.tsx diff --git a/.gitignore b/.gitignore index e7782c5131..2d1d9bda93 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea *.iml .env +.vscode/* # test coverage -coverage-summary.json \ No newline at end of file +coverage-summary.json diff --git a/app/client/.gitignore b/app/client/.gitignore index c5e430d2db..f801f7de68 100755 --- a/app/client/.gitignore +++ b/app/client/.gitignore @@ -43,3 +43,5 @@ storybook-static/* build-storybook.log .eslintcache +.vscode +TODO \ No newline at end of file diff --git a/app/client/cypress/.eslintrc.json b/app/client/cypress/.eslintrc.json index cb810a1722..2772e19d62 100644 --- a/app/client/cypress/.eslintrc.json +++ b/app/client/cypress/.eslintrc.json @@ -2,6 +2,9 @@ "env": { "cypress/globals": true }, + "rules": { + "cypress/no-unnecessary-waiting": 0 + }, "extends": [ "plugin:cypress/recommended" ] diff --git a/app/client/cypress/fixtures/listdsl.json b/app/client/cypress/fixtures/listdsl.json new file mode 100644 index 0000000000..25c0fb517a --- /dev/null +++ b/app/client/cypress/fixtures/listdsl.json @@ -0,0 +1,161 @@ +{ + "dsl": { + "widgetName": "MainContainer", + "backgroundColor": "none", + "rightColumn": 1224, + "snapColumns": 16, + "detachFromLayout": true, + "widgetId": "0", + "topRow": 0, + "bottomRow": 1280, + "containerStyle": "none", + "snapRows": 33, + "parentRowSpace": 1, + "type": "CANVAS_WIDGET", + "canExtend": true, + "version": 9, + "minHeight": 1292, + "parentColumnSpace": 1, + "dynamicBindingPathList": [], + "leftColumn": 0, + "children": [ + { + "isVisible": true, + "enhancements": true, + "backgroundColor": "", + "gridType": "vertical", + "gridGap": 0, + "items": "[\n {\n \"id\": 1,\n \"email\": \"michael.lawson@reqres.in\",\n \"first_name\": \"Michael\",\n \"last_name\": \"Lawson\",\n \"avatar\": \"https://reqres.in/img/faces/7-image.jpg\"\n },\n {\n \"id\": 2,\n \"email\": \"lindsay.ferguson@reqres.in\",\n \"first_name\": \"Lindsay\",\n \"last_name\": \"Ferguson\",\n \"avatar\": \"https://reqres.in/img/faces/8-image.jpg\"\n },\n {\n \"id\": 3,\n \"email\": \"brock.lesnar@reqres.in\",\n \"first_name\": \"Brock\",\n \"last_name\": \"Lesnar\",\n \"avatar\": \"https://reqres.in/img/faces/8-image.jpg\"\n }\n]", + "widgetName": "List1", + "children": [ + { + "isVisible": true, + "widgetName": "Canvas1", + "containerStyle": "none", + "canExtend": false, + "detachFromLayout": true, + "dropDisabled": true, + "children": [ + { + "isVisible": true, + "backgroundColor": "white", + "widgetName": "Container1", + "containerStyle": "card", + "children": [ + { + "isVisible": true, + "widgetName": "Canvas2", + "containerStyle": "none", + "canExtend": false, + "detachFromLayout": true, + "children": [ + { + "isVisible": true, + "text": "Label", + "textStyle": "LABEL", + "textAlign": "LEFT", + "widgetName": "Text1", + "type": "TEXT_WIDGET", + "isLoading": false, + "parentColumnSpace": 32, + "parentRowSpace": 40, + "leftColumn": 2, + "rightColumn": 6, + "topRow": 0, + "bottomRow": 1, + "parentId": "dinv2tsatk", + "widgetId": "k6ct7dxg4w" + }, + { + "isVisible":true, + "text":"Submit", + "buttonStyle":"PRIMARY_BUTTON", + "widgetName":"Button1", + "isDisabled":false, + "isDefaultClickDisabled":true, + "version":1, + "type":"BUTTON_WIDGET", + "isLoading":false, + "parentColumnSpace":29.25, + "parentRowSpace":40, + "leftColumn":6, + "rightColumn":8, + "topRow":1, + "bottomRow":2, + "parentId":"dinv2tsatk", + "widgetId":"fuw9p7cuek" + } + ], + "minHeight": null, + "type": "CANVAS_WIDGET", + "isLoading": false, + "parentColumnSpace": 1, + "parentRowSpace": 1, + "leftColumn": 0, + "rightColumn": null, + "topRow": 0, + "bottomRow": null, + "parentId": "4ruj7xl5ri", + "widgetId": "dinv2tsatk" + } + ], + "dragDisabled": true, + "isDeletable": false, + "disablePropertyPane": true, + "type": "CONTAINER_WIDGET", + "isLoading": false, + "leftColumn": 0, + "rightColumn": 16, + "topRow": 0, + "bottomRow": 4, + "parentId": "0pvmmqr77m", + "widgetId": "4ruj7xl5ri" + } + ], + "minHeight": 400, + "type": "CANVAS_WIDGET", + "isLoading": false, + "parentColumnSpace": 1, + "parentRowSpace": 1, + "leftColumn": 0, + "rightColumn": 592, + "topRow": 0, + "bottomRow": 400, + "parentId": "5bwz8xcvhj", + "widgetId": "0pvmmqr77m" + } + ], + "type": "LIST_WIDGET", + "isLoading": false, + "parentColumnSpace": 74, + "parentRowSpace": 40, + "leftColumn": 0, + "rightColumn": 8, + "topRow": 0, + "bottomRow": 10, + "parentId": "0", + "widgetId": "5bwz8xcvhj", + "dynamicBindingPathList": [], + "template": { + "Text1": { + "isVisible": true, + "text": "Label", + "textStyle": "LABEL", + "textAlign": "LEFT", + "widgetName": "Text1", + "type": "TEXT_WIDGET", + "isLoading": false, + "parentColumnSpace": 32, + "parentRowSpace": 40, + "leftColumn": 0, + "rightColumn": 4, + "topRow": 0, + "bottomRow": 1, + "parentId": "dinv2tsatk", + "widgetId": "k6ct7dxg4w" + } + } + } + ] + } +} diff --git a/app/client/cypress/integration/Smoke_TestSuite/LayoutWidgets/List_spec.js b/app/client/cypress/integration/Smoke_TestSuite/LayoutWidgets/List_spec.js new file mode 100644 index 0000000000..8221072b87 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/LayoutWidgets/List_spec.js @@ -0,0 +1,89 @@ +const commonlocators = require("../../../locators/commonlocators.json"); +const widgetsPage = require("../../../locators/Widgets.json"); +const dsl = require("../../../fixtures/listdsl.json"); +const publishPage = require("../../../locators/publishWidgetspage.json"); + +describe("Container Widget Functionality", function() { + const items = JSON.parse(dsl.dsl.children[0].items); + + before(() => { + cy.addDsl(dsl); + }); + + it("checks if list shows correct no. of items", function() { + cy.get(commonlocators.containerWidget).then(function($lis) { + expect($lis).to.have.length(2); + }); + }); + + it("checks currentItem binding", function() { + cy.SearchEntityandOpen("Text1"); + cy.getCodeMirror().then(($cm) => { + cy.get(".CodeMirror textarea") + .first() + .type(`{{currentItem.first_name}}`, { + force: true, + parseSpecialCharSequences: false, + }); + }); + + cy.wait(1000); + + cy.closePropertyPane(); + + cy.get(commonlocators.TextInside).then(function($lis) { + expect($lis.eq(0)).to.contain(items[0].first_name); + expect($lis.eq(1)).to.contain(items[1].first_name); + }); + }); + + it("checks button action", function() { + cy.SearchEntityandOpen("Button1"); + cy.getCodeMirror().then(($cm) => { + cy.get(".CodeMirror textarea") + .first() + .type(`{{currentItem.first_name}}`, { + force: true, + parseSpecialCharSequences: false, + }); + }); + cy.addAction("{{currentItem.first_name}}"); + + cy.PublishtheApp(); + + cy.get(`${widgetsPage.widgetBtn}`) + .first() + .click(); + + cy.get(commonlocators.toastmsg).contains(items[0].first_name); + }); + + it("it checks onListItem click action", function() { + cy.get(publishPage.backToEditor).click({ force: true }); + + cy.SearchEntityandOpen("List1"); + cy.addAction("{{currentItem.first_name}}"); + + cy.PublishtheApp(); + + cy.get( + "div[type='LIST_WIDGET'] .t--widget-containerwidget:first-child", + ).click(); + + cy.get(commonlocators.toastmsg).contains(items[0].first_name); + }); + + it("it checks pagination", function() { + // clicking on second pagination button + cy.get(`${commonlocators.paginationButton}-2`).click(); + + // now we are on the second page which shows first the 3rd item in the list + cy.get(commonlocators.TextInside).then(function($lis) { + expect($lis.eq(0)).to.contain(items[2].first_name); + }); + }); + + afterEach(() => { + // put your clean up code if any + }); +}); diff --git a/app/client/cypress/locators/commonlocators.json b/app/client/cypress/locators/commonlocators.json index d575271bcf..ad383bfcd2 100644 --- a/app/client/cypress/locators/commonlocators.json +++ b/app/client/cypress/locators/commonlocators.json @@ -105,6 +105,8 @@ "globalSearchInput": ".t--global-search-input", "globalSearchTrigger": ".t--global-search-modal-trigger", "globalSearchClearInput": ".t--global-clear-input", + "containerWidget": ".t--widget-containerwidget", + "paginationButton": ".rc-pagination-item", "switchWidgetActive": ".t--switch-widget-active", "switchWidgetInActive": ".t--switch-widget-inactive", "switchWidgetLoading": ".t--switch-widget-loading" diff --git a/app/client/cypress/manual_TestSuite/new_Table_Spec.js b/app/client/cypress/manual_TestSuite/new_Table_Spec.js index 9e74f08edd..1af26c075a 100644 --- a/app/client/cypress/manual_TestSuite/new_Table_Spec.js +++ b/app/client/cypress/manual_TestSuite/new_Table_Spec.js @@ -12,13 +12,11 @@ describe("Table functionality ", function() { // Navigate to add background colour and Text colour // Ensure the row colour gets overlapped on table colour }); - it("Collapse the tabs of Property pane", function() { // Add a table // Click on the property pane // Collapse the General ,Action and Tab option }); - it("Bind the column with same name", function() { // Add a table // Click on the property pane diff --git a/app/client/package.json b/app/client/package.json index 19a022a10e..858379e523 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -87,6 +87,7 @@ "popper.js": "^1.15.0", "prettier": "^1.18.2", "prismjs": "^1.23.0", + "rc-pagination": "^3.1.3", "re-reselect": "^3.4.0", "react": "^16.12.0", "react-base-table": "^1.9.1", @@ -129,7 +130,8 @@ "tinycolor2": "^1.4.1", "toposort": "^2.0.2", "ts-loader": "^6.0.4", - "typescript": "^3.9.2", + "tslib": "^2.1.0", + "typescript": "^4.1.3", "unescape-js": "^1.1.4", "url-search-params-polyfill": "^8.0.0", "worker-loader": "^3.0.2" @@ -176,7 +178,7 @@ "@storybook/preset-create-react-app": "^3.1.4", "@storybook/react": "^5.3.19", "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.2.5", + "@testing-library/react": "^11.2.6", "@testing-library/user-event": "^13.1.1", "@types/codemirror": "^0.0.96", "@types/deep-diff": "^1.0.0", @@ -193,8 +195,8 @@ "@types/styled-system": "^5.1.9", "@types/tern": "0.22.0", "@types/toposort": "^2.0.3", - "@typescript-eslint/eslint-plugin": "^4.6.0", - "@typescript-eslint/parser": "^4.6.0", + "@typescript-eslint/eslint-plugin": "^4.15.0", + "@typescript-eslint/parser": "^4.15.0", "babel-loader": "^8.1.0", "babel-plugin-styled-components": "^1.10.7", "craco-babel-loader": "^0.1.4", diff --git a/app/client/src/actions/controlActions.tsx b/app/client/src/actions/controlActions.tsx index d04a9410cc..2880e30ac6 100644 --- a/app/client/src/actions/controlActions.tsx +++ b/app/client/src/actions/controlActions.tsx @@ -22,6 +22,7 @@ export const updateWidgetPropertyRequest = ( export interface BatchPropertyUpdatePayload { modify?: Record; //Key value pairs of paths and values to update remove?: string[]; //Array of paths to delete + triggerPaths?: string[]; // Array of paths in the modify and remove list which are trigger paths } export const batchUpdateWidgetProperty = ( diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx index a199240e90..da38f73e31 100644 --- a/app/client/src/actions/pageActions.tsx +++ b/app/client/src/actions/pageActions.tsx @@ -1,5 +1,3 @@ -import { FetchPageRequest, PageLayout, SavePageResponse } from "api/PageApi"; -import { WidgetOperation } from "widgets/BaseWidget"; import { WidgetType } from "constants/WidgetConstants"; import { EvaluationReduxAction, @@ -7,9 +5,11 @@ import { ReduxActionTypes, UpdateCanvasPayload, } from "constants/ReduxActionConstants"; -import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; import AnalyticsUtil from "utils/AnalyticsUtil"; +import { WidgetOperation } from "widgets/BaseWidget"; +import { FetchPageRequest, PageLayout, SavePageResponse } from "api/PageApi"; import { APP_MODE, UrlDataState } from "reducers/entityReducers/appReducer"; +import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; export interface FetchPageListPayload { applicationId: string; diff --git a/app/client/src/actions/propertyPaneActions.test.ts b/app/client/src/actions/propertyPaneActions.test.ts new file mode 100644 index 0000000000..5914e6c8b8 --- /dev/null +++ b/app/client/src/actions/propertyPaneActions.test.ts @@ -0,0 +1,11 @@ +import * as actions from "./propertyPaneActions"; +import { ReduxActionTypes } from "constants/ReduxActionConstants"; + +describe("property pane action actions", () => { + it("should create an action hide Property Pane", () => { + const expectedAction = { + type: ReduxActionTypes.HIDE_PROPERTY_PANE, + }; + expect(actions.hidePropertyPane()).toEqual(expectedAction); + }); +}); diff --git a/app/client/src/actions/propertyPaneActions.ts b/app/client/src/actions/propertyPaneActions.ts index 615daa5170..a90d3a1453 100644 --- a/app/client/src/actions/propertyPaneActions.ts +++ b/app/client/src/actions/propertyPaneActions.ts @@ -8,3 +8,9 @@ export const updateWidgetName = (widgetId: string, newName: string) => { }, }; }; + +export const hidePropertyPane = () => { + return { + type: ReduxActionTypes.HIDE_PROPERTY_PANE, + }; +}; diff --git a/app/client/src/assets/icons/widget/list.svg b/app/client/src/assets/icons/widget/list.svg new file mode 100644 index 0000000000..87348a0e9e --- /dev/null +++ b/app/client/src/assets/icons/widget/list.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/designSystems/appsmith/ContainerComponent.tsx b/app/client/src/components/designSystems/appsmith/ContainerComponent.tsx index dcc2dc0c09..5faeb25a28 100644 --- a/app/client/src/components/designSystems/appsmith/ContainerComponent.tsx +++ b/app/client/src/components/designSystems/appsmith/ContainerComponent.tsx @@ -25,6 +25,8 @@ const StyledContainerComponent = styled.div< background: ${(props) => props.backgroundColor}; ${(props) => (!props.isVisible ? invisible : "")}; + opacity: ${(props) => (props.resizeDisabled ? "0.5" : "1")}; + pointer-events: ${(props) => (props.resizeDisabled ? "none" : "inherit")}; overflow: hidden; ${(props) => (props.shouldScrollContents ? scrollContents : "")} }`; @@ -32,6 +34,7 @@ const StyledContainerComponent = styled.div< const ContainerComponent = (props: ContainerComponentProps) => { const containerStyle = props.containerStyle || "card"; const containerRef: RefObject = useRef(null); + useEffect(() => { if (!props.shouldScrollContents) { const supportsNativeSmoothScroll = @@ -69,6 +72,7 @@ export interface ContainerComponentProps extends ComponentProps { className?: string; backgroundColor?: Color; shouldScrollContents?: boolean; + resizeDisabled?: boolean; } export default ContainerComponent; diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx index 46a845f2f2..b3aa56177f 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from "react"; +import React, { CSSProperties, ReactNode, useMemo } from "react"; import { BaseStyle } from "widgets/BaseWidget"; import { WIDGET_PADDING } from "constants/WidgetConstants"; import { generateClassName } from "utils/generators"; @@ -23,27 +23,35 @@ export const PositionedContainer = (props: PositionedContainerProps) => { const padding = WIDGET_PADDING; const openPropertyPane = useClickOpenPropPane(); + // memoized classname + const containerClassName = useMemo(() => { + return ( + generateClassName(props.widgetId) + + " positioned-widget " + + `t--widget-${props.widgetType + .split("_") + .join("") + .toLowerCase()}` + ); + }, [props.widgetType, props.widgetId]); + const containerStyle: CSSProperties = useMemo(() => { + return { + position: "absolute", + left: x, + top: y, + height: props.style.componentHeight + (props.style.heightUnit || "px"), + width: props.style.componentWidth + (props.style.widthUnit || "px"), + padding: padding + "px", + }; + }, [props.style]); + return ( {props.children} diff --git a/app/client/src/components/editorComponents/CodeEditor/CodeEditor.test.tsx b/app/client/src/components/editorComponents/CodeEditor/CodeEditor.test.tsx new file mode 100644 index 0000000000..d353e014f6 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/CodeEditor.test.tsx @@ -0,0 +1,77 @@ +import CodeEditor from "./index"; +import store from "store"; +import TestRenderer from "react-test-renderer"; +import React from "react"; +import { Provider } from "react-redux"; + +import EvaluatedValuePopup from "./EvaluatedValuePopup"; +import { ThemeProvider } from "styled-components"; +import { theme, light } from "constants/DefaultTheme"; +import { + EditorSize, + EditorTheme, + TabBehaviour, + EditorModes, +} from "./EditorConfig"; + +describe("CodeEditor", () => { + it("should check EvaluatedValuePopup's hideEvaluatedValue is false when hideEvaluatedValue is passed as false to codeditor", () => { + const finalTheme = { ...theme, colors: { ...theme.colors, ...light } }; + + const testRenderer = TestRenderer.create( + + + { + // + }, + }} + hideEvaluatedValue={false} + additionalDynamicData={{}} + mode={EditorModes.TEXT} + theme={EditorTheme.LIGHT} + size={EditorSize.COMPACT} + tabBehaviour={TabBehaviour.INDENT} + /> + + , + ); + const testInstance = testRenderer.root; + + expect( + testInstance.findByType(EvaluatedValuePopup).props.hideEvaluatedValue, + ).toBe(false); + }); + + it("should check EvaluatedValuePopup's hideEvaluatedValue is true when hideEvaluatedValue is passed as true to codeditor", () => { + const finalTheme = { ...theme, colors: { ...theme.colors, ...light } }; + + const testRenderer = TestRenderer.create( + + + { + // + }, + }} + hideEvaluatedValue={true} + additionalDynamicData={{}} + mode={EditorModes.TEXT} + theme={EditorTheme.LIGHT} + size={EditorSize.COMPACT} + tabBehaviour={TabBehaviour.INDENT} + /> + + , + ); + const testInstance = testRenderer.root; + + expect( + testInstance.findByType(EvaluatedValuePopup).props.hideEvaluatedValue, + ).toBe(true); + }); +}); diff --git a/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.test.tsx b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.test.tsx new file mode 100644 index 0000000000..cfc0ea245d --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.test.tsx @@ -0,0 +1,50 @@ +import store from "store"; +import React from "react"; +import { Provider } from "react-redux"; +import { render, screen } from "@testing-library/react"; + +import EvaluatedValuePopup from "./EvaluatedValuePopup"; +import { ThemeProvider, theme } from "constants/DefaultTheme"; +import { EditorTheme } from "./EditorConfig"; + +describe("EvaluatedValuePopup", () => { + it("should render evaluated popup when hideEvaluatedValue is false", () => { + render( + + + +
children
+
+
+
, + ); + const input = screen.queryByTestId("evaluated-value-popup-title"); + + expect(input).toBeTruthy(); + }); + + it("should not render evaluated popup when hideEvaluatedValue is true", () => { + render( + + + +
children
+
+
+
, + ); + const input = screen.queryByTestId("evaluated-value-popup-title"); + + expect(input).toBeNull(); + }); +}); diff --git a/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx index 5635e7fc5c..82d1518f7c 100644 --- a/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx @@ -106,6 +106,7 @@ interface Props { children: JSX.Element; error?: string; useValidationMessage?: boolean; + hideEvaluatedValue?: boolean; } interface PopoverContentProps { @@ -117,6 +118,7 @@ interface PopoverContentProps { theme: EditorTheme; onMouseEnter: () => void; onMouseLeave: () => void; + hideEvaluatedValue?: boolean; } export const CurrentValueViewer = (props: { @@ -164,7 +166,11 @@ export const CurrentValueViewer = (props: { } return ( - {!props.hideLabel && Evaluated Value} + {!props.hideLabel && ( + + Evaluated Value + + )} <> {content} @@ -200,10 +206,12 @@ const PopoverContent = (props: PopoverContentProps) => { )} - + {!props.hideEvaluatedValue && ( + + )} ); }; @@ -242,6 +250,7 @@ const EvaluatedValuePopup = (props: Props) => { useValidationMessage={props.useValidationMessage} hasError={props.hasError} theme={props.theme} + hideEvaluatedValue={props.hideEvaluatedValue} onMouseLeave={() => { setContentHovered(false); }} diff --git a/app/client/src/components/editorComponents/CodeEditor/index.tsx b/app/client/src/components/editorComponents/CodeEditor/index.tsx index 9789ae121b..56a6e2ce14 100644 --- a/app/client/src/components/editorComponents/CodeEditor/index.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/index.tsx @@ -92,6 +92,7 @@ export type EditorProps = EditorStyleProps & } & { additionalDynamicData?: Record>; promptMessage?: React.ReactNode | string; + hideEvaluatedValue?: boolean; }; type Props = ReduxStateProps & EditorProps; @@ -350,6 +351,7 @@ class CodeEditor extends Component { hoverInteraction, fill, useValidationMessage, + hideEvaluatedValue, } = this.props; const hasError = !!(meta && meta.error); let evaluated = evaluatedValue; @@ -395,6 +397,7 @@ class CodeEditor extends Component { hasError={hasError} error={meta?.error} useValidationMessage={useValidationMessage} + hideEvaluatedValue={hideEvaluatedValue} > { + it("it checks noPad prop", () => { + const dummyWidget = { + type: WidgetTypes.CANVAS_WIDGET, + widgetId: "0", + widgetName: "canvas", + parentColumnSpace: 1, + parentRowSpace: 1, + parentRowHeight: 0, + canDropTargetExtend: false, + parentColumnWidth: 0, + leftColumn: 0, + visible: true, + rightColumn: 0, + topRow: 0, + bottomRow: 0, + version: 17, + isLoading: false, + renderMode: RenderModes.CANVAS, + children: [], + noPad: true, + onBoundsUpdate: () => { + // + }, + isOver: true, + parentWidgetId: "parent", + force: true, + }; + const testRenderer = TestRenderer.create( + + + + + , + ); + const testInstance = testRenderer.root; + + expect(testInstance.findByType(DragLayerComponent).props.noPad).toBe(true); + }); +}); diff --git a/app/client/src/components/editorComponents/DragLayerComponent.tsx b/app/client/src/components/editorComponents/DragLayerComponent.tsx index 27f333f71e..7bee6db010 100644 --- a/app/client/src/components/editorComponents/DragLayerComponent.tsx +++ b/app/client/src/components/editorComponents/DragLayerComponent.tsx @@ -12,16 +12,17 @@ import { getNearestParentCanvas } from "utils/generators"; const WrappedDragLayer = styled.div<{ columnWidth: number; rowHeight: number; + noPad: boolean; ref: RefObject; }>` position: absolute; pointer-events: none; - left: 0; - top: 0; - left: ${CONTAINER_GRID_PADDING}px; - top: ${CONTAINER_GRID_PADDING}px; - height: calc(100% - ${CONTAINER_GRID_PADDING}px); - width: calc(100% - ${CONTAINER_GRID_PADDING}px); + left: ${(props) => (props.noPad ? "0" : `${CONTAINER_GRID_PADDING}px;`)}; + top: ${(props) => (props.noPad ? "0" : `${CONTAINER_GRID_PADDING}px;`)}; + height: ${(props) => + props.noPad ? `100%` : `calc(100% - ${CONTAINER_GRID_PADDING}px)`}; + width: ${(props) => + props.noPad ? `100%` : `calc(100% - ${CONTAINER_GRID_PADDING}px)`}; background-image: radial-gradient( circle, @@ -47,6 +48,7 @@ type DragLayerProps = { isResizing?: boolean; parentWidgetId: string; force: boolean; + noPad: boolean; }; const DragLayerComponent = (props: DragLayerProps) => { @@ -133,9 +135,9 @@ const DragLayerComponent = (props: DragLayerProps) => { return null; } - /* + /* When the parent offsets are not updated, we don't need to show the dropzone, as the dropzone - will be rendered at an incorrect coordinates. + will be rendered at an incorrect coordinates. We can be sure that the parent offset has been calculated when the coordiantes are not [0,0]. */ @@ -146,6 +148,7 @@ const DragLayerComponent = (props: DragLayerProps) => { columnWidth={props.parentColumnWidth} rowHeight={props.parentRowHeight} ref={dropTargetMask} + noPad={props.noPad} > {props.visible && props.isOver && diff --git a/app/client/src/components/editorComponents/DraggableComponent.test.tsx b/app/client/src/components/editorComponents/DraggableComponent.test.tsx new file mode 100644 index 0000000000..6d2a6c0b6b --- /dev/null +++ b/app/client/src/components/editorComponents/DraggableComponent.test.tsx @@ -0,0 +1,10 @@ +import { canDrag } from "./DraggableComponent"; + +describe("DraggableComponent", () => { + it("it tests draggable canDrag helper function", () => { + expect(canDrag(false, false, { dragDisabled: false })).toBe(true); + expect(canDrag(true, false, { dragDisabled: false })).toBe(false); + expect(canDrag(false, true, { dragDisabled: false })).toBe(false); + expect(canDrag(false, false, { dragDisabled: true })).toBe(false); + }); +}); diff --git a/app/client/src/components/editorComponents/DraggableComponent.tsx b/app/client/src/components/editorComponents/DraggableComponent.tsx index d6baa7c357..74253c5c3a 100644 --- a/app/client/src/components/editorComponents/DraggableComponent.tsx +++ b/app/client/src/components/editorComponents/DraggableComponent.tsx @@ -38,6 +38,22 @@ type DraggableComponentProps = WidgetProps; /* eslint-disable react/display-name */ +/** + * can drag helper function for react-dnd hook + * + * @param isResizing + * @param isDraggingDisabled + * @param props + * @returns + */ +export const canDrag = ( + isResizing: boolean, + isDraggingDisabled: boolean, + props: any, +) => { + return !isResizing && !isDraggingDisabled && !props.dragDisabled; +}; + const DraggableComponent = (props: DraggableComponentProps) => { // Dispatch hook handy to toggle property pane const showPropertyPane = useShowPropertyPane(); @@ -119,7 +135,7 @@ const DraggableComponent = (props: DraggableComponentProps) => { }, canDrag: () => { // Dont' allow drag if we're resizing or the drag of `DraggableComponent` is disabled - return !isResizing && !isDraggingDisabled; + return canDrag(isResizing, isDraggingDisabled, props); }, }); diff --git a/app/client/src/components/editorComponents/DropTargetComponent.tsx b/app/client/src/components/editorComponents/DropTargetComponent.tsx index 0077eaea77..7b3afb681b 100644 --- a/app/client/src/components/editorComponents/DropTargetComponent.tsx +++ b/app/client/src/components/editorComponents/DropTargetComponent.tsx @@ -38,6 +38,7 @@ type DropTargetComponentProps = WidgetProps & { snapColumnSpace: number; snapRowSpace: number; minHeight: number; + noPad?: boolean; }; const StyledDropTarget = styled.div` @@ -65,7 +66,7 @@ export const DropTargetContext: Context<{ persistDropTargetRows?: (widgetId: string, row: number) => void; }> = createContext({}); -export const DropTargetComponent = memo((props: DropTargetComponentProps) => { +export const DropTargetComponent = (props: DropTargetComponentProps) => { const canDropTargetExtend = props.canExtend; const snapRows = getCanvasSnapRows(props.bottomRow, props.canExtend); @@ -244,7 +245,8 @@ export const DropTargetComponent = memo((props: DropTargetComponentProps) => { focusWidget && focusWidget(props.parentId); } } - e.stopPropagation(); + // commenting this out to allow propagation of click events + // e.stopPropagation(); e.preventDefault(); }; const height = canDropTargetExtend @@ -258,13 +260,15 @@ export const DropTargetComponent = memo((props: DropTargetComponentProps) => { ? "1px solid #DDDDDD" : "1px solid transparent"; + const dropRef = !props.dropDisabled ? drop : undefined; + return ( { parentRows={rows} parentCols={props.snapColumns} isResizing={isChildResizing} + noPad={props.noPad || false} force={isDragging && !isOver && !props.parentId} /> ); -}); +}; -export default DropTargetComponent; +const MemoizedDropTargetComponent = memo(DropTargetComponent); + +export default MemoizedDropTargetComponent; diff --git a/app/client/src/components/editorComponents/ResizableComponent.tsx b/app/client/src/components/editorComponents/ResizableComponent.tsx index 918d350301..58135566d4 100644 --- a/app/client/src/components/editorComponents/ResizableComponent.tsx +++ b/app/client/src/components/editorComponents/ResizableComponent.tsx @@ -265,7 +265,7 @@ export const ResizableComponent = memo((props: ResizableComponentProps) => { onStart={handleResizeStart} onStop={updateSize} snapGrid={{ x: props.parentColumnSpace, y: props.parentRowSpace }} - enable={!isDragging && isWidgetFocused} + enable={!isDragging && isWidgetFocused && !props.resizeDisabled} isColliding={isColliding} > { currentActivity = Activities.ACTIVE; return showWidgetName ? ( - + void; deleteProperties: (propertyPaths: string[]) => void; theme: EditorTheme; + hideEvaluatedValue?: boolean; } export default BaseControl; diff --git a/app/client/src/components/propertyControls/DropDownControl.tsx b/app/client/src/components/propertyControls/DropDownControl.tsx index 076625ec11..f496aeb4fa 100644 --- a/app/client/src/components/propertyControls/DropDownControl.tsx +++ b/app/client/src/components/propertyControls/DropDownControl.tsx @@ -24,7 +24,7 @@ class DropDownControl extends BaseControl { options={this.props.options} selected={defaultSelected} onSelect={this.onItemSelect} - width="231px" + width="100%" showLabelOnly={true} optionWidth={ this.props.optionWidth ? this.props.optionWidth : "231px" diff --git a/app/client/src/components/propertyControls/InputTextControl.tsx b/app/client/src/components/propertyControls/InputTextControl.tsx index 9d6e740365..c01c0df87b 100644 --- a/app/client/src/components/propertyControls/InputTextControl.tsx +++ b/app/client/src/components/propertyControls/InputTextControl.tsx @@ -22,6 +22,7 @@ export function InputText(props: { dataTreePath?: string; additionalAutocomplete?: Record>; theme?: EditorTheme; + hideEvaluatedValue?: boolean; }) { const { errorMessage, @@ -32,7 +33,9 @@ export function InputText(props: { placeholder, dataTreePath, evaluatedValue, + hideEvaluatedValue, } = props; + return ( ); @@ -69,7 +73,10 @@ class InputTextControl extends BaseControl { dataTreePath, validationMessage, defaultValue, + additionalAutoComplete, + hideEvaluatedValue, } = this.props; + return ( { dataTreePath={dataTreePath} placeholder={placeholderText} theme={this.props.theme} + additionalAutocomplete={additionalAutoComplete} + hideEvaluatedValue={hideEvaluatedValue} /> ); } diff --git a/app/client/src/constants/FieldExpectedValue.ts b/app/client/src/constants/FieldExpectedValue.ts index 530b8dcc14..e75ddd092d 100644 --- a/app/client/src/constants/FieldExpectedValue.ts +++ b/app/client/src/constants/FieldExpectedValue.ts @@ -166,6 +166,11 @@ const FIELD_VALUES: Record< shouldScroll: "boolean", isVisible: "boolean", }, + LIST_WIDGET: { + items: "Array", + isVisible: "boolean", + gridGap: "number", + }, }; export default FIELD_VALUES; diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index 9aabd4bffc..19e72b7c50 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -103,6 +103,10 @@ export const HelpMap = { path: "/core-concepts/connecting-to-data-sources/connecting-to-databases", searchKey: "Connecting to databases", }, + LIST_WIDGET: { + path: "/widget-reference/list", + searchKey: "List", + }, SWITCH_WIDGET: { path: "/widget-reference/switch", searchKey: "Switch", diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index 94ee3c1157..3f066457bd 100644 --- a/app/client/src/constants/WidgetConstants.tsx +++ b/app/client/src/constants/WidgetConstants.tsx @@ -24,6 +24,7 @@ export enum WidgetTypes { FILE_PICKER_WIDGET = "FILE_PICKER_WIDGET", VIDEO_WIDGET = "VIDEO_WIDGET", SKELETON_WIDGET = "SKELETON_WIDGET", + LIST_WIDGET = "LIST_WIDGET", SWITCH_WIDGET = "SWITCH_WIDGET", } diff --git a/app/client/src/constants/WidgetValidation.ts b/app/client/src/constants/WidgetValidation.ts index 39d7878cd3..82f089d83b 100644 --- a/app/client/src/constants/WidgetValidation.ts +++ b/app/client/src/constants/WidgetValidation.ts @@ -18,6 +18,7 @@ export enum VALIDATION_TYPES { MAX_DATE = "MAX_DATE", TABS_DATA = "TABS_DATA", CHART_DATA = "CHART_DATA", + LIST_DATA = "LIST_DATA", CUSTOM_FUSION_CHARTS_DATA = "CUSTOM_FUSION_CHARTS_DATA", MARKERS = "MARKERS", ACTION_SELECTOR = "ACTION_SELECTOR", diff --git a/app/client/src/constants/messages.test.ts b/app/client/src/constants/messages.test.ts new file mode 100644 index 0000000000..f06d1941c0 --- /dev/null +++ b/app/client/src/constants/messages.test.ts @@ -0,0 +1,9 @@ +import { ERROR_WIDGET_COPY_NOT_ALLOWED } from "./messages"; + +describe("messages", () => { + it("checks for ERROR_WIDGET_COPY_NOT_ALLOWED string", () => { + expect(ERROR_WIDGET_COPY_NOT_ALLOWED()).toBe( + "This selected widget cannot be copied.", + ); + }); +}); diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 37fb04ac0d..ad79602a28 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -250,6 +250,8 @@ export const WIDGET_DELETE = (widgetName: string) => export const WIDGET_COPY = (widgetName: string) => `Copied ${widgetName}`; export const ERROR_WIDGET_COPY_NO_WIDGET_SELECTED = () => `Please select a widget to copy`; +export const ERROR_WIDGET_COPY_NOT_ALLOWED = () => + `This selected widget cannot be copied.`; export const WIDGET_CUT = (widgetName: string) => `Cut ${widgetName}`; export const ERROR_WIDGET_CUT_NO_WIDGET_SELECTED = () => `Please select a widget to cut`; diff --git a/app/client/src/icons/WidgetIcons.test.tsx b/app/client/src/icons/WidgetIcons.test.tsx new file mode 100644 index 0000000000..ef91c74382 --- /dev/null +++ b/app/client/src/icons/WidgetIcons.test.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { WidgetIcons } from "./WidgetIcons"; +import { render, screen } from "@testing-library/react"; + +import { ThemeProvider, theme } from "constants/DefaultTheme"; + +const ListWidgetIcon = WidgetIcons["LIST_WIDGET"]; + +describe("WidgetIcons", () => { + it("checks widget icon for list widget", () => { + render( + + + , + ); + + const input = screen.queryByTestId("list-widget-icon"); + expect(input).toBeTruthy(); + }); +}); diff --git a/app/client/src/icons/WidgetIcons.tsx b/app/client/src/icons/WidgetIcons.tsx index c95ff4fdaa..259c6588b5 100644 --- a/app/client/src/icons/WidgetIcons.tsx +++ b/app/client/src/icons/WidgetIcons.tsx @@ -21,6 +21,7 @@ import { ReactComponent as ChartIcon } from "assets/icons/widget/chart.svg"; import { ReactComponent as FormIcon } from "assets/icons/widget/form.svg"; import { ReactComponent as MapIcon } from "assets/icons/widget/map.svg"; import { ReactComponent as ModalIcon } from "assets/icons/widget/modal.svg"; +import { ReactComponent as ListIcon } from "assets/icons/widget/list.svg"; /* eslint-disable react/display-name */ export const WidgetIcons: { @@ -136,6 +137,11 @@ export const WidgetIcons: { ), + LIST_WIDGET: (props: IconProps) => ( + + + + ), }; export type WidgetIcon = typeof WidgetIcons[keyof typeof WidgetIcons]; diff --git a/app/client/src/mockResponses/WidgetConfigResponse.test.tsx b/app/client/src/mockResponses/WidgetConfigResponse.test.tsx new file mode 100644 index 0000000000..d8bcf161a6 --- /dev/null +++ b/app/client/src/mockResponses/WidgetConfigResponse.test.tsx @@ -0,0 +1,77 @@ +import WIDGET_CONFIG_RESPONSE from "./WidgetConfigResponse"; + +describe("WidgetConfigResponse", () => { + it("it tests autocomplete child enhancements", () => { + const mockProps = { + childAutoComplete: "child-autocomplet", + }; + + expect( + WIDGET_CONFIG_RESPONSE.config.LIST_WIDGET.enhancements.child.autocomplete( + mockProps, + ), + ).toBe(mockProps.childAutoComplete); + }); + + it("it tests hideEvaluatedValue child enhancements", () => { + expect( + WIDGET_CONFIG_RESPONSE.config.LIST_WIDGET.enhancements.child.hideEvaluatedValue(), + ).toBe(true); + }); + + it("it tests propertyUpdateHook child enhancements with undefined parent widget", () => { + const mockParentWidget = { + widgetId: undefined, + }; + + const result = WIDGET_CONFIG_RESPONSE.config.LIST_WIDGET.enhancements.child.propertyUpdateHook( + mockParentWidget, + "child-widget-name", + "text", + "value", + false, + ); + + expect(result).toStrictEqual([]); + }); + + it("it tests propertyUpdateHook child enhancements with undefined parent widget", () => { + const mockParentWidget = { + widgetId: undefined, + }; + + const result = WIDGET_CONFIG_RESPONSE.config.LIST_WIDGET.enhancements.child.propertyUpdateHook( + mockParentWidget, + "child-widget-name", + "text", + "value", + false, + ); + + expect(result).toStrictEqual([]); + }); + + it("it tests propertyUpdateHook child enhancements with defined parent widget", () => { + const mockParentWidget = { + widgetId: "parent-widget-id", + widgetName: "parent-widget-name", + }; + + const result = WIDGET_CONFIG_RESPONSE.config.LIST_WIDGET.enhancements.child.propertyUpdateHook( + mockParentWidget, + "child-widget-name", + "text", + "value", + false, + ); + + expect(result).toStrictEqual([ + { + widgetId: "parent-widget-id", + propertyPath: "template.child-widget-name.text", + propertyValue: "{{parent-widget-name.items.map((currentItem) => )}}", + isDynamicTrigger: false, + }, + ]); + }); +}); diff --git a/app/client/src/mockResponses/WidgetConfigResponse.tsx b/app/client/src/mockResponses/WidgetConfigResponse.tsx index b1e1545e93..6cb064df86 100644 --- a/app/client/src/mockResponses/WidgetConfigResponse.tsx +++ b/app/client/src/mockResponses/WidgetConfigResponse.tsx @@ -1,10 +1,18 @@ import { WidgetConfigReducerState } from "reducers/entityReducers/widgetConfigReducer"; import { WidgetProps } from "widgets/BaseWidget"; import moment from "moment-timezone"; +import { cloneDeep, get, indexOf, isString } from "lodash"; import { generateReactKey } from "utils/generators"; +import { WidgetTypes } from "constants/WidgetConstants"; +import { BlueprintOperationTypes } from "sagas/WidgetBlueprintSagasEnums"; +import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; +import { getDynamicBindings } from "utils/DynamicBindingUtils"; import { Colors } from "constants/Colors"; import FileDataTypes from "widgets/FileDataTypes"; +/** + * this config sets the default values of properties being used in the widget + */ const WidgetConfigResponse: WidgetConfigReducerState = { config: { BUTTON_WIDGET: { @@ -225,7 +233,7 @@ const WidgetConfigResponse: WidgetConfigReducerState = { blueprint: { operations: [ { - type: "MODIFY_PROPS", + type: BlueprintOperationTypes.MODIFY_PROPS, fn: (widget: WidgetProps & { children?: WidgetProps[] }) => { const tabs = [...widget.tabs]; @@ -320,9 +328,10 @@ const WidgetConfigResponse: WidgetConfigReducerState = { ], operations: [ { - type: "MODIFY_PROPS", + type: BlueprintOperationTypes.MODIFY_PROPS, fn: ( widget: WidgetProps & { children?: WidgetProps[] }, + widgets: { [widgetId: string]: FlattenedWidgetProps }, parent?: WidgetProps & { children?: WidgetProps[] }, ) => { const iconChild = @@ -490,6 +499,323 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "Skeleton", version: 1, }, + [WidgetTypes.LIST_WIDGET]: { + backgroundColor: "", + itemBackgroundColor: "white", + rows: 10, + columns: 8, + gridType: "vertical", + enhancements: { + child: { + autocomplete: (parentProps: any) => { + return parentProps.childAutoComplete; + }, + hideEvaluatedValue: () => true, + propertyUpdateHook: ( + parentProps: any, + widgetName: string, + propertyPath: string, // onClick + propertyValue: string, + isTriggerProperty: boolean, + ) => { + let value = propertyValue; + + if (!parentProps.widgetId) return []; + + const { jsSnippets } = getDynamicBindings(propertyValue); + + const modifiedAction = jsSnippets.reduce( + (prev: string, next: string) => { + return `${prev}${next}`; + }, + "", + ); + + value = `{{${parentProps.widgetName}.items.map((currentItem) => ${modifiedAction})}}`; + const path = `template.${widgetName}.${propertyPath}`; + + return [ + { + widgetId: parentProps.widgetId, + propertyPath: path, + propertyValue: isTriggerProperty ? propertyValue : value, + isDynamicTrigger: isTriggerProperty, + }, + ]; + }, + }, + }, + gridGap: 0, + items: [ + { + id: 1, + num: "001", + name: "Bulbasaur", + img: "http://www.serebii.net/pokemongo/pokemon/001.png", + }, + { + id: 2, + num: "002", + name: "Ivysaur", + img: "http://www.serebii.net/pokemongo/pokemon/002.png", + }, + { + id: 3, + num: "003", + name: "Venusaur", + img: "http://www.serebii.net/pokemongo/pokemon/003.png", + }, + { + id: 4, + num: "004", + name: "Charmander", + img: "http://www.serebii.net/pokemongo/pokemon/004.png", + }, + { + id: 5, + num: "005", + name: "Charmeleon", + img: "http://www.serebii.net/pokemongo/pokemon/005.png", + }, + { + id: 6, + num: "006", + name: "Charizard", + img: "http://www.serebii.net/pokemongo/pokemon/006.png", + }, + ], + widgetName: "List", + children: [], + blueprint: { + view: [ + { + type: "CANVAS_WIDGET", + position: { top: 0, left: 0 }, + props: { + containerStyle: "none", + canExtend: false, + detachFromLayout: true, + dropDisabled: true, + noPad: true, + children: [], + blueprint: { + view: [ + { + type: "CONTAINER_WIDGET", + size: { rows: 4, cols: 16 }, + position: { top: 0, left: 0 }, + props: { + backgroundColor: "white", + containerStyle: "card", + dragDisabled: true, + isDeletable: false, + disallowCopy: true, + disablePropertyPane: true, + children: [], + blueprint: { + view: [ + { + type: "CANVAS_WIDGET", + position: { top: 0, left: 0 }, + props: { + containerStyle: "none", + canExtend: false, + detachFromLayout: true, + children: [], + version: 1, + blueprint: { + view: [ + { + type: "IMAGE_WIDGET", + size: { rows: 3, cols: 4 }, + position: { top: 0, left: 0 }, + props: { + defaultImage: + "https://res.cloudinary.com/drako999/image/upload/v1589196259/default.png", + imageShape: "RECTANGLE", + maxZoomLevel: 1, + image: "{{currentItem.img}}", + dynamicBindingPathList: [ + { + key: "image", + }, + ], + dynamicTriggerPathList: [], + }, + }, + { + type: "TEXT_WIDGET", + size: { rows: 1, cols: 6 }, + position: { top: 0, left: 4 }, + props: { + text: "{{currentItem.name}}", + textStyle: "HEADING", + textAlign: "LEFT", + dynamicBindingPathList: [ + { + key: "text", + }, + ], + dynamicTriggerPathList: [], + }, + }, + { + type: "TEXT_WIDGET", + size: { rows: 1, cols: 6 }, + position: { top: 1, left: 4 }, + props: { + text: "{{currentItem.num}}", + textStyle: "BODY", + textAlign: "LEFT", + dynamicBindingPathList: [ + { + key: "text", + }, + ], + dynamicTriggerPathList: [], + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + operations: [ + { + type: BlueprintOperationTypes.MODIFY_PROPS, + fn: ( + widget: WidgetProps & { children?: WidgetProps[] }, + widgets: { [widgetId: string]: FlattenedWidgetProps }, + ) => { + let template = {}; + const container = get( + widgets, + `${get(widget, "children.0.children.0")}`, + ); + const canvas = get(widgets, `${get(container, "children.0")}`); + let updatePropertyMap: any = []; + const dynamicBindingPathList: any[] = get( + widget, + "dynamicBindingPathList", + [], + ); + + canvas.children && + get(canvas, "children", []).forEach((child: string) => { + const childWidget = cloneDeep(get(widgets, `${child}`)); + const keys = Object.keys(childWidget); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + let value = childWidget[key]; + + if (isString(value) && value.indexOf("currentItem") > -1) { + const { jsSnippets } = getDynamicBindings(value); + + const modifiedAction = jsSnippets.reduce( + (prev: string, next: string) => { + return prev + `${next}`; + }, + "", + ); + + value = `{{${widget.widgetName}.items.map((currentItem) => ${modifiedAction})}}`; + + childWidget[key] = value; + + dynamicBindingPathList.push({ + key: `template.${childWidget.widgetName}.${key}`, + }); + } + } + + template = { + ...template, + [childWidget.widgetName]: childWidget, + }; + }); + + updatePropertyMap = [ + { + widgetId: widget.widgetId, + propertyName: "dynamicBindingPathList", + propertyValue: dynamicBindingPathList, + }, + { + widgetId: widget.widgetId, + propertyName: "template", + propertyValue: template, + }, + ]; + return updatePropertyMap; + }, + }, + { + type: BlueprintOperationTypes.CHILD_OPERATIONS, + fn: ( + widgets: { [widgetId: string]: FlattenedWidgetProps }, + widgetId: string, + parentId: string, + widgetPropertyMaps: { + defaultPropertyMap: Record; + }, + ) => { + if (!parentId) return { widgets }; + const widget = { ...widgets[widgetId] }; + const parent = { ...widgets[parentId] }; + + const disallowedWidgets = [WidgetTypes.FILE_PICKER_WIDGET]; + + if ( + Object.keys(widgetPropertyMaps.defaultPropertyMap).length > 0 || + indexOf(disallowedWidgets, widget.type) > -1 + ) { + const widget = widgets[widgetId]; + if (widget.children && widget.children.length > 0) { + widget.children.forEach((childId: string) => { + delete widgets[childId]; + }); + } + if (widget.parentId) { + const _parent = { ...widgets[widget.parentId] }; + _parent.children = _parent.children?.filter( + (id) => id !== widgetId, + ); + widgets[widget.parentId] = _parent; + } + delete widgets[widgetId]; + + return { + widgets, + message: `${ + WidgetConfigResponse.config[widget.type].widgetName + } widgets cannot be used inside the list widget right now.`, + }; + } + + const template = { + ...get(parent, "template", {}), + [widget.widgetName]: widget, + }; + + parent.template = template; + + widgets[parentId] = parent; + + return { widgets }; + }, + }, + ], + }, + }, }, configVersion: 1, }; diff --git a/app/client/src/mockResponses/WidgetSidebarResponse.tsx b/app/client/src/mockResponses/WidgetSidebarResponse.tsx index 3290fa508f..cec889206f 100644 --- a/app/client/src/mockResponses/WidgetSidebarResponse.tsx +++ b/app/client/src/mockResponses/WidgetSidebarResponse.tsx @@ -49,6 +49,12 @@ const WidgetSidebarResponse: WidgetCardProps[] = [ widgetCardName: "Form", key: generateReactKey(), }, + { + type: "LIST_WIDGET", + widgetCardName: "List", + key: generateReactKey(), + isBeta: true, + }, { type: "IMAGE_WIDGET", widgetCardName: "Image", diff --git a/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx b/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx index 9d3f7b60a2..d69f843176 100644 --- a/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx +++ b/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx @@ -1,5 +1,5 @@ import React, { memo, useCallback } from "react"; -import _ from "lodash"; +import _, { get } from "lodash"; import { ControlPropertyLabelContainer, ControlWrapper, @@ -31,6 +31,11 @@ import { OnboardingStep } from "constants/OnboardingConstants"; import Indicator from "components/editorComponents/Onboarding/Indicator"; import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; +import { + useChildWidgetEnhancementFns, + useParentWithEnhancementFn, +} from "sagas/WidgetEnhancementHelpers"; + type Props = PropertyPaneControlConfig & { panel: IPanelProps; theme: EditorTheme; @@ -39,6 +44,17 @@ type Props = PropertyPaneControlConfig & { const PropertyControl = memo((props: Props) => { const dispatch = useDispatch(); const widgetProperties: any = useSelector(getWidgetPropsForPropertyPane); + const parentWithEnhancement = useParentWithEnhancementFn( + widgetProperties.widgetId, + ); + + /** get all child enhancments functions */ + const { + propertyPaneEnhancmentFn: childWidgetPropertyUpdateEnhancementFn, + autoCompleteEnhancementFn: childWidgetAutoCompleteEnhancementFn, + customJSControlEnhancementFn: childWidgetCustomJSControlEnhancementFn, + hideEvaluatedValueEnhancementFn: childWidgetHideEvaluatedValueEnhancementFn, + } = useChildWidgetEnhancementFns(widgetProperties.widgetId); const toggleDynamicProperty = useCallback( (propertyName: string, isDynamic: boolean) => { @@ -79,7 +95,28 @@ const PropertyControl = memo((props: Props) => { ), [widgetProperties.widgetId, dispatch], ); + // this function updates the properties of widget passed + const onBatchUpdatePropertiesOfWidget = useCallback( + ( + allUpdates: Record, + widgetId: string, + triggerPaths: string[], + ) => { + dispatch( + batchUpdateWidgetProperty(widgetId, { + modify: allUpdates, + triggerPaths, + }), + ); + }, + [dispatch], + ); + /** + * this function is called whenever we change any property in the property pane + * it updates the widget property by updateWidgetPropertyRequest + * It also calls the beforeChildPropertyUpdate hook + */ const onPropertyChange = useCallback( (propertyName: string, propertyValue: any) => { AnalyticsUtil.logEvent("WIDGET_PROPERTY_UPDATE", { @@ -88,7 +125,6 @@ const PropertyControl = memo((props: Props) => { propertyName: propertyName, updatedValue: propertyValue, }); - let propertiesToUpdate: | Array<{ propertyPath: string; @@ -102,6 +138,39 @@ const PropertyControl = memo((props: Props) => { propertyValue, ); } + + // if there are enhancements related to the widget, calling them here + // enhancements are basically group of functions that are called before widget propety + // is changed on propertypane. For e.g - set/update parent property + if (childWidgetPropertyUpdateEnhancementFn) { + const hookPropertiesUpdates = childWidgetPropertyUpdateEnhancementFn( + widgetProperties.widgetName, + propertyName, + propertyValue, + props.isTriggerProperty, + ); + + if ( + Array.isArray(hookPropertiesUpdates) && + hookPropertiesUpdates.length > 0 + ) { + const allUpdates: Record = {}; + const triggerPaths: string[] = []; + hookPropertiesUpdates.forEach( + ({ propertyPath, propertyValue, isDynamicTrigger }) => { + allUpdates[propertyPath] = propertyValue; + if (isDynamicTrigger) triggerPaths.push(propertyPath); + }, + ); + + onBatchUpdatePropertiesOfWidget( + allUpdates, + get(parentWithEnhancement, "widgetId", ""), + triggerPaths, + ); + } + } + if (propertiesToUpdate) { const allUpdates: Record = {}; propertiesToUpdate.forEach(({ propertyPath, propertyValue }) => { @@ -190,6 +259,7 @@ const PropertyControl = memo((props: Props) => { expected: FIELD_EXPECTED_VALUE[widgetProperties.type as WidgetType][ propertyName ] as any, + additionalDynamicData: {}, }; if (isPathADynamicTrigger(widgetProperties, propertyName)) { config.isValid = true; @@ -209,6 +279,36 @@ const PropertyControl = memo((props: Props) => { .join("") .toLowerCase(); + let additionAutocomplete = undefined; + if (additionalAutoComplete) { + additionAutocomplete = additionalAutoComplete(widgetProperties); + } else if (childWidgetAutoCompleteEnhancementFn) { + additionAutocomplete = childWidgetAutoCompleteEnhancementFn(); + } + + /** + * if the current widget requires a customJSControl, use that. + */ + const getCustomJSControl = () => { + if (childWidgetCustomJSControlEnhancementFn) { + return childWidgetCustomJSControlEnhancementFn(); + } + + return props.customJSControl; + }; + + /** + * should the property control hide evaluated popover + * @returns + */ + const hideEvaluatedValue = () => { + if (childWidgetHideEvaluatedValueEnhancementFn) { + return childWidgetHideEvaluatedValueEnhancementFn(); + } + + return false; + }; + try { return ( { theme: props.theme, }, isDynamic, - props.customJSControl, - additionalAutoComplete - ? additionalAutoComplete(widgetProperties) - : undefined, + getCustomJSControl(), + additionAutocomplete, + hideEvaluatedValue(), )} diff --git a/app/client/src/pages/Editor/PropertyPane/PropertyPaneGenerator.tsx b/app/client/src/pages/Editor/PropertyPane/PropertyPaneGenerator.tsx new file mode 100644 index 0000000000..c14ee9bdb2 --- /dev/null +++ b/app/client/src/pages/Editor/PropertyPane/PropertyPaneGenerator.tsx @@ -0,0 +1,66 @@ +import { IPanelProps } from "@blueprintjs/core"; +import { + PropertyPaneConfig, + PropertyPaneControlConfig, + PropertyPaneSectionConfig, +} from "constants/PropertyControlConstants"; +import { WidgetType } from "constants/WidgetConstants"; +import React from "react"; +import WidgetFactory from "utils/WidgetFactory"; +import PropertyControl from "./PropertyControl"; +import PropertySection from "./PropertySection"; +import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; + +export type PropertyControlsGeneratorProps = { + id: string; + type: WidgetType; + panel: IPanelProps; + theme: EditorTheme; +}; + +export const generatePropertyControl = ( + propertyPaneConfig: readonly PropertyPaneConfig[], + props: PropertyControlsGeneratorProps, +) => { + if (!propertyPaneConfig) return null; + return propertyPaneConfig.map((config: PropertyPaneConfig) => { + if ((config as PropertyPaneSectionConfig).sectionName) { + const sectionConfig: PropertyPaneSectionConfig = config as PropertyPaneSectionConfig; + return ( + + ); + } else if ((config as PropertyPaneControlConfig).controlType) { + return ( + + ); + } + throw Error("Unknown configuration provided: " + props.type); + }); +}; + +export const PropertyControlsGenerator = ( + props: PropertyControlsGeneratorProps, +) => { + const config = WidgetFactory.getWidgetPropertyPaneConfig(props.type); + return ( + <> + {generatePropertyControl(config as readonly PropertyPaneConfig[], props)} + + ); +}; + +export default PropertyControlsGenerator; diff --git a/app/client/src/pages/Editor/PropertyPane/index.tsx b/app/client/src/pages/Editor/PropertyPane/index.tsx index 7e5ca7a4fa..e8860c8e12 100644 --- a/app/client/src/pages/Editor/PropertyPane/index.tsx +++ b/app/client/src/pages/Editor/PropertyPane/index.tsx @@ -38,6 +38,7 @@ import { FormIcons } from "icons/FormIcons"; import PropertyPaneHelpButton from "pages/Editor/PropertyPaneHelpButton"; import { getProppanePreference } from "selectors/usersSelectors"; import { PropertyPanePositionConfig } from "reducers/uiReducers/usersReducer"; +import { get } from "lodash"; const PropertyPaneWrapper = styled(PaneWrapper)<{ themeMode?: EditorTheme; @@ -178,6 +179,10 @@ class PropertyPane extends Component { } render() { + if (get(this.props, "widgetProperties.disablePropertyPane")) { + return null; + } + if (this.props.isVisible) { log.debug("Property pane rendered"); const content = this.renderPropertyPane(); @@ -212,13 +217,15 @@ class PropertyPane extends Component { renderPropertyPane() { const { widgetProperties } = this.props; - if (!widgetProperties) - return ( - - ); + + if (!widgetProperties) { + return <>; + } + + // if settings control is disabled, don't render anything + // for e.g - this will be true for list widget tempalte container widget + if (widgetProperties?.disablePropertyPane) return <>; + return ( ` align-items: center; height: ${(props) => props.theme.propertyPane.titleHeight}px; background-color: ${(props) => props.theme.colors.propertyPane.bg}; - & span.${BlueprintClasses.POPOVER_TARGET} { cursor: pointer; display: flex; align-items: center; justify-content: center; } - &&& .${BlueprintClasses.EDITABLE_TEXT} { height: auto; padding: 0; width: 100%; } - &&& .${BlueprintClasses.EDITABLE_TEXT_CONTENT}, &&& @@ -59,7 +56,6 @@ const Wrapper = styled.div<{ iconCount: number }>` color: ${(props) => props.theme.colors.propertyPane.title}; font-size: ${(props) => props.theme.fontSizes[4]}px; } - && svg path { fill: ${(props) => props.theme.colors.propertyPane.label}; } @@ -71,7 +67,6 @@ const NameWrapper = styled.div<{ isPanelTitle?: boolean }>` min-width: 100%; padding-right: 25px; max-width: 134px; - &&&&&&& > * { overflow: hidden; } diff --git a/app/client/src/pages/Editor/WidgetCard.tsx b/app/client/src/pages/Editor/WidgetCard.tsx index 5cc01b3f57..f7e0c75fe3 100644 --- a/app/client/src/pages/Editor/WidgetCard.tsx +++ b/app/client/src/pages/Editor/WidgetCard.tsx @@ -23,6 +23,7 @@ export const Wrapper = styled.div` padding: 10px 5px 10px 5px; border-radius: 0px; border: none; + position: relative; color: ${Colors.ALTO}; height: 72px; display: flex; @@ -54,6 +55,17 @@ export const Wrapper = styled.div` } `; +export const BetaLabel = styled.div` + font-size: 10px; + background: ${Colors.TUNDORA}; + margin-top: 3px; + padding: 2px 4px; + border-radius: 3px; + position: absolute; + top: 0; + right: -2%; +`; + export const IconLabel = styled.h5` text-align: center; margin: 0; @@ -116,6 +128,7 @@ const WidgetCard = (props: CardProps) => {
{props.details.widgetCardName} + {props.details.isBeta && Beta}
diff --git a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx index 155fae76c6..f112bae987 100644 --- a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx +++ b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx @@ -28,6 +28,7 @@ import { IconWidgetProps } from "widgets/IconWidget"; import { VideoWidgetProps } from "widgets/VideoWidget"; import { SkeletonWidgetProps } from "../../widgets/SkeletonWidget"; import { SwitchWidgetProps } from "widgets/SwitchWidget"; +import { ListWidgetProps } from "../../widgets/ListWidget/ListWidget"; const initialState: WidgetConfigReducerState = WidgetConfigResponse; @@ -78,6 +79,7 @@ export interface WidgetConfigReducerState { WidgetConfigProps; ICON_WIDGET: Partial & WidgetConfigProps; SKELETON_WIDGET: Partial & WidgetConfigProps; + LIST_WIDGET: Partial> & WidgetConfigProps; }; configVersion: number; } diff --git a/app/client/src/reducers/uiReducers/index.tsx b/app/client/src/reducers/uiReducers/index.tsx index d4015785dc..b8ea3806d5 100644 --- a/app/client/src/reducers/uiReducers/index.tsx +++ b/app/client/src/reducers/uiReducers/index.tsx @@ -56,4 +56,5 @@ const uiReducer = combineReducers({ globalSearch: globalSearchReducer, releases: releasesReducer, }); + export default uiReducer; diff --git a/app/client/src/sagas/PageSagas.tsx b/app/client/src/sagas/PageSagas.tsx index 6a31c101cb..7e41f72dbc 100644 --- a/app/client/src/sagas/PageSagas.tsx +++ b/app/client/src/sagas/PageSagas.tsx @@ -182,6 +182,7 @@ export function* fetchPageSaga( id, }); const isValidResponse = yield validateResponse(fetchPageResponse); + if (isValidResponse) { // Clear any existing caches yield call(clearEvalCache); @@ -234,7 +235,10 @@ export function* fetchPublishedPageSaga( const { pageId, bustCache } = pageRequestAction.payload; PerformanceTracker.startAsyncTracking( PerformanceTransactionName.FETCH_PAGE_API, - { pageId: pageId, published: true }, + { + pageId: pageId, + published: true, + }, ); const request: FetchPublishedPageRequest = { pageId, diff --git a/app/client/src/sagas/WidgetBlueprintSagas.test.ts b/app/client/src/sagas/WidgetBlueprintSagas.test.ts new file mode 100644 index 0000000000..f260207f20 --- /dev/null +++ b/app/client/src/sagas/WidgetBlueprintSagas.test.ts @@ -0,0 +1,51 @@ +import WidgetFactory from "utils/WidgetFactory"; + +import { + BlueprintOperation, + executeWidgetBlueprintChildOperations, +} from "./WidgetBlueprintSagas"; +import { BlueprintOperationTypes } from "./WidgetBlueprintSagasEnums"; + +describe("WidgetBlueprintSagas", () => { + it("should returns widgets after executing the child operation", async () => { + const mockBlueprintChildOperation: BlueprintOperation = { + type: BlueprintOperationTypes.CHILD_OPERATIONS, + fn: () => { + return { widgets: {} }; + }, + }; + + jest + .spyOn(WidgetFactory, "getWidgetDefaultPropertiesMap") + .mockReturnValue({}); + + const generator = executeWidgetBlueprintChildOperations( + mockBlueprintChildOperation, + { + widgetId: { + image: "", + defaultImage: "", + widgetId: "Widget1", + type: "LIST_WIDGET", + widgetName: "List1", + parentId: "parentId", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + }, + }, + "widgetId", + "parentId", + ); + + expect(generator.next().value).toStrictEqual({}); + }); +}); diff --git a/app/client/src/sagas/WidgetBlueprintSagas.ts b/app/client/src/sagas/WidgetBlueprintSagas.ts index a739971940..13499c496c 100644 --- a/app/client/src/sagas/WidgetBlueprintSagas.ts +++ b/app/client/src/sagas/WidgetBlueprintSagas.ts @@ -4,6 +4,16 @@ import { WidgetProps } from "widgets/BaseWidget"; import { generateReactKey } from "utils/generators"; import { call } from "redux-saga/effects"; import { get } from "lodash"; +import WidgetFactory from "utils/WidgetFactory"; + +import { + MAIN_CONTAINER_WIDGET_ID, + WidgetType, +} from "constants/WidgetConstants"; +import WidgetConfigResponse from "mockResponses/WidgetConfigResponse"; +import { Variant } from "components/ads/common"; +import { Toaster } from "components/ads/Toast"; +import { BlueprintOperationTypes } from "./WidgetBlueprintSagasEnums"; function buildView(view: WidgetBlueprint["view"], widgetId: string) { const children = []; @@ -46,17 +56,28 @@ export type UpdatePropertyArgs = { export type BlueprintOperationAddActionFn = () => void; export type BlueprintOperationModifyPropsFn = ( widget: WidgetProps & { children?: WidgetProps[] }, + widgets: { [widgetId: string]: FlattenedWidgetProps }, parent?: WidgetProps, ) => UpdatePropertyArgs[] | undefined; +export interface ChildOperationFnResponse { + widgets: Record; + message?: string; +} + +export type BlueprintOperationChildOperationsFn = ( + widgets: { [widgetId: string]: FlattenedWidgetProps }, + widgetId: string, + parentId: string, + widgetPropertyMaps: { + defaultPropertyMap: Record; + }, +) => ChildOperationFnResponse; + export type BlueprintOperationFunction = | BlueprintOperationModifyPropsFn - | BlueprintOperationAddActionFn; - -export enum BlueprintOperationTypes { - MODIFY_PROPS = "MODIFY_PROPS", - ADD_ACTION = "ADD_ACTION", -} + | BlueprintOperationAddActionFn + | BlueprintOperationChildOperationsFn; export type BlueprintOperationType = keyof typeof BlueprintOperationTypes; @@ -71,11 +92,12 @@ export function* executeWidgetBlueprintOperations( widgetId: string, ) { operations.forEach((operation: BlueprintOperation) => { + const widget: WidgetProps & { children?: string[] | WidgetProps[] } = { + ...widgets[widgetId], + }; + switch (operation.type) { case BlueprintOperationTypes.MODIFY_PROPS: - const widget: WidgetProps & { children?: string[] | WidgetProps[] } = { - ...widgets[widgetId], - }; if (widget.children && widget.children.length > 0) { widget.children = (widget.children as string[]).map( (childId: string) => widgets[childId], @@ -85,6 +107,7 @@ export function* executeWidgetBlueprintOperations( | UpdatePropertyArgs[] | undefined = (operation.fn as BlueprintOperationModifyPropsFn)( widget as WidgetProps & { children?: WidgetProps[] }, + widgets, get(widgets, widget.parentId || "", undefined), ); updatePropertyPayloads && @@ -92,7 +115,115 @@ export function* executeWidgetBlueprintOperations( widgets[params.widgetId][params.propertyName] = params.propertyValue; }); + break; } }); + return yield widgets; } + +/** + * this saga executes the blueprint child operation + * + * @param parent + * @param newWidgetId + * @param widgets + * + * @returns { [widgetId: string]: FlattenedWidgetProps } + */ +export function* executeWidgetBlueprintChildOperations( + operation: BlueprintOperation, + canvasWidgets: { [widgetId: string]: FlattenedWidgetProps }, + widgetId: string, + parentId: string, +) { + // TODO(abhinav): Special handling for child operaionts + // This needs to be deprecated soon + + // Get the default properties map of the current widget + // The operation can handle things based on this map + // Little abstraction leak, but will be deprecated soon + const widgetPropertyMaps = { + defaultPropertyMap: WidgetFactory.getWidgetDefaultPropertiesMap( + canvasWidgets[widgetId].type as WidgetType, + ), + }; + + const { + widgets, + message, + } = (operation.fn as BlueprintOperationChildOperationsFn)( + canvasWidgets, + widgetId, + parentId, + widgetPropertyMaps, + ); + + // If something odd happens show the message related to the odd scenario + if (message) { + Toaster.show({ + text: message, + hideProgressBar: false, + variant: Variant.info, + }); + } + + // Flow returns to the usual from here. + return widgets; +} + +/** + * this saga traverse the tree till we get + * to MAIN_CONTAINER_WIDGET_ID while travesring, if we find + * any widget which has CHILD_OPERATION, we will call the fn in it + * + * @param parent + * @param newWidgetId + * @param widgets + * + * @returns { [widgetId: string]: FlattenedWidgetProps } + */ +export function* traverseTreeAndExecuteBlueprintChildOperations( + parent: FlattenedWidgetProps, + newWidgetId: string, + widgets: { [widgetId: string]: FlattenedWidgetProps }, +) { + let root = parent; + + while (root.parentId && root.widgetId !== MAIN_CONTAINER_WIDGET_ID) { + const parentConfig = { + ...(WidgetConfigResponse as any).config[root.type], + }; + + // find the blueprint with type CHILD_OPERATIONS + const blueprintChildOperation = get( + parentConfig, + "blueprint.operations", + [], + ).find( + (operation: BlueprintOperation) => + operation.type === BlueprintOperationTypes.CHILD_OPERATIONS, + ); + + // if there is blueprint operation with CHILD_OPERATION type, call the fn in it + if (blueprintChildOperation) { + const updatedWidgets: + | { [widgetId: string]: FlattenedWidgetProps } + | undefined = yield call( + executeWidgetBlueprintChildOperations, + blueprintChildOperation, + widgets, + newWidgetId, + root.widgetId, + ); + + if (updatedWidgets) { + widgets = updatedWidgets; + } + } + + root = widgets[root.parentId]; + } + + return widgets; +} diff --git a/app/client/src/sagas/WidgetBlueprintSagasEnums.ts b/app/client/src/sagas/WidgetBlueprintSagasEnums.ts new file mode 100644 index 0000000000..1285eccfa2 --- /dev/null +++ b/app/client/src/sagas/WidgetBlueprintSagasEnums.ts @@ -0,0 +1,5 @@ +export enum BlueprintOperationTypes { + MODIFY_PROPS = "MODIFY_PROPS", + ADD_ACTION = "ADD_ACTION", + CHILD_OPERATIONS = "CHILD_OPERATIONS", +} diff --git a/app/client/src/sagas/WidgetEnhancementHelpers.ts b/app/client/src/sagas/WidgetEnhancementHelpers.ts new file mode 100644 index 0000000000..4273a5e4ba --- /dev/null +++ b/app/client/src/sagas/WidgetEnhancementHelpers.ts @@ -0,0 +1,221 @@ +import { + MAIN_CONTAINER_WIDGET_ID, + WidgetType, +} from "constants/WidgetConstants"; +import { get, set } from "lodash"; +import WidgetConfigResponse from "mockResponses/WidgetConfigResponse"; +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; +import { select } from "redux-saga/effects"; +import { getWidgets } from "./selectors"; + +/* +TODO(abhinav/pawan): Write unit tests for the following functions +Note: +Signature for enhancements in WidgetConfigResponse is as follows: +enhancements: { + child: { + autocomplete: (parentProps: any) => Record>, + customJSControl: (parentProps: any) => string, + propertyUpdateHook: (parentProps: any, widgetName: string, propertyPath: string, propertyValue: string), + action: (parentProps: any, dynamicString: string, responseData?: any[]) => { actionString: string, dataToApply?: any[]}, + } +} +*/ + +// Enum which identifies the path in the enhancements for the +export enum WidgetEnhancementType { + WIDGET_ACTION = "child.action", + PROPERTY_UPDATE = "child.propertyUpdateHook", + CUSTOM_CONTROL = "child.customJSControl", + AUTOCOMPLETE = "child.autocomplete", + HIDE_EVALUATED_VALUE = "child.hideEvaluatedValue", +} + +function getParentWithEnhancementFn( + widgetId: string, + widgets: CanvasWidgetsReduxState, +) { + let widget = get(widgets, widgetId, undefined); + // While this widget has a parent + while (widget?.parentId) { + // Get parent widget props + const parent = get(widgets, widget.parentId, undefined); + // If parent has enhancements property + // enhancements property is a new widget property which tells us that + // the property pane, properties or actions of this widget or its children + // can be enhanced + + if (parent && parent.enhancements) { + return parent; + } + // If we didn't find any enhancements + // keep walking up the tree to find the parent which does + // if the parent doesn't have a parent stop walking the tree. + // also stop if the parent is the main container (Main container doesn't have enhancements) + if (parent?.parentId && parent.parentId !== MAIN_CONTAINER_WIDGET_ID) { + widget = get(widgets, widget.parentId, undefined); + + continue; + } + + return; + } +} + +export function getWidgetEnhancementFn( + type: WidgetType, + enhancementType: WidgetEnhancementType, +) { + // Get enhancements for the widget type from the config response + // Spread the config response so that we don't pollute the original + // configs + const { enhancements = {} } = { + ...(WidgetConfigResponse as any).config[type], + }; + return get(enhancements, enhancementType, undefined); +} + +// TODO(abhinav): Getting data from the tree may not be needed +// confirm this. +export const getPropsFromTree = ( + state: AppState, + widgetName?: string, +): unknown => { + // Get the evaluated data of this widget from the evaluations tree. + if (!widgetName) return; + + return get(state.evaluations.tree, widgetName, undefined); +}; + +export function* getChildWidgetEnhancementFn( + widgetId: string, + enhancementType: WidgetEnhancementType, +) { + // Get all widgets from the canvas + const widgets: CanvasWidgetsReduxState = yield select(getWidgets); + // Get the parent which wants to enhance this widget + const parentWithEnhancementFn = getParentWithEnhancementFn(widgetId, widgets); + // If such a parent is found + if (parentWithEnhancementFn) { + // Get the enhancement function based on the enhancementType + // from the configs + const enhancementFn = getWidgetEnhancementFn( + parentWithEnhancementFn.type, + enhancementType, + ); + // Get the parent's evaluated data from the evaluatedTree + const parentDataFromDataTree: unknown = yield select( + getPropsFromTree, + parentWithEnhancementFn.widgetName, + ); + if (parentDataFromDataTree) { + // Update the enhancement function by passing the widget data as the first parameter + return (...args: unknown[]) => + enhancementFn(parentDataFromDataTree, ...args); + } + } +} + +/** + * hook that returns parent with enhancments + * + * @param widgetId + * @returns + */ +export function useParentWithEnhancementFn(widgetId: string) { + const widgets: CanvasWidgetsReduxState = useSelector(getWidgets); + return getParentWithEnhancementFn(widgetId, widgets); +} + +export function useChildWidgetEnhancementFn( + widgetId: string, + enhancementType: WidgetEnhancementType, +) { + // Get all widgets from the canvas + const widgets: CanvasWidgetsReduxState = useSelector(getWidgets); + // Get the parent which wants to enhance this widget + const parentWithEnhancementFn = getParentWithEnhancementFn(widgetId, widgets); + // If such a parent is found + // Get the parent's evaluated data from the evaluatedTree + const parentDataFromDataTree: unknown = useSelector((state: AppState) => + getPropsFromTree(state, parentWithEnhancementFn?.widgetName), + ); + + if (parentWithEnhancementFn) { + // Get the enhancement function based on the enhancementType + // from the configs + const enhancementFn = getWidgetEnhancementFn( + parentWithEnhancementFn.type, + enhancementType, + ); + + if (parentDataFromDataTree && enhancementFn) { + // Update the enhancement function by passing the widget data as the first parameter + return (...args: unknown[]) => + enhancementFn(parentDataFromDataTree, ...args); + } + } +} + +type EnhancmentFns = { + propertyPaneEnhancmentFn: any; + autoCompleteEnhancementFn: any; + customJSControlEnhancementFn: any; + hideEvaluatedValueEnhancementFn: any; +}; + +export function useChildWidgetEnhancementFns(widgetId: string): EnhancmentFns { + const enhancmentFns = { + propertyPaneEnhancmentFn: undefined, + autoCompleteEnhancementFn: undefined, + customJSControlEnhancementFn: undefined, + hideEvaluatedValueEnhancementFn: undefined, + }; + + // Get all widgets from the canvas + const widgets: CanvasWidgetsReduxState = useSelector(getWidgets); + // Get the parent which wants to enhance this widget + const parentWithEnhancementFn = getParentWithEnhancementFn(widgetId, widgets); + // If such a parent is found + // Get the parent's evaluated data from the evaluatedTree + const parentDataFromDataTree: unknown = useSelector((state: AppState) => + getPropsFromTree(state, parentWithEnhancementFn?.widgetName), + ); + + if (parentWithEnhancementFn) { + // Get the enhancement function based on the enhancementType + // from the configs + const widgetEnhancmentFns = { + propertyPaneEnhancmentFn: getWidgetEnhancementFn( + parentWithEnhancementFn.type, + WidgetEnhancementType.PROPERTY_UPDATE, + ), + autoCompleteEnhancementFn: getWidgetEnhancementFn( + parentWithEnhancementFn.type, + WidgetEnhancementType.AUTOCOMPLETE, + ), + customJSControlEnhancementFn: getWidgetEnhancementFn( + parentWithEnhancementFn.type, + WidgetEnhancementType.CUSTOM_CONTROL, + ), + hideEvaluatedValueEnhancementFn: getWidgetEnhancementFn( + parentWithEnhancementFn.type, + WidgetEnhancementType.HIDE_EVALUATED_VALUE, + ), + }; + + Object.keys(widgetEnhancmentFns).map((key: string) => { + const enhancementFn = get(widgetEnhancmentFns, `${key}`); + + if (parentDataFromDataTree && enhancementFn) { + set(enhancmentFns, `${key}`, (...args: unknown[]) => + enhancementFn(parentDataFromDataTree, ...args), + ); + } + }); + } + + return enhancmentFns; +} diff --git a/app/client/src/sagas/WidgetOperationSagas.tsx b/app/client/src/sagas/WidgetOperationSagas.tsx index 88a0fb3b0a..dda9be5335 100644 --- a/app/client/src/sagas/WidgetOperationSagas.tsx +++ b/app/client/src/sagas/WidgetOperationSagas.tsx @@ -57,6 +57,7 @@ import WidgetFactory from "utils/WidgetFactory"; import { buildWidgetBlueprint, executeWidgetBlueprintOperations, + traverseTreeAndExecuteBlueprintChildOperations, } from "sagas/WidgetBlueprintSagas"; import { resetWidgetMetaProperty } from "actions/metaActions"; import { @@ -110,7 +111,12 @@ import { WIDGET_COPY, WIDGET_CUT, WIDGET_DELETE, + ERROR_WIDGET_COPY_NOT_ALLOWED, } from "constants/messages"; +import { + doesTriggerPathsContainPropertyPath, + handleSpecificCasesWhilePasting, +} from "./WidgetOperationUtils"; function* getChildWidgetProps( parent: FlattenedWidgetProps, @@ -243,25 +249,43 @@ function* generateChildWidgets( widget.widgetId, ); } + // Add the parentId prop to this widget widget.parentId = parent.widgetId; // Remove the blueprint from the widget (if any) // as blueprints are not useful beyond this point. delete widget.blueprint; + + // deleting propertyPaneEnchancements too as it shouldn't go in dsl because + // function can't be cloned into dsl + + // instead of passing whole enhancments function in widget props, we are just setting + // enhancments as true so that we know this widget contains enhancments + if ("enhancements" in widget) { + widget.enhancements = true; + } + return { widgetId: widget.widgetId, widgets }; } +/** + * this saga is called when we drop a widget on the canvas. + * + * @param addChildAction + */ export function* addChildSaga(addChildAction: ReduxAction) { try { const start = performance.now(); Toaster.clear(); + + // NOTE: widgetId here is the parentId of the dropped widget ( we should rename it to avoid confusion ) const { widgetId } = addChildAction.payload; // Get the current parent widget whose child will be the new widget. const stateParent: FlattenedWidgetProps = yield select(getWidget, widgetId); // const parent = Object.assign({}, stateParent); // Get all the widgets from the canvasWidgetsReducer const stateWidgets = yield select(getWidgets); - const widgets = Object.assign({}, stateWidgets); + let widgets = Object.assign({}, stateWidgets); // Generate the full WidgetProps of the widget to be added. const childWidgetPayload: GeneratedWidgetPayload = yield generateChildWidgets( stateParent, @@ -278,6 +302,21 @@ export function* addChildSaga(addChildAction: ReduxAction) { widgets[parent.widgetId] = parent; log.debug("add child computations took", performance.now() - start, "ms"); + + // some widgets need to update property of parent if the parent have CHILD_OPERATIONS + // so here we are traversing up the tree till we get to MAIN_CONTAINER_WIDGET_ID + // while travesring, if we find any widget which has CHILD_OPERATION, we will call the fn in it + const updatedWidgets: { + [widgetId: string]: FlattenedWidgetProps; + } = yield call( + traverseTreeAndExecuteBlueprintChildOperations, + parent, + addChildAction.payload.newWidgetId, + widgets, + ); + + widgets = updatedWidgets; + yield put({ type: ReduxActionTypes.WIDGET_CHILD_ADDED, payload: { @@ -286,6 +325,9 @@ export function* addChildSaga(addChildAction: ReduxAction) { }, }); yield put(updateAndSaveLayout(widgets)); + + // go up till MAIN_CONTAINER, if there is a operation CHILD_OPERATIONS IN ANY PARENT, + // call execute } catch (error) { yield put({ type: ReduxActionErrorTypes.WIDGET_OPERATION_ERROR, @@ -402,6 +444,10 @@ export function* deleteSaga(deleteAction: ReduxAction) { if (!widgetId) { const selectedWidget = yield select(getSelectedWidget); if (!selectedWidget) return; + + // if widget is not deletable, don't don anything + if (selectedWidget.isDeletable === false) return false; + widgetId = selectedWidget.widgetId; parentId = selectedWidget.parentId; } @@ -835,6 +881,7 @@ function* setWidgetDynamicPropertySaga( function getPropertiesToUpdate( widget: WidgetProps, updates: Record, + triggerPaths?: string[], ): { propertyUpdates: Record; dynamicTriggerPathList: DynamicPath[]; @@ -869,11 +916,17 @@ function getPropertiesToUpdate( } // Check if the path is a of a dynamic trigger property - const isTriggerProperty = isPropertyATriggerPath( + let isTriggerProperty = isPropertyATriggerPath( widgetWithUpdates, propertyPath, ); + isTriggerProperty = doesTriggerPathsContainPropertyPath( + isTriggerProperty, + propertyPath, + triggerPaths, + ); + // If it is a trigger property, it will go in a different list than the general // dynamicBindingPathList. if (isTriggerProperty) { @@ -912,7 +965,7 @@ function* batchUpdateWidgetPropertySaga( // Handling the case where sometimes widget id is not passed through here return; } - const { modify = {}, remove = [] } = updates; + const { modify = {}, remove = [], triggerPaths } = updates; const stateWidget: WidgetProps = yield select(getWidget, widgetId); @@ -926,7 +979,7 @@ function* batchUpdateWidgetPropertySaga( propertyUpdates, dynamicTriggerPathList, dynamicBindingPathList, - } = getPropertiesToUpdate(widget, modify); + } = getPropertiesToUpdate(widget, modify, triggerPaths); // We loop over all updates Object.entries(propertyUpdates).forEach( @@ -1091,6 +1144,13 @@ function* createWidgetCopy() { ); } +/** + * copy here actually means saving a JSON in local storage + * so when a user hits copy on a selected widget, we save widget in localStorage + * + * @param action + * @returns + */ function* copyWidgetSaga(action: ReduxAction<{ isShortcut: boolean }>) { const selectedWidget = yield select(getSelectedWidget); if (!selectedWidget) { @@ -1101,6 +1161,15 @@ function* copyWidgetSaga(action: ReduxAction<{ isShortcut: boolean }>) { return; } + if (selectedWidget.disallowCopy === true) { + Toaster.show({ + text: createMessage(ERROR_WIDGET_COPY_NOT_ALLOWED), + variant: Variant.info, + }); + + return; + } + const saveResult = yield createWidgetCopy(); const eventName = action.payload.isShortcut @@ -1157,6 +1226,9 @@ function getNextWidgetName( ]); } +/** + * this saga create a new widget from the copied one to store + */ function* pasteWidgetSaga() { const copiedWidgets: { widgetId: string; @@ -1178,7 +1250,20 @@ function* pasteWidgetSaga() { const stateWidgets = yield select(getWidgets); let widgets = { ...stateWidgets }; - const selectedWidget = yield select(getSelectedWidget); + let selectedWidget = yield select(getSelectedWidget); + + // when list widget is selected, if the user is pasting, we want it to be pasted in the template + // which is first children of list widget + if (selectedWidget?.type === WidgetTypes.LIST_WIDGET) { + const childrenIds: string[] = yield call( + getWidgetChildren, + selectedWidget.children[0], + ); + const firstChildId = childrenIds[0]; + + selectedWidget = yield select(getWidget, firstChildId); + } + let newWidgetParentId = MAIN_CONTAINER_WIDGET_ID; let parentWidget = widgets[MAIN_CONTAINER_WIDGET_ID]; @@ -1251,6 +1336,7 @@ function* pasteWidgetSaga() { // Get a flat list of all the widgets to be updated const widgetList = copiedWidgets.list; const widgetIdMap: Record = {}; + const widgetNameMap: Record = {}; const newWidgetList: FlattenedWidgetProps[] = []; let newWidgetId: string = copiedWidget.widgetId; // Generate new widgetIds for the flat list of all the widgets to be updated @@ -1260,12 +1346,16 @@ function* pasteWidgetSaga() { newWidget.widgetId = generateReactKey(); // Add the new widget id so that it maps the previous widget id widgetIdMap[widget.widgetId] = newWidget.widgetId; + // Add the new widget to the list newWidgetList.push(newWidget); }); // For each of the new widgets generated - newWidgetList.forEach((widget) => { + for (let i = 0; i < newWidgetList.length; i++) { + const widget = newWidgetList[i]; + const oldWidgetName = widget.widgetName; + // Update the children widgetIds if it has children if (widget.children && widget.children.length > 0) { widget.children.forEach((childWidgetId: string, index: number) => { @@ -1327,6 +1417,8 @@ function* pasteWidgetSaga() { widget.widgetName = getNextWidgetName(widgets, widget.type, evalTree); } + widgetNameMap[oldWidgetName] = widget.widgetName; + // If it is the copied widget, update position properties if (widget.widgetId === widgetIdMap[copiedWidget.widgetId]) { newWidgetId = widget.widgetId; @@ -1385,11 +1477,27 @@ function* pasteWidgetSaga() { widget.widgetName = getNextWidgetName(widgets, widget.type, evalTree); // Add the new widget to the canvas widgets widgets[widget.widgetId] = widget; - }); + } + + // 1. updating template in the copied widget and deleting old template associations + // 2. updating dynamicBindingPathList in the copied grid widget + for (let i = 0; i < newWidgetList.length; i++) { + const widget = newWidgetList[i]; + + widgets = handleSpecificCasesWhilePasting( + widget, + widgets, + widgetNameMap, + newWidgetList, + ); + } // save the new DSL yield put(updateAndSaveLayout(widgets)); + // hydrating enhancements map after save layout so that enhancement map + // for newly copied widget is hydrated + // Flash the newly pasted widget once the DSL is re-rendered setTimeout(() => flashElementById(newWidgetId), 100); yield put({ diff --git a/app/client/src/sagas/WidgetOperationUtils.test.ts b/app/client/src/sagas/WidgetOperationUtils.test.ts new file mode 100644 index 0000000000..56bb2fa02e --- /dev/null +++ b/app/client/src/sagas/WidgetOperationUtils.test.ts @@ -0,0 +1,207 @@ +import { get } from "lodash"; +import { + handleIfParentIsListWidgetWhilePasting, + handleSpecificCasesWhilePasting, + doesTriggerPathsContainPropertyPath, +} from "./WidgetOperationUtils"; + +describe("WidgetOperationSaga", () => { + it("should returns widgets after executing handleIfParentIsListWidgetWhilePasting", async () => { + expect( + doesTriggerPathsContainPropertyPath(false, "trigger-path-1", [ + "trigger-path-1", + ]), + ).toBe(true); + + expect( + doesTriggerPathsContainPropertyPath(false, "trigger-path-1", [ + "trigger-path-2", + ]), + ).toBe(false); + + expect( + doesTriggerPathsContainPropertyPath(true, "trigger-path-1", [ + "trigger-path-2", + ]), + ).toBe(true); + }); + + it("should returns widgets after executing handleIfParentIsListWidgetWhilePasting", async () => { + const result = handleIfParentIsListWidgetWhilePasting( + { + widgetId: "text1", + type: "TEXT_WIDGET", + widgetName: "Text1", + parentId: "list1", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + text: "{{currentItem.text}}", + version: 16, + disablePropertyPane: false, + }, + { + list1: { + widgetId: "list1", + type: "LIST_WIDGET", + widgetName: "List1", + parentId: "0", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + 0: { + image: "", + defaultImage: "", + widgetId: "0", + type: "CANVAS_WIDGET", + widgetName: "MainContainer", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + }, + ); + + expect(result.list1.template["Text1"].text).toStrictEqual( + "{{List1.items.map((currentItem) => currentItem.text)}}", + ); + expect(get(result, "list1.dynamicBindingPathList.0.key")).toStrictEqual( + "template.Text1.text", + ); + }); + + it("should returns widgets after executing handleSpecificCasesWhilePasting", async () => { + const result = handleSpecificCasesWhilePasting( + { + widgetId: "text2", + type: "TEXT_WIDGET", + widgetName: "Text2", + parentId: "list2", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + text: "{{currentItem.text}}", + version: 16, + disablePropertyPane: false, + }, + { + list1: { + widgetId: "list1", + type: "LIST_WIDGET", + widgetName: "List1", + parentId: "0", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + 0: { + image: "", + defaultImage: "", + widgetId: "0", + type: "CANVAS_WIDGET", + widgetName: "MainContainer", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + list2: { + widgetId: "list2", + type: "LIST_WIDGET", + widgetName: "List2", + parentId: "0", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + }, + { + List1: "List2", + }, + [ + { + widgetId: "list2", + type: "LIST_WIDGET", + widgetName: "List2", + parentId: "0", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + template: {}, + }, + ], + ); + + expect(result.list2.template["Text2"].text).toStrictEqual( + "{{List2.items.map((currentItem) => currentItem.text)}}", + ); + expect(get(result, "list2.dynamicBindingPathList.0.key")).toStrictEqual( + "template.Text2.text", + ); + }); +}); diff --git a/app/client/src/sagas/WidgetOperationUtils.ts b/app/client/src/sagas/WidgetOperationUtils.ts new file mode 100644 index 0000000000..38e11ed8bc --- /dev/null +++ b/app/client/src/sagas/WidgetOperationUtils.ts @@ -0,0 +1,177 @@ +import { + MAIN_CONTAINER_WIDGET_ID, + WidgetTypes, +} from "constants/WidgetConstants"; +import { cloneDeep, get, isString } from "lodash"; +import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; +import { getDynamicBindings } from "utils/DynamicBindingUtils"; + +/** + * checks if triggerpaths contains property path passed + * + * @param isTriggerProperty + * @param propertyPath + * @param triggerPaths + * @returns + */ +export const doesTriggerPathsContainPropertyPath = ( + isTriggerProperty: boolean, + propertyPath: string, + triggerPaths?: string[], +) => { + if (!isTriggerProperty) { + if ( + triggerPaths && + triggerPaths.length && + triggerPaths.includes(propertyPath) + ) { + return true; + } + } + + return isTriggerProperty; +}; + +/** + * + * check if copied widget is being pasted in list widget, + * if yes, change all keys in template of list widget and + * update dynamicBindingPathList of ListWidget + * + * updates in list widget : + * 1. `dynamicBindingPathList` + * 2. `template` + * + * @param widget + * @param widgets + */ +export const handleIfParentIsListWidgetWhilePasting = ( + widget: FlattenedWidgetProps, + widgets: { [widgetId: string]: FlattenedWidgetProps }, +): { [widgetId: string]: FlattenedWidgetProps } => { + let root = get(widgets, `${widget.parentId}`); + + while (root.parentId && root.widgetId !== MAIN_CONTAINER_WIDGET_ID) { + if (root.type === WidgetTypes.LIST_WIDGET) { + const listWidget = root; + const currentWidget = cloneDeep(widget); + let template = get(listWidget, "template", {}); + const dynamicBindingPathList: any[] = get( + listWidget, + "dynamicBindingPathList", + [], + ).slice(); + + // iterating over each keys of the new createdWidget checking if value contains currentItem + const keys = Object.keys(currentWidget); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + let value = currentWidget[key]; + + if (isString(value) && value.indexOf("currentItem") > -1) { + const { jsSnippets } = getDynamicBindings(value); + + const modifiedAction = jsSnippets.reduce( + (prev: string, next: string) => { + return prev + `${next}`; + }, + "", + ); + + value = `{{${listWidget.widgetName}.items.map((currentItem) => ${modifiedAction})}}`; + + currentWidget[key] = value; + + dynamicBindingPathList.push({ + key: `template.${currentWidget.widgetName}.${key}`, + }); + } + } + + template = { + ...template, + [currentWidget.widgetName]: currentWidget, + }; + + // now we have updated `dynamicBindingPathList` and updatedTemplate + // we need to update it the list widget + widgets[listWidget.widgetId] = { + ...listWidget, + template, + dynamicBindingPathList, + }; + } + + root = widgets[root.parentId]; + } + + return widgets; +}; + +/** + * this saga handles special cases when pasting the widget + * + * for e.g - when the list widget is being copied, we want to update template of list widget + * with new widgets name + * + * @param widget + * @param widgets + * @param widgetNameMap + * @param newWidgetList + * @returns + */ +export const handleSpecificCasesWhilePasting = ( + widget: FlattenedWidgetProps, + widgets: { [widgetId: string]: FlattenedWidgetProps }, + widgetNameMap: Record, + newWidgetList: FlattenedWidgetProps[], +) => { + // this is the case when whole list widget is copied and pasted + if (widget.type === WidgetTypes.LIST_WIDGET) { + Object.keys(widget.template).map((widgetName) => { + const oldWidgetName = widgetName; + const newWidgetName = widgetNameMap[oldWidgetName]; + + const newWidget = newWidgetList.find( + (w: any) => w.widgetName === newWidgetName, + ); + + if (newWidget) { + newWidget.widgetName = newWidgetName; + + if (widgetName === oldWidgetName) { + widget.template[newWidgetName] = { + ...widget.template[oldWidgetName], + widgetId: newWidget.widgetId, + widgetName: newWidget.widgetName, + }; + + delete widget.template[oldWidgetName]; + } + } + + // updating dynamicBindingPath in copied widget if the copied widge thas reference to oldWidgetNames + widget.dynamicBindingPathList = (widget.dynamicBindingPathList || []).map( + (path: any) => { + if (path.key.startsWith(`template.${oldWidgetName}`)) { + return { + key: path.key.replace( + `template.${oldWidgetName}`, + `template.${newWidgetName}`, + ), + }; + } + + return path; + }, + ); + }); + + widgets[widget.widgetId] = widget; + } + + widgets = handleIfParentIsListWidgetWhilePasting(widget, widgets); + + return widgets; +}; diff --git a/app/client/src/selectors/propertyPaneSelectors.tsx b/app/client/src/selectors/propertyPaneSelectors.tsx index c9a3cf955a..055e7dd560 100644 --- a/app/client/src/selectors/propertyPaneSelectors.tsx +++ b/app/client/src/selectors/propertyPaneSelectors.tsx @@ -1,12 +1,13 @@ -import { createSelector } from "reselect"; +import { find, get } from "lodash"; import { AppState } from "reducers"; +import { createSelector } from "reselect"; + +import { WidgetProps } from "widgets/BaseWidget"; +import { getCanvasWidgets } from "./entitiesSelector"; +import { getDataTree } from "selectors/dataTreeSelectors"; +import { DataTree, DataTreeWidget } from "entities/DataTree/dataTreeFactory"; import { PropertyPaneReduxState } from "reducers/uiReducers/propertyPaneReducer"; import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; -import { WidgetProps } from "widgets/BaseWidget"; -import { DataTree, DataTreeWidget } from "entities/DataTree/dataTreeFactory"; -import { find } from "lodash"; -import { getDataTree } from "selectors/dataTreeSelectors"; -import { getCanvasWidgets } from "./entitiesSelector"; const getPropertyPaneState = (state: AppState): PropertyPaneReduxState => state.ui.propertyPane; @@ -23,7 +24,7 @@ export const getCurrentWidgetProperties = createSelector( widgets: CanvasWidgetsReduxState, pane: PropertyPaneReduxState, ): WidgetProps | undefined => { - return pane.widgetId && widgets ? widgets[pane.widgetId] : undefined; + return get(widgets, `${pane.widgetId}`); }, ); @@ -39,12 +40,14 @@ export const getWidgetPropsForPropertyPane = createSelector( widgetId: widget.widgetId, }) as DataTreeWidget; const widgetProperties = { ...widget }; + if (evaluatedWidget) { if (evaluatedWidget.evaluatedValues) { widgetProperties.evaluatedValues = { ...evaluatedWidget.evaluatedValues, }; } + if (evaluatedWidget.invalidProps) { const { invalidProps, validationMessages } = evaluatedWidget; widgetProperties.invalidProps = invalidProps; diff --git a/app/client/src/utils/PropertyControlFactory.tsx b/app/client/src/utils/PropertyControlFactory.tsx index e94ef1c0cc..2a1a804ab4 100644 --- a/app/client/src/utils/PropertyControlFactory.tsx +++ b/app/client/src/utils/PropertyControlFactory.tsx @@ -22,6 +22,7 @@ class PropertyControlFactory { preferEditor: boolean, customEditor?: string, additionalAutoComplete?: Record>, + hideEvaluatedValue?: boolean, ): JSX.Element { let controlBuilder = this.controlMap.get(controlData.controlType); if (preferEditor) { @@ -35,7 +36,9 @@ class PropertyControlFactory { key: controlData.id, customJSControl: customEditor, additionalAutoComplete, + hideEvaluatedValue, }; + const control = controlBuilder.buildPropertyControl(controlProps); return control; } else { diff --git a/app/client/src/utils/WidgetPropsUtils.tsx b/app/client/src/utils/WidgetPropsUtils.tsx index 56c97aa001..cd3247903a 100644 --- a/app/client/src/utils/WidgetPropsUtils.tsx +++ b/app/client/src/utils/WidgetPropsUtils.tsx @@ -643,6 +643,7 @@ export const widgetOperationParams = ( columns: widget.columns, rows: widget.rows, }; + return { operation: WidgetOperations.ADD_CHILD, widgetId: parentWidgetId, diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx index 9a27a44666..797f986691 100644 --- a/app/client/src/utils/WidgetRegistry.tsx +++ b/app/client/src/utils/WidgetRegistry.tsx @@ -90,6 +90,12 @@ import SkeletonWidget, { ProfiledSkeletonWidget, SkeletonWidgetProps, } from "../widgets/SkeletonWidget"; + +import ListWidget, { + ListWidgetProps, + ProfiledListWidget, +} from "widgets/ListWidget/ListWidget"; + import SwitchWidget, { ProfiledSwitchWidget, SwitchWidgetProps, @@ -407,7 +413,18 @@ export default class WidgetBuilderRegistry { SkeletonWidget.getMetaPropertiesMap(), SkeletonWidget.getPropertyPaneConfig(), ); - + WidgetFactory.registerWidgetBuilder( + WidgetTypes.LIST_WIDGET, + { + buildWidget(widgetProps: ListWidgetProps): JSX.Element { + return ; + }, + }, + ListWidget.getDerivedPropertiesMap(), + ListWidget.getDefaultPropertiesMap(), + ListWidget.getMetaPropertiesMap(), + ListWidget.getPropertyPaneConfig(), + ); WidgetFactory.registerWidgetBuilder( WidgetTypes.MODAL_WIDGET, { diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.test.ts b/app/client/src/utils/autocomplete/EntityDefinitions.test.ts new file mode 100644 index 0000000000..02d32626f1 --- /dev/null +++ b/app/client/src/utils/autocomplete/EntityDefinitions.test.ts @@ -0,0 +1,47 @@ +import { entityDefinitions } from "utils/autocomplete/EntityDefinitions"; +import { WidgetTypes } from "../../constants/WidgetConstants"; + +describe("EntityDefinitions", () => { + it("it tests list widget selectRow", () => { + const listWidgetProps = { + widgetId: "yolo", + widgetName: "List1", + parentId: "123", + renderMode: "CANVAS", + text: "yo", + type: WidgetTypes.INPUT_WIDGET, + parentColumnSpace: 1, + parentRowSpace: 2, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 2, + isLoading: false, + version: 1, + selectedItem: { + id: 1, + name: "Some random name", + }, + }; + const listWidgetEntityDefinitions = entityDefinitions.LIST_WIDGET( + listWidgetProps, + ); + + const output = { + "!doc": + "Containers are used to group widgets together to form logical higher order widgets. Containers let you organize your page better and move all the widgets inside them together.", + "!url": "https://docs.appsmith.com/widget-reference/list", + backgroundColor: { + "!type": "string", + "!url": "https://docs.appsmith.com/widget-reference/how-to-use-widgets", + }, + isVisible: { + "!type": "bool", + "!doc": "Boolean value indicating if the widget is in visible state", + }, + selectedItem: { id: "number", name: "string" }, + }; + + expect(listWidgetEntityDefinitions).toStrictEqual(output); + }); +}); diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index af706aec92..33bbfacb48 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -224,6 +224,17 @@ export const entityDefinitions = { isDisabled: "bool", uploadedFileUrls: "string", }, + LIST_WIDGET: (widget: any) => ({ + "!doc": + "Containers are used to group widgets together to form logical higher order widgets. Containers let you organize your page better and move all the widgets inside them together.", + "!url": "https://docs.appsmith.com/widget-reference/list", + backgroundColor: { + "!type": "string", + "!url": "https://docs.appsmith.com/widget-reference/how-to-use-widgets", + }, + isVisible: isVisible, + selectedItem: generateTypeDef(widget.selectedItem), + }), }; export const GLOBAL_DEFS = { diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx index 9b3da0b8e9..6a9f52bf05 100644 --- a/app/client/src/utils/helpers.tsx +++ b/app/client/src/utils/helpers.tsx @@ -195,6 +195,16 @@ export const isNameValid = ( ); }; +/* + * Filter out empty items from an array + * for e.g - ['Pawan', undefined, 'Hetu'] --> ['Pawan', 'Hetu'] + * + * @param array any[] + */ +export const removeFalsyEntries = (arr: any[]): any[] => { + return arr.filter(Boolean); +}; + /** * checks if variable passed is of type string or not * diff --git a/app/client/src/widgets/BaseWidget.tsx b/app/client/src/widgets/BaseWidget.tsx index 4bc1a15ea2..2572ad2719 100644 --- a/app/client/src/widgets/BaseWidget.tsx +++ b/app/client/src/widgets/BaseWidget.tsx @@ -165,6 +165,12 @@ abstract class BaseWidget< return this.getWidgetView(); } + /** + * this function is responsive for making the widget resizable. + * A widget can be made by non-resizable by passing resizeDisabled prop. + * + * @param content + */ makeResizable(content: ReactNode) { return ( ); } + + /** + * this functions wraps the widget in a component that shows a setting control at the top right + * which gets shown on hover. A widget can enable/disable this by setting `disablePropertyPane` prop + * + * @param content + * @param showControls + */ showWidgetName(content: ReactNode, showControls = false) { return ( - - + <> + {!this.props.disablePropertyPane && ( + + )} {content} - + ); } + /** + * wraps the widget in a draggable component. + * Note: widget drag can be disabled by setting `dragDisabled` prop to true + * + * @param content + */ makeDraggable(content: ReactNode) { return {content}; } @@ -213,11 +235,12 @@ abstract class BaseWidget< private getWidgetView(): ReactNode { let content: ReactNode; + switch (this.props.renderMode) { case RenderModes.CANVAS: content = this.getCanvasView(); if (!this.props.detachFromLayout) { - content = this.makeResizable(content); + if (!this.props.resizeDisabled) content = this.makeResizable(content); content = this.showWidgetName(content); content = this.makeDraggable(content); content = this.makePositioned(content); @@ -257,17 +280,22 @@ abstract class BaseWidget< ); } + /** + * generates styles that positions the widget + */ private getPositionStyle(): BaseStyle { const { componentHeight, componentWidth } = this.getComponentDimensions(); + return { positionType: PositionTypes.ABSOLUTE, componentHeight, componentWidth, yPosition: - this.props.topRow * this.props.parentRowSpace + CONTAINER_GRID_PADDING, + this.props.topRow * this.props.parentRowSpace + + (this.props.noContainerOffset ? 0 : CONTAINER_GRID_PADDING), xPosition: this.props.leftColumn * this.props.parentColumnSpace + - CONTAINER_GRID_PADDING, + (this.props.noContainerOffset ? 0 : CONTAINER_GRID_PADDING), xPositionUnit: CSSUnits.PIXEL, yPositionUnit: CSSUnits.PIXEL, }; @@ -281,6 +309,11 @@ abstract class BaseWidget< leftColumn: 0, isLoading: false, renderMode: RenderModes.CANVAS, + dragDisabled: false, + dropDisabled: false, + isDeletable: true, + resizeDisabled: false, + disablePropertyPane: false, }; } @@ -328,6 +361,7 @@ export interface WidgetPositionProps extends WidgetRowCols { // Examples: MainContainer is detached from layout, // MODAL_WIDGET is also detached from layout. detachFromLayout?: boolean; + noContainerOffset?: boolean; // This won't offset the child in parent } export const WIDGET_STATIC_PROPS = { @@ -345,6 +379,7 @@ export const WIDGET_STATIC_PROPS = { parentId: true, renderMode: true, detachFromLayout: true, + noContainerOffset: false, }; export interface WidgetDisplayProps { @@ -373,6 +408,7 @@ export interface WidgetCardProps { type: WidgetType; key?: string; widgetCardName: string; + isBeta?: boolean; } export const WidgetOperations = { diff --git a/app/client/src/widgets/ButtonWidget.tsx b/app/client/src/widgets/ButtonWidget.tsx index 5adb5df62e..47c838354f 100644 --- a/app/client/src/widgets/ButtonWidget.tsx +++ b/app/client/src/widgets/ButtonWidget.tsx @@ -113,7 +113,9 @@ class ButtonWidget extends BaseWidget { }; } - onButtonClick() { + onButtonClick(e: React.MouseEvent) { + e.stopPropagation(); + if (this.props.onClick) { this.setState({ isLoading: true, diff --git a/app/client/src/widgets/CanvasWidget.tsx b/app/client/src/widgets/CanvasWidget.tsx index d5fef8650e..04708289aa 100644 --- a/app/client/src/widgets/CanvasWidget.tsx +++ b/app/client/src/widgets/CanvasWidget.tsx @@ -6,6 +6,7 @@ import DropTargetComponent from "components/editorComponents/DropTargetComponent import { getCanvasSnapRows } from "utils/WidgetPropsUtils"; import { getCanvasClassName } from "utils/generators"; import * as Sentry from "@sentry/react"; +import WidgetFactory from "utils/WidgetFactory"; class CanvasWidget extends ContainerWidget { static getPropertyPaneConfig() { @@ -39,12 +40,30 @@ class CanvasWidget extends ContainerWidget { ); } + renderChildWidget(childWidgetData: WidgetProps): React.ReactNode { + if (!childWidgetData) return null; + // For now, isVisible prop defines whether to render a detached widget + if (childWidgetData.detachFromLayout && !childWidgetData.isVisible) { + return null; + } + const snapSpaces = this.getSnapSpaces(); + + childWidgetData.parentColumnSpace = snapSpaces.snapColumnSpace; + childWidgetData.parentRowSpace = snapSpaces.snapRowSpace; + if (this.props.noPad) childWidgetData.noContainerOffset = true; + childWidgetData.parentId = this.props.widgetId; + + return WidgetFactory.createWidget(childWidgetData, this.props.renderMode); + } + getPageView() { + let height = 0; const snapRows = getCanvasSnapRows( this.props.bottomRow, this.props.canExtend, ); - const height = snapRows * GridDefaults.DEFAULT_GRID_ROW_HEIGHT; + height = snapRows * GridDefaults.DEFAULT_GRID_ROW_HEIGHT; + const style: CSSProperties = { width: "100%", height: `${height}px`, @@ -61,6 +80,7 @@ class CanvasWidget extends ContainerWidget { } getCanvasView() { + if (this.props.dropDisabled) return this.getPageView(); return this.renderAsDropTarget(); } } diff --git a/app/client/src/widgets/ContainerWidget.tsx b/app/client/src/widgets/ContainerWidget.tsx index 39667e11cb..7fbca6bdfd 100644 --- a/app/client/src/widgets/ContainerWidget.tsx +++ b/app/client/src/widgets/ContainerWidget.tsx @@ -1,19 +1,18 @@ import React from "react"; -import _ from "lodash"; +import * as Sentry from "@sentry/react"; +import { map, sortBy, compact } from "lodash"; -import ContainerComponent, { - ContainerStyle, -} from "components/designSystems/appsmith/ContainerComponent"; -import { WidgetType, WidgetTypes } from "constants/WidgetConstants"; -import WidgetFactory from "utils/WidgetFactory"; import { GridDefaults, CONTAINER_GRID_PADDING, WIDGET_PADDING, } from "constants/WidgetConstants"; - +import WidgetFactory from "utils/WidgetFactory"; +import ContainerComponent, { + ContainerStyle, +} from "components/designSystems/appsmith/ContainerComponent"; +import { WidgetType, WidgetTypes } from "constants/WidgetConstants"; import BaseWidget, { WidgetProps, WidgetState } from "./BaseWidget"; -import * as Sentry from "@sentry/react"; import { VALIDATION_TYPES } from "constants/WidgetValidation"; class ContainerWidget extends BaseWidget< @@ -64,11 +63,15 @@ class ContainerWidget extends BaseWidget< getSnapSpaces = () => { const { componentWidth } = this.getComponentDimensions(); + const padding = (CONTAINER_GRID_PADDING + WIDGET_PADDING) * 2; + let width = componentWidth; + if (!this.props.noPad) width -= padding; + else width -= WIDGET_PADDING * 2; + return { snapRowSpace: GridDefaults.DEFAULT_GRID_ROW_HEIGHT, snapColumnSpace: componentWidth - ? (componentWidth - (CONTAINER_GRID_PADDING + WIDGET_PADDING) * 2) / - GridDefaults.DEFAULT_GRID_COLUMNS + ? width / GridDefaults.DEFAULT_GRID_COLUMNS : 0, }; }; @@ -79,24 +82,16 @@ class ContainerWidget extends BaseWidget< return null; } - const snapSpaces = this.getSnapSpaces(); const { componentWidth, componentHeight } = this.getComponentDimensions(); - if (childWidgetData.type !== WidgetTypes.CANVAS_WIDGET) { - childWidgetData.parentColumnSpace = snapSpaces.snapColumnSpace; - childWidgetData.parentRowSpace = snapSpaces.snapRowSpace; - } else { - // This is for the detached child like the default CANVAS_WIDGET child - - childWidgetData.rightColumn = componentWidth; - childWidgetData.bottomRow = this.props.shouldScrollContents - ? childWidgetData.bottomRow - : componentHeight; - childWidgetData.minHeight = componentHeight; - childWidgetData.isVisible = this.props.isVisible; - childWidgetData.shouldScrollContents = false; - childWidgetData.canExtend = this.props.shouldScrollContents; - } + childWidgetData.rightColumn = componentWidth; + childWidgetData.bottomRow = this.props.shouldScrollContents + ? childWidgetData.bottomRow + : componentHeight; + childWidgetData.minHeight = componentHeight; + childWidgetData.isVisible = this.props.isVisible; + childWidgetData.shouldScrollContents = false; + childWidgetData.canExtend = this.props.shouldScrollContents; childWidgetData.parentId = this.props.widgetId; @@ -104,11 +99,11 @@ class ContainerWidget extends BaseWidget< } renderChildren = () => { - return _.map( + return map( // sort by row so stacking context is correct // TODO(abhinav): This is hacky. The stacking context should increase for widgets rendered top to bottom, always. // Figure out a way in which the stacking context is consistent. - _.sortBy(_.compact(this.props.children), (child) => child.topRow), + sortBy(compact(this.props.children), (child) => child.topRow), this.renderChildWidget, ); }; @@ -135,6 +130,7 @@ export interface ContainerWidgetProps children?: T[]; containerStyle?: ContainerStyle; shouldScrollContents?: boolean; + noPad?: boolean; } export default ContainerWidget; diff --git a/app/client/src/widgets/ListWidget/ListComponent.tsx b/app/client/src/widgets/ListWidget/ListComponent.tsx new file mode 100644 index 0000000000..d81b933351 --- /dev/null +++ b/app/client/src/widgets/ListWidget/ListComponent.tsx @@ -0,0 +1,67 @@ +import { Color } from "constants/Colors"; +import styled from "styled-components"; +import React, { RefObject, ReactNode, useMemo } from "react"; + +import { ListWidgetProps } from "./ListWidget"; +import { WidgetProps } from "widgets/BaseWidget"; +import { generateClassName, getCanvasClassName } from "utils/generators"; +import { ComponentProps } from "components/designSystems/appsmith/BaseComponent"; +import { getBorderCSSShorthand } from "constants/DefaultTheme"; + +interface GridComponentProps extends ComponentProps { + children?: ReactNode; + shouldScrollContents?: boolean; + backgroundColor?: Color; + items: Array>; + hasPagination?: boolean; +} + +const GridContainer = styled.div` + height: 100%; + width: 100%; + position: relative; + background: ${(props) => props.backgroundColor}; +`; + +const ScrollableCanvasWrapper = styled.div< + ListWidgetProps & { + ref: RefObject; + } +>` + width: 100%; + height: 100%; +`; + +const ListComponent = (props: GridComponentProps) => { + // using memoized class name + const scrollableCanvasClassName = useMemo(() => { + return `${ + props.shouldScrollContents ? `${getCanvasClassName()}` : "" + } ${generateClassName(props.widgetId)}`; + }, [props.widgetId]); + + return ( + + + {props.children} + + + ); +}; + +export const ListComponentEmpty = styled.div` + height: 100%; + width: 100%; + position: relative; + background: white; + display: flex; + align-items: center; + justify-content: center; + font-family: Verdana, sans; + font-size: 10px; + text-anchor: middle; + color: rgb(102, 102, 102); + border: ${(props) => getBorderCSSShorthand(props.theme.borders[2])}; +`; + +export default ListComponent; diff --git a/app/client/src/widgets/ListWidget/ListPagination.tsx b/app/client/src/widgets/ListWidget/ListPagination.tsx new file mode 100644 index 0000000000..1f9947ca26 --- /dev/null +++ b/app/client/src/widgets/ListWidget/ListPagination.tsx @@ -0,0 +1,326 @@ +import React from "react"; +import Pagination from "rc-pagination"; +import styled from "styled-components"; + +const locale = { + // Options.jsx + items_per_page: "/ page", + jump_to: "Go to", + jump_to_confirm: "confirm", + page: "", + // Pagination.jsx + prev_page: "Previous Page", + next_page: "Next Page", + prev_5: "Previous 5 Pages", + next_5: "Next 5 Pages", + prev_3: "Previous 3 Pages", + next_3: "Next 3 Pages", +}; + +const StyledPagination = styled(Pagination)<{ + disabled?: boolean; +}>` + margin: 0 auto; + padding: 0; + font-size: 14px; + display: flex; + justify-content: center; + position: absolute; + bottom: 4px; + left: 0; + right: 0; + pointer-events: ${(props) => (props.disabled ? "none" : "all")}; + opacity: ${(props) => (props.disabled ? "0.4" : "1")}; + + .rc-pagination::after { + display: block; + clear: both; + height: 0; + overflow: hidden; + visibility: hidden; + content: " "; + } + .rc-pagination-total-text { + display: inline-block; + height: 28px; + margin-right: 8px; + line-height: 26px; + vertical-align: middle; + } + .rc-pagination-item { + display: inline-block; + min-width: 28px; + height: 28px; + margin-right: 8px; + font-family: Arial; + line-height: 26px; + text-align: center; + vertical-align: middle; + list-style: none; + background-color: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 2px; + outline: 0; + cursor: pointer; + user-select: none; + } + .rc-pagination-item a { + display: block; + padding: 0 6px; + color: rgba(0, 0, 0, 0.85); + transition: none; + } + .rc-pagination-item a:hover { + text-decoration: none; + } + .rc-pagination-item:focus, + .rc-pagination-item:hover { + border-color: #1890ff; + transition: all 0.3s; + } + .rc-pagination-item:focus a, + .rc-pagination-item:hover a { + color: #1890ff; + } + .rc-pagination-item-active { + font-weight: 500; + background: #ffffff; + border-color: #1890ff; + } + .rc-pagination-item-active a { + color: #1890ff; + } + .rc-pagination-item-active:focus, + .rc-pagination-item-active:hover { + border-color: #40a9ff; + } + .rc-pagination-item-active:focus a, + .rc-pagination-item-active:hover a { + color: #40a9ff; + } + .rc-pagination-jump-prev, + .rc-pagination-jump-next { + outline: 0; + } + .rc-pagination-jump-prev button, + .rc-pagination-jump-next button { + background: transparent; + border: none; + cursor: pointer; + color: #666; + } + .rc-pagination-jump-prev button:after, + .rc-pagination-jump-next button:after { + display: block; + content: "β€’β€’β€’"; + } + .rc-pagination-prev, + .rc-pagination-jump-prev, + .rc-pagination-jump-next { + margin-right: 8px; + } + .rc-pagination-prev, + .rc-pagination-next, + .rc-pagination-jump-prev, + .rc-pagination-jump-next { + display: inline-block; + min-width: 28px; + height: 28px; + color: rgba(0, 0, 0, 0.85); + font-family: Arial; + line-height: 28px; + text-align: center; + vertical-align: middle; + list-style: none; + border-radius: 2px; + cursor: pointer; + transition: all 0.3s; + } + .rc-pagination-prev, + .rc-pagination-next { + outline: 0; + } + .rc-pagination-prev button, + .rc-pagination-next button { + color: rgba(0, 0, 0, 0.85); + cursor: pointer; + user-select: none; + } + .rc-pagination-prev:hover button, + .rc-pagination-next:hover button { + border-color: #40a9ff; + } + .rc-pagination-prev .rc-pagination-item-link, + .rc-pagination-next .rc-pagination-item-link { + display: block; + width: 100%; + height: 100%; + font-size: 12px; + text-align: center; + background-color: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 2px; + outline: none; + transition: all 0.3s; + } + .rc-pagination-prev:focus .rc-pagination-item-link, + .rc-pagination-next:focus .rc-pagination-item-link, + .rc-pagination-prev:hover .rc-pagination-item-link, + .rc-pagination-next:hover .rc-pagination-item-link { + color: #1890ff; + border-color: #1890ff; + } + .rc-pagination-prev button:after { + content: "β€Ή"; + display: block; + } + .rc-pagination-next button:after { + content: "β€Ί"; + display: block; + } + .rc-pagination-disabled, + .rc-pagination-disabled:hover, + .rc-pagination-disabled:focus { + cursor: not-allowed; + } + .rc-pagination-disabled .rc-pagination-item-link, + .rc-pagination-disabled:hover .rc-pagination-item-link, + .rc-pagination-disabled:focus .rc-pagination-item-link { + color: rgba(0, 0, 0, 0.25); + border-color: #d9d9d9; + cursor: not-allowed; + } + .rc-pagination-slash { + margin: 0 10px 0 5px; + } + .rc-pagination-options { + display: inline-block; + margin-left: 16px; + vertical-align: middle; + } + @media all and (-ms-high-contrast: none) { + .rc-pagination-options *::-ms-backdrop, + .rc-pagination-options { + vertical-align: top; + } + } + .rc-pagination-options-size-changer.rc-select { + display: inline-block; + width: auto; + margin-right: 8px; + } + .rc-pagination-options-quick-jumper { + display: inline-block; + height: 28px; + line-height: 28px; + vertical-align: top; + } + .rc-pagination-options-quick-jumper input { + width: 50px; + margin: 0 8px; + } + .rc-pagination-simple .rc-pagination-prev, + .rc-pagination-simple .rc-pagination-next { + height: 24px; + line-height: 24px; + vertical-align: top; + } + .rc-pagination-simple .rc-pagination-prev .rc-pagination-item-link, + .rc-pagination-simple .rc-pagination-next .rc-pagination-item-link { + height: 24px; + background-color: transparent; + border: 0; + } + .rc-pagination-simple .rc-pagination-prev .rc-pagination-item-link::after, + .rc-pagination-simple .rc-pagination-next .rc-pagination-item-link::after { + height: 24px; + line-height: 24px; + } + .rc-pagination-simple .rc-pagination-simple-pager { + display: inline-block; + height: 24px; + margin-right: 8px; + } + .rc-pagination-simple .rc-pagination-simple-pager input { + box-sizing: border-box; + height: 100%; + margin-right: 8px; + padding: 0 6px; + text-align: center; + background-color: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 2px; + outline: none; + transition: border-color 0.3s; + } + .rc-pagination-simple .rc-pagination-simple-pager input:hover { + border-color: #1890ff; + } + .rc-pagination.rc-pagination-disabled { + cursor: not-allowed; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item { + background: #f5f5f5; + border-color: #d9d9d9; + cursor: not-allowed; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item a { + color: rgba(0, 0, 0, 0.25); + background: transparent; + border: none; + cursor: not-allowed; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item-active { + background: #dbdbdb; + border-color: transparent; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item-active a { + color: #ffffff; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item-link { + color: rgba(0, 0, 0, 0.25); + background: #f5f5f5; + border-color: #d9d9d9; + cursor: not-allowed; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item-link-icon { + opacity: 0; + } + .rc-pagination.rc-pagination-disabled .rc-pagination-item-ellipsis { + opacity: 1; + } + @media only screen and (max-width: 992px) { + .rc-pagination-item-after-jump-prev, + .rc-pagination-item-before-jump-next { + display: none; + } + } + @media only screen and (max-width: 576px) { + .rc-pagination-options { + display: none; + } + } +`; + +interface ListPaginationProps { + current: number; + total: number; + perPage: number; + disabled?: boolean; + onChange: (page: number) => void; +} + +const ListPagination = (props: ListPaginationProps) => { + return ( + + ); +}; + +export default ListPagination; diff --git a/app/client/src/widgets/ListWidget/ListPropertyPaneConfig.ts b/app/client/src/widgets/ListWidget/ListPropertyPaneConfig.ts new file mode 100644 index 0000000000..55ff003f4c --- /dev/null +++ b/app/client/src/widgets/ListWidget/ListPropertyPaneConfig.ts @@ -0,0 +1,86 @@ +import { get } from "lodash"; +import { WidgetProps } from "widgets/BaseWidget"; +import { ListWidgetProps } from "./ListWidget"; +import { VALIDATION_TYPES } from "constants/WidgetValidation"; + +const PropertyPaneConfig = [ + { + sectionName: "General", + children: [ + { + helpText: "Takes in an array of objects to display items in the list.", + propertyName: "items", + label: "Items", + controlType: "INPUT_TEXT", + placeholderText: 'Enter [{ "col1": "val1" }]', + inputType: "ARRAY", + isBindProperty: true, + isTriggerProperty: false, + validation: VALIDATION_TYPES.LIST_DATA, + }, + { + propertyName: "backgroundColor", + label: "Background", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + }, + { + propertyName: "itemBackgroundColor", + label: "Item Background", + controlType: "COLOR_PICKER", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + }, + + { + helpText: "Spacing between items in Pixels", + placeholderText: "0", + propertyName: "gridGap", + label: "Item Spacing (px)", + controlType: "INPUT_TEXT", + isBindProperty: false, + isTriggerProperty: false, + }, + { + propertyName: "isVisible", + label: "Visible", + helpText: "Controls the visibility of the widget", + controlType: "SWITCH", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + }, + ], + }, + { + sectionName: "Actions", + children: [ + { + helpText: "Triggers an action when a grid list item is clicked", + propertyName: "onListItemClick", + label: "onListItemClick", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: true, + additionalAutoComplete: (props: ListWidgetProps) => { + return { + currentItem: Object.assign( + {}, + ...Object.keys(get(props, "evaluatedValues.items.0", {})).map( + (key) => ({ + [key]: "", + }), + ), + ), + }; + }, + }, + ], + }, +]; + +export { PropertyPaneConfig as default }; diff --git a/app/client/src/widgets/ListWidget/ListWidget.test.tsx b/app/client/src/widgets/ListWidget/ListWidget.test.tsx new file mode 100644 index 0000000000..b6e65184c5 --- /dev/null +++ b/app/client/src/widgets/ListWidget/ListWidget.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { WidgetProps } from "widgets/BaseWidget"; +import ListWidget, { ListWidgetProps } from "./ListWidget"; +import configureStore from "redux-mock-store"; +import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { ThemeProvider, theme, dark } from "constants/DefaultTheme"; + +jest.mock("react-dnd", () => ({ + useDrag: jest.fn().mockReturnValue([{ isDragging: false }, jest.fn()]), +})); + +describe("", () => { + const initialState = { + ui: { + widgetDragResize: { + selectedWidget: "Widget1", + }, + propertyPane: { + isVisible: true, + widgetId: "Widget1", + }, + }, + entities: { canvasWidgets: {}, app: { mode: "canvas" } }, + }; + + function renderListWidget(props: Partial> = {}) { + const defaultProps: ListWidgetProps = { + image: "", + defaultImage: "", + widgetId: "Widget1", + type: "LIST_WIDGET", + widgetName: "List1", + parentId: "Container1", + renderMode: "CANVAS", + parentColumnSpace: 2, + parentRowSpace: 3, + leftColumn: 2, + rightColumn: 3, + topRow: 1, + bottomRow: 3, + isLoading: false, + items: [], + version: 16, + disablePropertyPane: false, + ...props, + }; + // Mock store to bypass the error of react-redux + const store = configureStore()(initialState); + return render( + + + + + , + ); + } + + test("should render settings control wrapper", async () => { + const { queryByTestId } = renderListWidget(); + + expect( + queryByTestId("t--settings-controls-positioned-wrapper"), + ).toBeTruthy(); + }); + + test("should not render settings control wrapper", async () => { + const { queryByTestId } = renderListWidget({ widgetId: "ListNew1" }); + + expect( + queryByTestId("t--settings-controls-positioned-wrapper"), + ).toBeFalsy(); + }); +}); diff --git a/app/client/src/widgets/ListWidget/ListWidget.tsx b/app/client/src/widgets/ListWidget/ListWidget.tsx new file mode 100644 index 0000000000..828aca5669 --- /dev/null +++ b/app/client/src/widgets/ListWidget/ListWidget.tsx @@ -0,0 +1,569 @@ +import React from "react"; +import log from "loglevel"; +import { compact, get, set, xor, isPlainObject, isNumber, round } from "lodash"; +import * as Sentry from "@sentry/react"; + +import WidgetFactory from "utils/WidgetFactory"; +import { removeFalsyEntries } from "utils/helpers"; +import BaseWidget, { WidgetProps, WidgetState } from "../BaseWidget"; +import { + RenderModes, + WidgetType, + WidgetTypes, +} from "constants/WidgetConstants"; +import ListComponent, { ListComponentEmpty } from "./ListComponent"; +import { ContainerStyle } from "components/designSystems/appsmith/ContainerComponent"; +import { ContainerWidgetProps } from "../ContainerWidget"; +import propertyPaneConfig from "./ListPropertyPaneConfig"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; +import { getDynamicBindings } from "utils/DynamicBindingUtils"; +import ListPagination from "./ListPagination"; +import withMeta from "./../MetaHOC"; +import { GridDefaults, WIDGET_PADDING } from "constants/WidgetConstants"; + +class ListWidget extends BaseWidget, WidgetState> { + state = { + page: 1, + }; + + /** + * returns the property pane config of the widget + */ + static getPropertyPaneConfig() { + return propertyPaneConfig; + } + + static getDerivedPropertiesMap() { + return { + selectedItem: `{{(()=>{ + const selectedItemIndex = + this.selectedItemIndex === undefined || + Number.isNaN(parseInt(this.selectedItemIndex)) + ? -1 + : parseInt(this.selectedItemIndex); + const items = this.items || []; + if (selectedItemIndex === -1) { + const emptyRow = { ...items[0] }; + Object.keys(emptyRow).forEach((key) => { + emptyRow[key] = ""; + }); + return emptyRow; + } + const selectedItem = { ...items[selectedItemIndex] }; + return selectedItem; + })()}}`, + }; + } + + /** + * creates object of keys + * + * @param items + */ + getCurrentItemStructure = (items: Array>) => { + return Array.isArray(items) && items.length > 0 + ? Object.assign( + {}, + ...Object.keys(items[0]).map((key) => ({ + [key]: "", + })), + ) + : {}; + }; + + componentDidMount() { + if ( + !this.props.childAutoComplete || + (Object.keys(this.props.childAutoComplete).length === 0 && + this.props.items && + Array.isArray(this.props.items)) + ) { + const structure = this.getCurrentItemStructure(this.props.items); + super.updateWidgetProperty("childAutoComplete", { + currentItem: structure, + }); + } + } + + componentDidUpdate(prevProps: ListWidgetProps) { + const oldRowStructure = this.getCurrentItemStructure(prevProps.items); + const newRowStructure = this.getCurrentItemStructure(this.props.items); + + if ( + xor(Object.keys(oldRowStructure), Object.keys(newRowStructure)).length > 0 + ) { + super.updateWidgetProperty("childAutoComplete", { + currentItem: newRowStructure, + }); + } + } + + static getDefaultPropertiesMap(): Record { + return { + itemBackgroundColor: "#FFFFFF", + }; + } + + /** + * on click item action + * + * @param rowIndex + * @param action + * @param onComplete + */ + onItemClick = (rowIndex: number, action: string | undefined) => { + // setting selectedItemIndex on click of container + const selectedItemIndex = isNumber(this.props.selectedItemIndex) + ? this.props.selectedItemIndex + : -1; + + if (selectedItemIndex !== rowIndex) { + this.props.updateWidgetMetaProperty("selectedItemIndex", rowIndex, { + dynamicString: this.props.onRowSelected, + event: { + type: EventType.ON_ROW_SELECTED, + }, + }); + } + + if (!action) return; + + try { + const rowData = [this.props.items[rowIndex]]; + const { jsSnippets } = getDynamicBindings(action); + const modifiedAction = jsSnippets.reduce((prev: string, next: string) => { + return prev + `{{(currentItem) => { ${next} }}} `; + }, ""); + + super.executeAction({ + dynamicString: modifiedAction, + event: { + type: EventType.ON_CLICK, + }, + responseData: rowData, + }); + } catch (error) { + log.debug("Error parsing row action", error); + } + }; + + renderChild = (childWidgetData: WidgetProps) => { + const { componentWidth, componentHeight } = this.getComponentDimensions(); + + childWidgetData.parentId = this.props.widgetId; + childWidgetData.shouldScrollContents = this.props.shouldScrollContents; + childWidgetData.canExtend = + childWidgetData.virtualizedEnabled && false + ? true + : this.props.shouldScrollContents; + childWidgetData.isVisible = this.props.isVisible; + childWidgetData.minHeight = componentHeight; + childWidgetData.rightColumn = componentWidth; + childWidgetData.noPad = true; + + return WidgetFactory.createWidget(childWidgetData, this.props.renderMode); + }; + + /** + * here we are updating the position of each items and disabled resizing for + * all items except template ( first item ) + * + * @param children + */ + updatePosition = ( + children: ContainerWidgetProps[], + ): ContainerWidgetProps[] => { + const gridGap = this.props.gridGap || 0; + return children.map((child: ContainerWidgetProps, index) => { + const gap = gridGap - 8; + + return { + ...child, + gap, + backgroundColor: this.props.itemBackgroundColor, + topRow: + index * children[0].bottomRow + + index * (gap / GridDefaults.DEFAULT_GRID_ROW_HEIGHT), + bottomRow: + (index + 1) * children[0].bottomRow + + index * (gap / GridDefaults.DEFAULT_GRID_ROW_HEIGHT), + resizeDisabled: + index > 0 && this.props.renderMode === RenderModes.CANVAS, + }; + }); + }; + + updateTemplateWidgetProperties = (widget: WidgetProps, itemIndex: number) => { + const { + template, + dynamicBindingPathList, + dynamicTriggerPathList, + } = this.props; + const { widgetName = "" } = widget; + // Update properties if they're dynamic + // `template` property should have an array of values + // if it is a dynamicbinding + + if ( + Array.isArray(dynamicBindingPathList) && + dynamicBindingPathList.length > 0 + ) { + // Get all paths in the dynamicBindingPathList sans the List Widget name prefix + const dynamicPaths: string[] = compact( + dynamicBindingPathList.map((path: Record<"key", string>) => + path.key.split(".").pop(), + ), + ); + + // Update properties in the widget based on the paths + // By picking the correct value from the evaluated values in the template + dynamicPaths.forEach((path: string) => { + const evaluatedProperty = get(template, `${widgetName}.${path}`); + if ( + Array.isArray(evaluatedProperty) && + evaluatedProperty.length > itemIndex + ) { + const evaluatedValue = evaluatedProperty[itemIndex]; + if (isPlainObject(evaluatedValue)) + set(widget, path, JSON.stringify(evaluatedValue)); + else set(widget, path, evaluatedValue); + } + }); + } + + if ( + Array.isArray(dynamicTriggerPathList) && + dynamicTriggerPathList.length > 0 + ) { + // Get all paths in the dynamicBindingPathList sans the List Widget name prefix + const triggerPaths: string[] = compact( + dynamicTriggerPathList.map((path: Record<"key", string>) => + path.key.indexOf(`template.${widgetName}`) === 0 + ? path.key.split(".").pop() + : undefined, + ), + ); + + triggerPaths.forEach((path: string) => { + const propertyValue = get(this.props.template[widget.widgetName], path); + + if ( + propertyValue.indexOf("currentItem") > -1 && + propertyValue.indexOf("{{((currentItem) => {") === -1 + ) { + const { jsSnippets } = getDynamicBindings(propertyValue); + const listItem = this.props.items[itemIndex]; + + const newPropertyValue = jsSnippets.reduce( + (prev: string, next: string) => { + if (next.indexOf("currentItem") > -1) { + return ( + prev + + `{{((currentItem) => { ${next}})(JSON.parse('${JSON.stringify( + listItem, + )}'))}}` + ); + } + return prev + `{{${next}}}`; + }, + "", + ); + set(widget, path, newPropertyValue); + } + }); + } + + return this.updateNonTemplateWidgetProperties(widget, itemIndex); + }; + + updateNonTemplateWidgetProperties = ( + widget: WidgetProps, + itemIndex: number, + ) => { + const { page } = this.state; + const { perPage } = this.shouldPaginate(); + + if (itemIndex > 0) { + const originalIndex = ((page - 1) * perPage - itemIndex) * -1; + + if (this.props.renderMode === RenderModes.PAGE) { + set( + widget, + `widgetId`, + `list-widget-child-id-${itemIndex}-${widget.widgetName}`, + ); + } + + if (originalIndex !== 0) { + set( + widget, + `widgetId`, + `list-widget-child-id-${itemIndex}-${widget.widgetName}`, + ); + + if (this.props.renderMode === RenderModes.CANVAS) { + set(widget, `resizeDisabled`, true); + set(widget, `disablePropertyPane`, true); + set(widget, `dragDisabled`, true); + set(widget, `dropDisabled`, true); + } + } + } + + return widget; + }; + + /** + * @param children + */ + useNewValues = (children: ContainerWidgetProps[]) => { + const updatedChildren = children.map( + ( + listItemContainer: ContainerWidgetProps, + listItemIndex: number, + ) => { + let updatedListItemContainer = listItemContainer; + // Get an array of children in the current list item + const listItemChildren = get( + updatedListItemContainer, + "children[0].children", + [], + ); + // If children exist + if (listItemChildren.length > 0) { + // Update the properties of all the children + const updatedListItemChildren = listItemChildren.map( + (templateWidget: WidgetProps) => { + // This will return the updated child widget + return this.updateTemplateWidgetProperties( + templateWidget, + listItemIndex, + ); + }, + ); + // Set the update list of children as the new children for the current list item + set( + updatedListItemContainer, + "children[0].children", + updatedListItemChildren, + ); + } + // Get the item container's canvas child widget + const listItemContainerCanvas = get( + updatedListItemContainer, + "children[0]", + ); + // Set properties of the container's canvas child widget + const updatedListItemContainerCanvas = this.updateNonTemplateWidgetProperties( + listItemContainerCanvas, + listItemIndex, + ); + // Set the item container's canvas child widget + set( + updatedListItemContainer, + "children[0]", + updatedListItemContainerCanvas, + ); + // Set properties of the item container + updatedListItemContainer = this.updateNonTemplateWidgetProperties( + listItemContainer, + listItemIndex, + ); + return updatedListItemContainer; + }, + ); + + return updatedChildren; + }; + + updateGridChildrenProps = (children: ContainerWidgetProps[]) => { + let updatedChildren = this.useNewValues(children); + updatedChildren = this.updateActions(updatedChildren); + updatedChildren = this.paginateItems(updatedChildren); + updatedChildren = this.updatePosition(updatedChildren); + + return updatedChildren; + }; + + updateActions = (children: ContainerWidgetProps[]) => { + return children.map((child: ContainerWidgetProps, index) => { + return { + ...child, + onClick: () => this.onItemClick(index, this.props.onListItemClick), + }; + }); + }; + + /** + * paginate items + * + * @param children + */ + paginateItems = (children: ContainerWidgetProps[]) => { + const { page } = this.state; + const { shouldPaginate, perPage } = this.shouldPaginate(); + + if (shouldPaginate) { + return children.slice((page - 1) * perPage, page * perPage); + } + + return children; + }; + + // { + // list: { + // children: [ <--- children + // { + // canvas: { <--- childCanvas + // children: [ <---- canvasChildren + // { + // container: { + // children: [ + // 0: { + // canvas: [ + // { + // button + // image + // } + // ] + // }, + // 1: { + // canvas: [ + // { + // button + // image + // } + // ] + // } + // ] + // } + // } + // ] + // } + // } + // ] + // } + // } + + /** + * renders children + */ + renderChildren = () => { + const numberOfItemsInGrid = this.props.items.length; + if (this.props.children && this.props.children.length > 0) { + const children = removeFalsyEntries(this.props.children); + const childCanvas = children[0]; + let canvasChildren = childCanvas.children; + + try { + // here we are duplicating the template for each items in the data array + // first item of the canvasChildren acts as a template + const template = canvasChildren.slice(0, 1).shift(); + + for (let i = 0; i < numberOfItemsInGrid; i++) { + canvasChildren[i] = JSON.parse(JSON.stringify(template)); + } + + // TODO(pawan): This is recalculated everytime for not much reason + // We should either use https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops + // Or use memoization https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#what-about-memoization + // In particular useNewValues can be memoized, if others can't. + canvasChildren = this.updateGridChildrenProps(canvasChildren); + + childCanvas.children = canvasChildren; + } catch (e) { + console.log({ error: e }); + } + + return this.renderChild(childCanvas); + } + }; + + /** + * 400 + * 200 + * can data be paginated + */ + shouldPaginate = () => { + let { gridGap } = this.props; + const { items, children } = this.props; + const { componentHeight } = this.getComponentDimensions(); + const templateBottomRow = get(children, "0.children.0.bottomRow"); + const templateHeight = templateBottomRow * 40; + + try { + gridGap = parseInt(gridGap); + + if (!isNumber(gridGap) || isNaN(gridGap)) { + gridGap = 0; + } + } catch { + gridGap = 0; + } + + const shouldPaginate = + templateHeight * items.length + parseInt(gridGap) * (items.length - 1) > + componentHeight; + + const totalSpaceAvailable = componentHeight - (100 + WIDGET_PADDING * 2); + const spaceTakenByOneContainer = + templateHeight + (gridGap * (items.length - 1)) / items.length; + + const perPage = totalSpaceAvailable / spaceTakenByOneContainer; + + return { shouldPaginate, perPage: round(perPage) }; + }; + + /** + * view that is rendered in editor + */ + getPageView() { + const children = this.renderChildren(); + const { shouldPaginate, perPage } = this.shouldPaginate(); + + if (!isNumber(perPage) || perPage === 0) { + return ( + <>Please make sure the list widget size is greater than the template + ); + } + + if (Array.isArray(this.props.items) && this.props.items.length === 0) { + return No data to display; + } + + return ( + + {children} + + {shouldPaginate && ( + this.setState({ page })} + disabled={false && this.props.renderMode === RenderModes.CANVAS} + /> + )} + + ); + } + + /** + * returns type of the widget + */ + getWidgetType(): WidgetType { + return WidgetTypes.LIST_WIDGET; + } +} + +export interface ListWidgetProps extends WidgetProps { + children?: T[]; + containerStyle?: ContainerStyle; + shouldScrollContents?: boolean; + onListItemClick?: string; + items: Array>; + currentItemStructure?: Record; +} + +export default ListWidget; +export const ProfiledListWidget = Sentry.withProfiler(withMeta(ListWidget)); diff --git a/app/client/src/widgets/ListWidget/index.tsx b/app/client/src/widgets/ListWidget/index.tsx new file mode 100644 index 0000000000..a9779c9e51 --- /dev/null +++ b/app/client/src/widgets/ListWidget/index.tsx @@ -0,0 +1 @@ +export { ProfiledListWidget, default } from "./ListWidget"; diff --git a/app/client/src/workers/validations.test.ts b/app/client/src/workers/validations.test.ts index 2b3488b1b9..e890b1f1ed 100644 --- a/app/client/src/workers/validations.test.ts +++ b/app/client/src/workers/validations.test.ts @@ -538,3 +538,66 @@ describe("validateDateString test", () => { }); }); }); + +describe("List data validator", () => { + const validator = VALIDATORS.LIST_DATA; + it("correctly validates ", () => { + const cases = [ + { + input: [], + output: { + isValid: true, + parsed: [], + }, + }, + { + input: [{ a: 1 }], + output: { + isValid: true, + parsed: [{ a: 1 }], + }, + }, + { + input: "sting text", + output: { + isValid: false, + message: + 'Value does not match type: [{ "key1" : "val1", "key2" : "val2" }]', + parsed: [], + transformed: "sting text", + }, + }, + { + input: undefined, + output: { + isValid: false, + message: + 'Value does not match type: [{ "key1" : "val1", "key2" : "val2" }]', + parsed: [], + transformed: undefined, + }, + }, + { + input: {}, + output: { + isValid: false, + message: + 'Value does not match type: [{ "key1" : "val1", "key2" : "val2" }]', + parsed: [], + transformed: {}, + }, + }, + { + input: `[{ "b": 1 }]`, + output: { + isValid: true, + parsed: JSON.parse(`[{ "b": 1 }]`), + }, + }, + ]; + for (const testCase of cases) { + const response = validator(testCase.input, DUMMY_WIDGET, {}); + expect(response).toStrictEqual(testCase.output); + } + }); +}); diff --git a/app/client/src/workers/validations.ts b/app/client/src/workers/validations.ts index 68a9356dd8..af377b4332 100644 --- a/app/client/src/workers/validations.ts +++ b/app/client/src/workers/validations.ts @@ -211,7 +211,6 @@ export const VALIDATORS: Record = { } return { isValid: true, parsed, transformed: parsed }; } catch (e) { - console.error(e); return { isValid: false, parsed: [], @@ -259,6 +258,44 @@ export const VALIDATORS: Record = { } return { isValid, parsed }; }, + [VALIDATION_TYPES.LIST_DATA]: ( + value: any, + props: WidgetProps, + dataTree?: DataTree, + ): ValidationResponse => { + const { isValid, transformed, parsed } = VALIDATORS.ARRAY( + value, + props, + dataTree, + ); + + if (!isValid) { + return { + isValid, + parsed: [], + transformed, + message: `${WIDGET_TYPE_VALIDATION_ERROR}: [{ "key1" : "val1", "key2" : "val2" }]`, + }; + } + + const isValidListData = every(parsed, (datum) => { + return ( + isObject(datum) && + Object.keys(datum).filter((key) => isString(key) && key.length === 0) + .length === 0 + ); + }); + + if (!isValidListData) { + return { + isValid: false, + parsed: [], + transformed, + message: `${WIDGET_TYPE_VALIDATION_ERROR}: [{ "key1" : "val1", "key2" : "val2" }]`, + }; + } + return { isValid, parsed }; + }, [VALIDATION_TYPES.TABLE_DATA]: ( value: any, props: WidgetProps, @@ -468,7 +505,6 @@ export const VALIDATORS: Record = { } return { isValid, parsed }; } catch (e) { - console.error(e); return { isValid: false, parsed: [], diff --git a/app/client/yarn.lock b/app/client/yarn.lock index e29d24ea29..62dc274e41 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -1624,7 +1624,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.5": +"@babel/runtime@^7.10.1", "@babel/runtime@^7.12.5": version "7.13.10" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== @@ -3424,10 +3424,10 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^11.2.5": - version "11.2.5" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.5.tgz#ae1c36a66c7790ddb6662c416c27863d87818eb9" - integrity sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ== +"@testing-library/react@^11.2.6": + version "11.2.6" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.6.tgz#586a23adc63615985d85be0c903f374dab19200b" + integrity sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ== dependencies: "@babel/runtime" "^7.12.5" "@testing-library/dom" "^7.28.1" @@ -4002,7 +4002,21 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@^4.5.0", "@typescript-eslint/eslint-plugin@^4.6.0": +"@typescript-eslint/eslint-plugin@^4.15.0": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.1.tgz#835f64aa0a403e5e9e64c10ceaf8d05c3f015180" + integrity sha512-yW2epMYZSpNJXZy22Biu+fLdTG8Mn6b22kR3TqblVk50HGNV8Zya15WAXuQCr8tKw4Qf1BL4QtI6kv6PCkLoJw== + dependencies: + "@typescript-eslint/experimental-utils" "4.15.1" + "@typescript-eslint/scope-manager" "4.15.1" + debug "^4.1.1" + functional-red-black-tree "^1.0.1" + lodash "^4.17.15" + regexpp "^3.0.0" + semver "^7.3.2" + tsutils "^3.17.1" + +"@typescript-eslint/eslint-plugin@^4.5.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.6.0.tgz#210cd538bb703f883aff81d3996961f5dba31fdb" dependencies: @@ -4014,6 +4028,18 @@ semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/experimental-utils@4.15.1": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.1.tgz#d744d1ac40570a84b447f7aa1b526368afd17eec" + integrity sha512-9LQRmOzBRI1iOdJorr4jEnQhadxK4c9R2aEAsm7WE/7dq8wkKD1suaV0S/JucTL8QlYUPU1y2yjqg+aGC0IQBQ== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/scope-manager" "4.15.1" + "@typescript-eslint/types" "4.15.1" + "@typescript-eslint/typescript-estree" "4.15.1" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + "@typescript-eslint/experimental-utils@4.6.0", "@typescript-eslint/experimental-utils@^4.0.1": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.6.0.tgz#f750aef4dd8e5970b5c36084f0a5ca2f0db309a4" @@ -4035,7 +4061,17 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.5.0", "@typescript-eslint/parser@^4.6.0": +"@typescript-eslint/parser@^4.15.0": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.15.1.tgz#4c91a0602733db63507e1dbf13187d6c71a153c4" + integrity sha512-V8eXYxNJ9QmXi5ETDguB7O9diAXlIyS+e3xzLoP/oVE4WCAjssxLIa0mqCLsCGXulYJUfT+GV70Jv1vHsdKwtA== + dependencies: + "@typescript-eslint/scope-manager" "4.15.1" + "@typescript-eslint/types" "4.15.1" + "@typescript-eslint/typescript-estree" "4.15.1" + debug "^4.1.1" + +"@typescript-eslint/parser@^4.5.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.6.0.tgz#7e9ff7df2f21d5c8f65f17add3b99eeeec33199d" dependencies: @@ -4044,6 +4080,14 @@ "@typescript-eslint/typescript-estree" "4.6.0" debug "^4.1.1" +"@typescript-eslint/scope-manager@4.15.1": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.15.1.tgz#f6511eb38def2a8a6be600c530c243bbb56ac135" + integrity sha512-ibQrTFcAm7yG4C1iwpIYK7vDnFg+fKaZVfvyOm3sNsGAerKfwPVFtYft5EbjzByDJ4dj1WD8/34REJfw/9wdVA== + dependencies: + "@typescript-eslint/types" "4.15.1" + "@typescript-eslint/visitor-keys" "4.15.1" + "@typescript-eslint/scope-manager@4.6.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.6.0.tgz#b7d8b57fe354047a72dfb31881d9643092838662" @@ -4055,6 +4099,11 @@ version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" +"@typescript-eslint/types@4.15.1": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.15.1.tgz#da702f544ef1afae4bc98da699eaecd49cf31c8c" + integrity sha512-iGsaUyWFyLz0mHfXhX4zO6P7O3sExQpBJ2dgXB0G5g/8PRVfBBsmQIc3r83ranEQTALLR3Vko/fnCIVqmH+mPw== + "@typescript-eslint/types@4.6.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.6.0.tgz#157ca925637fd53c193c6bf226a6c02b752dde2f" @@ -4072,6 +4121,19 @@ semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@4.15.1": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.1.tgz#fa9a9ff88b4a04d901ddbe5b248bc0a00cd610be" + integrity sha512-z8MN3CicTEumrWAEB2e2CcoZa3KP9+SMYLIA2aM49XW3cWIaiVSOAGq30ffR5XHxRirqE90fgLw3e6WmNx5uNw== + dependencies: + "@typescript-eslint/types" "4.15.1" + "@typescript-eslint/visitor-keys" "4.15.1" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + semver "^7.3.2" + tsutils "^3.17.1" + "@typescript-eslint/typescript-estree@4.6.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.6.0.tgz#85bd98dcc8280511cfc5b2ce7b03a9ffa1732b08" @@ -4091,6 +4153,14 @@ dependencies: eslint-visitor-keys "^1.1.0" +"@typescript-eslint/visitor-keys@4.15.1": + version "4.15.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.1.tgz#c76abbf2a3be8a70ed760f0e5756bf62de5865dd" + integrity sha512-tYzaTP9plooRJY8eNlpAewTOqtWW/4ff/5wBjNVaJ0S0wC4Gpq/zDVRTJa5bq2v1pCNQ08xxMCndcvR+h7lMww== + dependencies: + "@typescript-eslint/types" "4.15.1" + eslint-visitor-keys "^2.0.0" + "@typescript-eslint/visitor-keys@4.6.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.6.0.tgz#fb05d6393891b0a089b243fc8f9fb8039383d5da" @@ -6006,7 +6076,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2, classnames@^2.2.5, classnames@^2.2.6: +classnames@^2.2, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -13773,6 +13843,14 @@ raw-loader@^4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" +rc-pagination@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/rc-pagination/-/rc-pagination-3.1.3.tgz#afd779839fefab2cb14248d5e7b74027960bb48b" + integrity sha512-Z7CdC4xGkedfAwcUHPtfqNhYwVyDgkmhkvfsmoByCOwAd89p42t5O5T3ORar1wRmVWf3jxk/Bf4k0atenNvlFA== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.1" + re-reselect@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/re-reselect/-/re-reselect-3.4.0.tgz#0f2303f3c84394f57f0cd31fea08a1ca4840a7cd" @@ -16503,6 +16581,11 @@ tslib@^2.0.0, tslib@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" +tslib@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + tslib@~1.13.0: version "1.13.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" @@ -16604,9 +16687,10 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@^3.9.2: - version "3.9.7" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" +typescript@^4.1.3: + version "4.1.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72" + integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA== ua-parser-js@^0.7.18: version "0.7.22" From fdb483fc3c5b5418036a61e0d3d9831fabb5667e Mon Sep 17 00:00:00 2001 From: Confidence Okoghenun Date: Fri, 23 Apr 2021 08:48:41 +0100 Subject: [PATCH 05/35] chore: Adds officehours for Apr 8th and 15th --- office_hours.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/office_hours.md b/office_hours.md index f17c85506d..ed44f37fbc 100644 --- a/office_hours.md +++ b/office_hours.md @@ -28,6 +28,26 @@ You can find the archives of the calls below with a brief summary of each sessio ## Archives +15th April 2021: Bug fixes, Fusion charts demo + +Video Link + +#### Summary + +Nidhi from Appsmith talks about the issues that were discovered on the table widget and how they have been fixed. She also demos the new Fusion chart API that lets developers build custom charts using the chart widget. + +------------------ + +8th April 2021: Thanks for the 4k Github stars, Sticky property pane, Custom charts + +Video Link + +#### Summary + +Abhinav shows a handful of the cool new Appsmith features, some of which are sticky property pane, custom charts, tables in rich-text-editor, text widget styling, and auto data format detection in Datepicker widget. Also, Sumit from Appsmith talks about bug fixes and community feedback. + +------------------ + 25th Mar 2021: Intelligent JSON substitution, Generic S3 integration, Url table column type and new datepicker formats Video Link From ff5f4f4ccbcaead7fb27e2f2acae40a24c0c1c7d Mon Sep 17 00:00:00 2001 From: Confidence Okoghenun Date: Fri, 23 Apr 2021 08:58:07 +0100 Subject: [PATCH 06/35] chore: Adds summary for google sheet live demo --- office_hours.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/office_hours.md b/office_hours.md index ed44f37fbc..11994a861c 100644 --- a/office_hours.md +++ b/office_hours.md @@ -28,6 +28,16 @@ You can find the archives of the calls below with a brief summary of each sessio ## Archives +Appsmith Live Demo, 22nd April 2021: Build an app using Google Sheets + +Video Link + +#### Summary + +Nikhil shows the community how to build a CRM dashboard on top of Google Sheets using the new Google Sheet integration on Appsmith. Questions from members of our community was also discussed. + +------------------ + 15th April 2021: Bug fixes, Fusion charts demo Video Link From 724f290e3dda81d36b1aa484c4c2e8689e9f64cc Mon Sep 17 00:00:00 2001 From: Nikhil Nandagopal Date: Fri, 23 Apr 2021 14:39:01 +0530 Subject: [PATCH 07/35] Update config.json --- .github/config.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/config.json b/.github/config.json index c16f3b48f2..48b03bac49 100644 --- a/.github/config.json +++ b/.github/config.json @@ -185,6 +185,11 @@ "color": "79e53b", "description": "An unexpected or annoying bug" }, + "List Widget": { + "name": "List Widget", + "color": "79e53b", + "description": "Issues Related to the list widget" + }, "Map Widget": { "name": "Map Widget", "color": "7eef7a", @@ -564,6 +569,11 @@ "type": "hasLabel", "value": true }, + { + "label": "List Widget", + "type": "hasLabel", + "value": true + }, { "label": "Map Widget", "type": "hasLabel", From ce4ae70377c90bd34327a215c17d1c7acbfe61e7 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Fri, 23 Apr 2021 14:45:19 +0530 Subject: [PATCH 08/35] add check for root existence (#4120) Co-authored-by: Pawan Kumar --- app/client/src/sagas/WidgetOperationUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/src/sagas/WidgetOperationUtils.ts b/app/client/src/sagas/WidgetOperationUtils.ts index 38e11ed8bc..8a12124c9d 100644 --- a/app/client/src/sagas/WidgetOperationUtils.ts +++ b/app/client/src/sagas/WidgetOperationUtils.ts @@ -51,7 +51,7 @@ export const handleIfParentIsListWidgetWhilePasting = ( ): { [widgetId: string]: FlattenedWidgetProps } => { let root = get(widgets, `${widget.parentId}`); - while (root.parentId && root.widgetId !== MAIN_CONTAINER_WIDGET_ID) { + while (root && root.parentId && root.widgetId !== MAIN_CONTAINER_WIDGET_ID) { if (root.type === WidgetTypes.LIST_WIDGET) { const listWidget = root; const currentWidget = cloneDeep(widget); From 11e9276964ca62eb8dd4e25e0dadc2875c68e0eb Mon Sep 17 00:00:00 2001 From: Hetu Nandu Date: Fri, 23 Apr 2021 17:04:33 +0530 Subject: [PATCH 09/35] Fix set evaluated tree performance tracking --- app/client/src/sagas/EvaluationsSaga.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/src/sagas/EvaluationsSaga.ts b/app/client/src/sagas/EvaluationsSaga.ts index 338fa02883..037268f85e 100644 --- a/app/client/src/sagas/EvaluationsSaga.ts +++ b/app/client/src/sagas/EvaluationsSaga.ts @@ -132,7 +132,7 @@ function* evaluateTreeSaga(postEvalActions?: ReduxAction[]) { type: ReduxActionTypes.SET_EVALUATED_TREE, payload: dataTree, }); - PerformanceTracker.startAsyncTracking( + PerformanceTracker.stopAsyncTracking( PerformanceTransactionName.SET_EVALUATED_TREE, ); yield put({ From 432b3659805a624d4c41768f0e8ba57c4cfba4c1 Mon Sep 17 00:00:00 2001 From: Trisha Anand Date: Fri, 23 Apr 2021 17:36:31 +0530 Subject: [PATCH 10/35] [Hotfix] Fixed the migration script where actions with no default smart substitution configuration get the correct configuration of turned off (#4122) * Fixed the migration script where actions with no default smart substitution configuration get the correct configuration of turned off * Removed debug logs * Stupid comment correction --- .../server/migrations/DatabaseChangelog.java | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java index d4ff70e074..1cfe941f7e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java @@ -2112,7 +2112,34 @@ public class DatabaseChangelog { )); } - @ChangeSet(order = "064", id = "migrate-smartSubstitution-dataType", author = "") + @ChangeSet(order = "065", id = "create-entry-in-sequence-per-organization-for-datasource", author = "") + public void createEntryInSequencePerOrganizationForDatasource(MongoTemplate mongoTemplate) { + + Map maxDatasourceCount = new HashMap<>(); + mongoTemplate + .find(query(where("name").regex("^Untitled Datasource \\d+$")), Datasource.class) + .forEach(datasource -> { + long count = 1; + String datasourceCnt = datasource.getName().substring("Untitled Datasource ".length()).trim(); + if (!datasourceCnt.isEmpty()) { + count = Long.parseLong(datasourceCnt); + } + + if (maxDatasourceCount.containsKey(datasource.getOrganizationId()) + && (count < maxDatasourceCount.get(datasource.getOrganizationId()))) { + return; + } + maxDatasourceCount.put(datasource.getOrganizationId(), count); + }); + maxDatasourceCount.forEach((key, val) -> { + Sequence sequence = new Sequence(); + sequence.setName("datasource for organization with _id : " + key); + sequence.setNextNumber(val + 1); + mongoTemplate.save(sequence); + }); + } + + @ChangeSet(order = "066", id = "migrate-smartSubstitution-dataType", author = "") public void migrateSmartSubstitutionDataTypeBoolean(MongoTemplate mongoTemplate, MongoOperations mongoOperations) { Set smartSubTurnedOn = new HashSet<>(); Set smartSubTurnedOff = new HashSet<>(); @@ -2158,12 +2185,9 @@ public class DatabaseChangelog { } } } - } - - // If the current action id exists in either smartSubTurnedOn or smartSubTurnedOff, then these - // actions would get the required correct values. If in none of the above two, then add this action - // for default configuring - if (!smartSubTurnedOn.contains(action.getId()) && !smartSubTurnedOff.contains(action.getId())) { + } else if (pluginSpecifiedTemplates == null) { + // No pluginSpecifiedTemplates array exists. This is possible when an action was created before + // the smart substitution feature was available noSmartSubConfig.add(action.getId()); } } @@ -2178,41 +2202,20 @@ public class DatabaseChangelog { NewAction.class ); - // Default for no valid smart substitution configuration to be false aka smart substitution turned off. - smartSubTurnedOff.addAll(noSmartSubConfig); - // Migrate actions where smart substitution is turned off mongoOperations.updateMulti( query(where("_id").in(smartSubTurnedOff)), new Update().set("unpublishedAction.actionConfiguration.pluginSpecifiedTemplates.0.value", false), NewAction.class ); - } - @ChangeSet(order = "065", id = "create-entry-in-sequence-per-organization-for-datasource", author = "") - public void createEntryInSequencePerOrganizationForDatasource(MongoTemplate mongoTemplate) { - - Map maxDatasourceCount = new HashMap<>(); - mongoTemplate - .find(query(where("name").regex("^Untitled Datasource \\d+$")), Datasource.class) - .forEach(datasource -> { - long count = 1; - String datasourceCnt = datasource.getName().substring("Untitled Datasource ".length()).trim(); - if (!datasourceCnt.isEmpty()) { - count = Long.parseLong(datasourceCnt); - } - - if (maxDatasourceCount.containsKey(datasource.getOrganizationId()) - && (count < maxDatasourceCount.get(datasource.getOrganizationId()))) { - return; - } - maxDatasourceCount.put(datasource.getOrganizationId(), count); - }); - maxDatasourceCount.forEach((key, val) -> { - Sequence sequence = new Sequence(); - sequence.setName("datasource for organization with _id : " + key); - sequence.setNextNumber(val + 1); - mongoTemplate.save(sequence); - }); + Property property = new Property(); + property.setValue(false); + // Migrate actions where there is no configuration for smart substitution, aka add the array. + mongoOperations.updateMulti( + query(where("_id").in(noSmartSubConfig)), + new Update().addToSet("unpublishedAction.actionConfiguration.pluginSpecifiedTemplates", property), + NewAction.class + ); } } From 2971324be64bbb271f2d466a84d2e4c5e868a82c Mon Sep 17 00:00:00 2001 From: Shrikant Sharat Kandula Date: Fri, 23 Apr 2021 18:21:36 +0530 Subject: [PATCH 11/35] Fix unable to connect to MongoDB without username (#4130) --- .../java/com/external/plugins/MongoPlugin.java | 7 ++++--- .../com/external/plugins/MongoPluginTest.java | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java b/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java index dc8dd131e0..0ae7de3a57 100644 --- a/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java +++ b/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java @@ -1,5 +1,6 @@ package com.external.plugins; +import com.appsmith.external.constants.ActionResultDataType; import com.appsmith.external.dtos.ExecuteActionDTO; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; @@ -9,7 +10,6 @@ import com.appsmith.external.helpers.MustacheHelper; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionRequest; import com.appsmith.external.models.ActionExecutionResult; -import com.appsmith.external.constants.ActionResultDataType; import com.appsmith.external.models.Connection; import com.appsmith.external.models.DBAuth; import com.appsmith.external.models.DatasourceConfiguration; @@ -377,9 +377,10 @@ public class MongoPlugin extends BasePlugin { builder.append("mongodb://"); } + boolean hasUsername = false; DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication(); if (authentication != null) { - final boolean hasUsername = StringUtils.hasText(authentication.getUsername()); + hasUsername = StringUtils.hasText(authentication.getUsername()); final boolean hasPassword = StringUtils.hasText(authentication.getPassword()); if (hasUsername) { builder.append(urlEncode(authentication.getUsername())); @@ -449,7 +450,7 @@ public class MongoPlugin extends BasePlugin { ); } - if (authentication != null && authentication.getAuthType() != null) { + if (hasUsername && authentication.getAuthType() != null) { queryParams.add("authMechanism=" + authentication.getAuthType().name().replace('_', '-')); } diff --git a/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java b/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java index 8b19894bc6..559cd24093 100644 --- a/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java +++ b/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java @@ -1,12 +1,13 @@ package com.external.plugins; -import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; +import com.appsmith.external.constants.ActionResultDataType; import com.appsmith.external.dtos.ExecuteActionDTO; +import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionRequest; import com.appsmith.external.models.ActionExecutionResult; -import com.appsmith.external.constants.ActionResultDataType; import com.appsmith.external.models.Connection; +import com.appsmith.external.models.DBAuth; import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.DatasourceStructure; import com.appsmith.external.models.DatasourceTestResult; @@ -23,6 +24,7 @@ import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoClients; import com.mongodb.reactivestreams.client.MongoCollection; import org.bson.Document; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; @@ -130,6 +132,16 @@ public class MongoPluginTest { .verifyComplete(); } + @Test + public void testConnectToMongoWithoutUsername() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + dsConfig.setAuthentication(new DBAuth(DBAuth.Type.SCRAM_SHA_1, "", "", "admin")); + Mono dsConnectionMono = pluginExecutor.datasourceCreate(dsConfig); + StepVerifier.create(dsConnectionMono) + .assertNext(Assert::assertNotNull) + .verifyComplete(); + } + /** * 1. Test "testDatasource" method in MongoPluginExecutor class. */ From 36532cbcc856bb8d8b372b83683b6b73fad57943 Mon Sep 17 00:00:00 2001 From: akash-codemonk <67054171+akash-codemonk@users.noreply.github.com> Date: Fri, 23 Apr 2021 19:20:55 +0530 Subject: [PATCH 12/35] [Feature] Debugger logs (#3633) --- .../cypress/fixtures/executionParamsDsl.json | 2 +- .../ActionExecution/ExecutionParams_spec.js | 19 +- .../ClientSideTests/Debugger/Logs_spec.js | 14 + .../ApiPaneTests/API_All_Verb_spec.js | 2 +- .../QueryPane/EmptyDataSource_spec.js | 8 +- .../cypress/locators/commonlocators.json | 2 +- app/client/cypress/support/commands.js | 30 +- app/client/src/actions/debuggerActions.ts | 31 + app/client/src/assets/icons/ads/bug.svg | 3 + app/client/src/assets/icons/ads/cancel.svg | 3 + app/client/src/assets/icons/ads/cross.svg | 4 + app/client/src/assets/icons/ads/open.svg | 4 + app/client/src/assets/icons/ads/wand.svg | 10 + .../src/assets/icons/ads/warning-triangle.svg | 4 + app/client/src/components/ads/Icon.tsx | 49 +- app/client/src/components/ads/Tabs.tsx | 6 +- app/client/src/components/ads/TextInput.tsx | 2 +- app/client/src/components/ads/Toast.tsx | 17 +- .../editorComponents/ApiResponseView.tsx | 107 ++- .../editorComponents/CloseEditor.tsx | 74 ++ .../CodeEditor/EvaluatedValuePopup.tsx | 18 +- .../editorComponents/Debugger/DebugCTA.tsx | 69 ++ .../Debugger/DebuggerLogs.tsx | 106 +++ .../Debugger/DebuggerMessage.tsx | 25 + .../Debugger/DebuggerTabs.tsx | 85 +++ .../editorComponents/Debugger/EntityLink.tsx | 161 +++++ .../editorComponents/Debugger/Errors.tsx | 46 ++ .../Debugger/FilterHeader.tsx | 99 +++ .../editorComponents/Debugger/LogItem.tsx | 281 ++++++++ .../Debugger/Resizer/index.tsx | 79 ++ .../editorComponents/Debugger/helpers.tsx | 99 +++ .../editorComponents/Debugger/index.tsx | 85 +++ .../GlobalSearch/ResultsNotFound.tsx | 33 +- .../ActionConstants.tsx | 5 + app/client/src/constants/DefaultTheme.tsx | 96 +++ app/client/src/constants/Layers.tsx | 1 + .../src/constants/ReduxActionConstants.tsx | 7 + app/client/src/constants/messages.ts | 9 +- .../src/entities/AppsmithConsole/index.ts | 40 +- .../src/entities/AppsmithConsole/logtype.ts | 9 + .../mockResponses/WidgetConfigResponse.tsx | 12 + .../pages/Editor/APIEditor/ApiHomeScreen.tsx | 2 + .../src/pages/Editor/APIEditor/Form.tsx | 67 +- .../Editor/APIEditor/RapidApiEditorForm.tsx | 2 +- app/client/src/pages/Editor/GlobalHotKeys.tsx | 13 + .../Editor/PropertyPane/PropertyControl.tsx | 25 + .../src/pages/Editor/PropertyPane/index.tsx | 7 +- .../Editor/QueryEditor/EditorJSONtoForm.tsx | 679 ++++++++++-------- .../src/pages/Editor/QueryEditor/Form.tsx | 3 + .../Editor/QueryEditor/QueryHomeScreen.tsx | 4 +- .../src/pages/Editor/QueryEditor/Table.tsx | 3 +- app/client/src/pages/Editor/WidgetsEditor.tsx | 2 + app/client/src/pages/Editor/routes.tsx | 16 +- app/client/src/reducers/index.tsx | 2 + .../reducers/uiReducers/debuggerReducer.ts | 118 +++ app/client/src/reducers/uiReducers/index.tsx | 2 + app/client/src/sagas/ActionExecutionSagas.ts | 135 +++- app/client/src/sagas/ActionSagas.ts | 34 +- app/client/src/sagas/DatasourcesSagas.ts | 123 +++- app/client/src/sagas/DebuggerSagas.ts | 167 +++++ app/client/src/sagas/EvaluationsSaga.ts | 11 + app/client/src/sagas/ModalSagas.ts | 7 + app/client/src/sagas/WidgetOperationSagas.tsx | 27 +- app/client/src/sagas/index.tsx | 2 + .../src/selectors/debuggerSelectors.tsx | 3 + app/client/src/selectors/entitiesSelector.ts | 1 - app/client/src/utils/AnalyticsUtil.tsx | 5 +- app/client/src/utils/AppsmithConsole.ts | 66 +- app/client/src/utils/DynamicBindingUtils.ts | 1 + app/client/src/widgets/BaseWidget.tsx | 16 +- app/client/src/widgets/ButtonWidget.tsx | 2 + app/client/src/widgets/ChartWidget/index.tsx | 1 + app/client/src/widgets/CheckboxWidget.tsx | 1 + app/client/src/widgets/DatePickerWidget.tsx | 1 + app/client/src/widgets/DatePickerWidget2.tsx | 1 + app/client/src/widgets/DropdownWidget.tsx | 5 +- app/client/src/widgets/FilepickerWidget.tsx | 1 + app/client/src/widgets/FormButtonWidget.tsx | 2 + app/client/src/widgets/IconWidget.tsx | 1 + app/client/src/widgets/ImageWidget.tsx | 1 + app/client/src/widgets/InputWidget.tsx | 3 + app/client/src/widgets/MapWidget.tsx | 2 + app/client/src/widgets/MetaHOC.tsx | 27 +- app/client/src/widgets/RadioGroupWidget.tsx | 1 + .../src/widgets/RichTextEditorWidget.tsx | 1 + app/client/src/widgets/SwitchWidget.tsx | 1 + app/client/src/widgets/TableWidget/index.tsx | 7 + app/client/src/widgets/TabsWidget.tsx | 1 + app/client/src/widgets/VideoWidget.tsx | 3 + app/client/src/workers/DataTreeEvaluator.ts | 12 + app/client/src/workers/validations.test.ts | 16 +- app/client/src/workers/validations.ts | 58 +- 92 files changed, 2868 insertions(+), 579 deletions(-) create mode 100644 app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Debugger/Logs_spec.js create mode 100644 app/client/src/actions/debuggerActions.ts create mode 100644 app/client/src/assets/icons/ads/bug.svg create mode 100644 app/client/src/assets/icons/ads/cancel.svg create mode 100644 app/client/src/assets/icons/ads/cross.svg create mode 100644 app/client/src/assets/icons/ads/open.svg create mode 100644 app/client/src/assets/icons/ads/wand.svg create mode 100644 app/client/src/assets/icons/ads/warning-triangle.svg create mode 100644 app/client/src/components/editorComponents/CloseEditor.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/DebugCTA.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/DebuggerLogs.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/DebuggerMessage.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/DebuggerTabs.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/EntityLink.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/Errors.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/FilterHeader.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/LogItem.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/Resizer/index.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/helpers.tsx create mode 100644 app/client/src/components/editorComponents/Debugger/index.tsx create mode 100644 app/client/src/entities/AppsmithConsole/logtype.ts create mode 100644 app/client/src/reducers/uiReducers/debuggerReducer.ts create mode 100644 app/client/src/sagas/DebuggerSagas.ts create mode 100644 app/client/src/selectors/debuggerSelectors.tsx diff --git a/app/client/cypress/fixtures/executionParamsDsl.json b/app/client/cypress/fixtures/executionParamsDsl.json index 32de9477c0..dbcd0e9ff2 100644 --- a/app/client/cypress/fixtures/executionParamsDsl.json +++ b/app/client/cypress/fixtures/executionParamsDsl.json @@ -114,7 +114,7 @@ "inputType": "TEXT", "label": "Endpoint", "widgetName": "EndpointInput", - "defaultText": "todos", + "defaultText": "users", "type": "INPUT_WIDGET", "isLoading": false, "parentColumnSpace": 71.75, diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js index 32505c69b9..678ba9f8a1 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/ActionExecution/ExecutionParams_spec.js @@ -1,6 +1,7 @@ const dsl = require("../../../../fixtures/executionParamsDsl.json"); const publishPage = require("../../../../locators/publishWidgetspage.json"); const commonlocators = require("../../../../locators/commonlocators.json"); +const testdata = require("../../../../fixtures/testdata.json"); describe("API Panel Test Functionality", function() { before(() => { @@ -11,8 +12,8 @@ describe("API Panel Test Functionality", function() { cy.NavigateToAPI_Panel(); cy.CreateAPI("MultiApi"); cy.enterDatasourceAndPath( - "https://jsonplaceholder.typicode.com/", - "{{this.params.endpoint || 'posts'}}", + testdata.baseUrl, + "{{this.params.endpoint || 'users'}}", ); cy.WaitAutoSave(); // Run it @@ -20,12 +21,10 @@ describe("API Panel Test Functionality", function() { // Bind the table cy.SearchEntityandOpen("Table1"); - cy.testJsontext("tabledata", "{{MultiApi.data", false); + cy.testJsontext("tabledata", "{{MultiApi.data.users", false); // Assert 'posts' data (default) cy.readTabledataPublish("0", "2").then((cellData) => { - expect(cellData).to.be.equal( - "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", - ); + expect(cellData).to.be.equal("APPROVED"); }); // Choose static button @@ -61,9 +60,7 @@ describe("API Panel Test Functionality", function() { // Assert on load data in table cy.readTabledataPublish("0", "2").then((cellData) => { - expect(cellData).to.be.equal( - "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", - ); + expect(cellData).to.be.equal("APPROVED"); }); // Click Static button @@ -74,7 +71,7 @@ describe("API Panel Test Functionality", function() { cy.wait(2000); // Assert statically bound "users" data cy.readTabledataPublish("1", "1").then((cellData) => { - expect(cellData).to.be.equal("Ervin Howell"); + expect(cellData).to.be.equal("Jenelle Kibbys"); }); // Click dynamic button @@ -85,7 +82,7 @@ describe("API Panel Test Functionality", function() { cy.wait(2000); // Assert dynamically bound "todos" data cy.readTabledataPublish("0", "2").then((cellData) => { - expect(cellData).to.be.equal("delectus aut autem"); + expect(cellData).to.be.equal("APPROVED"); }); }); }); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Debugger/Logs_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Debugger/Logs_spec.js new file mode 100644 index 0000000000..e884caf535 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Debugger/Logs_spec.js @@ -0,0 +1,14 @@ +const dsl = require("../../../../fixtures/buttondsl.json"); + +describe("Debugger logs", function() { + before(() => { + cy.addDsl(dsl); + }); + it("Modifying widget properties should log the same", function() { + cy.openPropertyPane("buttonwidget"); + cy.testJsontext("label", "Test"); + + cy.get(".t--debugger").click(); + cy.get(".t--debugger-log-state").contains("Test"); + }); +}); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/ApiPaneTests/API_All_Verb_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/ApiPaneTests/API_All_Verb_spec.js index 84030d93bb..5411c7745f 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/ApiPaneTests/API_All_Verb_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/ApiPaneTests/API_All_Verb_spec.js @@ -184,7 +184,7 @@ describe("API Panel Test Functionality", function() { ); cy.WaitAutoSave(); cy.RunAPI(); - cy.validateRequest(testdata.baseUrl, testdata.methods, testdata.Get); + cy.validateRequest(testdata.baseUrl, testdata.methods, testdata.Get, true); cy.ResponseStatusCheck("5000"); cy.log("Response code check successful"); cy.ResponseCheck("Invalid value for Content-Type"); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/QueryPane/EmptyDataSource_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/QueryPane/EmptyDataSource_spec.js index cf8ceae494..b4ce6a9abd 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/QueryPane/EmptyDataSource_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ServerSideTests/QueryPane/EmptyDataSource_spec.js @@ -31,10 +31,8 @@ describe("Create a query with a empty datasource, run, save the query", function cy.EvaluateCurrentValue("select * from users limit 10"); cy.runQuery(); - cy.get(".react-tabs p") - .last() - .contains( - "[Missing endpoint., Missing username for authentication., Missing password for authentication.]", - ); + cy.get(".t--query-error").contains( + "[Missing endpoint., Missing username for authentication., Missing password for authentication.]", + ); }); }); diff --git a/app/client/cypress/locators/commonlocators.json b/app/client/cypress/locators/commonlocators.json index ad383bfcd2..7248cf2091 100644 --- a/app/client/cypress/locators/commonlocators.json +++ b/app/client/cypress/locators/commonlocators.json @@ -100,7 +100,7 @@ "filePickerUploadButton": ".uppy-StatusBar-actionBtn--upload", "filePickerOnFilesSelected": ".t--property-control-onfilesselected", "dataType": ".t--property-control-datatype .bp3-popover-target", - "evaluateMsg": ".t--CodeEditor-evaluatedValue p", + "evaluateMsg": ".t--evaluatedPopup-error", "globalSearchModal": ".t--global-search-modal", "globalSearchInput": ".t--global-search-input", "globalSearchTrigger": ".t--global-search-modal-trigger", diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index de5ce8f31c..781a118230 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -577,16 +577,26 @@ Cypress.Commands.add("SaveAndRunAPI", () => { cy.RunAPI(); }); -Cypress.Commands.add("validateRequest", (baseurl, path, verb) => { - cy.xpath(apiwidget.Request) - .should("be.visible") - .click({ force: true }); - cy.xpath(apiwidget.RequestURL).contains(baseurl.concat(path)); - cy.xpath(apiwidget.RequestMethod).contains(verb); - cy.xpath(apiwidget.Responsetab) - .should("be.visible") - .click({ force: true }); -}); +Cypress.Commands.add( + "validateRequest", + (baseurl, path, verb, error = false) => { + cy.get(".react-tabs__tab") + .contains("Logs") + .click(); + + if (!error) { + cy.get(".object-key") + .last() + .contains("request") + .click(); + } + cy.get(".string-value").contains(baseurl.concat(path)); + cy.get(".string-value").contains(verb); + cy.xpath(apiwidget.Responsetab) + .should("be.visible") + .click({ force: true }); + }, +); Cypress.Commands.add("SelectAction", (action) => { cy.get(ApiEditor.ApiVerb) diff --git a/app/client/src/actions/debuggerActions.ts b/app/client/src/actions/debuggerActions.ts new file mode 100644 index 0000000000..a0ca54e4a7 --- /dev/null +++ b/app/client/src/actions/debuggerActions.ts @@ -0,0 +1,31 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; +import { Message } from "entities/AppsmithConsole"; + +export const debuggerLogInit = (payload: Message) => ({ + type: ReduxActionTypes.DEBUGGER_LOG_INIT, + payload, +}); + +export const debuggerLog = (payload: Message) => ({ + type: ReduxActionTypes.DEBUGGER_LOG, + payload, +}); + +export const clearLogs = () => ({ + type: ReduxActionTypes.CLEAR_DEBUGGER_LOGS, +}); + +export const showDebugger = (payload?: boolean) => ({ + type: ReduxActionTypes.SHOW_DEBUGGER, + payload, +}); + +export const errorLog = (payload: Message) => ({ + type: ReduxActionTypes.DEBUGGER_ERROR_LOG, + payload, +}); + +export const updateErrorLog = (payload: Message) => ({ + type: ReduxActionTypes.DEBUGGER_UPDATE_ERROR_LOG, + payload, +}); diff --git a/app/client/src/assets/icons/ads/bug.svg b/app/client/src/assets/icons/ads/bug.svg new file mode 100644 index 0000000000..28f60f75af --- /dev/null +++ b/app/client/src/assets/icons/ads/bug.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/assets/icons/ads/cancel.svg b/app/client/src/assets/icons/ads/cancel.svg new file mode 100644 index 0000000000..254210469e --- /dev/null +++ b/app/client/src/assets/icons/ads/cancel.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/assets/icons/ads/cross.svg b/app/client/src/assets/icons/ads/cross.svg new file mode 100644 index 0000000000..9589ed4d54 --- /dev/null +++ b/app/client/src/assets/icons/ads/cross.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/assets/icons/ads/open.svg b/app/client/src/assets/icons/ads/open.svg new file mode 100644 index 0000000000..2b43521e49 --- /dev/null +++ b/app/client/src/assets/icons/ads/open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/assets/icons/ads/wand.svg b/app/client/src/assets/icons/ads/wand.svg new file mode 100644 index 0000000000..5a51055344 --- /dev/null +++ b/app/client/src/assets/icons/ads/wand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/client/src/assets/icons/ads/warning-triangle.svg b/app/client/src/assets/icons/ads/warning-triangle.svg new file mode 100644 index 0000000000..350e33a327 --- /dev/null +++ b/app/client/src/assets/icons/ads/warning-triangle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index f2eb7aa482..ac3a081541 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -1,6 +1,10 @@ import React, { forwardRef, Ref } from "react"; import { ReactComponent as DeleteIcon } from "assets/icons/ads/delete.svg"; import { ReactComponent as BookIcon } from "assets/icons/ads/book.svg"; +import { ReactComponent as BugIcon } from "assets/icons/ads/bug.svg"; +import { ReactComponent as CancelIcon } from "assets/icons/ads/cancel.svg"; +import { ReactComponent as CrossIcon } from "assets/icons/ads/cross.svg"; +import { ReactComponent as OpenIcon } from "assets/icons/ads/open.svg"; import { ReactComponent as UserIcon } from "assets/icons/ads/user.svg"; import { ReactComponent as GeneralIcon } from "assets/icons/ads/general.svg"; import { ReactComponent as BillingIcon } from "assets/icons/ads/billing.svg"; @@ -11,6 +15,7 @@ import { ReactComponent as SuccessIcon } from "assets/icons/ads/success.svg"; import { ReactComponent as SearchIcon } from "assets/icons/ads/search.svg"; import { ReactComponent as CloseIcon } from "assets/icons/ads/close.svg"; import { ReactComponent as WarningIcon } from "assets/icons/ads/warning.svg"; +import { ReactComponent as WarningTriangleIcon } from "assets/icons/ads/warning-triangle.svg"; import { ReactComponent as DownArrow } from "assets/icons/ads/down_arrow.svg"; import { ReactComponent as ShareIcon } from "assets/icons/ads/share.svg"; import { ReactComponent as RocketIcon } from "assets/icons/ads/launch.svg"; @@ -37,6 +42,7 @@ import { ReactComponent as RightArrowIcon } from "assets/icons/ads/right-arrow.s import { ReactComponent as DatasourceIcon } from "assets/icons/ads/datasource.svg"; import { ReactComponent as PlayIcon } from "assets/icons/ads/play.svg"; import { ReactComponent as DesktopIcon } from "assets/icons/ads/desktop.svg"; +import { ReactComponent as WandIcon } from "assets/icons/ads/wand.svg"; import { ReactComponent as MobileIcon } from "assets/icons/ads/mobile.svg"; import { ReactComponent as TabletIcon } from "assets/icons/ads/tablet.svg"; import { ReactComponent as FluidIcon } from "assets/icons/ads/fluid.svg"; @@ -95,7 +101,11 @@ export const sizeHandler = (size?: IconSize) => { export const IconCollection = [ "book", + "bug", + "cancel", + "cross", "delete", + "open", "user", "general", "billing", @@ -114,6 +124,7 @@ export const IconCollection = [ "view-all", "view-less", "warning", + "warning-triangle", "downArrow", "context-menu", "duplicate", @@ -133,6 +144,7 @@ export const IconCollection = [ "datasource", "play", "desktop", + "wand", "mobile", "tablet", "fluid", @@ -154,17 +166,29 @@ export const IconWrapper = styled.span` svg { width: ${(props) => sizeHandler(props.size)}px; height: ${(props) => sizeHandler(props.size)}px; + ${(props) => + !props.keepColors + ? ` path { - fill: ${(props) => props.fillColor || props.theme.colors.icon.normal}; + fill: ${props.fillColor || props.theme.colors.icon.normal}; } - } + circle { + fill: ${props.fillColor || props.theme.colors.icon.normal}; + } + ` + : ""} ${(props) => (props.invisible ? `visibility: hidden;` : null)}; &:hover { cursor: pointer; + ${(props) => + !props.keepColors + ? ` path { - fill: ${(props) => props.theme.colors.icon.hover}; + fill: ${props.theme.colors.icon.hover}; } + ` + : ""} } &:active { @@ -182,6 +206,7 @@ export type IconProps = { className?: string; onClick?: (e: React.MouseEvent) => void; fillColor?: string; + keepColors?: boolean; }; const Icon = forwardRef( @@ -191,9 +216,21 @@ const Icon = forwardRef( case "book": returnIcon = ; break; + case "bug": + returnIcon = ; + break; + case "cancel": + returnIcon = ; + break; + case "cross": + returnIcon = ; + break; case "delete": returnIcon = ; break; + case "open": + returnIcon = ; + break; case "user": returnIcon = ; break; @@ -233,6 +270,9 @@ const Icon = forwardRef( case "rocket": returnIcon = ; break; + case "wand": + returnIcon = ; + break; case "workspace": returnIcon = ; break; @@ -263,6 +303,9 @@ const Icon = forwardRef( case "warning": returnIcon = ; break; + case "warning-triangle": + returnIcon = ; + break; case "arrow-left": returnIcon = ; break; diff --git a/app/client/src/components/ads/Tabs.tsx b/app/client/src/components/ads/Tabs.tsx index b5b9b40e80..37b698a753 100644 --- a/app/client/src/components/ads/Tabs.tsx +++ b/app/client/src/components/ads/Tabs.tsx @@ -17,9 +17,6 @@ const TabsWrapper = styled.div<{ shouldOverflow?: boolean }>` user-select: none; border-radius: 0px; height: 100%; - .${Classes.ICON} { - margin-right: ${(props) => props.theme.spaces[3]}px; - } .react-tabs { height: 100%; } @@ -104,6 +101,9 @@ const TabsWrapper = styled.div<{ shouldOverflow?: boolean }>` const TabTitleWrapper = styled.div` display: flex; align-items: center; + .${Classes.ICON} { + margin-right: ${(props) => props.theme.spaces[3]}px; + } `; const TabTitle = styled.span` diff --git a/app/client/src/components/ads/TextInput.tsx b/app/client/src/components/ads/TextInput.tsx index 814670b05c..5bf0b9f4e7 100644 --- a/app/client/src/components/ads/TextInput.tsx +++ b/app/client/src/components/ads/TextInput.tsx @@ -92,7 +92,7 @@ const StyledInput = styled((props) => { dataType={dataType} /> ) : ( - + ); })` width: ${(props) => (props.fill ? "100%" : "320px")}; diff --git a/app/client/src/components/ads/Toast.tsx b/app/client/src/components/ads/Toast.tsx index ad54862175..e31fafe90a 100644 --- a/app/client/src/components/ads/Toast.tsx +++ b/app/client/src/components/ads/Toast.tsx @@ -7,6 +7,8 @@ import { toast, ToastOptions, ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { ReduxActionType } from "constants/ReduxActionConstants"; import { useDispatch } from "react-redux"; +import { Colors } from "constants/Colors"; +import DebugButton from "components/editorComponents/Debugger/DebugCTA"; type ToastProps = ToastOptions & CommonComponentProps & { @@ -59,7 +61,7 @@ const ToastBody = styled.div<{ justify-content: space-between; overflow-wrap: anywhere; - .${Classes.ICON} { + div > .${Classes.ICON} { cursor: auto; margin-right: ${(props) => props.theme.spaces[3]}px; margin-top: ${(props) => props.theme.spaces[1] / 2}px; @@ -104,6 +106,10 @@ const FlexContainer = styled.div` align-items: flex-start; `; +const StyledDebugButton = styled(DebugButton)` + margin-left: auto; +`; + const ToastComponent = (props: ToastProps & { undoAction?: () => void }) => { const dispatch = useDispatch(); @@ -116,14 +122,19 @@ const ToastComponent = (props: ToastProps & { undoAction?: () => void }) => { > {props.variant === Variant.success ? ( - + ) : props.variant === Variant.warning ? ( ) : null} {props.variant === Variant.danger ? ( ) : null} - {props.text} +
+ {props.text} + {props.variant === Variant.danger ? ( + + ) : null} +
{props.onUndo || props.dispatchableAction ? ( diff --git a/app/client/src/components/editorComponents/ApiResponseView.tsx b/app/client/src/components/editorComponents/ApiResponseView.tsx index 0844e89deb..368101f4fa 100644 --- a/app/client/src/components/editorComponents/ApiResponseView.tsx +++ b/app/client/src/components/editorComponents/ApiResponseView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef, RefObject, useCallback } from "react"; import { connect } from "react-redux"; import { withRouter, RouteComponentProps } from "react-router"; import { BaseText } from "components/designSystems/blueprint/TextComponent"; @@ -12,24 +12,26 @@ import ReadOnlyEditor from "components/editorComponents/ReadOnlyEditor"; import { getActionResponses } from "selectors/entitiesSelector"; import { Colors } from "constants/Colors"; import _ from "lodash"; -import { RequestView } from "./RequestView"; import { useLocalStorage } from "utils/hooks/localstorage"; -import { - CHECK_REQUEST_BODY, - createMessage, - SHOW_REQUEST, -} from "constants/messages"; +import { CHECK_REQUEST_BODY, createMessage } from "constants/messages"; import { TabComponent } from "components/ads/Tabs"; -import Text, { Case, TextType } from "components/ads/Text"; +import Text, { TextType } from "components/ads/Text"; import Icon from "components/ads/Icon"; import { Classes, Variant } from "components/ads/common"; import { EditorTheme } from "./CodeEditor/EditorConfig"; import Callout from "components/ads/Callout"; +import DebuggerLogs from "./Debugger/DebuggerLogs"; +import ErrorLogs from "./Debugger/Errors"; +import Resizer, { ResizerCSS } from "./Debugger/Resizer"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { DebugButton } from "./Debugger/DebugCTA"; const ResponseContainer = styled.div` - position: relative; - flex: 1; - height: 50%; + ${ResizerCSS} + // Initial height of bottom tabs + height: 60%; + // Minimum height of bottom tabs as it can be resized + min-height: 36px; background-color: ${(props) => props.theme.colors.apiPane.responseBody.bg}; .react-tabs__tab-panel { @@ -121,14 +123,7 @@ const NoResponseContainer = styled.div` const FailedMessage = styled.div` display: flex; align-items: center; -`; - -const ShowRequestText = styled.a` - display: flex; - margin-left: ${(props) => props.theme.spaces[1] + 1}px; - .${Classes.ICON} { - margin-left: ${(props) => props.theme.spaces[1] + 1}px; - } + margin-left: 5px; `; interface ReduxStateProps { @@ -137,7 +132,10 @@ interface ReduxStateProps { } type Props = ReduxStateProps & - RouteComponentProps & { theme?: EditorTheme }; + RouteComponentProps & { + theme?: EditorTheme; + apiName: string; + }; export const EMPTY_RESPONSE: ActionResponse = { statusCode: "", @@ -173,12 +171,20 @@ const ApiResponseView = (props: Props) => { isRunning = props.isRunning[apiId]; hasFailed = response.statusCode ? response.statusCode[0] !== "2" : false; } + const panelRef: RefObject = useRef(null); const [requestDebugVisible, setRequestDebugVisible] = useLocalStorage( "requestDebugVisible", "true", ); + const onDebugClick = useCallback(() => { + AnalyticsUtil.logEvent("OPEN_DEBUGGER", { + source: "API", + }); + setSelectedIndex(1); + }, []); + const [selectedIndex, setSelectedIndex] = useState(0); const tabs = [ { @@ -187,28 +193,20 @@ const ApiResponseView = (props: Props) => { panelComponent: ( {hasFailed && !isRunning && requestDebugVisible && ( - - { - setSelectedIndex(1); - }} - > - - {createMessage(SHOW_REQUEST)} - - - - - } - variant={Variant.warning} - fill - closeButton - onClose={() => setRequestDebugVisible(false)} - /> + <> + + + + } + variant={Variant.danger} + fill + closeButton + onClose={() => setRequestDebugVisible(false)} + /> + )} {_.isEmpty(response.statusCode) ? ( @@ -230,25 +228,20 @@ const ApiResponseView = (props: Props) => { ), }, { - key: "request", - title: "Request", - panelComponent: ( - - ), + key: "error-logs", + title: "Errors", + panelComponent: , + }, + { + key: "logs", + title: "Logs", + panelComponent: , }, ]; return ( - + + {isRunning && ( diff --git a/app/client/src/components/editorComponents/CloseEditor.tsx b/app/client/src/components/editorComponents/CloseEditor.tsx new file mode 100644 index 0000000000..0f8d3178c9 --- /dev/null +++ b/app/client/src/components/editorComponents/CloseEditor.tsx @@ -0,0 +1,74 @@ +import TooltipComponent from "components/ads/Tooltip"; +import { BUILDER_PAGE_URL } from "constants/routes"; +import React from "react"; +import { useSelector } from "react-redux"; +import { useHistory } from "react-router-dom"; +import styled from "styled-components"; +import { Position } from "@blueprintjs/core"; +import Text, { TextType } from "components/ads/Text"; +import { + getCurrentApplicationId, + getCurrentPageId, +} from "selectors/editorSelectors"; +import Icon, { IconSize } from "components/ads/Icon"; +import PerformanceTracker, { + PerformanceTransactionName, +} from "utils/PerformanceTracker"; + +const IconContainer = styled.div` + width: 22px; + height: 22px; + display: flex; + margin-right: 16px; + justify-content: center; + align-items: center; + cursor: pointer; + svg { + width: 12px; + height: 12px; + path { + fill: ${(props) => props.theme.colors.apiPane.closeIcon}; + } + } + &:hover { + background-color: ${(props) => props.theme.colors.apiPane.iconHoverBg}; + } +`; + +const CloseEditor = () => { + const applicationId = useSelector(getCurrentApplicationId); + const pageId = useSelector(getCurrentPageId); + + const history = useHistory(); + const handleClose = (e: React.MouseEvent) => { + PerformanceTracker.startTracking( + PerformanceTransactionName.CLOSE_SIDE_PANE, + { path: location.pathname }, + ); + e.stopPropagation(); + history.push(BUILDER_PAGE_URL(applicationId, pageId)); + }; + + return ( + + Close + + } + minWidth="auto !important" + > + + + + + ); +}; + +export default CloseEditor; diff --git a/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx index 82d1518f7c..b505fff205 100644 --- a/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/EvaluatedValuePopup.tsx @@ -7,6 +7,7 @@ import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig import { theme } from "constants/DefaultTheme"; import { Placement } from "popper.js"; import ScrollIndicator from "components/ads/ScrollIndicator"; +import DebugButton from "components/editorComponents/Debugger/DebugCTA"; const Wrapper = styled.div` position: relative; @@ -97,6 +98,10 @@ const StyledTitle = styled.p` margin: 8px 0; `; +const StyledDebugButton = styled(DebugButton)` + margin-left: auto; +`; + interface Props { theme: EditorTheme; isOpen: boolean; @@ -183,6 +188,7 @@ export const CurrentValueViewer = (props: { const PopoverContent = (props: PopoverContentProps) => { const typeTextRef = React.createRef(); + return ( { > {props.hasError && ( - {props.useValidationMessage && props.error - ? props.error - : `This value does not evaluate to type "${props.expected}". Transform it using JS inside '{{ }}'`} + + {props.useValidationMessage && props.error + ? props.error + : `This value does not evaluate to type "${props.expected}". Transform it using JS inside '{{ }}'`} + + )} {!props.hasError && props.expected && ( diff --git a/app/client/src/components/editorComponents/Debugger/DebugCTA.tsx b/app/client/src/components/editorComponents/Debugger/DebugCTA.tsx new file mode 100644 index 0000000000..2938b2f5f1 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/DebugCTA.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import styled from "styled-components"; +import Button from "components/ads/Button"; +import { showDebugger } from "actions/debuggerActions"; +import { useDispatch, useSelector } from "react-redux"; +import { Classes, Variant } from "components/ads/common"; +import { getAppMode } from "selectors/applicationSelectors"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { getTypographyByKey } from "constants/DefaultTheme"; + +const StyledButton = styled(Button)` + && { + width: fit-content; + margin-top: 4px; + text-transform: none; + ${(props) => getTypographyByKey(props, "p2")} + .${Classes.ICON} { + margin-right: 5px; + } + &:hover { + .${Classes.ICON} { + margin-right: 5px; + } + } + } +`; + +type DebugCTAProps = { + className?: string; + // For Analytics + source?: string; +}; + +const DebugCTA = (props: DebugCTAProps) => { + const dispatch = useDispatch(); + const appMode = useSelector(getAppMode); + + if (appMode === "PUBLISHED") return null; + + const onClick = () => { + props.source && + AnalyticsUtil.logEvent("OPEN_DEBUGGER", { + source: props.source, + }); + dispatch(showDebugger(true)); + }; + + return ; +}; + +type DebugButtonProps = { + className?: string; + onClick: () => void; +}; + +export const DebugButton = (props: DebugButtonProps) => { + return ( + + ); +}; + +export default DebugCTA; diff --git a/app/client/src/components/editorComponents/Debugger/DebuggerLogs.tsx b/app/client/src/components/editorComponents/Debugger/DebuggerLogs.tsx new file mode 100644 index 0000000000..b3ebca6c8f --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/DebuggerLogs.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useRef, useState, useMemo } from "react"; +import styled from "styled-components"; +import { isUndefined } from "lodash"; +import { Severity } from "entities/AppsmithConsole"; +import FilterHeader from "./FilterHeader"; +import { BlankState, useFilteredLogs, usePagination } from "./helpers"; +import LogItem, { getLogItemProps } from "./LogItem"; + +const LIST_HEADER_HEIGHT = "38px"; + +const ContainerWrapper = styled.div` + overflow: hidden; + height: 100%; +`; + +const ListWrapper = styled.div` + overflow: auto; + height: calc(100% - ${LIST_HEADER_HEIGHT}); +`; + +type Props = { + searchQuery: string; + hasShortCut?: boolean; +}; + +const LOGS_FILTER_OPTIONS = [ + { + label: "All", + value: "", + }, + { label: "Success", value: Severity.INFO }, + { label: "Warnings", value: Severity.WARNING }, + { label: "Errors", value: Severity.ERROR }, +]; + +const DebbuggerLogs = (props: Props) => { + const [filter, setFilter] = useState(""); + const [searchQuery, setSearchQuery] = useState(props.searchQuery); + const filteredLogs = useFilteredLogs(searchQuery, filter); + const { paginatedData, next } = usePagination(filteredLogs); + const listRef = useRef(null); + const selectedFilter = useMemo( + () => LOGS_FILTER_OPTIONS.find((option) => option.value === filter), + [filter], + ); + + const handleScroll = (e: Event) => { + if ((e.target as HTMLDivElement).scrollTop === 0) { + next(); + } + }; + + useEffect(() => { + const list = listRef.current; + if (!list) return; + list.addEventListener("scroll", handleScroll); + return () => list.removeEventListener("scroll", handleScroll); + }, []); + + useEffect(() => { + const list = listRef.current; + if (list) { + setTimeout(() => { + list.scrollTop = list.scrollHeight - list.clientHeight; + }, 0); + } + }, [paginatedData.length]); + + return ( + + !isUndefined(value) && setFilter(value)} + defaultValue={props.searchQuery} + searchQuery={searchQuery} + /> + + + {!paginatedData.length ? ( + + ) : ( + paginatedData.map((e, index: number) => { + const logItemProps = getLogItemProps(e); + + return ( + + ); + }) + )} + + + ); +}; + +// Set default props +DebbuggerLogs.defaultProps = { + searchQuery: "", +}; + +export default DebbuggerLogs; diff --git a/app/client/src/components/editorComponents/Debugger/DebuggerMessage.tsx b/app/client/src/components/editorComponents/Debugger/DebuggerMessage.tsx new file mode 100644 index 0000000000..29296b0081 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/DebuggerMessage.tsx @@ -0,0 +1,25 @@ +import { CLICK_ON, createMessage, OPEN_THE_DEBUGGER } from "constants/messages"; +import React from "react"; +import styled from "styled-components"; +import { DebugButton } from "./DebugCTA"; + +const StyledButton = styled(DebugButton)` + display: inline-flex; +`; + +const Container = styled.div` + padding: 15px 0px; + color: ${(props) => props.theme.colors.debugger.messageTextColor}; +`; + +const DebuggerMessage = (props: any) => { + return ( + + {createMessage(CLICK_ON)} + + {createMessage(OPEN_THE_DEBUGGER)} + + ); +}; + +export default DebuggerMessage; diff --git a/app/client/src/components/editorComponents/Debugger/DebuggerTabs.tsx b/app/client/src/components/editorComponents/Debugger/DebuggerTabs.tsx new file mode 100644 index 0000000000..f1c686865b --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/DebuggerTabs.tsx @@ -0,0 +1,85 @@ +import React, { RefObject, useRef, useState } from "react"; +import styled from "styled-components"; +import { TabComponent } from "components/ads/Tabs"; +import Icon, { IconSize } from "components/ads/Icon"; +import DebuggerLogs from "./DebuggerLogs"; +import { useDispatch } from "react-redux"; +import { showDebugger } from "actions/debuggerActions"; +import Errors from "./Errors"; +import Resizer, { ResizerCSS } from "./Resizer"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +const TABS_HEADER_HEIGHT = 36; + +const Container = styled.div` + ${ResizerCSS} + position: fixed; + bottom: 0; + height: 25%; + min-height: ${TABS_HEADER_HEIGHT}px; + background-color: ${(props) => props.theme.colors.debugger.background}; + + ul.react-tabs__tab-list { + padding: 0px ${(props) => props.theme.spaces[12]}px; + } + .react-tabs__tab-panel { + height: calc(100% - ${TABS_HEADER_HEIGHT}px); + } + + .close-debugger { + position: absolute; + top: 0px; + right: 0px; + padding: 12px 15px; + } +`; + +type DebuggerTabsProps = { + defaultIndex: number; +}; + +const DEBUGGER_TABS = [ + { + key: "ERROR", + title: "Errors", + panelComponent: , + }, + { + key: "LOGS", + title: "Logs", + panelComponent: , + }, +]; + +const DebuggerTabs = (props: DebuggerTabsProps) => { + const [selectedIndex, setSelectedIndex] = useState(props.defaultIndex); + const dispatch = useDispatch(); + const panelRef: RefObject = useRef(null); + const onTabSelect = (index: number) => { + AnalyticsUtil.logEvent("DEBUGGER_TAB_SWITCH", { + tabName: DEBUGGER_TABS[index].key, + }); + + setSelectedIndex(index); + }; + const onClose = () => dispatch(showDebugger(false)); + + return ( + + + + + + ); +}; + +export default DebuggerTabs; diff --git a/app/client/src/components/editorComponents/Debugger/EntityLink.tsx b/app/client/src/components/editorComponents/Debugger/EntityLink.tsx new file mode 100644 index 0000000000..c86fb00876 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/EntityLink.tsx @@ -0,0 +1,161 @@ +import { DATA_SOURCES_EDITOR_ID_URL } from "constants/routes"; +import { PluginType } from "entities/Action"; +import { ENTITY_TYPE, SourceEntity } from "entities/AppsmithConsole"; +import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; +import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/WidgetEntity"; +import React, { useCallback } from "react"; +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import { + getCurrentApplicationId, + getCurrentPageId, +} from "selectors/editorSelectors"; +import { + getAction, + getAllWidgetsMap, + getDatasource, +} from "selectors/entitiesSelector"; +import { getSelectedWidget } from "selectors/ui"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import history from "utils/history"; + +const ActionLink = (props: EntityLinkProps) => { + const applicationId = useSelector(getCurrentApplicationId); + const action = useSelector((state: AppState) => getAction(state, props.id)); + + const onClick = useCallback(() => { + if (action) { + const { pageId, pluginType, id } = action; + const actionConfig = getActionConfig(pluginType); + const url = + applicationId && actionConfig?.getURL(applicationId, pageId, id); + + if (url) { + history.push(url); + const actionType = + action.pluginType === PluginType.API ? "API" : "QUERY"; + + AnalyticsUtil.logEvent("DEBUGGER_ENTITY_NAVIGATION", { + entityType: actionType, + }); + } + } + }, []); + + return ( + + ); +}; + +const WidgetLink = (props: EntityLinkProps) => { + const widgetMap = useSelector(getAllWidgetsMap); + const selectedWidgetId = useSelector(getSelectedWidget); + const { navigateToWidget } = useNavigateToWidget(); + + const onClick = useCallback(() => { + const widget = widgetMap[props.id]; + if (!widget) return; + + navigateToWidget( + props.id, + widget.type, + widget.pageId, + props.id === selectedWidgetId, + widget.parentModalId, + ); + AnalyticsUtil.logEvent("DEBUGGER_ENTITY_NAVIGATION", { + entityType: "WIDGET", + }); + }, []); + + return ( + + ); +}; + +const DatasourceLink = (props: EntityLinkProps) => { + const datasource = useSelector((state: AppState) => + getDatasource(state, props.id), + ); + const pageId = useSelector(getCurrentPageId); + const appId = useSelector(getCurrentApplicationId); + + const onClick = () => { + if (datasource) { + history.push(DATA_SOURCES_EDITOR_ID_URL(appId, pageId, datasource.id)); + AnalyticsUtil.logEvent("DEBUGGER_ENTITY_NAVIGATION", { + entityType: "DATASOURCE", + }); + } + }; + + return ( + + ); +}; + +const Link = (props: { + name: string; + onClick: any; + entityType: ENTITY_TYPE; + uiComponent: DebuggerLinkUI; +}) => { + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + props.onClick(); + }; + + switch (props.uiComponent) { + case DebuggerLinkUI.ENTITY_TYPE: + return ( + + [{props.name}] + + ); + case DebuggerLinkUI.ENTITY_NAME: + return ( + + {props.name}.{props.entityType.toLowerCase()} + + ); + default: + return null; + } +}; + +const entityTypeLinkMap = { + [ENTITY_TYPE.WIDGET]: WidgetLink, + [ENTITY_TYPE.ACTION]: ActionLink, + [ENTITY_TYPE.DATASOURCE]: DatasourceLink, +}; + +const EntityLink = (props: EntityLinkProps) => { + const Component = entityTypeLinkMap[props.type]; + return ; +}; + +type EntityLinkProps = { + uiComponent: DebuggerLinkUI; +} & SourceEntity; + +export enum DebuggerLinkUI { + ENTITY_TYPE, + ENTITY_NAME, +} + +export default EntityLink; diff --git a/app/client/src/components/editorComponents/Debugger/Errors.tsx b/app/client/src/components/editorComponents/Debugger/Errors.tsx new file mode 100644 index 0000000000..91c0e4ef33 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/Errors.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import styled from "styled-components"; +import { getDebuggerErrors } from "selectors/debuggerSelectors"; +import LogItem, { getLogItemProps } from "./LogItem"; +import { BlankState } from "./helpers"; + +const ContainerWrapper = styled.div` + overflow: hidden; + height: 100%; +`; + +const ListWrapper = styled.div` + overflow: auto; + height: 100%; +`; + +const Errors = (props: { hasShortCut?: boolean }) => { + const errors = useSelector(getDebuggerErrors); + const expandId = useSelector((state: any) => state.ui.debugger.expandId); + + return ( + + + {!Object.values(errors).length ? ( + + ) : ( + Object.values(errors).map((e, index) => { + const logItemProps = getLogItemProps(e); + const id = Object.keys(errors)[index]; + + return ( + + ); + }) + )} + + + ); +}; + +export default Errors; diff --git a/app/client/src/components/editorComponents/Debugger/FilterHeader.tsx b/app/client/src/components/editorComponents/Debugger/FilterHeader.tsx new file mode 100644 index 0000000000..a9ffddcf2b --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/FilterHeader.tsx @@ -0,0 +1,99 @@ +import React, { MutableRefObject, useRef } from "react"; +import Dropdown, { DropdownOption } from "components/ads/Dropdown"; +import TextInput from "components/ads/TextInput"; +import styled from "styled-components"; +import Icon, { IconSize } from "components/ads/Icon"; +import { useDispatch } from "react-redux"; + +import { clearLogs } from "actions/debuggerActions"; +import { Classes } from "components/ads/common"; + +const Wrapper = styled.div` + flex-direction: row; + display: flex; + justify-content: flex-start; + margin-left: 30px; + padding: 5px 0; + & > div { + width: 160px; + margin: 0 16px; + } + + .debugger-search { + height: 28px; + width: 160px; + padding-right: 25px; + } + + .debugger-filter { + background: transparent; + border: none; + box-shadow: none; + width: 100px; + } + + .input-container { + position: relative; + .${Classes.ICON} { + position: absolute; + right: 9px; + top: 9px; + } + } +`; + +type FilterHeaderProps = { + options: DropdownOption[]; + selected: DropdownOption; + onChange: (value: string) => void; + onSelect: (value?: string) => void; + defaultValue: string; + searchQuery: string; +}; + +const FilterHeader = (props: FilterHeaderProps) => { + const dispatch = useDispatch(); + const searchRef: MutableRefObject = useRef(null); + + return ( + + dispatch(clearLogs())} + /> +
+ + {props.searchQuery && ( + { + if (searchRef.current) { + props.onChange(""); + searchRef.current.value = ""; + } + }} + /> + )} +
+ +
+ ); +}; + +export default FilterHeader; diff --git a/app/client/src/components/editorComponents/Debugger/LogItem.tsx b/app/client/src/components/editorComponents/Debugger/LogItem.tsx new file mode 100644 index 0000000000..f3ecaf69d4 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/LogItem.tsx @@ -0,0 +1,281 @@ +import { Collapse, Position } from "@blueprintjs/core"; +import { Classes } from "components/ads/common"; +import Icon, { IconName, IconSize } from "components/ads/Icon"; +import { Message, Severity, SourceEntity } from "entities/AppsmithConsole"; +import React, { useCallback, useState } from "react"; +import ReactJson from "react-json-view"; +import styled from "styled-components"; +import { isString } from "lodash"; +import EntityLink, { DebuggerLinkUI } from "./EntityLink"; +import { SeverityIcon, SeverityIconColor } from "./helpers"; +import { useDispatch } from "react-redux"; +import { + setGlobalSearchQuery, + toggleShowGlobalSearchModal, +} from "actions/globalSearchActions"; +import Text, { TextType } from "components/ads/Text"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import TooltipComponent from "components/ads/Tooltip"; +import { createMessage, TROUBLESHOOT_ISSUE } from "constants/messages"; + +const Log = styled.div<{ collapsed: boolean }>` + padding: 9px 30px; + display: flex; + + &.${Severity.INFO} { + border-bottom: 1px solid + ${(props) => props.theme.colors.debugger.info.borderBottom}; + } + + &.${Severity.ERROR} { + background-color: ${(props) => + props.theme.colors.debugger.error.backgroundColor}; + border-bottom: 1px solid + ${(props) => props.theme.colors.debugger.error.borderBottom}; + } + + &.${Severity.WARNING} { + background-color: ${(props) => + props.theme.colors.debugger.warning.backgroundColor}; + border-bottom: 1px solid + ${(props) => props.theme.colors.debugger.warning.borderBottom}; + } + + .bp3-popover-target { + display: inline; + } + + .${Classes.ICON} { + display: inline-block; + } + + .debugger-time { + ${(props) => getTypographyByKey(props, "h6")} + line-height: 17px; + color: ${(props) => props.theme.colors.debugger.time}; + margin-left: 10px; + } + .debugger-description { + display: inline-block; + margin-left: 7px; + + .debugger-toggle { + ${(props) => props.collapsed && `transform: rotate(-90deg);`} + } + + .debugger-label { + color: ${(props) => props.theme.colors.debugger.label}; + margin-left: 5px; + ${(props) => getTypographyByKey(props, "p2")} + } + .debugger-entity { + color: ${(props) => props.theme.colors.debugger.entity}; + ${(props) => getTypographyByKey(props, "h6")} + margin-left: 6px; + + & > span { + cursor: pointer; + + &:hover { + text-decoration: underline; + text-decoration-color: ${(props) => + props.theme.colors.debugger.entity}; + } + } + } + } + .debugger-timetaken { + color: ${(props) => props.theme.colors.debugger.entity}; + margin-left: 5px; + ${(props) => getTypographyByKey(props, "p2")} + line-height: 19px; + } + + .debugger-entity-link { + margin-left: auto; + ${(props) => getTypographyByKey(props, "p2")} + color: ${(props) => props.theme.colors.debugger.entityLink}; + text-decoration-line: underline; + cursor: pointer; + } +`; + +const JsonWrapper = styled.div` + padding-top: 4px; + svg { + color: ${(props) => props.theme.colors.debugger.jsonIcon} !important; + height: 12px !important; + width: 12px !important; + vertical-align: baseline !important; + } +`; + +const StyledCollapse = styled(Collapse)` + margin-top: 4px; + + .debugger-message { + ${(props) => getTypographyByKey(props, "p2")} + color: ${(props) => props.theme.colors.debugger.message}; + text-decoration-line: underline; + cursor: pointer; + } + + .${Classes.ICON} { + margin-left: 10px; + } +`; + +const StyledSearchIcon = styled(Icon)` + && { + margin-left: 10px; + vertical-align: middle; + + &:hover { + path { + fill: ${(props) => props.fillColor}; + } + } + } +`; + +export const getLogItemProps = (e: Message) => { + return { + icon: SeverityIcon[e.severity] as IconName, + iconColor: SeverityIconColor[e.severity], + timestamp: e.timestamp, + source: e.source, + label: e.text, + timeTaken: e.timeTaken ? `${e.timeTaken}ms` : "", + severity: e.severity, + text: e.text, + message: e.message && isString(e.message) ? e.message : "", + state: e.state, + id: e.source ? e.source.id : undefined, + }; +}; + +type LogItemProps = { + icon: IconName; + iconColor: string; + timestamp: string; + label: string; + timeTaken: string; + severity: Severity; + text: string; + message: string; + state?: Record; + id?: string; + source?: SourceEntity; + expand?: boolean; +}; + +const LogItem = (props: LogItemProps) => { + const [isOpen, setIsOpen] = useState(!!props.expand); + const reactJsonProps = { + name: null, + enableClipboard: false, + displayObjectSize: false, + displayDataTypes: false, + style: { + fontSize: "13px", + }, + collapsed: 1, + }; + const showToggleIcon = props.state || props.message; + const dispatch = useDispatch(); + + const openHelpModal = useCallback((e) => { + e.stopPropagation(); + const text = props.message || props.text; + + AnalyticsUtil.logEvent("OPEN_OMNIBAR", { + source: "DEBUGGER", + searchTerm: text, + }); + dispatch(setGlobalSearchQuery(text || "")); + dispatch(toggleShowGlobalSearchModal()); + }, []); + + return ( + setIsOpen(!isOpen)} + > + + {props.timestamp} +
+ {showToggleIcon && ( + setIsOpen(!isOpen)} + size={IconSize.XXS} + /> + )} + {props.source && ( + + )} + {props.text} + {props.timeTaken && ( + {props.timeTaken} + )} + {props.severity !== Severity.INFO && ( + + {createMessage(TROUBLESHOOT_ISSUE)} + + } + > + + + )} + + {showToggleIcon && ( + + {props.message && ( +
+ + {props.message} + +
+ )} + {props.state && ( + e.stopPropagation()} + className="t--debugger-log-state" + > + + + )} +
+ )} +
+ {props.source && ( + + )} +
+ ); +}; + +export default LogItem; diff --git a/app/client/src/components/editorComponents/Debugger/Resizer/index.tsx b/app/client/src/components/editorComponents/Debugger/Resizer/index.tsx new file mode 100644 index 0000000000..ade1d06f19 --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/Resizer/index.tsx @@ -0,0 +1,79 @@ +import { Layers } from "constants/Layers"; +import React, { useState, useEffect, RefObject } from "react"; +import styled, { css } from "styled-components"; + +export const ResizerCSS = css` + width: calc(100vw - ${(props) => props.theme.sidebarWidth}); + z-index: ${Layers.debugger}; + position: relative; +`; + +const Top = styled.div` + position: absolute; + cursor: ns-resize; + height: 4px; + width: 100%; + z-index: 1; + left: 0; + top: 0; +`; + +type ResizerProps = { + panelRef: RefObject; +}; + +const Resizer = (props: ResizerProps) => { + const [mouseDown, setMouseDown] = useState(false); + + const handleResize = (movementY: number) => { + const panel = props.panelRef.current; + if (!panel) return; + + const { height } = panel.getBoundingClientRect(); + const updatedHeight = height - movementY; + const headerHeightNumber = 35; + const minHeight = parseInt( + window.getComputedStyle(panel).minHeight.replace("px", ""), + ); + + if ( + updatedHeight < window.innerHeight - headerHeightNumber && + updatedHeight > minHeight + ) { + panel.style.height = `${height - movementY}px`; + } + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + e.preventDefault(); + handleResize(e.movementY); + }; + + if (mouseDown) { + window.addEventListener("mousemove", handleMouseMove); + } + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, [mouseDown]); + + useEffect(() => { + const handleMouseUp = () => setMouseDown(false); + + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + const handleMouseDown = () => { + setMouseDown(true); + }; + + return ; +}; + +export default Resizer; diff --git a/app/client/src/components/editorComponents/Debugger/helpers.tsx b/app/client/src/components/editorComponents/Debugger/helpers.tsx new file mode 100644 index 0000000000..018e25713e --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/helpers.tsx @@ -0,0 +1,99 @@ +import { Message, Severity } from "entities/AppsmithConsole"; +import React, { useCallback, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import { + createMessage, + NO_LOGS, + OPEN_THE_DEBUGGER, + PRESS, +} from "constants/messages"; + +const BlankStateWrapper = styled.div` + overflow: auto; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + color: ${(props) => props.theme.colors.debugger.blankState.color}; + ${(props) => getTypographyByKey(props, "p1")} + + .debugger-shortcut { + color: ${(props) => props.theme.colors.debugger.blankState.shortcut}; + ${(props) => getTypographyByKey(props, "h5")} + } +`; + +export const BlankState = (props: { hasShortCut?: boolean }) => { + return ( + + {props.hasShortCut ? ( + + {createMessage(PRESS)} + Cmd + D + {createMessage(OPEN_THE_DEBUGGER)} + + ) : ( + {createMessage(NO_LOGS)} + )} + + ); +}; + +export const SeverityIcon: Record = { + [Severity.INFO]: "success", + [Severity.ERROR]: "error", + [Severity.WARNING]: "warning", +}; + +export const SeverityIconColor: Record = { + [Severity.INFO]: "#03B365", + [Severity.ERROR]: "#F22B2B", + [Severity.WARNING]: "rgb(224, 179, 14)", +}; + +export const useFilteredLogs = (query: string, filter?: any) => { + let logs = useSelector((state: AppState) => state.ui.debugger.logs); + + if (filter) { + logs = logs.filter((log: Message) => log.severity === filter); + } + + if (query) { + logs = logs.filter((log: Message) => { + if (log.source?.name) + return ( + log.source?.name.toUpperCase().indexOf(query.toUpperCase()) !== -1 + ); + }); + } + + return logs; +}; + +export const usePagination = (data: Message[], itemsPerPage = 50) => { + const [currentPage, setCurrentPage] = useState(1); + const [paginatedData, setPaginatedData] = useState([]); + const maxPage = Math.ceil(data.length / itemsPerPage); + + useEffect(() => { + const data = currentData(); + setPaginatedData(data); + }, [currentPage, data.length]); + + const currentData = useCallback(() => { + const end = currentPage * itemsPerPage; + return data.slice(0, end); + }, [data]); + + const next = useCallback(() => { + setCurrentPage((currentPage) => { + const newCurrentPage = Math.min(currentPage + 1, maxPage); + return newCurrentPage <= 0 ? 1 : newCurrentPage; + }); + }, []); + + return { next, paginatedData }; +}; diff --git a/app/client/src/components/editorComponents/Debugger/index.tsx b/app/client/src/components/editorComponents/Debugger/index.tsx new file mode 100644 index 0000000000..af2b7f7d9c --- /dev/null +++ b/app/client/src/components/editorComponents/Debugger/index.tsx @@ -0,0 +1,85 @@ +import { Classes } from "components/ads/common"; +import Icon, { IconSize } from "components/ads/Icon"; +import React from "react"; +import { useDispatch } from "react-redux"; +import { useSelector } from "store"; +import styled from "styled-components"; +import DebuggerTabs from "./DebuggerTabs"; +import { AppState } from "reducers"; +import { showDebugger as showDebuggerAction } from "actions/debuggerActions"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { Colors } from "constants/Colors"; +import { getTypographyByKey } from "constants/DefaultTheme"; + +const Container = styled.div<{ errorCount: number }>` + background-color: ${(props) => + props.theme.colors.debugger.floatingButton.background}; + position: fixed; + right: 20px; + bottom: 20px; + cursor: pointer; + padding: 19px; + color: ${(props) => props.theme.colors.debugger.floatingButton.color}; + border-radius: 50px; + box-shadow: ${(props) => props.theme.colors.debugger.floatingButton.shadow}; + + .${Classes.ICON} { + &:hover { + path { + fill: ${(props) => props.theme.colors.icon.normal}; + } + } + } + + .debugger-count { + color: ${Colors.WHITE}; + font-size: 14px; + font-weight: 500; + ${(props) => getTypographyByKey(props, "h6")} + height: 20px; + padding: 6px; + background-color: ${(props) => + !!props.errorCount + ? props.theme.colors.debugger.floatingButton.errorCount + : props.theme.colors.debugger.floatingButton.noErrorCount}; + border-radius: 10px; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + top: 0; + right: 0; + } +`; + +const Debugger = () => { + const dispatch = useDispatch(); + const errorCount = useSelector( + (state: AppState) => Object.keys(state.ui.debugger.errors).length, + ); + const showDebugger = useSelector( + (state: AppState) => state.ui.debugger.isOpen, + ); + + const onClick = () => { + AnalyticsUtil.logEvent("OPEN_DEBUGGER", { + source: "CANVAS", + }); + dispatch(showDebuggerAction(true)); + }; + + if (!showDebugger) + return ( + + +
{errorCount}
+
+ ); + return ; +}; + +export default Debugger; diff --git a/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx index 70fc40e63f..afe878f8e3 100644 --- a/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx +++ b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx @@ -3,6 +3,7 @@ import styled from "styled-components"; import NoSearchDataImage from "assets/images/no_search_data.png"; import { NO_SEARCH_DATA_TEXT } from "constants/messages"; import { getTypographyByKey } from "constants/DefaultTheme"; +import { ReactComponent as DiscordIcon } from "assets/icons/help/discord.svg"; const Container = styled.div` display: flex; @@ -18,12 +19,42 @@ const Container = styled.div` .no-data-title { margin-top: ${(props) => props.theme.spaces[3]}px; } + + .discord { + margin-top: ${(props) => props.theme.spaces[3]}px; + } + + .discord-link { + cursor: pointer; + color: white; + font-weight: 700; + } +`; + +const StyledDiscordIcon = styled(DiscordIcon)` + path { + fill: #5c6bc0; + } + vertical-align: -7px; `; const ResultsNotFound = () => ( No data -
{NO_SEARCH_DATA_TEXT}
+
{NO_SEARCH_DATA_TEXT()}
+ + πŸ€– Join our{" "} + { + window.open("https://discord.gg/rBTTVJp", "_blank"); + }} + > + + Discord Server + {" "} + for more help. +
); diff --git a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx index d36d9d1e62..0bcc540960 100644 --- a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx +++ b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx @@ -21,6 +21,11 @@ export type ExecuteActionPayload = { responseData?: Array; }; +// triggerPropertyName was added as a requirement for logging purposes +export type WidgetExecuteActionPayload = ExecuteActionPayload & { + triggerPropertyName?: string; +}; + export type ContentType = | "application/json" | "application/x-www-form-urlencoded"; diff --git a/app/client/src/constants/DefaultTheme.tsx b/app/client/src/constants/DefaultTheme.tsx index 5f40c2761c..77ebc940d3 100644 --- a/app/client/src/constants/DefaultTheme.tsx +++ b/app/client/src/constants/DefaultTheme.tsx @@ -924,6 +924,38 @@ type ColorType = { }; scrollbar: string; scrollbarBG: string; + debugger: { + background: string; + messageTextColor: string; + time: string; + label: string; + entity: string; + entityLink: string; + floatingButton: { + background: string; + color: string; + shadow: string; + errorCount: string; + noErrorCount: string; + }; + blankState: { + shortcut: string; + color: string; + }; + info: { + borderBottom: string; + }; + warning: { + borderBottom: string; + backgroundColor: string; + }; + error: { + borderBottom: string; + backgroundColor: string; + }; + jsonIcon: string; + message: string; + }; helpModal: { itemHighlight: string; background: string; @@ -1397,6 +1429,38 @@ export const dark: ColorType = { }, scrollbar: getColorWithOpacity(Colors.LIGHT_GREY, 0.5), scrollbarBG: getColorWithOpacity(Colors.CODE_GRAY, 0.5), + debugger: { + background: darkShades[11], + messageTextColor: "#D4D4D4", + time: "#D4D4D4", + label: "#D4D4D4", + entity: "rgba(212, 212, 212, 0.5)", + entityLink: "#D4D4D4", + jsonIcon: "#9F9F9F", + message: "#D4D4D4", + floatingButton: { + background: "#2b2b2b", + color: "#d4d4d4", + shadow: "0px 12px 28px -6px rgba(0, 0, 0, 0.32)", + errorCount: "#F22B2B", + noErrorCount: "#03B365", + }, + blankState: { + color: "#D4D4D4", + shortcut: "#D4D4D4", + }, + info: { + borderBottom: "black", + }, + warning: { + borderBottom: "black", + backgroundColor: "#29251A", + }, + error: { + borderBottom: "black", + backgroundColor: "#291B1D", + }, + }, }; export const light: ColorType = { @@ -1809,6 +1873,38 @@ export const light: ColorType = { }, scrollbar: getColorWithOpacity(Colors.CHARCOAL, 0.5), scrollbarBG: "transparent", + debugger: { + background: "#FFFFFF", + messageTextColor: "#716e6e", + time: "#4b4848", + label: "#4b4848", + entity: "rgba(75, 72, 72, 0.7)", + entityLink: "#6d6d6d", + jsonIcon: "#a9a7a7", + message: "#4b4848", + floatingButton: { + background: "#2b2b2b", + color: "#d4d4d4", + shadow: "0px 12px 28px -6px rgba(0, 0, 0, 0.32)", + errorCount: "#F22B2B", + noErrorCount: "#03B365", + }, + blankState: { + color: "#716e6e", + shortcut: "black", + }, + info: { + borderBottom: "rgba(0, 0, 0, 0.05)", + }, + warning: { + borderBottom: "white", + backgroundColor: "rgba(254, 184, 17, 0.1)", + }, + error: { + borderBottom: "white", + backgroundColor: "rgba(242, 43, 43, 0.08)", + }, + }, }; export const theme: Theme = { diff --git a/app/client/src/constants/Layers.tsx b/app/client/src/constants/Layers.tsx index 23ecd01ebf..7942685514 100644 --- a/app/client/src/constants/Layers.tsx +++ b/app/client/src/constants/Layers.tsx @@ -19,6 +19,7 @@ export const Layers = { apiPane: Indices.Layer3, help: Indices.Layer4, dynamicAutoComplete: Indices.Layer5, + debugger: Indices.Layer6, productUpdates: Indices.Layer7, max: Indices.LayerMax, }; diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index 560b754bab..fea86bfb34 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -33,6 +33,13 @@ export const ReduxActionTypes: { [key: string]: string } = { UPDATE_APP_LAYOUT: "UPDATE_APP_LAYOUT", UPDATE_APPLICATION_SUCCESS: "UPDATE_APPLICATION_SUCCESS", PUBLISH: "PUBLISH", + DEBUGGER_LOG: "DEBUGGER_LOG", + DEBUGGER_LOG_INIT: "DEBUGGER_LOG_INIT", + DEBUGGER_ERROR_LOG: "DEBUGGER_ERROR_LOG", + DEBUGGER_UPDATE_ERROR_LOG: "DEBUGGER_UPDATE_ERROR_LOG", + DEBUGGER_UPDATE_ERROR_LOGS: "DEBUGGER_UPDATE_ERROR_LOGS", + CLEAR_DEBUGGER_LOGS: "CLEAR_DEBUGGER_LOGS", + SHOW_DEBUGGER: "SHOW_DEBUGGER", SET_THEME: "SET_THEME", FETCH_WIDGET_CARDS: "FETCH_WIDGET_CARDS", FETCH_WIDGET_CARDS_SUCCESS: "FETCH_WIDGET_CARDS_SUCCESS", diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index ad79602a28..f856159610 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -280,7 +280,7 @@ export const LOCAL_STORAGE_NO_SPACE_LEFT_ON_DEVICE_MESSAGE = () => export const OMNIBAR_PLACEHOLDER = () => "Search Widgets, Queries, Documentation"; export const HELPBAR_PLACEHOLDER = () => "Quick search & navigation"; -export const NO_SEARCH_DATA_TEXT = () => "Search you must meaningful but"; +export const NO_SEARCH_DATA_TEXT = () => "No results found"; export const WIDGET_BIND_HELP = () => "Having trouble taking inputs from widgets?"; @@ -288,3 +288,10 @@ export const WIDGET_BIND_HELP = () => export const BACK_TO_HOMEPAGE = () => "Go back to homepage"; export const PAGE_NOT_FOUND = () => "Page not found"; + +export const CLICK_ON = () => "πŸ™Œ Click on "; +export const PRESS = () => "πŸŽ‰ Press "; +export const OPEN_THE_DEBUGGER = () => " to open the issue in debugger"; +export const NO_LOGS = () => "No logs to show"; + +export const TROUBLESHOOT_ISSUE = () => "Troubleshoot issue"; diff --git a/app/client/src/entities/AppsmithConsole/index.ts b/app/client/src/entities/AppsmithConsole/index.ts index b708f69a1f..4d15eb84b1 100644 --- a/app/client/src/entities/AppsmithConsole/index.ts +++ b/app/client/src/entities/AppsmithConsole/index.ts @@ -3,13 +3,19 @@ import { BindingError } from "entities/AppsmithConsole/binding"; import { ActionError } from "entities/AppsmithConsole/action"; import { WidgetError } from "entities/AppsmithConsole/widget"; import { EvalError } from "entities/AppsmithConsole/eval"; -import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; +import LOG_TYPE from "./logtype"; + +export enum ENTITY_TYPE { + ACTION = "ACTION", + DATASOURCE = "DATASOURCE", + WIDGET = "WIDGET", +} export type ErrorType = BindingError | ActionError | WidgetError | EvalError; export enum Severity { // Everything, irrespective of what the user should see or not - DEBUG = "debug", + // DEBUG = "debug", // Something the dev user should probably know about INFO = "info", // Doesn't break the app, but can cause slowdowns / ux issues/ unexpected behaviour @@ -17,7 +23,7 @@ export enum Severity { // Can cause an error in some cases/ single widget, app will work in other cases ERROR = "error", // Makes the app unusable, can't progress without fixing this. - CRITICAL = "critical", + // CRITICAL = "critical", } export type UserAction = { @@ -37,19 +43,27 @@ export interface SourceEntity { // Id of the widget or action id: string; // property path of the child - propertyPath: string; + propertyPath?: string; } -export interface Message { +export interface LogActionPayload { + // What is the log about. Is it a datasource update, widget update, eval error etc. + logType?: LOG_TYPE; + text: string; + // More contextual message + message?: string; + // Time taken for the event to complete + timeTaken?: string; + // "where" source entity and propertyPsath. + source?: SourceEntity; + // Snapshot KV pair of scope variables or state associated with this event. + state?: Record; +} + +export interface Message extends LogActionPayload { severity: Severity; // "when" did this event happen - timestamp: Date; - // "what": Human readable description of what happened. - text: string; - // "where" source entity and propertyPsath. - source: SourceEntity; - // Snapshot KV pair of scope variables or state associated with this event. - state: Record; + timestamp: string; } /** @@ -83,7 +97,7 @@ export interface ActionableError extends Message { // Error type of the event. type: ErrorType; - severity: Severity.ERROR | Severity.CRITICAL; + severity: Severity.ERROR; // Actions a user can take to resolve this issue userActions: Array; diff --git a/app/client/src/entities/AppsmithConsole/logtype.ts b/app/client/src/entities/AppsmithConsole/logtype.ts new file mode 100644 index 0000000000..8fc202ca15 --- /dev/null +++ b/app/client/src/entities/AppsmithConsole/logtype.ts @@ -0,0 +1,9 @@ +enum LOG_TYPE { + WIDGET_PROPERTY_VALIDATION_ERROR, + WIDGET_UPDATE, + ACTION_EXECUTION_ERROR, + ACTION_EXECUTION_SUCCESS, + ENTITY_DELETED, +} + +export default LOG_TYPE; diff --git a/app/client/src/mockResponses/WidgetConfigResponse.tsx b/app/client/src/mockResponses/WidgetConfigResponse.tsx index 6cb064df86..d9dcfb2062 100644 --- a/app/client/src/mockResponses/WidgetConfigResponse.tsx +++ b/app/client/src/mockResponses/WidgetConfigResponse.tsx @@ -67,6 +67,8 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "Input", version: 1, resetOnSubmit: true, + isRequired: false, + isDisabled: false, }, SWITCH_WIDGET: { label: "Label", @@ -76,6 +78,7 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "Switch", alignWidget: "LEFT", version: 1, + isDisabled: false, }, ICON_WIDGET: { widgetName: "Icon", @@ -127,6 +130,7 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "DatePicker", defaultDate: moment().toISOString(), version: 2, + isRequired: false, }, VIDEO_WIDGET: { rows: 7, @@ -185,6 +189,8 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "Dropdown", defaultOptionValue: "VEG", version: 1, + isRequired: false, + isDisabled: false, }, CHECKBOX_WIDGET: { rows: 1, @@ -194,6 +200,8 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "Checkbox", version: 1, alignWidget: "LEFT", + isDisabled: false, + isRequired: false, }, RADIO_GROUP_WIDGET: { rows: 2, @@ -206,6 +214,8 @@ const WidgetConfigResponse: WidgetConfigReducerState = { defaultOptionValue: "M", widgetName: "RadioGroup", version: 1, + isRequired: false, + isDisabled: false, }, FILE_PICKER_WIDGET: { rows: 1, @@ -218,6 +228,8 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "FilePicker", isDefaultClickDisabled: true, version: 1, + isRequired: false, + isDisabled: false, }, TABS_WIDGET: { rows: 7, diff --git a/app/client/src/pages/Editor/APIEditor/ApiHomeScreen.tsx b/app/client/src/pages/Editor/APIEditor/ApiHomeScreen.tsx index 03cb26bd3d..be917f4e2f 100644 --- a/app/client/src/pages/Editor/APIEditor/ApiHomeScreen.tsx +++ b/app/client/src/pages/Editor/APIEditor/ApiHomeScreen.tsx @@ -48,6 +48,7 @@ import AnalyticsUtil, { EventLocation } from "utils/AnalyticsUtil"; import { getAppsmithConfigs } from "configs"; import { getAppCardColorPalette } from "selectors/themeSelectors"; import { CURL } from "constants/AppsmithActionConstants/ActionConstants"; +import CloseEditor from "components/editorComponents/CloseEditor"; import { PluginType } from "entities/Action"; const { enableRapidAPI } = getAppsmithConfigs(); @@ -662,6 +663,7 @@ class ApiHomeScreen extends React.Component { style={{ overflow: showSearchResults ? "hidden" : "auto" }} className="t--apiHomePage" > + {isSwitchingCategory || !enableRapidAPI ? ( <> {ApiHomepageTopSection} diff --git a/app/client/src/pages/Editor/APIEditor/Form.tsx b/app/client/src/pages/Editor/APIEditor/Form.tsx index e1343a5672..448da31d20 100644 --- a/app/client/src/pages/Editor/APIEditor/Form.tsx +++ b/app/client/src/pages/Editor/APIEditor/Form.tsx @@ -24,12 +24,7 @@ import ActionSettings from "pages/Editor/ActionSettings"; import RequestDropdownField from "components/editorComponents/form/fields/RequestDropdownField"; import { ExplorerURLParams } from "../Explorer/helpers"; import MoreActionsMenu from "../Explorer/Actions/MoreActionsMenu"; -import PerformanceTracker, { - PerformanceTransactionName, -} from "utils/PerformanceTracker"; -import { useHistory, useLocation, useParams } from "react-router-dom"; -import { BUILDER_PAGE_URL } from "constants/routes"; -import Icon, { IconSize } from "components/ads/Icon"; +import Icon from "components/ads/Icon"; import Button, { Size } from "components/ads/Button"; import { TabComponent } from "components/ads/Tabs"; import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; @@ -37,9 +32,9 @@ import Text, { Case, TextType } from "components/ads/Text"; import { Classes, Variant } from "components/ads/common"; import Callout from "components/ads/Callout"; import { useLocalStorage } from "utils/hooks/localstorage"; -import TooltipComponent from "components/ads/Tooltip"; -import { Position } from "@blueprintjs/core"; import { createMessage, WIDGET_BIND_HELP } from "constants/messages"; +import CloseEditor from "components/editorComponents/CloseEditor"; +import { useParams } from "react-router"; const Form = styled.form` display: flex; @@ -92,8 +87,9 @@ const SecondaryWrapper = styled.div` `; const TabbedViewContainer = styled.div` + flex: 1; + overflow: auto; border-top: 2px solid ${(props) => props.theme.colors.apiPane.dividerBg}; - height: 50%; ${FormRow} { min-height: auto; padding: ${(props) => props.theme.spaces[0]}px; @@ -193,26 +189,6 @@ export const NameWrapper = styled.div` } `; -const IconContainer = styled.div` - width: 22px; - height: 22px; - display: flex; - justify-content: center; - align-items: center; - margin-right: 16px; - cursor: pointer; - svg { - width: 12px; - height: 12px; - path { - fill: ${(props) => props.theme.colors.apiPane.closeIcon}; - } - } - &:hover { - background-color: ${(props) => props.theme.colors.apiPane.iconHoverBg}; - } -`; - const ApiEditorForm: React.FC = (props: Props) => { const [selectedIndex, setSelectedIndex] = useState(0); const [ @@ -243,18 +219,8 @@ const ApiEditorForm: React.FC = (props: Props) => { const currentActionConfig: Action | undefined = actions.find( (action) => action.id === params.apiId || action.id === params.queryId, ); - const history = useHistory(); - const location = useLocation(); - const { applicationId, pageId } = useParams(); + const { pageId } = useParams(); - const handleClose = (e: React.MouseEvent) => { - PerformanceTracker.startTracking( - PerformanceTransactionName.CLOSE_SIDE_PANE, - { path: location.pathname }, - ); - e.stopPropagation(); - history.replace(BUILDER_PAGE_URL(applicationId, pageId)); - }; const theme = EditorTheme.LIGHT; return ( @@ -262,24 +228,7 @@ const ApiEditorForm: React.FC = (props: Props) => { - - Close - - } - minWidth="auto !important" - > - - - - + @@ -433,7 +382,7 @@ const ApiEditorForm: React.FC = (props: Props) => { /> - + ); diff --git a/app/client/src/pages/Editor/APIEditor/RapidApiEditorForm.tsx b/app/client/src/pages/Editor/APIEditor/RapidApiEditorForm.tsx index 0b38690c8b..5ae2f784ec 100644 --- a/app/client/src/pages/Editor/APIEditor/RapidApiEditorForm.tsx +++ b/app/client/src/pages/Editor/APIEditor/RapidApiEditorForm.tsx @@ -263,7 +263,7 @@ const RapidApiEditorForm: React.FC = (props: Props) => { /> - + ); diff --git a/app/client/src/pages/Editor/GlobalHotKeys.tsx b/app/client/src/pages/Editor/GlobalHotKeys.tsx index 6b5db918c7..2036d76a9a 100644 --- a/app/client/src/pages/Editor/GlobalHotKeys.tsx +++ b/app/client/src/pages/Editor/GlobalHotKeys.tsx @@ -19,6 +19,7 @@ import { ENTITY_EXPLORER_SEARCH_ID, WIDGETS_SEARCH_ID, } from "constants/Explorer"; +import { showDebugger } from "actions/debuggerActions"; type Props = { copySelectedWidget: () => void; @@ -26,6 +27,7 @@ type Props = { deleteSelectedWidget: () => void; cutSelectedWidget: () => void; toggleShowGlobalSearchModal: () => void; + openDebugger: () => void; selectedWidget?: string; children: React.ReactNode; }; @@ -77,6 +79,16 @@ class GlobalHotKeys extends React.Component { label="Show omnibar" global={true} /> + { + this.props.openDebugger(); + }} + preventDefault + /> { deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), cutSelectedWidget: () => dispatch(cutWidget()), toggleShowGlobalSearchModal: () => dispatch(toggleShowGlobalSearchModal()), + openDebugger: () => dispatch(showDebugger()), }; }; diff --git a/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx b/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx index d69f843176..94ed5fd93e 100644 --- a/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx +++ b/app/client/src/pages/Editor/PropertyPane/PropertyControl.tsx @@ -30,6 +30,9 @@ import Boxed from "components/editorComponents/Onboarding/Boxed"; import { OnboardingStep } from "constants/OnboardingConstants"; import Indicator from "components/editorComponents/Onboarding/Indicator"; import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; +import AppsmithConsole from "utils/AppsmithConsole"; +import { ENTITY_TYPE } from "entities/AppsmithConsole"; +import LOG_TYPE from "entities/AppsmithConsole/logtype"; import { useChildWidgetEnhancementFns, @@ -178,6 +181,16 @@ const PropertyControl = memo((props: Props) => { }); allUpdates[propertyName] = propertyValue; onBatchUpdateProperties(allUpdates); + AppsmithConsole.info({ + logType: LOG_TYPE.WIDGET_UPDATE, + text: "Widget properties were updated", + source: { + type: ENTITY_TYPE.WIDGET, + name: widgetProperties.widgetName, + id: widgetProperties.widgetId, + }, + state: allUpdates, + }); } if (!propertiesToUpdate) { dispatch( @@ -188,6 +201,18 @@ const PropertyControl = memo((props: Props) => { RenderModes.CANVAS, // This seems to be not needed anymore. ), ); + AppsmithConsole.info({ + logType: LOG_TYPE.WIDGET_UPDATE, + text: "Widget properties were updated", + source: { + type: ENTITY_TYPE.WIDGET, + name: widgetProperties.widgetName, + id: widgetProperties.widgetId, + }, + state: { + [propertyName]: propertyValue, + }, + }); } }, [dispatch, widgetProperties], diff --git a/app/client/src/pages/Editor/PropertyPane/index.tsx b/app/client/src/pages/Editor/PropertyPane/index.tsx index e8860c8e12..6ecc6a4f9d 100644 --- a/app/client/src/pages/Editor/PropertyPane/index.tsx +++ b/app/client/src/pages/Editor/PropertyPane/index.tsx @@ -94,10 +94,9 @@ const PropertyPaneView = ( const widgetProperties: any = useSelector(getWidgetPropsForPropertyPane); const dispatch = useDispatch(); - const handleDelete = useCallback( - () => dispatch(deleteSelectedWidget(false)), - [dispatch], - ); + const handleDelete = useCallback(() => { + dispatch(deleteSelectedWidget(false)); + }, [dispatch]); const handleCopy = useCallback(() => dispatch(copyWidget(false)), [dispatch]); return ( diff --git a/app/client/src/pages/Editor/QueryEditor/EditorJSONtoForm.tsx b/app/client/src/pages/Editor/QueryEditor/EditorJSONtoForm.tsx index 5f954a3c51..f85af0190f 100644 --- a/app/client/src/pages/Editor/QueryEditor/EditorJSONtoForm.tsx +++ b/app/client/src/pages/Editor/QueryEditor/EditorJSONtoForm.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { RefObject, useRef, useState } from "react"; import { InjectedFormProps } from "redux-form"; -import styled, { createGlobalStyle } from "styled-components"; -import { Icon, Popover, Tag } from "@blueprintjs/core"; +import { Icon, Tag } from "@blueprintjs/core"; +import { isString } from "lodash"; import { components, MenuListComponentProps, @@ -9,54 +9,57 @@ import { OptionTypeBase, SingleValueProps, } from "react-select"; -import { isString, isArray } from "lodash"; -import Button from "components/editorComponents/Button"; -import FormRow from "components/editorComponents/FormRow"; -import DropdownField from "components/editorComponents/form/fields/DropdownField"; -import { BaseButton } from "components/designSystems/blueprint/ButtonComponent"; import { Datasource } from "entities/Datasource"; import { BaseTabbedView } from "components/designSystems/appsmith/TabbedView"; import { Colors } from "constants/Colors"; +import { BaseButton } from "components/designSystems/blueprint/ButtonComponent"; import JSONViewer from "./JSONViewer"; import FormControl from "../FormControl"; import Table from "./Table"; import { Action } from "entities/Action"; import { useDispatch } from "react-redux"; import ActionNameEditor from "components/editorComponents/ActionNameEditor"; +import DropdownField from "components/editorComponents/form/fields/DropdownField"; import { ControlProps } from "components/formControls/BaseControl"; import ActionSettings from "pages/Editor/ActionSettings"; import { addTableWidgetFromQuery } from "actions/widgetActions"; import { OnboardingStep } from "constants/OnboardingConstants"; import Boxed from "components/editorComponents/Onboarding/Boxed"; -import OnboardingIndicator from "components/editorComponents/Onboarding/Indicator"; import log from "loglevel"; +import Text, { TextType } from "components/ads/Text"; +import styled, { getTypographyByKey } from "constants/DefaultTheme"; +import { TabComponent } from "components/ads/Tabs"; +import AdsIcon from "components/ads/Icon"; +import { Classes } from "components/ads/common"; +import FormRow from "components/editorComponents/FormRow"; +import Button from "components/editorComponents/Button"; +import OnboardingIndicator from "components/editorComponents/Onboarding/Indicator"; +import DebuggerLogs from "components/editorComponents/Debugger/DebuggerLogs"; +import ErrorLogs from "components/editorComponents/Debugger/Errors"; +import Resizable, { + ResizerCSS, +} from "components/editorComponents/Debugger/Resizer"; +import DebuggerMessage from "components/editorComponents/Debugger/DebuggerMessage"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import CloseEditor from "components/editorComponents/CloseEditor"; const QueryFormContainer = styled.form` display: flex; flex-direction: column; - padding: 20px 0px; + overflow: hidden; + padding: 20px 0px 0px 0px; width: 100%; height: calc(100vh - ${(props) => props.theme.smallHeaderHeight}); - a { - font-size: 14px; - line-height: 20px; - margin-top: 15px; - } .statementTextArea { font-size: 14px; line-height: 20px; color: #2e3d49; margin-top: 5px; } - .queryInput { max-width: 30%; padding-right: 10px; } - span.bp3-popover-target { - display: initial !important; - } - .executeOnLoad { display: flex; justify-content: flex-end; @@ -64,6 +67,143 @@ const QueryFormContainer = styled.form` } `; +const ErrorMessage = styled.p` + font-size: 14px; + color: ${Colors.RED}; + display: inline-block; + margin-right: 10px; +`; + +const TabbedViewContainer = styled.div` + ${ResizerCSS} + height: 50%; + // Minimum height of bottom tabs as it can be resized + min-height: 36px; + .react-tabs__tab-panel { + overflow: hidden; + } + .react-tabs__tab-list { + margin: 0px; + } + &&& { + ul.react-tabs__tab-list { + padding: 0px ${(props) => props.theme.spaces[12]}px; + background-color: ${(props) => + props.theme.colors.apiPane.responseBody.bg}; + } + .react-tabs__tab-panel { + height: calc(100% - 36px); + } + } + background-color: ${(props) => props.theme.colors.apiPane.responseBody.bg}; + border-top: 2px solid #e8e8e8; +`; + +const SettingsWrapper = styled.div` + padding: 5px 30px; + overflow-y: auto; + height: 100%; +`; + +const GenerateWidgetButton = styled.a` + display: flex; + align-items: center; + position: absolute; + right: 30px; + top: 8px; + ${(props) => getTypographyByKey(props, "h5")} + color: #716e6e; + && { + margin: 0; + } + &:hover { + text-decoration: none; + color: #716e6e; + } +`; + +const FieldWrapper = styled.div` + margin-top: 15px; +`; + +const DocumentationLink = styled.a` + position: absolute; + right: 23px; + top: -6px; +`; + +const SecondaryWrapper = styled.div` + display: flex; + flex-direction: column; + height: calc(100% - 50px); +`; + +const ResponseContentWrapper = styled.div` + padding: 10px 15px; + overflow-y: auto; + height: 100%; +`; + +const NoResponseContainer = styled.div` + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + .${Classes.ICON} { + margin-right: 0px; + svg { + width: 150px; + height: 150px; + } + } + .${Classes.TEXT} { + margin-top: ${(props) => props.theme.spaces[9]}px; + } +`; + +const ErrorContainer = styled.div` + height: 100%; + width: 100%; + display: flex; + align-items: center; + padding-top: 10px; + flex-direction: column; + & > .${Classes.ICON} { + margin-right: 0px; + svg { + width: 75px; + height: 75px; + } + } + .${Classes.TEXT} { + margin-top: ${(props) => props.theme.spaces[9]}px; + } +`; + +const ErrorDescriptionText = styled(Text)` + width: 500px; + text-align: center; + line-height: 25px; + letter-spacing: -0.195px; +`; + +const StyledFormRow = styled(FormRow)` + padding: 0px 24px; + flex: 0; +`; + +const NameWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + input { + margin: 0; + box-sizing: border-box; + } +`; + const ActionsWrapper = styled.div` display: flex; align-items: center; @@ -71,6 +211,42 @@ const ActionsWrapper = styled.div` justify-content: flex-end; `; +const DropdownSelect = styled.div` + font-size: 14px; + margin-right: 10px; +`; + +const CreateDatasource = styled.div` + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + border-top: 1px solid ${Colors.ATHENS_GRAY}; + :hover { + cursor: pointer; + } + .createIcon { + margin-right: 6px; + } +`; + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + .plugin-image { + height: 20px; + width: auto; + } + .selected-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: no-wrap; + margin-left: 6px; + } +`; + const ActionButton = styled(BaseButton)` &&&& { min-width: 72px; @@ -80,11 +256,6 @@ const ActionButton = styled(BaseButton)` } `; -const DropdownSelect = styled.div` - font-size: 14px; - margin-right: 10px; -`; - const NoDataSourceContainer = styled.div` align-items: center; display: flex; @@ -100,83 +271,6 @@ const NoDataSourceContainer = styled.div` } `; -const TooltipStyles = createGlobalStyle` - .helper-tooltip{ - width: 378px; - .bp3-popover { - height: 137px; - max-width: 378px; - box-shadow: none; - display: inherit !important; - .bp3-popover-arrow { - display: block; - fill: none; - } - .bp3-popover-arrow-fill { - fill: #23292E; - } - .bp3-popover-content { - padding: 15px; - background-color: #23292E; - color: #fff; - text-align: left; - border-radius: 4px; - text-transform: initial; - font-weight: 500; - font-size: 16px; - line-height: 20px; - } - .popoverBtn { - float: right; - margin-top: 25px; - } - .popuptext { - padding-right: 30px; - } - } - } -`; - -const ErrorMessage = styled.p` - font-size: 14px; - color: ${Colors.RED}; - display: inline-block; - margin-right: 10px; -`; -const CreateDatasource = styled.div` - height: 44px; - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - border-top: 1px solid ${Colors.ATHENS_GRAY}; - :hover { - cursor: pointer; - } - - .createIcon { - margin-right: 6px; - } -`; - -const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - - .plugin-image { - height: 20px; - width: auto; - } - - .selected-value { - overflow: hidden; - text-overflow: ellipsis; - white-space: no-wrap; - margin-left: 6px; - } -`; - const StyledOpenDocsIcon = styled(Icon)` svg { width: 12px; @@ -184,16 +278,14 @@ const StyledOpenDocsIcon = styled(Icon)` } `; -const NameWrapper = styled.div` - display: flex; - justify-content: space-between; - input { - margin: 0; - box-sizing: border-box; - } -`; - const TabContainerView = styled.div` + flex: 1; + overflow: auto; + a { + font-size: 14px; + line-height: 20px; + margin-top: 15px; + } .react-tabs__tab-panel { overflow: scroll; } @@ -206,47 +298,8 @@ const TabContainerView = styled.div` } } position: relative; - margin-top: 31px; - height: calc(100vh - 150px); `; -const SettingsWrapper = styled.div` - padding: 5px 23px; -`; - -const AddWidgetButton = styled(BaseButton)` - &&&& { - height: 36px; - max-width: 125px; - border: 1px solid ${Colors.GEYSER_LIGHT}; - } -`; - -const OutputHeader = styled.div` - flex-direction: row; - justify-content: space-between; - display: flex; - margin: 10px 0px; - align-items: center; -`; - -const FieldWrapper = styled.div` - margin-top: 15px; -`; - -const StyledFormRow = styled(FormRow)` - padding: 0px 24px; - flex: 0; -`; - -const DocumentationLink = styled.a` - position: absolute; - right: 23px; - top: -6px; -`; - -const OutputWrapper = styled.div``; - type QueryFormProps = { onDeleteClick: () => void; onRunClick: () => void; @@ -302,7 +355,8 @@ export const EditorJSONtoForm: React.FC = (props: Props) => { let error = runErrorMessage; let output: Record[] | null = null; - let displayMessage = ""; + const panelRef: RefObject = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(0); if (executedQueryData) { if (!executedQueryData.isExecutionSuccess) { @@ -314,19 +368,6 @@ export const EditorJSONtoForm: React.FC = (props: Props) => { } } - // Constructing the header of the response based on the response - if (!output) { - displayMessage = "No data records to display"; - } else if (isArray(output)) { - // The returned output is an array - displayMessage = output.length - ? "Query response" - : "No data records to display"; - } else { - // Output is a JSON object. We can display a single object - displayMessage = "Query response"; - } - const isTableResponse = responseType === "TABLE"; const dispatch = useDispatch(); @@ -384,6 +425,7 @@ export const EditorJSONtoForm: React.FC = (props: Props) => { + @@ -405,156 +447,175 @@ export const EditorJSONtoForm: React.FC = (props: Props) => { loading={isDeleting} onClick={onDeleteClick} /> - {dataSources.length === 0 ? ( - <> - - - -
-

- You don’t have a Data Source to run this query -

-
-
- - ) : ( - - - - )} + + + +
- - {documentationLink && ( - - {"Documentation "} - - - )} - - {editorConfig && editorConfig.length > 0 ? ( - editorConfig.map(renderEachConfig(formName)) - ) : ( - <> - An unexpected error occurred - window.location.reload()} - > - Refresh - - - )} - {dataSources.length === 0 && ( - -

- Seems like you don’t have any Datasources to create a - query -

-