diff --git a/app/client/src/actions/widgetActions.tsx b/app/client/src/actions/widgetActions.tsx index fc064d25da81..822f0deaf798 100644 --- a/app/client/src/actions/widgetActions.tsx +++ b/app/client/src/actions/widgetActions.tsx @@ -121,9 +121,12 @@ export const copyWidget = (isShortcut: boolean) => { }; }; -export const pasteWidget = () => { +export const pasteWidget = (groupWidgets = false) => { return { type: ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, + payload: { + groupWidgets: groupWidgets, + }, }; }; @@ -152,3 +155,15 @@ export const addSuggestedWidget = (payload: Partial) => { payload, }; }; + +/** + * action to group selected widgets into container + * + * @param queryName + * @returns + */ +export const groupWidgets = () => { + return { + type: ReduxActionTypes.GROUP_WIDGETS_INIT, + }; +}; diff --git a/app/client/src/components/editorComponents/ActionRightPane/SuggestedWidgets.tsx b/app/client/src/components/editorComponents/ActionRightPane/SuggestedWidgets.tsx index eab10d531ba9..5a732af368bf 100644 --- a/app/client/src/components/editorComponents/ActionRightPane/SuggestedWidgets.tsx +++ b/app/client/src/components/editorComponents/ActionRightPane/SuggestedWidgets.tsx @@ -18,7 +18,7 @@ import { SuggestedWidget } from "api/ActionAPI"; import { useSelector } from "store"; import { getDataTree } from "selectors/dataTreeSelectors"; import { getWidgets } from "sagas/selectors"; -import { getNextWidgetName } from "sagas/WidgetOperationSagas"; +import { getNextWidgetName } from "sagas/WidgetOperationUtils"; const WidgetList = styled.div` ${(props) => getTypographyByKey(props, "p1")} diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index 3e0b41d03eb5..048a1bc45ed3 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -504,6 +504,7 @@ export const ReduxActionTypes = { SET_CRUD_INFO_MODAL_OPEN: "SET_CRUD_INFO_MODAL_OPEN", SET_PAGE_ORDER_INIT: "SET_PAGE_ORDER_INIT", SET_PAGE_ORDER_SUCCESS: "SET_PAGE_ORDER_SUCCESS", + GROUP_WIDGETS_INIT: "GROUP_WIDGETS_INIT", }; export type ReduxActionType = typeof ReduxActionTypes[keyof typeof ReduxActionTypes]; diff --git a/app/client/src/entities/Widget/utils.ts b/app/client/src/entities/Widget/utils.ts index 67eddd669ef0..7956db6b8e26 100644 --- a/app/client/src/entities/Widget/utils.ts +++ b/app/client/src/entities/Widget/utils.ts @@ -3,9 +3,10 @@ import { PropertyPaneConfig, ValidationConfig, } from "constants/PropertyControlConstants"; -import { get, isObject, isUndefined } from "lodash"; +import { get, isObject, isUndefined, omitBy } from "lodash"; import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; import { EvaluationSubstitutionType } from "entities/DataTree/dataTreeFactory"; +import { WidgetTypes } from "constants/WidgetConstants"; export const getAllPathsFromPropertyConfig = ( widget: WidgetProps, @@ -146,12 +147,24 @@ export const getAllPathsFromPropertyConfig = ( return { bindingPaths, triggerPaths, validationPaths }; }; +/** + * this function gets the next available row for pasting widgets + * NOTE: this function excludes modal widget when calculating next available row + * + * @param parentContainerId + * @param canvasWidgets + * @returns + */ export const nextAvailableRowInContainer = ( parentContainerId: string, canvasWidgets: { [widgetId: string]: FlattenedWidgetProps }, ) => { + const filteredCanvasWidgets = omitBy(canvasWidgets, (widget) => { + return widget.type === WidgetTypes.MODAL_WIDGET; + }); + return ( - Object.values(canvasWidgets).reduce( + Object.values(filteredCanvasWidgets).reduce( (prev: number, next: any) => next?.parentId === parentContainerId && next.bottomRow > prev ? next.bottomRow diff --git a/app/client/src/pages/Editor/Explorer/Widgets/useNavigateToWidget.ts b/app/client/src/pages/Editor/Explorer/Widgets/useNavigateToWidget.ts index 6695a44e0ccf..d700a8c5f65e 100644 --- a/app/client/src/pages/Editor/Explorer/Widgets/useNavigateToWidget.ts +++ b/app/client/src/pages/Editor/Explorer/Widgets/useNavigateToWidget.ts @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { WidgetTypes, WidgetType } from "constants/WidgetConstants"; import { useParams } from "react-router"; import { ExplorerURLParams } from "../helpers"; -import { flashElementById } from "utils/helpers"; +import { flashElementsById } from "utils/helpers"; import { useDispatch, useSelector } from "react-redux"; import { forceOpenPropertyPane, @@ -23,7 +23,7 @@ export const useNavigateToWidget = () => { } = useWidgetSelection(); const multiSelectWidgets = (widgetId: string, pageId: string) => { navigateToCanvas(params, window.location.pathname, pageId, widgetId); - flashElementById(widgetId); + flashElementsById(widgetId); selectWidget(widgetId, true); }; @@ -41,7 +41,8 @@ export const useNavigateToWidget = () => { else dispatch(closeAllModals()); selectWidget(widgetId, false); navigateToCanvas(params, window.location.pathname, pageId, widgetId); - flashElementById(widgetId); + + flashElementsById(widgetId); // Navigating to a widget from query pane seems to make the property pane // appear below the entity explorer hence adding a timeout here setTimeout(() => { diff --git a/app/client/src/pages/Editor/GlobalHotKeys.tsx b/app/client/src/pages/Editor/GlobalHotKeys.tsx index 3eda33486f5e..bba646c541aa 100644 --- a/app/client/src/pages/Editor/GlobalHotKeys.tsx +++ b/app/client/src/pages/Editor/GlobalHotKeys.tsx @@ -9,6 +9,7 @@ import { copyWidget, cutWidget, deleteSelectedWidget, + groupWidgets, pasteWidget, } from "actions/widgetActions"; import { @@ -41,6 +42,7 @@ type Props = { pasteCopiedWidget: () => void; deleteSelectedWidget: () => void; cutSelectedWidget: () => void; + groupSelectedWidget: () => void; toggleShowGlobalSearchModal: (category: SearchCategory) => void; resetSnipingMode: () => void; openDebugger: () => void; @@ -235,6 +237,17 @@ class GlobalHotKeys extends React.Component { preventDefault stopPropagation /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.groupSelectedWidget(); + } + }} + /> ); } @@ -256,6 +269,7 @@ const mapDispatchToProps = (dispatch: any) => { pasteCopiedWidget: () => dispatch(pasteWidget()), deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), cutSelectedWidget: () => dispatch(cutWidget()), + groupSelectedWidget: () => dispatch(groupWidgets()), toggleShowGlobalSearchModal: (category: SearchCategory) => dispatch(toggleShowGlobalSearchModal(category)), resetSnipingMode: () => dispatch(resetSnipingModeAction()), diff --git a/app/client/src/pages/Editor/WidgetsEditor.tsx b/app/client/src/pages/Editor/WidgetsEditor.tsx index cfb0cd6d34f9..7dbd06dc48c9 100644 --- a/app/client/src/pages/Editor/WidgetsEditor.tsx +++ b/app/client/src/pages/Editor/WidgetsEditor.tsx @@ -14,7 +14,7 @@ import { Spinner } from "@blueprintjs/core"; import AnalyticsUtil from "utils/AnalyticsUtil"; import * as log from "loglevel"; import { getCanvasClassName } from "utils/generators"; -import { flashElementById } from "utils/helpers"; +import { flashElementsById } from "utils/helpers"; import { useParams } from "react-router"; import { fetchPage } from "actions/pageActions"; import PerformanceTracker, { @@ -92,7 +92,7 @@ function WidgetsEditor() { useEffect(() => { if (!isFetchingPage && window.location.hash.length > 0) { const widgetIdFromURLHash = window.location.hash.substr(1); - flashElementById(widgetIdFromURLHash); + flashElementsById(widgetIdFromURLHash); if (document.getElementById(widgetIdFromURLHash)) selectWidget(widgetIdFromURLHash); } diff --git a/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx b/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx index d939f868a816..ac3932ded37f 100644 --- a/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx +++ b/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx @@ -6,6 +6,7 @@ import { useSelector, useDispatch } from "react-redux"; import { copyWidget, cutWidget, + groupWidgets, deleteSelectedWidget, } from "actions/widgetActions"; import { isMac } from "utils/helpers"; @@ -127,6 +128,7 @@ export const PopoverModifiers: IPopoverSharedProps["modifiers"] = { const CopyIcon = ControlIcons.COPY2_CONTROL; const DeleteIcon = FormIcons.DELETE_ICON; const CutIcon = ControlIcons.CUT_CONTROL; +const GroupIcon = ControlIcons.GROUP_CONTROL; /** * helper text that comes in popover on hover of actions in context menu @@ -148,6 +150,11 @@ const deleteHelpText = ( Click or Del ); +const groupHelpText = ( + <> + Click or {modText()} + G to group + +); interface OffsetBox { top: number; @@ -299,6 +306,20 @@ function WidgetsMultiSelectBox(props: { dispatch(deleteSelectedWidget(true)); }; + /** + * group widgets into container + * + * @param e + */ + const onGroupWidgets = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + e.stopPropagation(); + + dispatch(groupWidgets()); + }; + if (!shouldRender) return false; return ( @@ -371,6 +392,21 @@ function WidgetsMultiSelectBox(props: { + {/* group widgets */} + + + + + diff --git a/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx b/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx index aeece8e4817b..0726a34f27e1 100644 --- a/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx +++ b/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx @@ -11,9 +11,11 @@ import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; const initialState: CanvasWidgetsReduxState = {}; -export type FlattenedWidgetProps = WidgetProps & { - children?: string[]; -}; +export type FlattenedWidgetProps = + | (WidgetProps & { + children?: string[]; + }) + | orType; const canvasWidgetsReducer = createImmerReducer(initialState, { [ReduxActionTypes.INIT_CANVAS_LAYOUT]: ( diff --git a/app/client/src/sagas/WidgetOperationSagas.tsx b/app/client/src/sagas/WidgetOperationSagas.tsx index fbdc10f348db..8d91d47b46f7 100644 --- a/app/client/src/sagas/WidgetOperationSagas.tsx +++ b/app/client/src/sagas/WidgetOperationSagas.tsx @@ -16,12 +16,7 @@ import { CanvasWidgetsReduxState, FlattenedWidgetProps, } from "reducers/entityReducers/canvasWidgetsReducer"; -import { - getFocusedWidget, - getSelectedWidget, - getWidget, - getWidgets, -} from "./selectors"; +import { getSelectedWidget, getWidget, getWidgets } from "./selectors"; import { generateWidgetProps } from "utils/WidgetPropsUtils"; import { all, @@ -64,7 +59,6 @@ import { MAIN_CONTAINER_WIDGET_ID, RenderModes, WIDGET_DELETE_UNDO_TIMEOUT, - WidgetType, WidgetTypes, } from "constants/WidgetConstants"; import WidgetConfigResponse from "mockResponses/WidgetConfigResponse"; @@ -76,7 +70,7 @@ import { saveDeletedWidgets, } from "utils/storage"; import { generateReactKey } from "utils/generators"; -import { flashElementById } from "utils/helpers"; +import { flashElementsById } from "utils/helpers"; import AnalyticsUtil from "utils/AnalyticsUtil"; import log from "loglevel"; import { navigateToCanvas } from "pages/Editor/Explorer/Widgets/utils"; @@ -122,12 +116,21 @@ import AppsmithConsole from "utils/AppsmithConsole"; import { ENTITY_TYPE } from "entities/AppsmithConsole"; import LOG_TYPE from "entities/AppsmithConsole/logtype"; import { - checkIfPastingIntoListWidget, + CopiedWidgetGroup, doesTriggerPathsContainPropertyPath, getParentBottomRowAfterAddingWidget, getParentWidgetIdForPasting, getWidgetChildren, + groupWidgetsIntoContainer, handleSpecificCasesWhilePasting, + getSelectedWidgetWhenPasting, + createSelectedWidgetsAsCopiedWidgets, + filterOutSelectedWidgets, + isSelectedWidgetsColliding, + getBoundaryWidgetsFromCopiedGroups, + getAllWidgetsInTree, + createWidgetCopy, + getNextWidgetName, } from "./WidgetOperationUtils"; import { getSelectedWidgets } from "selectors/ui"; import { getParentWithEnhancementFn } from "./WidgetEnhancementHelpers"; @@ -423,22 +426,6 @@ export function* addChildrenSaga( } } -const getAllWidgetsInTree = ( - widgetId: string, - canvasWidgets: CanvasWidgetsReduxState, -) => { - const widget = canvasWidgets[widgetId]; - const widgetList = widget ? [widget] : []; - if (widget && widget.children) { - widget.children - .filter(Boolean) - .forEach((childWidgetId: string) => - widgetList.push(...getAllWidgetsInTree(childWidgetId, canvasWidgets)), - ); - } - return widgetList; -}; - /** * Note: Mutates finalWidgets[parentId].bottomRow for CANVAS_WIDGET * @param finalWidgets @@ -460,9 +447,11 @@ const resizeCanvasToLowestWidget = ( GridDefaults.DEFAULT_GRID_ROW_HEIGHT, ); const childIds = finalWidgets[parentId].children || []; + // find lowest row childIds.forEach((cId) => { const child = finalWidgets[cId]; + if (child.bottomRow > lowestBottomRow) { lowestBottomRow = child.bottomRow; } @@ -857,9 +846,7 @@ export function* undoDeleteSaga(action: ReduxAction<{ widgetId: string }>) { resizeCanvasToLowestWidget(finalWidgets, parentId); } yield put(updateAndSaveLayout(finalWidgets)); - deletedWidgetIds.forEach((widgetId) => { - setTimeout(() => flashElementById(widgetId), 100); - }); + flashElementsById(deletedWidgetIds, 100); yield put(selectMultipleWidgetsInitAction(deletedWidgetIds)); if (deletedWidgetIds.length === 1) { yield put(forceOpenPropertyPane(action.payload.widgetId)); @@ -1303,18 +1290,6 @@ function* updateCanvasSize( } } -function* createWidgetCopy(widget: FlattenedWidgetProps) { - const allWidgets: { [widgetId: string]: FlattenedWidgetProps } = yield select( - getWidgets, - ); - const widgetsToStore = getAllWidgetsInTree(widget.widgetId, allWidgets); - return { - widgetId: widget.widgetId, - list: widgetsToStore, - parentId: widget.parentId, - }; -} - function* createSelectedWidgetsCopy(selectedWidgets: FlattenedWidgetProps[]) { if (!selectedWidgets || !selectedWidgets.length) return; const widgetListsToStore: { @@ -1322,6 +1297,7 @@ function* createSelectedWidgetsCopy(selectedWidgets: FlattenedWidgetProps[]) { parentId: string; list: FlattenedWidgetProps[]; }[] = yield all(selectedWidgets.map((each) => call(createWidgetCopy, each))); + return yield saveCopiedWidgets(JSON.stringify(widgetListsToStore)); } @@ -1384,30 +1360,51 @@ function* copyWidgetSaga(action: ReduxAction<{ isShortcut: boolean }>) { } } +/** + * We take the bottom most widget in the canvas, then calculate the top,left,right,bottom + * co-ordinates for the new widget, such that it can be placed at the bottom of the canvas. + * + * @param widget + * @param parentId + * @param canvasWidgets + * @param parentBottomRow + * @param persistColumnPosition + * @returns + */ export function calculateNewWidgetPosition( widget: WidgetProps, parentId: string, canvasWidgets: { [widgetId: string]: FlattenedWidgetProps }, parentBottomRow?: number, - persistColumnPosition = false, -) { - // Note: This is a very simple algorithm. - // We take the bottom most widget in the canvas, then calculate the top,left,right,bottom - // co-ordinates for the new widget, such that it can be placed at the bottom of the canvas. + shouldPersistColumnPosition = false, + isThereACollision = false, + shouldGroup = false, +): { + topRow: number; + bottomRow: number; + leftColumn: number; + rightColumn: number; +} { const nextAvailableRow = parentBottomRow ? parentBottomRow : nextAvailableRowInContainer(parentId, canvasWidgets); return { - leftColumn: persistColumnPosition ? widget.leftColumn : 0, - rightColumn: persistColumnPosition + leftColumn: shouldPersistColumnPosition ? widget.leftColumn : 0, + rightColumn: shouldPersistColumnPosition ? widget.rightColumn : widget.rightColumn - widget.leftColumn, - topRow: parentBottomRow - ? nextAvailableRow + widget.topRow - : nextAvailableRow, - bottomRow: parentBottomRow - ? nextAvailableRow + widget.bottomRow - : nextAvailableRow + (widget.bottomRow - widget.topRow), + topRow: + !isThereACollision && shouldGroup + ? widget.topRow + : parentBottomRow + ? nextAvailableRow + widget.topRow + : nextAvailableRow, + bottomRow: + !isThereACollision && shouldGroup + ? widget.bottomRow + : parentBottomRow + ? nextAvailableRow + widget.bottomRow + : nextAvailableRow + (widget.bottomRow - widget.topRow), }; } @@ -1416,300 +1413,286 @@ function* getEntityNames() { return Object.keys(evalTree); } -export function getNextWidgetName( - widgets: CanvasWidgetsReduxState, - type: WidgetType, - evalTree: DataTree, - options?: Record, -) { - // Compute the new widget's name - const defaultConfig: any = WidgetConfigResponse.config[type]; - const widgetNames = Object.keys(widgets).map((w) => widgets[w].widgetName); - const entityNames = Object.keys(evalTree); - let prefix = defaultConfig.widgetName; - if (options && options.prefix) { - prefix = `${options.prefix}${ - widgetNames.indexOf(options.prefix as string) > -1 ? "Copy" : "" - }`; - } - - return getNextEntityName( - prefix, - [...widgetNames, ...entityNames], - options?.startWithoutIndex as boolean, - ); -} - /** * this saga create a new widget from the copied one to store */ -function* pasteWidgetSaga() { - const copiedWidgetGroups: { - widgetId: string; - parentId: string; - list: WidgetProps[]; - }[] = yield getCopiedWidgets(); +function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { + let copiedWidgetGroups: CopiedWidgetGroup[] = yield getCopiedWidgets(); + const shouldGroup: boolean = action.payload.groupWidgets; - if (!Array.isArray(copiedWidgetGroups)) { - return; - // to avoid invoking old copied widgets - } - const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); - let selectedWidget: FlattenedWidgetProps | undefined = yield select( - getSelectedWidget, - ); - const focusedWidget: FlattenedWidgetProps | undefined = yield select( - getFocusedWidget, - ); + const newlyCreatedWidgetIds: string[] = []; + const evalTree: DataTree = yield select(getDataTree); + const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); + let widgets: CanvasWidgetsReduxState = canvasWidgets; + const selectedWidget: FlattenedWidgetProps = yield getSelectedWidgetWhenPasting(); - selectedWidget = yield checkIfPastingIntoListWidget( - stateWidgets, - selectedWidget || focusedWidget, - copiedWidgetGroups, + const pastingIntoWidgetId: string = yield getParentWidgetIdForPasting( + canvasWidgets, + selectedWidget, ); - selectedWidget = yield checkIfPastingIntoListWidget( - stateWidgets, - selectedWidget, + let isThereACollision: boolean = yield isSelectedWidgetsColliding( + widgets, copiedWidgetGroups, + pastingIntoWidgetId, ); - const pastingIntoWidgetId: string = yield getParentWidgetIdForPasting( - { ...stateWidgets }, - selectedWidget, - ); + // if this is true, selected widgets will be grouped in container + if (shouldGroup) { + copiedWidgetGroups = yield createSelectedWidgetsAsCopiedWidgets(); + widgets = yield filterOutSelectedWidgets( + copiedWidgetGroups[0].parentId, + copiedWidgetGroups, + ); + isThereACollision = yield isSelectedWidgetsColliding( + widgets, + copiedWidgetGroups, + pastingIntoWidgetId, + ); - let widgets = { ...stateWidgets }; - const newlyCreatedWidgetIds: string[] = []; - const sortedWidgetList = copiedWidgetGroups.sort( - (a, b) => a.list[0].topRow - b.list[0].topRow, + copiedWidgetGroups = yield groupWidgetsIntoContainer( + copiedWidgetGroups, + pastingIntoWidgetId, + ); + } + + // to avoid invoking old copied widgets + if (!Array.isArray(copiedWidgetGroups)) return; + + const { topMostWidget } = getBoundaryWidgetsFromCopiedGroups( + copiedWidgetGroups, ); - const copiedGroupTopRow = sortedWidgetList[0].list[0].topRow; const nextAvailableRow: number = nextAvailableRowInContainer( pastingIntoWidgetId, widgets, ); + yield all( copiedWidgetGroups.map((copiedWidgets) => call(function*() { // Don't try to paste if there is no copied widget if (!copiedWidgets) return; - const copiedWidgetId = copiedWidgets.widgetId; - const unUpdatedCopyOfWidget = copiedWidgets.list.find( - (widget) => widget.widgetId === copiedWidgetId, - ); - if (unUpdatedCopyOfWidget) { - const copiedWidget = { - ...unUpdatedCopyOfWidget, - topRow: unUpdatedCopyOfWidget.topRow - copiedGroupTopRow, - bottomRow: unUpdatedCopyOfWidget.bottomRow - copiedGroupTopRow, - }; + const copiedWidgetId = copiedWidgets.widgetId; + const unUpdatedCopyOfWidget = copiedWidgets.list[0]; + const newTopRow = shouldGroup + ? isThereACollision + ? topMostWidget.topRow + : 0 + : topMostWidget.topRow; + + const copiedWidget = { + ...unUpdatedCopyOfWidget, + topRow: unUpdatedCopyOfWidget.topRow - newTopRow, + bottomRow: unUpdatedCopyOfWidget.bottomRow - newTopRow, + }; - // Log the paste event + // Log the paste or group event. + if (shouldGroup) { + AnalyticsUtil.logEvent("WIDGET_GROUP", { + widgetName: copiedWidget.widgetName, + widgetType: copiedWidget.type, + }); + } else { AnalyticsUtil.logEvent("WIDGET_PASTE", { widgetName: copiedWidget.widgetName, widgetType: copiedWidget.type, }); + } - // Compute the new widget's positional properties - const { - bottomRow, - leftColumn, - rightColumn, - topRow, - } = yield calculateNewWidgetPosition( - copiedWidget, - pastingIntoWidgetId, - widgets, - nextAvailableRow, - true, - ); - // goToNextAvailableRow = true, - // persistColumnPosition = false, - - const evalTree: DataTree = yield select(getDataTree); - - // 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 - widgetList.forEach((widget) => { - // Create a copy of the widget properties - const newWidget = cloneDeep(widget); - 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); - }); + // Compute the new widget's positional properties + const newWidgetPosition = calculateNewWidgetPosition( + copiedWidget, + pastingIntoWidgetId, + widgets, + nextAvailableRow, + true, + isThereACollision, + shouldGroup, + ); - // For each of the new widgets generated - for (let i = 0; i < newWidgetList.length; i++) { - const widget = newWidgetList[i]; - const oldWidgetName = widget.widgetName; - // Generate a new unique widget name - const newWidgetName = getNextWidgetName( - widgets, - widget.type, - evalTree, - { - prefix: oldWidgetName, - startWithoutIndex: true, - }, - ); - // Update the children widgetIds if it has children - if (widget.children && widget.children.length > 0) { - widget.children.forEach( - (childWidgetId: string, index: number) => { - if (widget.children) { - widget.children[index] = widgetIdMap[childWidgetId]; - } - }, - ); - } + // 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 + + widgetList.forEach((widget) => { + // Create a copy of the widget properties + const newWidget = cloneDeep(widget); + 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); + }); - // Update the tabs for the tabs widget. - if (widget.tabsObj && widget.type === WidgetTypes.TABS_WIDGET) { - try { - const tabs = Object.values(widget.tabsObj); - if (Array.isArray(tabs)) { - widget.tabsObj = tabs.reduce((obj: any, tab) => { - tab.widgetId = widgetIdMap[tab.widgetId]; - obj[tab.id] = tab; - return obj; - }, {}); - } - } catch (error) { - log.debug("Error updating tabs", error); + // For each of the new widgets generated + for (let i = 0; i < newWidgetList.length; i++) { + const widget = newWidgetList[i]; + const oldWidgetName = widget.widgetName; + let newWidgetName = oldWidgetName; + + if (!shouldGroup) { + newWidgetName = getNextWidgetName(widgets, widget.type, evalTree, { + prefix: oldWidgetName, + startWithoutIndex: true, + }); + } + + // Update the children widgetIds if it has children + if (widget.children && widget.children.length > 0) { + widget.children.forEach((childWidgetId: string, index: number) => { + if (widget.children) { + widget.children[index] = widgetIdMap[childWidgetId]; + } + }); + } + + // Update the tabs for the tabs widget. + if (widget.tabsObj && widget.type === WidgetTypes.TABS_WIDGET) { + try { + const tabs = Object.values(widget.tabsObj); + if (Array.isArray(tabs)) { + widget.tabsObj = tabs.reduce((obj: any, tab) => { + tab.widgetId = widgetIdMap[tab.widgetId]; + obj[tab.id] = tab; + return obj; + }, {}); } + } catch (error) { + log.debug("Error updating tabs", error); } + } - // Update the table widget column properties - if (widget.type === WidgetTypes.TABLE_WIDGET) { - try { - // If the primaryColumns of the table exist - if (widget.primaryColumns) { - // For each column - for (const [columnId, column] of Object.entries( - widget.primaryColumns, + // Update the table widget column properties + if (widget.type === WidgetTypes.TABLE_WIDGET) { + try { + // If the primaryColumns of the table exist + if (widget.primaryColumns) { + // For each column + for (const [columnId, column] of Object.entries( + widget.primaryColumns, + )) { + // For each property in the column + for (const [key, value] of Object.entries( + column as ColumnProperties, )) { - // For each property in the column - for (const [key, value] of Object.entries( - column as ColumnProperties, - )) { - // Replace reference of previous widget with the new widgetName - // This handles binding scenarios like `{{Table2.tableData.map((currentRow) => (currentRow.id))}}` - widget.primaryColumns[columnId][key] = isString(value) - ? value.replace( - `${oldWidgetName}.`, - `${newWidgetName}.`, - ) - : value; - } + // Replace reference of previous widget with the new widgetName + // This handles binding scenarios like `{{Table2.tableData.map((currentRow) => (currentRow.id))}}` + widget.primaryColumns[columnId][key] = isString(value) + ? value.replace(`${oldWidgetName}.`, `${newWidgetName}.`) + : value; } } - // Use the new widget name we used to replace the column properties above. - widget.widgetName = newWidgetName; - } catch (error) { - log.debug("Error updating table widget properties", error); } + // Use the new widget name we used to replace the column properties above. + widget.widgetName = newWidgetName; + } catch (error) { + log.debug("Error updating table widget properties", error); } + } - // If it is the copied widget, update position properties - if (widget.widgetId === widgetIdMap[copiedWidget.widgetId]) { - newWidgetId = widget.widgetId; - widget.leftColumn = leftColumn; - widget.topRow = topRow; - widget.bottomRow = bottomRow; - widget.rightColumn = rightColumn; - widget.parentId = pastingIntoWidgetId; - // Also, update the parent widget in the canvas widgets - // to include this new copied widget's id in the parent's children - let parentChildren = [widget.widgetId]; - const widgetChildren = widgets[pastingIntoWidgetId].children; - if (widgetChildren && Array.isArray(widgetChildren)) { - // Add the new child to existing children - parentChildren = parentChildren.concat(widgetChildren); - } - const parentBottomRow = getParentBottomRowAfterAddingWidget( - widgets[pastingIntoWidgetId], - widget, - ); + // If it is the copied widget, update position properties + if (widget.widgetId === widgetIdMap[copiedWidget.widgetId]) { + const { + bottomRow, + leftColumn, + rightColumn, + topRow, + } = newWidgetPosition; + newWidgetId = widget.widgetId; + widget.leftColumn = leftColumn; + widget.topRow = topRow; + widget.bottomRow = bottomRow; + widget.rightColumn = rightColumn; + widget.parentId = pastingIntoWidgetId; + // Also, update the parent widget in the canvas widgets + // to include this new copied widget's id in the parent's children + let parentChildren = [widget.widgetId]; + const widgetChildren = widgets[pastingIntoWidgetId].children; + if (widgetChildren && Array.isArray(widgetChildren)) { + // Add the new child to existing children + parentChildren = parentChildren.concat(widgetChildren); + } + const parentBottomRow = getParentBottomRowAfterAddingWidget( + widgets[pastingIntoWidgetId], + widget, + ); - widgets = { - ...widgets, - [pastingIntoWidgetId]: { - ...widgets[pastingIntoWidgetId], - bottomRow: parentBottomRow, - children: parentChildren, - }, - }; - // If the copied widget's boundaries exceed the parent's - // Make the parent scrollable + widgets = { + ...widgets, + [pastingIntoWidgetId]: { + ...widgets[pastingIntoWidgetId], + bottomRow: parentBottomRow, + children: parentChildren, + }, + }; + // If the copied widget's boundaries exceed the parent's + // Make the parent scrollable + if ( + widgets[pastingIntoWidgetId].bottomRow * + widgets[widget.parentId].parentRowSpace <= + widget.bottomRow * widget.parentRowSpace + ) { + const parentOfPastingWidget = + widgets[pastingIntoWidgetId].parentId; if ( - widgets[pastingIntoWidgetId].bottomRow * - widgets[widget.parentId].parentRowSpace <= - widget.bottomRow * widget.parentRowSpace + parentOfPastingWidget && + widget.parentId !== MAIN_CONTAINER_WIDGET_ID ) { - const parentOfPastingWidget = - widgets[pastingIntoWidgetId].parentId; - if ( - parentOfPastingWidget && - widget.parentId !== MAIN_CONTAINER_WIDGET_ID - ) { - const parent = widgets[parentOfPastingWidget]; - widgets[parentOfPastingWidget] = { - ...parent, - shouldScrollContents: true, - }; - } + const parent = widgets[parentOfPastingWidget]; + widgets[parentOfPastingWidget] = { + ...parent, + shouldScrollContents: true, + }; } - } else { - // For all other widgets in the list - // (These widgets will be descendants of the copied widget) - // This means, that their parents will also be newly copied widgets - // Update widget's parent widget ids with the new parent widget ids - const newParentId = newWidgetList.find((newWidget) => - widget.parentId - ? newWidget.widgetId === widgetIdMap[widget.parentId] - : false, - )?.widgetId; - if (newParentId) widget.parentId = newParentId; } + } else { + // For all other widgets in the list + // (These widgets will be descendants of the copied widget) + // This means, that their parents will also be newly copied widgets + // Update widget's parent widget ids with the new parent widget ids + const newParentId = newWidgetList.find((newWidget) => + widget.parentId + ? newWidget.widgetId === widgetIdMap[widget.parentId] + : false, + )?.widgetId; + if (newParentId) widget.parentId = newParentId; + } + // Generate a new unique widget name + if (!shouldGroup) { widget.widgetName = newWidgetName; - widgetNameMap[oldWidgetName] = widget.widgetName; - // Add the new widget to the canvas widgets - widgets[widget.widgetId] = widget; } - newlyCreatedWidgetIds.push(widgetIdMap[copiedWidgetId]); - // 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, - ); - } + widgetNameMap[oldWidgetName] = widget.widgetName; + // Add the new widget to the canvas widgets + widgets[widget.widgetId] = widget; + } + newlyCreatedWidgetIds.push(widgetIdMap[copiedWidgetId]); + // 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)); - newlyCreatedWidgetIds.forEach((newWidgetId) => { - setTimeout(() => flashElementById(newWidgetId), 100); - }); - // hydrating enhancements map after save layout so that enhancement map - // for newly copied widget is hydrated + + flashElementsById(newlyCreatedWidgetIds, 100); + yield put(selectMultipleWidgetsInitAction(newlyCreatedWidgetIds)); } @@ -1825,6 +1808,29 @@ function* addSuggestedWidget(action: ReduxAction>) { } } +/** + * saga to group selected widgets into a new container + * + * @param action + */ +export function* groupWidgetsSaga() { + const selectedWidgetIDs: string[] = yield select(getSelectedWidgets); + const isMultipleWidgetsSelected = selectedWidgetIDs.length > 1; + + if (isMultipleWidgetsSelected) { + try { + yield put({ + type: ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, + payload: { + groupWidgets: true, + }, + }); + } catch (error) { + log.error(error); + } + } +} + export default function* widgetOperationSagas() { yield fork(widgetSelectionSagas); yield all([ @@ -1868,5 +1874,6 @@ export default function* widgetOperationSagas() { takeEvery(ReduxActionTypes.UNDO_DELETE_WIDGET, undoDeleteSaga), takeEvery(ReduxActionTypes.CUT_SELECTED_WIDGET, cutWidgetSaga), takeEvery(WidgetReduxActionTypes.WIDGET_ADD_CHILDREN, addChildrenSaga), + takeEvery(ReduxActionTypes.GROUP_WIDGETS_INIT, groupWidgetsSaga), ]); } diff --git a/app/client/src/sagas/WidgetOperationUtils.ts b/app/client/src/sagas/WidgetOperationUtils.ts index bb2dfa7713ed..2d061f766e06 100644 --- a/app/client/src/sagas/WidgetOperationUtils.ts +++ b/app/client/src/sagas/WidgetOperationUtils.ts @@ -1,20 +1,41 @@ +import { + getFocusedWidget, + getSelectedWidget, + getWidgetMetaProps, + getWidgets, +} from "./selectors"; +import _, { isString } from "lodash"; import { GridDefaults, MAIN_CONTAINER_WIDGET_ID, + RenderModes, + WidgetType, WidgetTypes, } from "constants/WidgetConstants"; -import { cloneDeep, get, isString, filter, set } from "lodash"; +import { all, call } from "redux-saga/effects"; +import { DataTree } from "entities/DataTree/dataTreeFactory"; +import { select } from "redux-saga/effects"; +import { getCopiedWidgets } from "utils/storage"; +import { WidgetProps } from "widgets/BaseWidget"; +import { getSelectedWidgets } from "selectors/ui"; +import { generateReactKey } from "utils/generators"; import { CanvasWidgetsReduxState, FlattenedWidgetProps, } from "reducers/entityReducers/canvasWidgetsReducer"; -import { select } from "redux-saga/effects"; +import { getDataTree } from "selectors/dataTreeSelectors"; import { - combineDynamicBindings, getDynamicBindings, + combineDynamicBindings, } from "utils/DynamicBindingUtils"; -import { WidgetProps } from "widgets/BaseWidget"; -import { getWidgetMetaProps } from "./selectors"; +import WidgetConfigResponse from "mockResponses/WidgetConfigResponse"; +import { getNextEntityName } from "utils/AppsmithUtils"; + +export interface CopiedWidgetGroup { + widgetId: string; + parentId: string; + list: WidgetProps[]; +} /** * checks if triggerpaths contains property path passed @@ -59,14 +80,14 @@ export const handleIfParentIsListWidgetWhilePasting = ( widget: FlattenedWidgetProps, widgets: { [widgetId: string]: FlattenedWidgetProps }, ): { [widgetId: string]: FlattenedWidgetProps } => { - let root = get(widgets, `${widget.parentId}`); + let root = _.get(widgets, `${widget.parentId}`); while (root && 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( + const currentWidget = _.cloneDeep(widget); + let template = _.get(listWidget, "template", {}); + const dynamicBindingPathList: any[] = _.get( listWidget, "dynamicBindingPathList", [], @@ -180,7 +201,7 @@ export const handleSpecificCasesWhilePasting = ( (key) => widgetNameMap[key] === widget.widgetName, ); // get all the button, icon widgets - const copiedBtnIcnWidgets = filter( + const copiedBtnIcnWidgets = _.filter( newWidgetList, (copyWidget) => copyWidget.type === "BUTTON_WIDGET" || @@ -193,7 +214,7 @@ export const handleSpecificCasesWhilePasting = ( oldWidgetName, widget.widgetName, ); - set(widgets[copyWidget.widgetId], "onClick", newOnClick); + _.set(widgets[copyWidget.widgetId], "onClick", newOnClick); } }); } @@ -208,7 +229,7 @@ export function getWidgetChildren( widgetId: string, ): any { const childrenIds: string[] = []; - const widget = get(canvasWidgets, widgetId); + const widget = _.get(canvasWidgets, widgetId); // When a form widget tries to resetChildrenMetaProperties // But one or more of its container like children // have just been deleted, widget can be undefined @@ -327,11 +348,420 @@ export const checkIfPastingIntoListWidget = function( } } - return get(canvasWidgets, firstChildId); + return _.get(canvasWidgets, firstChildId); } return selectedWidget; }; +/** + * get top, left, right, bottom most widgets from copied groups when pasting + * + * @param copiedWidgetGroups + * @returns + */ +export const getBoundaryWidgetsFromCopiedGroups = function( + copiedWidgetGroups: CopiedWidgetGroup[], +) { + const topMostWidget = copiedWidgetGroups.sort( + (a, b) => a.list[0].topRow - b.list[0].topRow, + )[0].list[0]; + const leftMostWidget = copiedWidgetGroups.sort( + (a, b) => a.list[0].leftColumn - b.list[0].leftColumn, + )[0].list[0]; + const rightMostWidget = copiedWidgetGroups.sort( + (a, b) => b.list[0].rightColumn - a.list[0].rightColumn, + )[0].list[0]; + const bottomMostWidget = copiedWidgetGroups.sort( + (a, b) => b.list[0].bottomRow - a.list[0].bottomRow, + )[0].list[0]; + + return { + topMostWidget, + leftMostWidget, + rightMostWidget, + bottomMostWidget, + }; +}; + +/** + * ------------------------------------------------------------------------------- + * OPERATION = PASTING + * ------------------------------------------------------------------------------- + * + * following are the functions are that used in pasting operation + */ + +/** + * selects the selectedWidget. + * In case of LIST_WIDGET, it selects the list widget instead of selecting the + * container inside the list widget + * + * @param canvasWidgets + * @param copiedWidgetGroups + * @returns + */ +export const getSelectedWidgetWhenPasting = function*() { + const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); + const copiedWidgetGroups: CopiedWidgetGroup[] = yield getCopiedWidgets(); + + let selectedWidget: FlattenedWidgetProps | undefined = yield select( + getSelectedWidget, + ); + + const focusedWidget: FlattenedWidgetProps | undefined = yield select( + getFocusedWidget, + ); + + selectedWidget = checkIfPastingIntoListWidget( + canvasWidgets, + selectedWidget || focusedWidget, + copiedWidgetGroups, + ); + + return selectedWidget; +}; + +/** + * group copied widgets into a container + * + * @param copiedWidgetGroups + * @param pastingIntoWidgetId + * @returns + */ +export const groupWidgetsIntoContainer = function*( + copiedWidgetGroups: CopiedWidgetGroup[], + pastingIntoWidgetId: string, +) { + const containerWidgetId = generateReactKey(); + const evalTree: DataTree = yield select(getDataTree); + const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); + const newContainerName = getNextWidgetName( + canvasWidgets, + WidgetTypes.CONTAINER_WIDGET, + evalTree, + ); + const newCanvasName = getNextWidgetName( + canvasWidgets, + WidgetTypes.CANVAS_WIDGET, + evalTree, + ); + const { + bottomMostWidget, + leftMostWidget, + rightMostWidget, + topMostWidget, + } = getBoundaryWidgetsFromCopiedGroups(copiedWidgetGroups); + + const copiedWidgets = copiedWidgetGroups.map((copiedWidgetGroup) => + copiedWidgetGroup.list.find( + (w) => w.widgetId === copiedWidgetGroup.widgetId, + ), + ); + const parentColumnSpace = + copiedWidgetGroups[0].list[0].parentColumnSpace || 1; + + const boundary = { + top: _.minBy(copiedWidgets, (copiedWidget) => copiedWidget?.topRow), + left: _.minBy(copiedWidgets, (copiedWidget) => copiedWidget?.leftColumn), + bottom: _.maxBy(copiedWidgets, (copiedWidget) => copiedWidget?.bottomRow), + right: _.maxBy(copiedWidgets, (copiedWidget) => copiedWidget?.rightColumn), + }; + + const widthPerColumn = + ((rightMostWidget.rightColumn - leftMostWidget.leftColumn) * + parentColumnSpace) / + GridDefaults.DEFAULT_GRID_COLUMNS; + const heightOfCanvas = + (bottomMostWidget.bottomRow - topMostWidget.topRow) * parentColumnSpace; + const widthOfCanvas = + (rightMostWidget.rightColumn - leftMostWidget.leftColumn) * + parentColumnSpace; + + const newCanvasWidget: FlattenedWidgetProps = { + ..._.omit( + _.get( + WidgetConfigResponse.config[WidgetTypes.CONTAINER_WIDGET], + "blueprint.view[0]", + ), + ["position"], + ), + ..._.get( + WidgetConfigResponse.config[WidgetTypes.CONTAINER_WIDGET], + "blueprint.view[0].props", + ), + bottomRow: heightOfCanvas, + isLoading: false, + isVisible: true, + leftColumn: 0, + minHeight: heightOfCanvas, + parentColumnSpace: 1, + parentId: pastingIntoWidgetId, + parentRowSpace: 1, + rightColumn: widthOfCanvas, + topRow: 0, + renderMode: RenderModes.CANVAS, + version: 1, + widgetId: generateReactKey(), + widgetName: newCanvasName, + }; + const newContainerWidget: FlattenedWidgetProps = { + ..._.omit(WidgetConfigResponse.config[WidgetTypes.CONTAINER_WIDGET], [ + "rows", + "columns", + "blueprint", + ]), + parentId: pastingIntoWidgetId, + widgetName: newContainerName, + type: WidgetTypes.CONTAINER_WIDGET, + widgetId: containerWidgetId, + leftColumn: boundary.left?.leftColumn || 0, + topRow: boundary.top?.topRow || 0, + bottomRow: (boundary.bottom?.bottomRow || 0) + 2, + rightColumn: boundary.right?.rightColumn || 0, + tabId: "", + children: [newCanvasWidget.widgetId], + renderMode: RenderModes.CANVAS, + version: 1, + isLoading: false, + isVisible: true, + parentRowSpace: GridDefaults.DEFAULT_GRID_ROW_HEIGHT, + parentColumnSpace: widthPerColumn, + }; + newCanvasWidget.parentId = newContainerWidget.widgetId; + const percentageIncrease = parentColumnSpace / widthPerColumn; + + const list = copiedWidgetGroups.map((copiedWidgetGroup) => { + return [ + ...copiedWidgetGroup.list.map((listItem) => { + if (listItem.widgetId === copiedWidgetGroup.widgetId) { + newCanvasWidget.children = _.get(newCanvasWidget, "children", []); + newCanvasWidget.children = [ + ...newCanvasWidget.children, + listItem.widgetId, + ]; + + return { + ...listItem, + leftColumn: + (listItem.leftColumn - leftMostWidget.leftColumn) * + percentageIncrease, + rightColumn: + (listItem.rightColumn - leftMostWidget.leftColumn) * + percentageIncrease, + topRow: listItem.topRow - topMostWidget.topRow, + bottomRow: listItem.bottomRow - topMostWidget.topRow, + parentId: newCanvasWidget.widgetId, + parentRowSpace: GridDefaults.DEFAULT_GRID_ROW_HEIGHT, + parentColumnSpace: widthPerColumn, + }; + } + + return listItem; + }), + ]; + }); + + const flatList = _.flattenDeep(list); + + return [ + { + list: [newContainerWidget, newCanvasWidget, ...flatList], + widgetId: newContainerWidget.widgetId, + parentId: pastingIntoWidgetId, + }, + ]; +}; + +/** + * create copiedWidgets objects from selected widgets + * + * @returns + */ +export const createSelectedWidgetsAsCopiedWidgets = function*() { + const canvasWidgets: { + [widgetId: string]: FlattenedWidgetProps; + } = yield select(getWidgets); + const selectedWidgetIDs: string[] = yield select(getSelectedWidgets); + const selectedWidgets = selectedWidgetIDs.map((each) => canvasWidgets[each]); + + if (!selectedWidgets || !selectedWidgets.length) return; + + const widgetListsToStore: { + widgetId: string; + parentId: string; + list: FlattenedWidgetProps[]; + }[] = yield all(selectedWidgets.map((each) => call(createWidgetCopy, each))); + + return widgetListsToStore; +}; + +/** + * return canvasWidgets without selectedWidgets and remove the selected widgets + * ids in the children of parent widget + * + * @return + */ +export const filterOutSelectedWidgets = function*( + parentId: string, + copiedWidgetGroups: CopiedWidgetGroup[], +) { + const canvasWidgets: CanvasWidgetsReduxState = yield _.cloneDeep( + select(getWidgets), + ); + + const selectedWidgetIDs: string[] = _.flattenDeep( + copiedWidgetGroups.map((copiedWidgetGroup) => { + return copiedWidgetGroup.list.map((widget) => widget.widgetId); + }), + ); + + const filteredWidgets: CanvasWidgetsReduxState = _.omit( + canvasWidgets, + selectedWidgetIDs, + ); + + return { + ...filteredWidgets, + [parentId]: { + ...filteredWidgets[parentId], + // removing the selected widgets ids in the children of parent widget + children: _.get(filteredWidgets[parentId], "children", []).filter( + (widgetId) => { + return !selectedWidgetIDs.includes(widgetId); + }, + ), + }, + }; +}; + +/** + * checks if selected widgets are colliding with other widgets or not + * + * @param widgets + * @param copiedWidgetGroups + * @returns + */ +export const isSelectedWidgetsColliding = function*( + widgets: CanvasWidgetsReduxState, + copiedWidgetGroups: CopiedWidgetGroup[], + pastingIntoWidgetId: string, +) { + if (!Array.isArray(copiedWidgetGroups)) return false; + + const { + bottomMostWidget, + leftMostWidget, + rightMostWidget, + topMostWidget, + } = getBoundaryWidgetsFromCopiedGroups(copiedWidgetGroups); + + const widgetsWithSameParent = _.omitBy(widgets, (widget) => { + return widget.parentId !== pastingIntoWidgetId; + }); + + const widgetsArray = Object.values(widgetsWithSameParent).filter( + (widget) => + widget.parentId === pastingIntoWidgetId && + widget.type !== WidgetTypes.MODAL_WIDGET, + ); + + let isColliding = false; + + for (let i = 0; i < widgetsArray.length; i++) { + const widget = widgetsArray[i]; + + if ( + widget.bottomRow + 2 < topMostWidget.topRow || + widget.topRow > bottomMostWidget.bottomRow + ) { + isColliding = false; + } else if ( + widget.rightColumn < leftMostWidget.leftColumn || + widget.leftColumn > rightMostWidget.rightColumn + ) { + isColliding = false; + } else { + return true; + } + } + + return isColliding; +}; + +/** + * get next widget name to be used + * + * @param widgets + * @param type + * @param evalTree + * @param options + * @returns + */ +export function getNextWidgetName( + widgets: CanvasWidgetsReduxState, + type: WidgetType, + evalTree: DataTree, + options?: Record, +) { + // Compute the new widget's name + const defaultConfig: any = WidgetConfigResponse.config[type]; + const widgetNames = Object.keys(widgets).map((w) => widgets[w].widgetName); + const entityNames = Object.keys(evalTree); + let prefix = defaultConfig.widgetName; + if (options && options.prefix) { + prefix = `${options.prefix}${ + widgetNames.indexOf(options.prefix as string) > -1 ? "Copy" : "" + }`; + } + + return getNextEntityName( + prefix, + [...widgetNames, ...entityNames], + options?.startWithoutIndex as boolean, + ); +} + +/** + * creates widget copied groups + * + * @param widget + * @returns + */ +export function* createWidgetCopy(widget: FlattenedWidgetProps) { + const allWidgets: { [widgetId: string]: FlattenedWidgetProps } = yield select( + getWidgets, + ); + const widgetsToStore = getAllWidgetsInTree(widget.widgetId, allWidgets); + return { + widgetId: widget.widgetId, + list: widgetsToStore, + parentId: widget.parentId, + }; +} + +/** + * get all widgets in tree + * + * @param widgetId + * @param canvasWidgets + * @returns + */ +export const getAllWidgetsInTree = ( + widgetId: string, + canvasWidgets: CanvasWidgetsReduxState, +) => { + const widget = canvasWidgets[widgetId]; + const widgetList = [widget]; + if (widget && widget.children) { + widget.children + .filter(Boolean) + .forEach((childWidgetId: string) => + widgetList.push(...getAllWidgetsInTree(childWidgetId, canvasWidgets)), + ); + } + return widgetList; +}; + export const getParentBottomRowAfterAddingWidget = ( stateParent: FlattenedWidgetProps, newWidget: FlattenedWidgetProps, diff --git a/app/client/src/selectors/dataTreeSelectors.ts b/app/client/src/selectors/dataTreeSelectors.ts index 6ad8a33ddcf9..a997d82a4305 100644 --- a/app/client/src/selectors/dataTreeSelectors.ts +++ b/app/client/src/selectors/dataTreeSelectors.ts @@ -53,7 +53,8 @@ export const getLoadingEntities = (state: AppState) => * * @param state */ -export const getDataTree = (state: AppState) => state.evaluations.tree; +export const getDataTree = (state: AppState): DataTree => + state.evaluations.tree; // For autocomplete. Use actions cached responses if // there isn't a response already diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index 518a3857f39e..92c3e21dee7a 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -156,6 +156,8 @@ export type EventName = | "CONNECT_DATA_CLICK" | "RESPONSE_TAB_RUN_ACTION_CLICK" | "ASSOCIATED_ENTITY_DROPDOWN_CLICK" + | "PAGES_LIST_LOAD" + | "WIDGET_GROUP" | "CLOSE_GEN_PAGE_INFO_MODAL" | "PAGES_LIST_LOAD"; diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx index d61b67d4b95c..3394e403b7f6 100644 --- a/app/client/src/utils/helpers.tsx +++ b/app/client/src/utils/helpers.tsx @@ -146,20 +146,39 @@ export const removeSpecialChars = (value: string, limit?: number) => { export const flashElement = (el: HTMLElement) => { el.style.backgroundColor = "#FFCB33"; + setTimeout(() => { el.style.backgroundColor = "transparent"; }, 1000); }; -export const flashElementById = (id: string) => { - const el = document.getElementById(id); - el?.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); +/** + * flash elements with a background color + * + * @param id + */ +export const flashElementsById = (id: string | string[], timeout = 0) => { + let ids: string[] = []; - if (el) flashElement(el); + if (Array.isArray(id)) { + ids = ids.concat(id); + } else { + ids = ids.concat([id]); + } + + ids.forEach((id) => { + setTimeout(() => { + const el = document.getElementById(id); + + el?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + + if (el) flashElement(el); + }, timeout); + }); }; export const resolveAsSpaceChar = (value: string, limit?: number) => { diff --git a/app/client/src/utils/storage.ts b/app/client/src/utils/storage.ts index 8421713ae68a..8fbfc002afe9 100644 --- a/app/client/src/utils/storage.ts +++ b/app/client/src/utils/storage.ts @@ -1,11 +1,12 @@ -import localforage from "localforage"; -import moment from "moment"; import log from "loglevel"; +import moment from "moment"; +import localforage from "localforage"; const STORAGE_KEYS: { [id: string]: string } = { AUTH_EXPIRATION: "Auth.expiration", ROUTE_BEFORE_LOGIN: "RedirectPath", COPIED_WIDGET: "CopiedWidget", + GROUP_COPIED_WIDGETS: "groupCopiedWidgets", DELETED_WIDGET_PREFIX: "DeletedWidget-", ONBOARDING_STATE: "OnboardingState", ONBOARDING_WELCOME_STATE: "OnboardingWelcomeState",