PromucFlow_constructor/app/client/src/sagas/WidgetSelectUtils.ts
Hetu Nandu c28bea180c
fix: Selected Widget Visibility (#21317)
## Description

### Part 3 of selected widget refactor

As part of context switching and selected widget refactor, we saw that
widgets that are inside modals or tabs and are hidden cannot be switched
to without updating some meta properties. The meta properties are
actually owned by the end user and the developer user would create some
default values for it as well. This becomes a problem soon when the
platform also tries to update it. So as part of this refactor, we will
use the selected widget ancestry (the chain of widgets from the top to
the currently selected widget) to handle if widgets need to be visible
or not. It will also indicate the widgets in the path of selection to
"make way" for the selected widget to be seen.

Media


https://user-images.githubusercontent.com/12022471/224916943-b10e8694-0c95-4157-bb93-288d7c0bf60a.mov

- This works on any number of levels of hirarchy
- The logic is supposed to handled by each widget that can potentially
hide other widgets inside it
- Improves some platform perf as the handing so widget meta is not done
by the platform anymore

Affected widgets:
- Modal Widget
- Tabs Widget

> tl;dr: Update the platform's way to show widgets that can be hidden.
Makes sure a selected widget is always shown.

Fixes #1282
Resolves #18173


## Type of change

- Bug fix (non-breaking change which fixes an issue)


## How Has This Been Tested?

- Manual
- Cypress

### Test Plan
> Test case link:-
[#2202](https://github.com/appsmithorg/TestSmith/issues/2202)

### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity:-
https://github.com/appsmithorg/appsmith/issues/1282#issuecomment-1472204952


## Checklist:
### Dev activity
- [x] My code follows the style guidelines of this project
- [x] 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
- [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
- [ ] PR is being merged under a feature flag


### QA activity:
- [x] Test plan has been approved by relevant developers
- [x] 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-03-23 11:13:07 +05:30

330 lines
10 KiB
TypeScript

import { setSelectedWidgetAncestry } from "actions/widgetSelectionActions";
import { createMessage, SELECT_ALL_WIDGETS_MSG } from "ce/constants/messages";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
} from "ce/constants/ReduxActionConstants";
import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants";
import { Toaster, Variant } from "design-system-old";
import { uniq } from "lodash";
import type {
CanvasWidgetsReduxState,
FlattenedWidgetProps,
} from "reducers/entityReducers/canvasWidgetsReducer";
import { call, put, select } from "redux-saga/effects";
import {
getWidgetImmediateChildren,
getWidgetMetaProps,
getWidgets,
} from "sagas/selectors";
import { getWidgetChildrenIds } from "sagas/WidgetOperationUtils";
import { getLastSelectedWidget, getSelectedWidgets } from "selectors/ui";
import WidgetFactory from "utils/WidgetFactory";
import { checkIsDropTarget } from "utils/WidgetFactoryHelpers";
/**
* 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",
/** Skip checks and just try to select. Page ID can be supplied to select a
* widget on another page */
UnsafeSelect = "UnsafeSelect",
}
export type SelectionPayload = string[];
export type SetSelectionResult = 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 [];
};
export const selectOneWidget = (
request: SelectionPayload,
): SetSelectionResult => {
if (request.length !== 1) {
throw new WidgetSelectionError(
"Wrong payload supplied",
request,
SelectionRequestType.One,
);
}
return request;
};
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 request;
};
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 request;
}
if (currentlySelectedWidgets.includes(request[0])) {
return currentlySelectedWidgets.filter((w) => request[0] !== w);
}
let widgets: string[] = [...currentlySelectedWidgets, ...request];
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 uniq(widgets);
};
export const pushPopWidgetSelection = (
request: SelectionPayload,
currentlySelectedWidgets: string[],
siblingWidgets: string[],
): SetSelectionResult => {
const widgetId = request[0];
const alreadySelected = currentlySelectedWidgets.includes(widgetId);
if (alreadySelected) {
return currentlySelectedWidgets.filter((each) => each !== widgetId);
} else if (!!widgetId) {
return [...currentlySelectedWidgets, widgetId].filter((w) =>
siblingWidgets.includes(w),
);
}
};
export const unselectWidget = (
request: SelectionPayload,
currentlySelectedWidgets: string[],
): SetSelectionResult => {
return currentlySelectedWidgets.filter((w) => !request.includes(w));
};
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,
widgetLastSelected,
);
return tabMetaProps.selectedTabWidgetId;
}
if (firstCanvas) {
return firstCanvas;
}
}
return widgetLastSelected.parentId;
}
function* getLastSelectedCanvas() {
const lastSelectedWidget: string = yield select(getLastSelectedWidget);
const selectedWidgets: string[] = yield select(getSelectedWidgets);
const areMultipleWidgetsSelected: boolean = selectedWidgets.length > 1;
const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const widgetLastSelected =
lastSelectedWidget && canvasWidgets[lastSelectedWidget];
if (widgetLastSelected) {
if (areMultipleWidgetsSelected) {
return widgetLastSelected.parentId || MAIN_CONTAINER_WIDGET_ID;
}
if (!areMultipleWidgetsSelected) {
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 | undefined,
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 allSelectableChildren;
}
} catch (error) {
yield put({
type: ReduxActionErrorTypes.WIDGET_SELECTION_ERROR,
payload: {
action: ReduxActionTypes.SELECT_WIDGET_INIT,
error,
},
});
}
}