PromucFlow_constructor/app/client/src/sagas/WidgetSelectUtils.ts

342 lines
11 KiB
TypeScript
Raw Normal View History

refactor: Widget Selection (#19643) ## Description This change is a refactor of widget selection logic. It consolidates all the business logic to make it easy to maintain. It also improves the performance a bit. It touched a lot of features as we heavily rely on this ``` Select one Select multiple with drag Select multiple with shift Select multiple with cmd/ctrl Selections should be on the same level of hierarchy Unselect all by clicking on the canvas Unselect all by pressing esc Select all with cmd + a Paste in main container Paste in another container Undo Redo Modal Selection Modal child selection Context switching cmd click snipping mode new widget suggestion onboarding ``` > Refactor widget selection logic Fixes #19570 ## Type of change - Refactor ## How Has This Been Tested? All existing tests should pass ### Test Plan > Add Testsmith test cases links that relate to this PR ### Issues raised during DP testing https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1383570810 https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1383607820 https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1385095478 [Bug bash issues](https://www.notion.so/appsmith/610aa302f3e146a7b090b7dc6bc63ef9?v=0d277a9b07bf4aac9d717bcaf138c33a) ## Checklist: ### Dev activity - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer reviewed by QA - [ ] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [ ] Added Test Plan Approved label after reveiwing all Cypress test
2023-01-28 02:17:06 +00:00
import {
CanvasWidgetsReduxState,
FlattenedWidgetProps,
} from "reducers/entityReducers/canvasWidgetsReducer";
import { uniq } from "lodash";
import { call, put, select } from "redux-saga/effects";
import { getLastSelectedWidget } from "selectors/ui";
import {
getWidgetImmediateChildren,
getWidgetMetaProps,
getWidgets,
} from "sagas/selectors";
import { getWidgetChildrenIds } from "sagas/WidgetOperationUtils";
import { checkIsDropTarget } from "components/designSystems/appsmith/PositionedContainer";
import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants";
import WidgetFactory from "utils/WidgetFactory";
import { setSelectedWidgetAncestry } from "actions/widgetSelectionActions";
import { Toaster, Variant } from "design-system-old";
import { createMessage, SELECT_ALL_WIDGETS_MSG } from "ce/constants/messages";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
} from "ce/constants/ReduxActionConstants";
/**
* Selection types that are possible for widget select
*
* It is currently used for widget selection,
* but can be used for other types of selections like tabs
*/
export enum SelectionRequestType {
/** Remove all selections, reset last selected widget to the main container */
Empty = "Empty",
/** Replace the existing selection with a new single selection.
* The new selection will be the last selected widget */
One = "One",
/** Replace the existing selection with a new selection of multiple widgets.
* The new selection's first widget becomes the last selected widget
* */
Multiple = "Multiple",
/** Adds or removes a widget selection. Similar to CMD/Ctrl selections,
* if the payload exits in the selection, it will be removed.
* If the payload is new, it will be added.*/
PushPop = "PushPop",
/** Selects all widgets in the last selected canvas */
All = "All",
/** Add selection like shift select where the widgets between two selections
* are also selected. Widget order is taken from children order of the canvas */
ShiftSelect = "ShiftSelect",
/**
* Unselect specific widgets */
Unselect = "Unselect",
}
export type SelectionPayload = string[];
export type SetSelectionResult =
| {
widgets: string[];
lastWidgetSelected?: string;
}
| undefined;
// Main container cannot be a selection, dont honour this request
export const isInvalidSelectionRequest = (id: unknown) =>
typeof id !== "string" || id === MAIN_CONTAINER_WIDGET_ID;
export class WidgetSelectionError extends Error {
request?: SelectionPayload;
type?: SelectionRequestType;
constructor(
message: string,
request?: SelectionPayload,
type?: SelectionRequestType,
) {
super(message);
this.request = request;
this.type = type;
this.name = "WidgetSelectionError";
}
}
export const deselectAll = (request: SelectionPayload): SetSelectionResult => {
if (request.length > 0) {
throw new WidgetSelectionError(
"Wrong payload supplied",
request,
SelectionRequestType.Empty,
);
}
return { widgets: [], lastWidgetSelected: "" };
};
export const selectOneWidget = (
request: SelectionPayload,
): SetSelectionResult => {
if (request.length !== 1) {
throw new WidgetSelectionError(
"Wrong payload supplied",
request,
SelectionRequestType.One,
);
}
return { widgets: request, lastWidgetSelected: request[0] };
};
export const selectMultipleWidgets = (
request: SelectionPayload,
allWidgets: CanvasWidgetsReduxState,
): SetSelectionResult => {
const parentToMatch = allWidgets[request[0]]?.parentId;
const areSiblings = request.every((each) => {
return allWidgets[each]?.parentId === parentToMatch;
});
if (!areSiblings) return;
return { widgets: request, lastWidgetSelected: request[0] };
};
export const shiftSelectWidgets = (
request: SelectionPayload,
siblingWidgets: string[],
currentlySelectedWidgets: string[],
lastSelectedWidget: string,
): SetSelectionResult => {
const selectedWidgetIndex = siblingWidgets.indexOf(request[0]);
const siblingIndexOfLastSelectedWidget = siblingWidgets.indexOf(
lastSelectedWidget,
);
if (siblingIndexOfLastSelectedWidget === -1) {
return { widgets: request, lastWidgetSelected: request[0] };
}
if (currentlySelectedWidgets.includes(request[0])) {
return {
widgets: currentlySelectedWidgets.filter((w) => request[0] !== w),
lastWidgetSelected: "",
};
}
let widgets: string[] = [...request, ...currentlySelectedWidgets];
const start =
siblingIndexOfLastSelectedWidget < selectedWidgetIndex
? siblingIndexOfLastSelectedWidget
: selectedWidgetIndex;
const end =
siblingIndexOfLastSelectedWidget < selectedWidgetIndex
? selectedWidgetIndex
: siblingIndexOfLastSelectedWidget;
const unSelectedSiblings = siblingWidgets.slice(start, end + 1);
if (unSelectedSiblings && unSelectedSiblings.length) {
widgets = widgets.concat(...unSelectedSiblings);
}
return { widgets: uniq(widgets), lastWidgetSelected: widgets[0] };
};
export const pushPopWidgetSelection = (
request: SelectionPayload,
currentlySelectedWidgets: string[],
siblingWidgets: string[],
): SetSelectionResult => {
const widgetId = request[0];
const alreadySelected = currentlySelectedWidgets.includes(widgetId);
if (alreadySelected) {
return {
lastWidgetSelected: "",
widgets: currentlySelectedWidgets.filter((each) => each !== widgetId),
};
} else if (!!widgetId) {
const widgets = [...currentlySelectedWidgets, widgetId].filter((w) =>
siblingWidgets.includes(w),
);
return {
widgets,
lastWidgetSelected: widgetId,
};
}
};
export const unselectWidget = (
request: SelectionPayload,
currentlySelectedWidgets: string[],
): SetSelectionResult => {
const widgets = currentlySelectedWidgets.filter((w) => !request.includes(w));
return {
widgets,
lastWidgetSelected: widgets[0],
};
};
const WidgetTypes = WidgetFactory.widgetTypes;
function* getDroppingCanvasOfWidget(widgetLastSelected: FlattenedWidgetProps) {
if (checkIsDropTarget(widgetLastSelected.type)) {
const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const childWidgets: string[] = yield select(
getWidgetImmediateChildren,
widgetLastSelected.widgetId,
);
const firstCanvas = childWidgets.find((each) => {
const widget = canvasWidgets[each];
return widget.type === WidgetTypes.CANVAS_WIDGET;
});
if (widgetLastSelected.type === WidgetTypes.TABS_WIDGET) {
const tabMetaProps: Record<string, unknown> = yield select(
getWidgetMetaProps,
feat: List V2 (#15839) ## Description TL;DR This is a complete architectural change of of List widget works to support all widgets we currently have and should automatically support any future widgets. It also introduces nested List widgets i.e a list widget can have a another list widget which in turn can have another list widget. Fixes #18206 Fixes #6775 Fixes #13211 Fixes #16582 Fixes #11739 Fixes #15094 Fixes #6840 Fixes #10841 Fixes #17386 Fixes #18340 Fixes #16898 Fixes #17555 Fixes #6858 Fixes #9568 Fixes #17480 Fixes #18523 Fixes #18206 Fixes #16586 Fixes #18106 Fixes #16576 Fixes #14697 Fixes #9607 Fixes #19648 Fixes #19739 Fixes #19652 Fixes #18730 Fixes #19503 Fixes #19498 Fixes #19437 Fixes #5245 Fixes #19150 Fixes #18638 Fixes #11332 Fixes #17901 Fixes #19043 Fixes #17777 Fixes #8237 Fixes #15487 Fixes #15988 Fixes #18621 Fixes #16788 Fixes #18110 Fixes #18382 Fixes #17427 Fixes #18105 Fixes #18287 Fixes #19808 Fixes #14655 ## Type of change - New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Cypress - Jest - Manual ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Tolulope Adetula <31691737+Tooluloope@users.noreply.github.com> Co-authored-by: Favour Ohanekwu <fohanekwu@gmail.com>
2023-02-14 16:07:31 +00:00
widgetLastSelected,
refactor: Widget Selection (#19643) ## Description This change is a refactor of widget selection logic. It consolidates all the business logic to make it easy to maintain. It also improves the performance a bit. It touched a lot of features as we heavily rely on this ``` Select one Select multiple with drag Select multiple with shift Select multiple with cmd/ctrl Selections should be on the same level of hierarchy Unselect all by clicking on the canvas Unselect all by pressing esc Select all with cmd + a Paste in main container Paste in another container Undo Redo Modal Selection Modal child selection Context switching cmd click snipping mode new widget suggestion onboarding ``` > Refactor widget selection logic Fixes #19570 ## Type of change - Refactor ## How Has This Been Tested? All existing tests should pass ### Test Plan > Add Testsmith test cases links that relate to this PR ### Issues raised during DP testing https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1383570810 https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1383607820 https://github.com/appsmithorg/appsmith/pull/19643#issuecomment-1385095478 [Bug bash issues](https://www.notion.so/appsmith/610aa302f3e146a7b090b7dc6bc63ef9?v=0d277a9b07bf4aac9d717bcaf138c33a) ## Checklist: ### Dev activity - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer reviewed by QA - [ ] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [ ] Added Test Plan Approved label after reveiwing all Cypress test
2023-01-28 02:17:06 +00:00
);
return tabMetaProps.selectedTabWidgetId;
}
if (firstCanvas) {
return firstCanvas;
}
}
return widgetLastSelected.parentId;
}
function* getLastSelectedCanvas() {
const lastSelectedWidget: string = yield select(getLastSelectedWidget);
const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const widgetLastSelected =
lastSelectedWidget && canvasWidgets[lastSelectedWidget];
if (widgetLastSelected) {
const canvasToSelect: string = yield call(
getDroppingCanvasOfWidget,
widgetLastSelected,
);
return canvasToSelect ? canvasToSelect : MAIN_CONTAINER_WIDGET_ID;
}
return MAIN_CONTAINER_WIDGET_ID;
}
// used for List widget cases
const isChildOfDropDisabledCanvas = (
canvasWidgets: CanvasWidgetsReduxState,
widgetId: string,
) => {
const widget = canvasWidgets[widgetId];
const parentId = widget.parentId || MAIN_CONTAINER_WIDGET_ID;
const parent = canvasWidgets[parentId];
return !!parent?.dropDisabled;
};
export function* getAllSelectableChildren() {
const lastSelectedWidget: string = yield select(getLastSelectedWidget);
const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const widgetLastSelected = canvasWidgets[lastSelectedWidget];
const canvasId: string = yield call(getLastSelectedCanvas);
let allChildren: string[];
const selectGrandChildren: boolean = lastSelectedWidget
? widgetLastSelected && widgetLastSelected.type === WidgetTypes.LIST_WIDGET
: false;
if (selectGrandChildren) {
allChildren = yield call(
getWidgetChildrenIds,
canvasWidgets,
lastSelectedWidget,
);
} else {
allChildren = yield select(getWidgetImmediateChildren, canvasId);
}
if (allChildren && allChildren.length) {
return allChildren.filter((each) => {
const isCanvasWidget =
each &&
canvasWidgets[each] &&
canvasWidgets[each].type === WidgetTypes.CANVAS_WIDGET;
const isImmovableWidget = isChildOfDropDisabledCanvas(
canvasWidgets,
each,
);
return !(isCanvasWidget || isImmovableWidget);
});
}
return [];
}
export function assertParentId(parentId: unknown): asserts parentId is string {
if (!parentId || typeof parentId !== "string") {
throw new WidgetSelectionError("Could not find a parent for the widget");
}
}
export function* setWidgetAncestry(
parentId: string,
allWidgets: CanvasWidgetsReduxState,
) {
// Fill up the ancestry of widget
// The following is computed to be used in the entity explorer
// Every time a widget is selected, we need to expand widget entities
// in the entity explorer so that the selected widget is visible
const widgetAncestry: string[] = [];
let ancestorWidgetId = parentId;
while (ancestorWidgetId) {
widgetAncestry.push(ancestorWidgetId);
if (allWidgets[ancestorWidgetId] && allWidgets[ancestorWidgetId].parentId) {
const parentId = allWidgets[ancestorWidgetId].parentId;
assertParentId(parentId);
ancestorWidgetId = parentId;
} else {
break;
}
}
yield put(setSelectedWidgetAncestry(widgetAncestry));
}
export function* selectAllWidgetsInCanvasSaga() {
try {
const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const allSelectableChildren: string[] = yield call(
getAllSelectableChildren,
);
if (allSelectableChildren && allSelectableChildren.length) {
const isAnyModalSelected = allSelectableChildren.some((each) => {
return (
each &&
canvasWidgets[each] &&
canvasWidgets[each].type === WidgetFactory.widgetTypes.MODAL_WIDGET
);
});
if (isAnyModalSelected) {
Toaster.show({
text: createMessage(SELECT_ALL_WIDGETS_MSG),
variant: Variant.info,
duration: 3000,
});
}
return {
widgets: allSelectableChildren,
lastWidgetSelected: allSelectableChildren[0],
};
}
} catch (error) {
yield put({
type: ReduxActionErrorTypes.WIDGET_SELECTION_ERROR,
payload: {
action: ReduxActionTypes.SELECT_WIDGET_INIT,
error,
},
});
}
}