import type { ReduxAction, ReduxActionType, } from "@appsmith/constants/ReduxActionConstants"; import { ReduxActionErrorTypes, ReduxActionTypes, WidgetReduxActionTypes, } from "@appsmith/constants/ReduxActionConstants"; import { resetWidgetMetaProperty } from "actions/metaActions"; import { selectWidgetInitAction } from "actions/widgetSelectionActions"; import { GridDefaults, MAIN_CONTAINER_WIDGET_ID, RenderModes, WIDGET_ID_SHOW_WALKTHROUGH, } from "constants/WidgetConstants"; import log from "loglevel"; import type { WidgetResize } from "actions/pageActions"; import { updateAndSaveLayout } from "actions/pageActions"; import type { CanvasWidgetsReduxState, FlattenedWidgetProps, } from "reducers/entityReducers/canvasWidgetsReducer"; import { actionChannel, all, call, fork, put, select, take, takeEvery, takeLatest, takeLeading, } from "redux-saga/effects"; import { getCanvasWidth, getContainerWidgetSpacesSelector, getCurrentPageId, getIsAutoLayout, getIsAutoLayoutMobileBreakPoint, } from "selectors/editorSelectors"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { convertToString } from "utils/AppsmithUtils"; import type { BatchUpdateDynamicPropertyUpdates, BatchUpdateWidgetDynamicPropertyPayload, DeleteWidgetPropertyPayload, SetWidgetDynamicPropertyPayload, UpdateWidgetPropertyPayload, UpdateWidgetPropertyRequestPayload, } from "actions/controlActions"; import { batchUpdateWidgetProperty, updateMultipleWidgetPropertiesAction, } from "actions/controlActions"; import type { DynamicPath } from "utils/DynamicBindingUtils"; import { getEntityDynamicBindingPathList, getWidgetDynamicPropertyPathList, getWidgetDynamicTriggerPathList, isChildPropertyPath, isDynamicValue, isPathADynamicBinding, isPathDynamicTrigger, } from "utils/DynamicBindingUtils"; import type { WidgetProps } from "widgets/BaseWidget"; import _, { cloneDeep, get, isString, set, uniq } from "lodash"; import WidgetFactory from "WidgetProvider/factory"; import { generateReactKey } from "utils/generators"; import { getCopiedWidgets, saveCopiedWidgets } from "utils/storage"; import { getWidget, getWidgets, getWidgetsMeta } from "./selectors"; import { ERROR_WIDGET_COPY_NOT_ALLOWED, ERROR_WIDGET_COPY_NO_WIDGET_SELECTED, ERROR_WIDGET_CUT_NOT_ALLOWED, ERROR_WIDGET_CUT_NO_WIDGET_SELECTED, WIDGET_COPY, WIDGET_CUT, createMessage, } from "@appsmith/constants/messages"; import { getAllPaths } from "@appsmith/workers/Evaluation/evaluationUtils"; import { getDataTree, getConfigTree } from "selectors/dataTreeSelectors"; import { validateProperty } from "./EvaluationsSaga"; import type { ColumnProperties } from "widgets/TableWidget/component/Constants"; import { getAllPathsFromPropertyConfig, nextAvailableRowInContainer, } from "entities/Widget/utils"; import { getSelectedWidgets } from "selectors/ui"; import { getReflow } from "selectors/widgetReflowSelectors"; import { addChildToPastedFlexLayers, getFlexLayersForSelectedWidgets, getLayerIndexOfWidget, getNewFlexLayers, isStack, pasteWidgetInFlexLayers, } from "../layoutSystems/autolayout/utils/AutoLayoutUtils"; import type { CopiedWidgetGroup, NewPastePositionVariables, } from "./WidgetOperationUtils"; import { WIDGET_PASTE_PADDING } from "./WidgetOperationUtils"; import { changeIdsOfPastePositions, createSelectedWidgetsAsCopiedWidgets, createWidgetCopy, doesTriggerPathsContainPropertyPath, filterOutSelectedWidgets, getBoundariesFromSelectedWidgets, getBoundaryWidgetsFromCopiedGroups, getCanvasIdForContainer, getContainerIdForCanvas, getDefaultCanvas, getMousePositions, getNewPositionsForCopiedWidgets, getNextWidgetName, getOccupiedSpacesFromProps, getParentWidgetIdForGrouping, getParentWidgetIdForPasting, getPastePositionMapFromMousePointer, getReflowedPositions, getSelectedWidgetWhenPasting, getSnappedGrid, getValueFromTree, getVerifiedSelectedWidgets, getVerticallyAdjustedPositions, getWidgetDescendantToReset, groupWidgetsIntoContainer, handleSpecificCasesWhilePasting, isDropTarget, isSelectedWidgetsColliding, mergeDynamicPropertyPaths, purgeOrphanedDynamicPaths, } from "./WidgetOperationUtils"; import { partialImportSaga, partialExportSaga, widgetSelectionSagas, } from "./WidgetSelectionSagas"; import type { WidgetEntityConfig } from "@appsmith/entities/DataTree/types"; import type { DataTree, ConfigTree } from "entities/DataTree/dataTreeTypes"; import { getCanvasSizeAfterWidgetMove } from "./CanvasSagas/DraggingCanvasSagas"; import widgetAdditionSagas from "./WidgetAdditionSagas"; import widgetDeletionSagas from "./WidgetDeletionSagas"; import type { widgetReflow } from "reducers/uiReducers/reflowReducer"; import { stopReflowAction } from "actions/reflowActions"; import { collisionCheckPostReflow, getBottomRowAfterReflow, } from "utils/reflowHookUtils"; import type { GridProps, PrevReflowState, SpaceMap } from "reflow/reflowTypes"; import { ReflowDirection } from "reflow/reflowTypes"; import type { WidgetSpace } from "constants/CanvasEditorConstants"; import { reflow } from "reflow"; import { getBottomMostRow } from "reflow/reflowUtils"; import { flashElementsById } from "utils/helpers"; import { getSlidingArenaName } from "constants/componentClassNameConstants"; import { builderURL } from "@appsmith/RouteBuilder"; import history from "utils/history"; import { generateAutoHeightLayoutTreeAction } from "actions/autoHeightActions"; import { executeWidgetBlueprintBeforeOperations, traverseTreeAndExecuteBlueprintChildOperations, } from "./WidgetBlueprintSagas"; import type { MetaState } from "reducers/entityReducers/metaReducer"; import { SelectionRequestType } from "sagas/WidgetSelectUtils"; import { BlueprintOperationTypes } from "WidgetProvider/constants"; import { toast } from "design-system"; import { LayoutSystemTypes } from "layoutSystems/types"; import { updatePositionsOfParentAndSiblings, updateWidgetPositions, } from "layoutSystems/autolayout/utils/positionUtils"; import { getWidgetWidth } from "layoutSystems/autolayout/utils/flexWidgetUtils"; import { FlexLayerAlignment, LayoutDirection, } from "layoutSystems/common/utils/constants"; import localStorage from "utils/localStorage"; import type { FlexLayer } from "layoutSystems/autolayout/utils/types"; import { EMPTY_BINDING } from "components/editorComponents/ActionCreator/constants"; import { getLayoutSystemType } from "selectors/layoutSystemSelectors"; import { addSuggestedWidgetAnvilAction } from "layoutSystems/anvil/integrations/actions/draggingActions"; import { updateAndSaveAnvilLayout } from "layoutSystems/anvil/utils/anvilChecksUtils"; export function* resizeSaga(resizeAction: ReduxAction) { try { toast.dismiss(); const start = performance.now(); const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const stateWidget: FlattenedWidgetProps = yield select( getWidget, resizeAction.payload.widgetId, ); const isMobile: boolean = yield select(getIsAutoLayoutMobileBreakPoint); const widgets = { ...stateWidgets }; let widget = { ...stateWidget }; const { bottomRow = widget.bottomRow, leftColumn = widget.leftColumn, mobileBottomRow = widget.mobileBottomRow, mobileLeftColumn = widget.mobileLeftColumn, mobileRightColumn = widget.mobileRightColumn, mobileTopRow = widget.mobileTopRow, parentId, rightColumn = widget.rightColumn, snapColumnSpace, snapRowSpace, topRow = widget.topRow, widgetId, } = resizeAction.payload; const layoutSystemType: LayoutSystemTypes = yield select(getLayoutSystemType); const mainCanvasWidth: number = yield select(getCanvasWidth); widget = { ...widget, leftColumn, rightColumn, topRow, bottomRow, mobileLeftColumn, mobileRightColumn, mobileTopRow, mobileBottomRow, }; if (layoutSystemType === LayoutSystemTypes.AUTO) { // Keeps track of user defined widget width in terms of percentage if (isMobile) { widget.mobileWidthInPercentage = (getWidgetWidth(widget, true) * snapColumnSpace) / mainCanvasWidth; } else { widget.widthInPercentage = (getWidgetWidth(widget, false) * snapColumnSpace) / mainCanvasWidth; } } const movedWidgets: { [widgetId: string]: FlattenedWidgetProps; } = yield call( reflowWidgets, widgets, widget, snapColumnSpace, snapRowSpace, ); const updatedCanvasBottomRow: number = yield call( getCanvasSizeAfterWidgetMove, parentId, [widgetId], bottomRow, ); // If it is a fixed canvas, update bottomRow directly. if ( updatedCanvasBottomRow && layoutSystemType === LayoutSystemTypes.FIXED ) { const canvasWidget = movedWidgets[parentId]; movedWidgets[parentId] = { ...canvasWidget, bottomRow: updatedCanvasBottomRow, }; } // If it is an auto-layout canvas, then use positionUtils to update canvas bottomRow. let updatedWidgetsAfterResizing = movedWidgets; if (layoutSystemType === LayoutSystemTypes.AUTO) { const metaProps: Record = yield select(getWidgetsMeta); updatedWidgetsAfterResizing = updatePositionsOfParentAndSiblings( movedWidgets, parentId, getLayerIndexOfWidget(widgets[parentId]?.flexLayers, widgetId), isMobile, mainCanvasWidth, false, metaProps, ); } log.debug("resize computations took", performance.now() - start, "ms"); yield put(stopReflowAction()); yield put(updateAndSaveLayout(updatedWidgetsAfterResizing)); // Widget resize based auto-height is only required for fixed-layout // Auto-layout has UPDATE_WIDGET_DIMENSIONS to handle auto height if (layoutSystemType === LayoutSystemTypes.FIXED) { yield put(generateAutoHeightLayoutTreeAction(true, true)); } } catch (error) { yield put({ type: ReduxActionErrorTypes.WIDGET_OPERATION_ERROR, payload: { action: WidgetReduxActionTypes.WIDGET_RESIZE, error, }, }); } } export function* reflowWidgets( widgets: { [widgetId: string]: FlattenedWidgetProps; }, widget: FlattenedWidgetProps, snapColumnSpace: number, snapRowSpace: number, ) { const reflowState: widgetReflow = yield select(getReflow); const currentWidgets: { [widgetId: string]: FlattenedWidgetProps; } = { ...widgets, [widget.widgetId]: { ...widget } }; if (!reflowState || !reflowState.isReflowing || !reflowState.reflowingWidgets) return currentWidgets; const reflowingWidgets = reflowState.reflowingWidgets; const reflowWidgetKeys = Object.keys(reflowingWidgets || {}); if (reflowWidgetKeys.length <= 0) return widgets; for (const reflowedWidgetId of reflowWidgetKeys) { const reflowWidget = reflowingWidgets[reflowedWidgetId]; const canvasWidget = { ...currentWidgets[reflowedWidgetId] }; if (reflowWidget.X !== undefined && reflowWidget.width !== undefined) { const leftColumn = canvasWidget.leftColumn + reflowWidget.X / snapColumnSpace; const rightColumn = leftColumn + reflowWidget.width / snapColumnSpace; currentWidgets[reflowedWidgetId] = { ...canvasWidget, leftColumn, rightColumn, }; } else if ( reflowWidget.Y !== undefined && reflowWidget.height !== undefined ) { const topRow = canvasWidget.topRow + reflowWidget.Y / snapRowSpace; const bottomRow = topRow + reflowWidget.height / snapRowSpace; currentWidgets[reflowedWidgetId] = { ...canvasWidget, topRow, bottomRow }; } } if ( collisionCheckPostReflow(currentWidgets, reflowWidgetKeys, widget.parentId) ) { return currentWidgets; } return widgets; } enum DynamicPathUpdateEffectEnum { ADD = "ADD", REMOVE = "REMOVE", NOOP = "NOOP", } interface DynamicPathUpdate { propertyPath: string; effect: DynamicPathUpdateEffectEnum; } function getDynamicTriggerPathListUpdate( widget: WidgetProps, propertyPath: string, propertyValue: string, ): DynamicPathUpdate { if (propertyValue && !isPathDynamicTrigger(widget, propertyPath)) { return { propertyPath, effect: DynamicPathUpdateEffectEnum.ADD, }; } else if (!propertyValue && !isPathDynamicTrigger(widget, propertyPath)) { return { propertyPath, effect: DynamicPathUpdateEffectEnum.REMOVE, }; } return { propertyPath, effect: DynamicPathUpdateEffectEnum.NOOP, }; } const DYNAMIC_BINDING_IGNORED_LIST = [ /* Table widget */ "primaryColumns", "derivedColumns", /* custom widget */ "srcDoc.html", "srcDoc.css", "srcDoc.js", "uncompiledSrcDoc.html", "uncompiledSrcDoc.css", "uncompiledSrcDoc.js", ]; function getDynamicBindingPathListUpdate( widget: WidgetProps, propertyPath: string, propertyValue: any, ): DynamicPathUpdate { let stringProp = propertyValue; if (_.isObject(propertyValue)) { // Stringify this because composite controls may have bindings in the sub controls stringProp = JSON.stringify(propertyValue); } /* * TODO(Balaji Soundararajan): This is not appropriate from the platform's archtecture's point of view. * This setting should come from widget configuration */ // Figure out a holistic solutions where we donot have to stringify above. if (DYNAMIC_BINDING_IGNORED_LIST.includes(propertyPath)) { return { propertyPath, effect: DynamicPathUpdateEffectEnum.NOOP, }; } const isDynamic = isDynamicValue(stringProp); if (!isDynamic && isPathADynamicBinding(widget, propertyPath)) { return { propertyPath, effect: DynamicPathUpdateEffectEnum.REMOVE, }; } else if (isDynamic && !isPathADynamicBinding(widget, propertyPath)) { return { propertyPath, effect: DynamicPathUpdateEffectEnum.ADD, }; } return { propertyPath, effect: DynamicPathUpdateEffectEnum.NOOP, }; } function applyDynamicPathUpdates( currentList: DynamicPath[], update: DynamicPathUpdate, ): DynamicPath[] { if (update.effect === DynamicPathUpdateEffectEnum.ADD) { currentList.push({ key: update.propertyPath, }); } else if (update.effect === DynamicPathUpdateEffectEnum.REMOVE) { currentList = _.reject(currentList, { key: update.propertyPath }); } return currentList; } function* updateWidgetPropertySaga( updateAction: ReduxAction, ) { const { payload: { propertyPath, propertyValue, widgetId }, } = updateAction; // Holder object to collect all updates const updates: Record = { [propertyPath]: propertyValue, }; // Push these updates via the batch update yield call( batchUpdateWidgetPropertySaga, batchUpdateWidgetProperty(widgetId, { modify: updates }), ); } export function removeDynamicBindingProperties( propertyPath: string, dynamicBindingPathList: DynamicPath[], ) { /* we are doing this because when you toggle js off we only receive the `primaryColumns.` properties not the `derivedColumns.` properties therefore we need just a hard-codded check. (TODO) - Arsalan remove this primaryColumns check when the Table widget v2 is live. */ if (_.startsWith(propertyPath, "primaryColumns")) { // primaryColumns.customColumn1.isVisible -> customColumn1.isVisible const tableProperty = propertyPath.split(".").splice(1).join("."); const tablePropertyPathsToRemove = [ propertyPath, // primaryColumns.customColumn1.isVisible `derivedColumns.${tableProperty}`, // derivedColumns.customColumn1.isVisible ]; return _.reject(dynamicBindingPathList, ({ key }) => tablePropertyPathsToRemove.includes(key), ); } else { return _.reject(dynamicBindingPathList, { key: propertyPath, }); } } export function* handleUpdateWidgetDynamicProperty( widget: WidgetProps, update: BatchUpdateDynamicPropertyUpdates, ) { const { isDynamic, propertyPath, shouldRejectDynamicBindingPathList = true, skipValidation = false, } = update; const propertyValue = _.get(widget, propertyPath); let dynamicPropertyPathList = getWidgetDynamicPropertyPathList(widget); let dynamicBindingPathList = getEntityDynamicBindingPathList(widget); if (isDynamic) { const keyExists = dynamicPropertyPathList.findIndex((path) => path.key === propertyPath) > -1; if (!keyExists) { dynamicPropertyPathList.push({ key: propertyPath, }); } widget = set(widget, propertyPath, convertToString(propertyValue)); } else { dynamicPropertyPathList = _.reject(dynamicPropertyPathList, { key: propertyPath, }); if (shouldRejectDynamicBindingPathList) { dynamicBindingPathList = removeDynamicBindingProperties( propertyPath, dynamicBindingPathList, ); } /* * We need to run the validation function to parse the value present in the * js mode to use that in the non js mode. * - if the value is valid to be used on non js mode, we will use the same value * - if the value is invalid to be used on non js mode, we will use the default value * returned by the validation function. * * Sometimes (eg: in one click binding control) we don't want to do validation and retain the * same value while switching from js to non js mode. use `skipValidation` flag to turn off validation. */ if (!skipValidation) { const { parsed } = yield call( validateProperty, propertyPath, propertyValue, widget, ); widget = set(widget, propertyPath, parsed); } } widget.dynamicPropertyPathList = dynamicPropertyPathList; widget.dynamicBindingPathList = dynamicBindingPathList; return widget; } export function* batchUpdateWidgetDynamicPropertySaga( action: ReduxAction, ) { const { updates, widgetId } = action.payload; const stateWidget: WidgetProps = yield select(getWidget, widgetId); let widget = cloneDeep({ ...stateWidget }); for (const update of updates) { widget = yield call(handleUpdateWidgetDynamicProperty, widget, update); } const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const widgets = { ...stateWidgets, [widgetId]: widget }; // Save the layout yield put(updateAndSaveLayout(widgets)); } export function* setWidgetDynamicPropertySaga( action: ReduxAction, ) { const { isDynamic, propertyPath, shouldRejectDynamicBindingPathList = true, skipValidation = false, widgetId, } = action.payload; const stateWidget: WidgetProps = yield select(getWidget, widgetId); let widget = cloneDeep({ ...stateWidget }); const update = { isDynamic, propertyPath, shouldRejectDynamicBindingPathList, skipValidation, }; widget = yield call(handleUpdateWidgetDynamicProperty, widget, update); const propertyValue = get(widget, propertyPath); if (!propertyValue && isDynamic) { set(widget, propertyPath, EMPTY_BINDING); } const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const widgets = { ...stateWidgets, [widgetId]: widget }; // Save the layout yield put(updateAndSaveLayout(widgets)); } export function getPropertiesToUpdate( widget: WidgetProps, updates: Record, triggerPaths?: string[], ): { propertyUpdates: Record; dynamicTriggerPathList: DynamicPath[]; dynamicBindingPathList: DynamicPath[]; } { // Create a const widgetWithUpdates = _.cloneDeep(widget); Object.entries(updates).forEach(([propertyPath, propertyValue]) => { set(widgetWithUpdates, propertyPath, propertyValue); }); // get the flat list of all updates (in case values are objects) const updatePaths = getAllPaths(updates); const propertyUpdates: Record = { ...updates, }; const currentDynamicTriggerPathList: DynamicPath[] = getWidgetDynamicTriggerPathList(widget); const currentDynamicBindingPathList: DynamicPath[] = getEntityDynamicBindingPathList(widget); const dynamicTriggerPathListUpdates: DynamicPathUpdate[] = []; const dynamicBindingPathListUpdates: DynamicPathUpdate[] = []; const widgetConfig = WidgetFactory.getWidgetPropertyPaneConfig( widget.type, widget, ); const { triggerPaths: triggerPathsFromPropertyConfig = {} } = getAllPathsFromPropertyConfig(widgetWithUpdates, widgetConfig, {}); Object.keys(updatePaths).forEach((propertyPath) => { const propertyValue = getValueFromTree(updates, propertyPath); // only check if if (!_.isString(propertyValue)) { return; } let isTriggerProperty = propertyPath in triggerPathsFromPropertyConfig; isTriggerProperty = doesTriggerPathsContainPropertyPath( isTriggerProperty, propertyPath, triggerPaths, ); // If it is a trigger property, it will go in a different list than the general // dynamicBindingPathList. if (isTriggerProperty) { dynamicTriggerPathListUpdates.push( getDynamicTriggerPathListUpdate(widget, propertyPath, propertyValue), ); } else { dynamicBindingPathListUpdates.push( getDynamicBindingPathListUpdate(widget, propertyPath, propertyValue), ); } }); const dynamicTriggerPathList = dynamicTriggerPathListUpdates.reduce( applyDynamicPathUpdates, currentDynamicTriggerPathList, ); const dynamicBindingPathList = dynamicBindingPathListUpdates.reduce( applyDynamicPathUpdates, currentDynamicBindingPathList, ); return { propertyUpdates, dynamicTriggerPathList, dynamicBindingPathList, }; } export function* getIsContainerLikeWidget(widget: FlattenedWidgetProps) { const children = widget.children; if (Array.isArray(children) && children.length > 0) { const firstChild: FlattenedWidgetProps = yield select( getWidget, children[0], ); if (firstChild.type === "CANVAS_WIDGET") return true; } return false; } export function* getPropertiesUpdatedWidget( updatesObj: UpdateWidgetPropertyPayload, ) { const { dynamicUpdates, updates, widgetId } = updatesObj; const { modify = {}, postUpdateAction, remove = [], triggerPaths } = updates; const stateWidget: WidgetProps = yield select(getWidget, widgetId); // if there is no widget in the state, don't do anything if (!stateWidget) return; let widget = cloneDeep(stateWidget); try { if (Object.keys(modify).length > 0) { const { dynamicBindingPathList, dynamicTriggerPathList, propertyUpdates, } = getPropertiesToUpdate(widget, modify, triggerPaths); // We loop over all updates Object.entries(propertyUpdates).forEach( ([propertyPath, propertyValue]) => { // since property paths could be nested, we use lodash set method widget = set(widget, propertyPath, propertyValue); }, ); widget.dynamicBindingPathList = dynamicBindingPathList; widget.dynamicTriggerPathList = dynamicTriggerPathList; if (dynamicUpdates?.dynamicPropertyPathList?.length) { widget.dynamicPropertyPathList = mergeDynamicPropertyPaths( widget.dynamicPropertyPathList, dynamicUpdates.dynamicPropertyPathList, ); } } } catch (e) { log.debug("Error updating property paths: ", { e }); } if (Array.isArray(remove) && remove.length > 0) { widget = yield removeWidgetProperties(widget, remove); } // Note: This may not be the best place to do this. // If there exists another spot in this workflow, where we are iterating over the dynamicTriggerPathList and dynamicBindingPathList, after // performing all updates to the widget, we can piggy back on that iteration to purge orphaned paths // I couldn't find it, so here it is. return { updatedWidget: purgeOrphanedDynamicPaths(widget), actionToDispatch: postUpdateAction, }; } function* batchUpdateWidgetPropertySaga( action: ReduxAction, ) { const start = performance.now(); const { shouldReplay, widgetId } = action.payload; if (!widgetId) { // Handling the case where sometimes widget id is not passed through here return; } const updatedWidgetAndActionsToDispatch: { updatedWidget: WidgetProps; actionToDispatch?: ReduxActionType; } = yield call(getPropertiesUpdatedWidget, action.payload); const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const widgets = { ...stateWidgets, [widgetId]: updatedWidgetAndActionsToDispatch.updatedWidget, }; log.debug( "Batch widget property update calculations took: ", action, performance.now() - start, "ms", ); // Save the layout yield put(updateAndSaveLayout(widgets, { shouldReplay })); if (updatedWidgetAndActionsToDispatch.actionToDispatch) { yield put({ type: updatedWidgetAndActionsToDispatch.actionToDispatch, payload: { widgetId }, }); } } function* batchUpdateMultipleWidgetsPropertiesSaga( action: ReduxAction<{ updatesArray: UpdateWidgetPropertyPayload[] }>, ) { const start = performance.now(); const { updatesArray } = action.payload; const stateWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const updatedWidgetsAndActionsToDispatch: Array<{ updatedWidget: WidgetProps; actionToDispatch?: ReduxActionType; }> = yield all( updatesArray.map((eachUpdate) => { return call(getPropertiesUpdatedWidget, eachUpdate); }), ); const updatedStateWidgets = updatedWidgetsAndActionsToDispatch.reduce( (allWidgets, eachUpdatedWidgetAndActionsToDispatch) => { return { ...allWidgets, [eachUpdatedWidgetAndActionsToDispatch.updatedWidget.widgetId]: eachUpdatedWidgetAndActionsToDispatch.updatedWidget, }; }, stateWidgets, ); const updatedWidgetIds = uniq( updatedWidgetsAndActionsToDispatch.map( (each) => each.updatedWidget.widgetId, ), ); log.debug( "Batch multi-widget properties update calculations took: ", performance.now() - start, "ms", ); // Save the layout yield put( updateAndSaveLayout(updatedStateWidgets, { updatedWidgetIds, }), ); for (const updatedWidgetAndActions of updatedWidgetsAndActionsToDispatch) { if (updatedWidgetAndActions.actionToDispatch) { yield put({ type: updatedWidgetAndActions.actionToDispatch, payload: { widgetId: updatedWidgetAndActions.updatedWidget.widgetId }, }); } } } function* removeWidgetProperties(widget: WidgetProps, paths: string[]) { try { let dynamicTriggerPathList: DynamicPath[] = getWidgetDynamicTriggerPathList(widget); let dynamicBindingPathList: DynamicPath[] = getEntityDynamicBindingPathList(widget); let dynamicPropertyPathList: DynamicPath[] = getWidgetDynamicPropertyPathList(widget); paths.forEach((propertyPath) => { dynamicTriggerPathList = dynamicTriggerPathList.filter((dynamicPath) => { return !isChildPropertyPath(propertyPath, dynamicPath.key); }); dynamicBindingPathList = dynamicBindingPathList.filter((dynamicPath) => { return !isChildPropertyPath(propertyPath, dynamicPath.key); }); dynamicPropertyPathList = dynamicPropertyPathList.filter( (dynamicPath) => { return !isChildPropertyPath(propertyPath, dynamicPath.key); }, ); }); widget.dynamicBindingPathList = dynamicBindingPathList; widget.dynamicTriggerPathList = dynamicTriggerPathList; widget.dynamicPropertyPathList = dynamicPropertyPathList; paths.forEach((propertyPath) => { widget = unsetPropertyPath(widget, propertyPath) as WidgetProps; }); } catch (e) { log.debug("Error removing propertyPaths: ", { e }); } return widget; } function* deleteWidgetPropertySaga( action: ReduxAction, ) { const { propertyPaths, widgetId } = action.payload; if (!widgetId) { // Handling the case where sometimes widget id is not passed through here return; } yield put(batchUpdateWidgetProperty(widgetId, { remove: propertyPaths })); } //TODO(abhinav): Move this to helpers and add tests const unsetPropertyPath = (obj: Record, path: string) => { const regex = /(.*)\[\d+\]$/; if (regex.test(path)) { const matches = path.match(regex); if ( matches && Array.isArray(matches) && matches[1] && matches[1].length > 0 ) { _.unset(obj, path); const arr = _.get(obj, matches[1]); if (arr && Array.isArray(arr)) { _.set(obj, matches[1], arr.filter(Boolean)); } } } else { _.unset(obj, path); } return obj; }; function* resetChildrenMetaSaga(action: ReduxAction<{ widgetId: string }>) { const { widgetId: parentWidgetId } = action.payload; const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const evaluatedDataTree: DataTree = yield select(getDataTree); const configTree: ConfigTree = yield select(getConfigTree); const widgetsMeta: MetaState = yield select(getWidgetsMeta); const childrenList = getWidgetDescendantToReset( canvasWidgets, parentWidgetId, evaluatedDataTree, widgetsMeta, ); for (const childIndex in childrenList) { const { evaluatedWidget: childWidget, id: childId } = childrenList[childIndex]; const evaluatedWidgetConfig = childWidget && configTree[childWidget?.widgetName]; yield put( resetWidgetMetaProperty( childId, childWidget, evaluatedWidgetConfig as WidgetEntityConfig, ), ); } } function* updateCanvasSize( action: ReduxAction<{ canvasWidgetId: string; snapRows: number }>, ) { const { canvasWidgetId, snapRows } = action.payload; const canvasWidget: FlattenedWidgetProps = yield select( getWidget, canvasWidgetId, ); const originalSnapRows = canvasWidget.bottomRow - canvasWidget.topRow; const newBottomRow = Math.round( snapRows * GridDefaults.DEFAULT_GRID_ROW_HEIGHT, ); /* Update the canvas's rows, ONLY if it has changed since the last render */ if (originalSnapRows !== newBottomRow) { // TODO(abhinav): This considers that the topRow will always be zero // Check this out when non canvas widgets are updating snapRows // erstwhile: Math.round((rows * props.snapRowSpace) / props.parentRowSpace), yield put( updateMultipleWidgetPropertiesAction({ [canvasWidgetId]: [ { propertyPath: "bottomRow", propertyValue: newBottomRow, }, ], }), ); } } function* createSelectedWidgetsCopy( selectedWidgets: FlattenedWidgetProps[], flexLayers: FlexLayer[], ) { if (!selectedWidgets || !selectedWidgets.length) return; const widgetListsToStore: { widgetId: string; parentId: string; list: FlattenedWidgetProps[]; }[] = yield all(selectedWidgets.map((each) => call(createWidgetCopy, each))); const saveResult: boolean = yield saveCopiedWidgets( JSON.stringify({ widgets: widgetListsToStore, flexLayers, }), ); return saveResult; } /** * 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 allWidgets: { [widgetId: string]: FlattenedWidgetProps } = yield select(getWidgets); const selectedWidgets: string[] = yield select(getSelectedWidgets); if (!selectedWidgets) { toast.show(createMessage(ERROR_WIDGET_COPY_NO_WIDGET_SELECTED), { kind: "info", }); return; } const allAllowedToCopy = selectedWidgets.some((each) => { //should not allow canvas widgets to be copied return ( allWidgets[each] && !allWidgets[each].disallowCopy && allWidgets[each].type !== "CANVAS_WIDGET" ); }); if (!allAllowedToCopy) { toast.show(createMessage(ERROR_WIDGET_COPY_NOT_ALLOWED), { kind: "info", }); return; } const selectedWidgetProps = selectedWidgets.map((each) => allWidgets[each]); const canvasId = selectedWidgetProps?.[0]?.parentId || ""; const flexLayers: FlexLayer[] = getFlexLayersForSelectedWidgets( selectedWidgets, canvasId ? allWidgets[canvasId] : undefined, ); const saveResult: boolean = yield createSelectedWidgetsCopy( selectedWidgetProps, flexLayers, ); selectedWidgetProps.forEach((each) => { const eventName = action.payload.isShortcut ? "WIDGET_COPY_VIA_SHORTCUT" : "WIDGET_COPY"; AnalyticsUtil.logEvent(eventName, { widgetName: each.widgetName, widgetType: each.type, }); }); if (saveResult) { toast.show( createMessage( WIDGET_COPY, selectedWidgetProps.length > 1 ? `${selectedWidgetProps.length} Widgets` : selectedWidgetProps[0].widgetName, ), { kind: "success", }, ); } } /** * 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 newPastingPositionMap * @param shouldPersistColumnPosition * @param isThereACollision * @param shouldGroup * @returns */ export function calculateNewWidgetPosition( widget: WidgetProps, parentId: string, canvasWidgets: { [widgetId: string]: FlattenedWidgetProps }, parentBottomRow?: number, newPastingPositionMap?: SpaceMap, shouldPersistColumnPosition = false, isThereACollision = false, shouldGroup = false, ): { topRow: number; bottomRow: number; leftColumn: number; rightColumn: number; } { if ( !shouldGroup && newPastingPositionMap && newPastingPositionMap[widget.widgetId] ) { const newPastingPosition = newPastingPositionMap[widget.widgetId]; return { topRow: newPastingPosition.top, bottomRow: newPastingPosition.bottom, leftColumn: newPastingPosition.left, rightColumn: newPastingPosition.right, }; } const nextAvailableRow = parentBottomRow ? parentBottomRow : nextAvailableRowInContainer(parentId, canvasWidgets); return { leftColumn: shouldPersistColumnPosition ? widget.leftColumn : 0, rightColumn: shouldPersistColumnPosition ? widget.rightColumn : widget.rightColumn - widget.leftColumn, topRow: !isThereACollision && shouldGroup ? widget.topRow : parentBottomRow ? nextAvailableRow + widget.topRow : nextAvailableRow, bottomRow: !isThereACollision && shouldGroup ? widget.bottomRow : parentBottomRow ? nextAvailableRow + widget.bottomRow : nextAvailableRow + (widget.bottomRow - widget.topRow), }; } /** * Method to provide the new positions where the widgets can be pasted. * It will return an empty object if it doesn't have any selected widgets, or if the mouse is outside the canvas. * * @param copiedWidgetGroups Contains information on the copied widgets * @param mouseLocation location of the mouse in absolute pixels * @param copiedTotalWidth total width of the copied widgets * @param copiedTopMostRow top row of the top most copied widget * @param copiedLeftMostColumn left column of the left most copied widget * @returns */ const getNewPositions = function* ( copiedWidgetGroups: CopiedWidgetGroup[], mouseLocation: { x: number; y: number }, copiedTotalWidth: number, copiedTopMostRow: number, copiedLeftMostColumn: number, ) { const selectedWidgetIDs: string[] = yield select(getSelectedWidgets); const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); const { isListWidgetPastingOnItself, selectedWidgets } = getVerifiedSelectedWidgets( selectedWidgetIDs, copiedWidgetGroups, canvasWidgets, ); //if the copied widget is a modal widget, then it has to paste on the main container if ( copiedWidgetGroups.length === 1 && copiedWidgetGroups[0].list[0] && copiedWidgetGroups[0].list[0].type === "MODAL_WIDGET" ) return {}; //if multiple widgets are selected or if a single non-layout widget is selected, // then call the method to calculate and return positions based on selected widgets. if ( !( selectedWidgets.length === 1 && isDropTarget(selectedWidgets[0].type, true) && !isListWidgetPastingOnItself ) && selectedWidgets.length > 0 ) { const newPastingPositionDetails: NewPastePositionVariables = yield call( getNewPositionsBasedOnSelectedWidgets, copiedWidgetGroups, selectedWidgets, canvasWidgets, copiedTotalWidth, copiedTopMostRow, copiedLeftMostColumn, ); return newPastingPositionDetails; } //if a layout widget is selected or mouse is on the main canvas // then call the method to calculate and return positions mouse positions. const newPastingPositionDetails: NewPastePositionVariables = yield call( getNewPositionsBasedOnMousePositions, copiedWidgetGroups, mouseLocation, selectedWidgets, canvasWidgets, copiedTotalWidth, copiedTopMostRow, copiedLeftMostColumn, ); return newPastingPositionDetails; }; /** * Calculates the new positions of the pasting widgets, based on the selected widgets * The new positions will be just below the selected widgets * * @param copiedWidgetGroups Contains information on the copied widgets * @param selectedWidgets array of selected widgets * @param canvasWidgets canvas widgets from the DSL * @param copiedTotalWidth total width of the copied widgets * @param copiedTopMostRow top row of the top most copied widget * @param copiedLeftMostColumn left column of the left most copied widget * @returns */ function* getNewPositionsBasedOnSelectedWidgets( copiedWidgetGroups: CopiedWidgetGroup[], selectedWidgets: WidgetProps[], canvasWidgets: CanvasWidgetsReduxState, copiedTotalWidth: number, copiedTopMostRow: number, copiedLeftMostColumn: number, ) { //get Parent canvasId const parentId: string | undefined = selectedWidgets[0].parentId; // If we failed to get the parent canvas widget Id then return empty object if (parentId === undefined) return {}; // get the Id of the container like widget based on the canvasId const containerId = getContainerIdForCanvas(parentId); // If we failed to get the containing container like widget Id then return empty object if (containerId === undefined) return {}; const containerWidget = canvasWidgets[containerId]; const canvasDOM = document.querySelector(`#${getSlidingArenaName(parentId)}`); if (!canvasDOM || !containerWidget) return {}; const rect = canvasDOM.getBoundingClientRect(); // get Grid values such as snapRowSpace and snapColumnSpace const { snapGrid } = getSnappedGrid(containerWidget, rect.width); const selectedWidgetsArray = selectedWidgets.length ? selectedWidgets : []; //from selected widgets get some information required for position calculation const { leftMostColumn: selectedLeftMostColumn, maxThickness, topMostRow: selectedTopMostRow, totalWidth, } = getBoundariesFromSelectedWidgets(selectedWidgetsArray); // calculation of left most column of where widgets are to be pasted let pasteLeftMostColumn = selectedLeftMostColumn - (copiedTotalWidth - totalWidth) / 2; pasteLeftMostColumn = Math.round(pasteLeftMostColumn); // conditions to adjust to the edges of the boundary, so that it doesn't go out of canvas if (pasteLeftMostColumn < 0) pasteLeftMostColumn = 0; if ( pasteLeftMostColumn + copiedTotalWidth > GridDefaults.DEFAULT_GRID_COLUMNS ) pasteLeftMostColumn = GridDefaults.DEFAULT_GRID_COLUMNS - copiedTotalWidth; // based on the above calculation get the new Positions that are aligned to the top left of selected widgets // i.e., the top of the selected widgets will be equal to the top of copied widgets and both are horizontally centered const newPositionsForCopiedWidgets = getNewPositionsForCopiedWidgets( copiedWidgetGroups, copiedTopMostRow, selectedTopMostRow, copiedLeftMostColumn, pasteLeftMostColumn, ); // with the new positions, calculate the map of new position, which are moved down to the point where // it doesn't overlap with any of the selected widgets. const newPastingPositionMap = getVerticallyAdjustedPositions( newPositionsForCopiedWidgets, getOccupiedSpacesFromProps(selectedWidgetsArray), maxThickness, ); if (!newPastingPositionMap) return {}; const gridProps = { parentColumnSpace: snapGrid.snapColumnSpace, parentRowSpace: snapGrid.snapRowSpace, maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS, }; const reflowSpacesSelector = getContainerWidgetSpacesSelector(parentId); const widgetSpaces: WidgetSpace[] = yield select(reflowSpacesSelector) || []; // Ids of each pasting are changed just for reflow const newPastePositions = changeIdsOfPastePositions(newPastingPositionMap); const { movementMap: reflowedMovementMap } = reflow( newPastePositions, newPastePositions, widgetSpaces, ReflowDirection.BOTTOM, gridProps, true, false, { prevSpacesMap: {} } as PrevReflowState, ); // calculate the new bottom most row of the canvas const bottomMostRow = getBottomRowAfterReflow( reflowedMovementMap, getBottomMostRow(newPastePositions), widgetSpaces, gridProps, ); return { bottomMostRow: (bottomMostRow + GridDefaults.CANVAS_EXTENSION_OFFSET) * gridProps.parentRowSpace, gridProps, newPastingPositionMap, reflowedMovementMap, canvasId: parentId, }; } /** * Calculates the new positions of the pasting widgets, based on the mouse position * If the mouse position is on the canvas it the top left of the new positions aligns itself to the mouse position * returns a empty object if the mouse is out of canvas * * @param copiedWidgetGroups Contains information on the copied widgets * @param mouseLocation location of the mouse in absolute pixels * @param selectedWidgets array of selected widgets * @param canvasWidgets canvas widgets from the DSL * @param copiedTotalWidth total width of the copied widgets * @param copiedTopMostRow top row of the top most copied widget * @param copiedLeftMostColumn left column of the left most copied widget * @returns */ function* getNewPositionsBasedOnMousePositions( copiedWidgetGroups: CopiedWidgetGroup[], mouseLocation: { x: number; y: number }, selectedWidgets: WidgetProps[], canvasWidgets: CanvasWidgetsReduxState, copiedTotalWidth: number, copiedTopMostRow: number, copiedLeftMostColumn: number, ) { let { canvasDOM, canvasId, containerWidget } = getDefaultCanvas(canvasWidgets); //if the selected widget is a layout widget then change the pasting canvas. if (selectedWidgets.length === 1 && isDropTarget(selectedWidgets[0].type)) { containerWidget = selectedWidgets[0]; ({ canvasDOM, canvasId } = getCanvasIdForContainer(containerWidget)); } if (!canvasDOM || !containerWidget || !canvasId) return {}; const canvasRect = canvasDOM.getBoundingClientRect(); // get Grid values such as snapRowSpace and snapColumnSpace const { padding, snapGrid } = getSnappedGrid( containerWidget, canvasRect.width, ); // get mouse positions in terms of grid rows and columns of the pasting canvas const mousePositions = getMousePositions( canvasRect, canvasId, snapGrid, padding, mouseLocation, ); if (!snapGrid || !mousePositions) return {}; const reflowSpacesSelector = getContainerWidgetSpacesSelector(canvasId); const widgetSpaces: WidgetSpace[] = yield select(reflowSpacesSelector) || []; let mouseTopRow = mousePositions.top; let mouseLeftColumn = mousePositions.left; // if the mouse position is on another widget on the canvas, then new positions are below it. for (const widgetSpace of widgetSpaces) { if ( widgetSpace.top < mousePositions.top && widgetSpace.left < mousePositions.left && widgetSpace.bottom > mousePositions.top && widgetSpace.right > mousePositions.left ) { mouseTopRow = widgetSpace.bottom + WIDGET_PASTE_PADDING; mouseLeftColumn = widgetSpace.left - (copiedTotalWidth - (widgetSpace.right - widgetSpace.left)) / 2; break; } } mouseLeftColumn = Math.round(mouseLeftColumn); // adjust the top left based on the edges of the canvas if (mouseLeftColumn < 0) mouseLeftColumn = 0; if (mouseLeftColumn + copiedTotalWidth > GridDefaults.DEFAULT_GRID_COLUMNS) mouseLeftColumn = GridDefaults.DEFAULT_GRID_COLUMNS - copiedTotalWidth; // get the new Pasting positions of the widgets based on the adjusted mouse top-left const newPastingPositionMap = getPastePositionMapFromMousePointer( copiedWidgetGroups, copiedTopMostRow, mouseTopRow, copiedLeftMostColumn, mouseLeftColumn, ); const gridProps = { parentColumnSpace: snapGrid.snapColumnSpace, parentRowSpace: snapGrid.snapRowSpace, maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS, }; // Ids of each pasting are changed just for reflow const newPastePositions = changeIdsOfPastePositions(newPastingPositionMap); const { movementMap: reflowedMovementMap } = reflow( newPastePositions, newPastePositions, widgetSpaces, ReflowDirection.BOTTOM, gridProps, true, false, { prevSpacesMap: {} } as PrevReflowState, ); // calculate the new bottom most row of the canvas. const bottomMostRow = getBottomRowAfterReflow( reflowedMovementMap, getBottomMostRow(newPastePositions), widgetSpaces, gridProps, ); return { bottomMostRow: (bottomMostRow + GridDefaults.CANVAS_EXTENSION_OFFSET) * gridProps.parentRowSpace, gridProps, newPastingPositionMap, reflowedMovementMap, canvasId, }; } /** * this saga create a new widget from the copied one to store */ function* pasteWidgetSaga( action: ReduxAction<{ groupWidgets: boolean; mouseLocation: { x: number; y: number }; }>, ) { const { flexLayers, widgets: copiedWidgets, }: { widgets: CopiedWidgetGroup[]; flexLayers: FlexLayer[]; } = yield getCopiedWidgets(); let copiedWidgetGroups = copiedWidgets ? [...copiedWidgets] : []; const shouldGroup: boolean = action.payload.groupWidgets; const newlyCreatedWidgetIds: string[] = []; const evalTree: DataTree = yield select(getDataTree); const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); let widgets: CanvasWidgetsReduxState = canvasWidgets; const selectedWidget: FlattenedWidgetProps = yield getSelectedWidgetWhenPasting(); const isMobile: boolean = yield select(getIsAutoLayoutMobileBreakPoint); const mainCanvasWidth: number = yield select(getCanvasWidth); try { let reflowedMovementMap, gridProps: GridProps | undefined, newPastingPositionMap: SpaceMap | undefined, canvasId; let pastingIntoWidgetId: string = yield getParentWidgetIdForPasting( canvasWidgets, selectedWidget, ); let isThereACollision = false; // if this is true, selected widgets will be grouped in container if (shouldGroup) { copiedWidgetGroups = yield createSelectedWidgetsAsCopiedWidgets(); pastingIntoWidgetId = yield getParentWidgetIdForGrouping( widgets, copiedWidgetGroups, ); widgets = yield filterOutSelectedWidgets( copiedWidgetGroups[0].parentId, copiedWidgetGroups, ); isThereACollision = yield isSelectedWidgetsColliding( widgets, copiedWidgetGroups, pastingIntoWidgetId, ); //while grouping, the container around the selected widgets will increase by 2 rows, //hence if there are any widgets in that path then we reflow those widgets // If there are already widgets inside the selection box even before grouping //then we will have to move it down to the bottom most row ({ copiedWidgetGroups, gridProps, reflowedMovementMap } = yield groupWidgetsIntoContainer( copiedWidgetGroups, pastingIntoWidgetId, isThereACollision, )); } if ( // to avoid invoking old way of copied widgets implementaion !Array.isArray(copiedWidgetGroups) || !copiedWidgetGroups.length ) return; const { leftMostWidget, topMostWidget, totalWidth: copiedTotalWidth, } = getBoundaryWidgetsFromCopiedGroups(copiedWidgetGroups); const nextAvailableRow: number = nextAvailableRowInContainer( pastingIntoWidgetId, widgets, ); // skip new position calculation if grouping if (!shouldGroup) { // new pasting positions, the variables are undefined if the positions cannot be calculated, // then it pastes the regular way at the bottom of the canvas ({ canvasId, gridProps, newPastingPositionMap, reflowedMovementMap } = yield call( getNewPositions, copiedWidgetGroups, action.payload.mouseLocation, copiedTotalWidth, topMostWidget.topRow, leftMostWidget.leftColumn, )); if (canvasId) pastingIntoWidgetId = canvasId; } for (const widgetGroup of copiedWidgetGroups) { //This is required when you cut the widget as CanvasWidgetState doesn't have the widget anymore const widgetType = widgetGroup.list.find( (widget) => widget.widgetId === widgetGroup.widgetId, )?.type; if (!widgetType) break; yield call( executeWidgetBlueprintBeforeOperations, BlueprintOperationTypes.BEFORE_PASTE, { parentId: pastingIntoWidgetId, widgetId: widgetGroup.widgetId, widgets, widgetType, }, ); } const widgetIdMap: Record = {}; const reverseWidgetIdMap: Record = {}; 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[0]; const newTopRow = shouldGroup ? isThereACollision ? topMostWidget.topRow : 0 : topMostWidget.topRow; const copiedWidget = { ...unUpdatedCopyOfWidget, topRow: unUpdatedCopyOfWidget.topRow - newTopRow, bottomRow: unUpdatedCopyOfWidget.bottomRow - newTopRow, }; // 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 newWidgetPosition = calculateNewWidgetPosition( copiedWidget, pastingIntoWidgetId, widgets, nextAvailableRow, newPastingPositionMap, true, isThereACollision, shouldGroup, ); // Get a flat list of all the widgets to be updated const widgetList = copiedWidgets.list; const widgetNameMap: Record = {}; const newWidgetList: FlattenedWidgetProps[] = []; // 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; reverseWidgetIdMap[newWidget.widgetId] = widget.widgetId; // Add the new widget to the list newWidgetList.push(newWidget); }); // 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 === "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 === "TABLE_WIDGET_V2" || widget.type === "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, )) { // 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); } } // TODO: here to move this to the widget definition // Update the Select widget defaultValue properties if ( widget.type === "MULTI_SELECT_WIDGET_V2" || widget.type === "SELECT_WIDGET" ) { try { // If the defaultOptionValue exist if (widget.defaultOptionValue) { const value = widget.defaultOptionValue; // replace All occurrence of old widget name widget.defaultOptionValue = isString(value) ? value.replaceAll(`${oldWidgetName}.`, `${newWidgetName}.`) : value; } // Use the new widget name we used to replace the defaultValue properties above. widget.widgetName = newWidgetName; } catch (error) { log.debug("Error updating widget properties", error); } } // If it is the copied widget, update position properties if (widget.widgetId === widgetIdMap[copiedWidget.widgetId]) { //when the widget is a modal widget, it has to paste on the main container const pastingParentId = widget.type === "MODAL_WIDGET" ? MAIN_CONTAINER_WIDGET_ID : pastingIntoWidgetId; const { bottomRow, leftColumn, rightColumn, topRow } = newWidgetPosition; widget.leftColumn = leftColumn; widget.topRow = topRow; widget.bottomRow = bottomRow; widget.rightColumn = rightColumn; widget.parentId = pastingParentId; // 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[pastingParentId].children; if (widgetChildren && Array.isArray(widgetChildren)) { // Add the new child to existing children after it's original siblings position. const originalWidgetId: string = widgetList[i].widgetId; const originalWidgetIndex: number = widgetChildren.indexOf(originalWidgetId); parentChildren = [ ...widgetChildren.slice(0, originalWidgetIndex + 1), ...parentChildren, ...widgetChildren.slice(originalWidgetIndex + 1), ]; } widgets = { ...widgets, [pastingParentId]: { ...widgets[pastingParentId], children: parentChildren, }, }; // If the copied widget's boundaries exceed the parent's // Make the parent scrollable if ( widgets[pastingParentId].bottomRow * widgets[widget.parentId].parentRowSpace <= widget.bottomRow * widget.parentRowSpace && !widget.detachFromLayout ) { const parentOfPastingWidget = widgets[pastingParentId].parentId; if ( parentOfPastingWidget && widget.parentId !== MAIN_CONTAINER_WIDGET_ID ) { 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; } // 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; /** * If new parent is a vertical stack, then update flex layers. */ if (widget.parentId) { const pastingIntoWidget = widgets[widget.parentId]; if ( pastingIntoWidget && isStack(widgets, pastingIntoWidget) && (pastingIntoWidgetId !== pastingIntoWidget.widgetId || !flexLayers || flexLayers.length <= 0) ) { const metaProps: Record = yield select(getWidgetsMeta); if (widget.widgetId === widgetIdMap[copiedWidget.widgetId]) widgets = pasteWidgetInFlexLayers( widgets, widget.parentId, widget, reverseWidgetIdMap[widget.widgetId], isMobile, mainCanvasWidth, metaProps, ); else if (widget.type !== "CANVAS_WIDGET") widgets = addChildToPastedFlexLayers( widgets, widget, widgetIdMap, isMobile, mainCanvasWidth, metaProps, ); } } } 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, ); } }), ), ); //calculate the new positions of the reflowed widgets let reflowedWidgets = getReflowedPositions( widgets, gridProps, reflowedMovementMap, ); if ( pastingIntoWidgetId && reflowedWidgets[pastingIntoWidgetId] && flexLayers && flexLayers.length > 0 ) { const newFlexLayers = getNewFlexLayers(flexLayers, widgetIdMap); reflowedWidgets[pastingIntoWidgetId] = { ...reflowedWidgets[pastingIntoWidgetId], flexLayers: [ ...(reflowedWidgets[pastingIntoWidgetId]?.flexLayers || []), ...newFlexLayers, ], }; const metaProps: Record = yield select(getWidgetsMeta); reflowedWidgets = updateWidgetPositions( reflowedWidgets, pastingIntoWidgetId, isMobile, mainCanvasWidth, false, metaProps, ); } // 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 traversing, if we find any widget which has CHILD_OPERATION, we will call the fn in it const updatedWidgets: CanvasWidgetsReduxState = yield call( traverseTreeAndExecuteBlueprintChildOperations, reflowedWidgets[pastingIntoWidgetId], newlyCreatedWidgetIds.filter( (widgetId) => !reflowedWidgets[widgetId]?.detachFromLayout, ), reflowedWidgets, ); yield call(updateAndSaveAnvilLayout, updatedWidgets); const pageId: string = yield select(getCurrentPageId); if (copiedWidgetGroups && copiedWidgetGroups.length > 0) { history.push(builderURL({ pageId })); } yield put({ type: ReduxActionTypes.RECORD_RECENTLY_ADDED_WIDGET, payload: newlyCreatedWidgetIds, }); yield put(generateAutoHeightLayoutTreeAction(true, true)); //if pasting at the bottom of the canvas, then flash it. if (shouldGroup || !newPastingPositionMap) { flashElementsById(newlyCreatedWidgetIds, 100); } yield put( selectWidgetInitAction( SelectionRequestType.Multiple, newlyCreatedWidgetIds, ), ); } catch (error) { yield put({ type: ReduxActionErrorTypes.WIDGET_OPERATION_ERROR, payload: { action: ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, error, }, }); } } function* cutWidgetSaga() { const allWidgets: { [widgetId: string]: FlattenedWidgetProps } = yield select(getWidgets); const selectedWidgets: string[] = yield select(getSelectedWidgets); if (!selectedWidgets) { toast.show(createMessage(ERROR_WIDGET_CUT_NO_WIDGET_SELECTED), { kind: "info", }); return; } const allAllowedToCut = selectedWidgets.some((each) => { //should not allow canvas widgets to be cut return ( allWidgets[each] && !allWidgets[each].disallowCopy && allWidgets[each].type !== "CANVAS_WIDGET" ); }); if (!allAllowedToCut) { toast.show(createMessage(ERROR_WIDGET_CUT_NOT_ALLOWED), { kind: "info", }); return; } const selectedWidgetProps = selectedWidgets.map((each) => allWidgets[each]); const canvasId = selectedWidgetProps?.[0]?.parentId || ""; const flexLayers: FlexLayer[] = getFlexLayersForSelectedWidgets( selectedWidgets, canvasId ? allWidgets[canvasId] : undefined, ); const saveResult: boolean = yield createSelectedWidgetsCopy( selectedWidgetProps, flexLayers, ); selectedWidgetProps.forEach((each) => { const eventName = "WIDGET_CUT_VIA_SHORTCUT"; // cut only supported through a shortcut AnalyticsUtil.logEvent(eventName, { widgetName: each.widgetName, widgetType: each.type, }); }); if (saveResult) { toast.show( createMessage( WIDGET_CUT, selectedWidgetProps.length > 1 ? `${selectedWidgetProps.length} Widgets` : selectedWidgetProps[0].widgetName, ), { kind: "success", }, ); } yield put({ type: WidgetReduxActionTypes.WIDGET_DELETE, payload: { disallowUndo: true, isShortcut: true, }, }); } function* addSuggestedWidget(action: ReduxAction>) { const isSetWidgetIdForWalkthrough = !!( action.payload.props.setWidgetIdForWalkthrough === "true" ); const widgetConfig = action.payload; delete widgetConfig.props?.setWidgetIdForWalkthrough; if (!widgetConfig.type) return; const defaultConfig = WidgetFactory.widgetConfigMap.get(widgetConfig.type); const evalTree: DataTree = yield select(getDataTree); const widgets: CanvasWidgetsReduxState = yield select(getWidgets); const widgetName = getNextWidgetName(widgets, widgetConfig.type, evalTree); const layoutSystemType: LayoutSystemTypes = yield select(getLayoutSystemType); try { let newWidget = { newWidgetId: generateReactKey(), widgetId: "0", parentId: "0", renderMode: RenderModes.CANVAS, isLoading: false, ...defaultConfig, widgetName, ...widgetConfig, }; const { bottomRow, leftColumn, rightColumn, topRow } = yield calculateNewWidgetPosition( newWidget as WidgetProps, MAIN_CONTAINER_WIDGET_ID, widgets, ); newWidget = { ...newWidget, leftColumn, topRow, rightColumn, bottomRow, parentRowSpace: GridDefaults.DEFAULT_GRID_ROW_HEIGHT, }; switch (layoutSystemType) { case LayoutSystemTypes.AUTO: yield put({ type: ReduxActionTypes.AUTOLAYOUT_ADD_NEW_WIDGETS, payload: { dropPayload: { isNewLayer: true, alignment: FlexLayerAlignment.Start, }, newWidget, parentId: MAIN_CONTAINER_WIDGET_ID, direction: LayoutDirection.Vertical, addToBottom: true, }, }); break; case LayoutSystemTypes.ANVIL: yield put( addSuggestedWidgetAnvilAction({ newWidgetId: newWidget.newWidgetId, rows: newWidget.rows, columns: newWidget.columns, type: newWidget.type, ...widgetConfig, }), ); break; default: yield put({ type: WidgetReduxActionTypes.WIDGET_ADD_CHILD, payload: newWidget, }); break; } yield take(ReduxActionTypes.UPDATE_LAYOUT); if (isSetWidgetIdForWalkthrough) { localStorage.setItem(WIDGET_ID_SHOW_WALKTHROUGH, newWidget.newWidgetId); } yield put( selectWidgetInitAction(SelectionRequestType.One, [newWidget.newWidgetId]), ); } catch (error) { log.error(error); } } /** * 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; // Grouping functionality has been temporarily disabled for auto-layout canvas. const isAutoLayout: boolean = yield select(getIsAutoLayout); if (isAutoLayout) return; if (isMultipleWidgetsSelected) { try { yield put({ type: ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, payload: { groupWidgets: true, }, }); } catch (error) { log.error(error); } } } function* widgetBatchUpdatePropertySaga() { /* * BATCH_UPDATE_WIDGET_PROPERTY should be processed serially as * it updates the state. We want the state updates from previous * batch update to be flushed out to the store before processing * the another batch update. */ const batchUpdateWidgetPropertyChannel: unknown = yield actionChannel( ReduxActionTypes.BATCH_UPDATE_WIDGET_PROPERTY, ); while (true) { // @ts-expect-error: Type mismatch const action: unknown = yield take(batchUpdateWidgetPropertyChannel); // @ts-expect-error: Type mismatch yield call(batchUpdateWidgetPropertySaga, action); } } export default function* widgetOperationSagas() { yield fork(widgetAdditionSagas); yield fork(widgetDeletionSagas); yield fork(widgetSelectionSagas); yield fork(widgetBatchUpdatePropertySaga); yield all([ takeEvery(ReduxActionTypes.ADD_SUGGESTED_WIDGET, addSuggestedWidget), takeLatest(WidgetReduxActionTypes.WIDGET_RESIZE, resizeSaga), takeEvery( ReduxActionTypes.UPDATE_WIDGET_PROPERTY_REQUEST, updateWidgetPropertySaga, ), takeEvery( WidgetReduxActionTypes.WIDGET_UPDATE_PROPERTY, updateWidgetPropertySaga, ), takeEvery( ReduxActionTypes.SET_WIDGET_DYNAMIC_PROPERTY, setWidgetDynamicPropertySaga, ), takeEvery( ReduxActionTypes.BATCH_SET_WIDGET_DYNAMIC_PROPERTY, batchUpdateWidgetDynamicPropertySaga, ), takeEvery( ReduxActionTypes.RESET_CHILDREN_WIDGET_META, resetChildrenMetaSaga, ), takeEvery( ReduxActionTypes.BATCH_UPDATE_MULTIPLE_WIDGETS_PROPERTY, batchUpdateMultipleWidgetsPropertiesSaga, ), takeEvery( ReduxActionTypes.DELETE_WIDGET_PROPERTY, deleteWidgetPropertySaga, ), takeLatest(ReduxActionTypes.UPDATE_CANVAS_SIZE, updateCanvasSize), takeLatest(ReduxActionTypes.COPY_SELECTED_WIDGET_INIT, copyWidgetSaga), takeLeading(ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, pasteWidgetSaga), takeEvery(ReduxActionTypes.CUT_SELECTED_WIDGET, cutWidgetSaga), takeEvery(ReduxActionTypes.GROUP_WIDGETS_INIT, groupWidgetsSaga), takeEvery(ReduxActionTypes.PARTIAL_IMPORT_INIT, partialImportSaga), takeEvery(ReduxActionTypes.PARTIAL_EXPORT_INIT, partialExportSaga), ]); }