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"