PromucFlow_constructor/app/client/src/layoutSystems/common/dropTarget/DropTargetComponent.tsx
Hetu Nandu 8cb733937c
chore: Refactor Context Switching (#29966)
## Description

Refactors the Context Switching functionality to make it usable of
different IDE types. It now will set a `FocusStrategy` based on the IDE
type { App, Module, Workflow } and perform the same operations.
Implementation of `FocusStrategy` for other IDE types will be done on
the EE side. It removes all dependence of `pageId` from the core
functionality and relies on the Strategy implementation to define what
states to store and set, and the key used for them.

Also renamed the functionality to `FocusRetention` for more clarity.


#### PR fixes following issue(s)
Fixes #29961

#### Type of change
- Chore (housekeeping or task changes that don't impact user perception)

## Testing

#### How Has This Been Tested?

- [ ] Manual
- [ ] JUnit
- [ ] Jest
- [ ] Cypress

#### Test Plan
> Add Testsmith test cases links that relate to this PR
>
>
#### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)
>
>
>
## 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:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added functionality for managing focus elements within the
application's integrated development environment (IDE).
- Introduced a focus strategy for handling focus elements in the
application.

- **Refactor**
- Restructured code for focus management configurations and strategies
to improve clarity and efficiency.
- Renamed `ConfigType` enum to `FocusElementConfigType` for better
reflection of its purpose.

- **Bug Fixes**
- Resolved issues with focus state restoration during navigation between
different URLs.

- **Tests**
- Updated test cases to align with the new focus management logic and
IDE type checks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-01-08 11:09:47 +05:30

405 lines
14 KiB
TypeScript

import type { AppState } from "@appsmith/reducers";
import {
GridDefaults,
MAIN_CONTAINER_WIDGET_ID,
} from "constants/WidgetConstants";
import equal from "fast-deep-equal/es6";
import type { Context, PropsWithChildren } from "react";
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useSelector } from "react-redux";
import styled from "styled-components";
import {
checkContainersForAutoHeightAction,
updateDOMDirectlyBasedOnAutoHeightAction,
} from "actions/autoHeightActions";
import { useDispatch } from "react-redux";
import { getDragDetails } from "sagas/selectors";
import {
combinedPreviewModeSelector,
getIsMobileCanvasLayout,
getOccupiedSpacesSelectorForContainer,
} from "selectors/editorSelectors";
import { getCanvasSnapRows } from "utils/WidgetPropsUtils";
import { useAutoHeightUIState } from "utils/hooks/autoHeightUIHooks";
import { useShowPropertyPane } from "utils/hooks/dragResizeHooks";
import { useWidgetSelection } from "utils/hooks/useWidgetSelection";
import { calculateDropTargetRows } from "./DropTargetUtils";
import { LayoutSystemTypes } from "layoutSystems/types";
import { getIsAppSettingsPaneWithNavigationTabOpen } from "selectors/appSettingsPaneSelectors";
import { getLayoutSystemType } from "selectors/layoutSystemSelectors";
import { getCurrentUser } from "selectors/usersSelectors";
import {
getUsersFirstApplicationId,
isUserSignedUpFlagSet,
} from "utils/storage";
import {
isAutoHeightEnabledForWidget,
isAutoHeightEnabledForWidgetWithLimits,
} from "widgets/WidgetUtils";
import DragLayerComponent from "./DragLayerComponent";
import StarterBuildingBlocks from "./starterBuildingBlocks";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "@appsmith/entities/FeatureFlag";
import { useCurrentAppState } from "pages/Editor/IDE/hooks";
import { EditorState as IDEAppState } from "@appsmith/entities/IDE/constants";
import { isAirgapped } from "@appsmith/utils/airgapHelpers";
export type DropTargetComponentProps = PropsWithChildren<{
snapColumnSpace: number;
widgetId: string;
parentId?: string;
noPad?: boolean;
bottomRow: number;
minHeight: number;
useAutoLayout?: boolean;
isMobile?: boolean;
mobileBottomRow?: number;
isListWidgetCanvas?: boolean;
}>;
const StyledDropTarget = styled.div`
transition: height 100ms ease-in;
width: 100%;
position: relative;
background: none;
user-select: none;
z-index: 1;
`;
function Onboarding() {
const [isUsersFirstApp, setIsUsersFirstApp] = useState(false);
const isMobileCanvas = useSelector(getIsMobileCanvasLayout);
const appState = useCurrentAppState();
const user = useSelector(getCurrentUser);
const showStarterTemplatesInsteadofBlankCanvas = useFeatureFlag(
FEATURE_FLAG.ab_show_templates_instead_of_blank_canvas_enabled,
);
const isAirgappedInstance = isAirgapped();
const currentApplicationId = useSelector(
(state: AppState) => state.ui.applications.currentApplication?.id,
);
const shouldShowStarterTemplates = useMemo(
() =>
showStarterTemplatesInsteadofBlankCanvas &&
!isMobileCanvas &&
isUsersFirstApp &&
!isAirgappedInstance,
[
isMobileCanvas,
isUsersFirstApp,
showStarterTemplatesInsteadofBlankCanvas,
isAirgappedInstance,
],
);
useEffect(() => {
(async () => {
const firstApplicationId = await getUsersFirstApplicationId();
const isNew = !!user && (await isUserSignedUpFlagSet(user.email));
const isFirstApp = firstApplicationId === currentApplicationId;
setIsUsersFirstApp(isNew && isFirstApp);
})();
}, [user, currentApplicationId]);
if (shouldShowStarterTemplates && appState === IDEAppState.EDITOR)
return <StarterBuildingBlocks />;
else if (!shouldShowStarterTemplates && appState === IDEAppState.EDITOR)
return (
<h2 className="absolute top-0 left-0 right-0 flex items-end h-108 justify-center text-2xl font-bold text-gray-300">
Drag and drop a widget here
</h2>
);
else return null;
}
/*
This context will provide the function which will help the draglayer and resizablecomponents trigger
an update of the main container's rows
*/
export const DropTargetContext: Context<{
updateDropTargetRows?: (
widgetIdsToExclude: string[],
widgetBottomRow: number,
) => number | false;
}> = createContext({});
/**
* This function sets the height in pixels to the provided ref to the number of rows
* @param ref : The ref to the dropTarget so that we can update the height
* @param currentRows : Number of rows to set the height
*/
const updateHeight = (
ref: React.MutableRefObject<HTMLDivElement | null>,
currentRows: number,
) => {
if (ref.current) {
const height = currentRows * GridDefaults.DEFAULT_GRID_ROW_HEIGHT;
ref.current.style.height = `${height}px`;
ref.current
.closest(".scroll-parent")
?.scrollTo({ top: height, behavior: "smooth" });
}
};
function useUpdateRows(
bottomRow: number,
widgetId: string,
parentId?: string,
mobileBottomRow?: number,
isMobile?: boolean,
isAutoLayoutActive?: boolean,
isListWidgetCanvas?: boolean,
) {
// This gives us the number of rows
const snapRows = getCanvasSnapRows(
bottomRow,
mobileBottomRow,
isMobile,
isAutoLayoutActive,
);
// Put the existing snap rows in a ref.
const rowRef = useRef(snapRows);
const dropTargetRef = useRef<HTMLDivElement>(null);
// The occupied spaces in this canvas. It is a data structure which has the rect values of each child.
const selectOccupiedSpaces = useCallback(
getOccupiedSpacesSelectorForContainer(widgetId),
[widgetId],
);
// Call the selector above.
const occupiedSpacesByChildren = useSelector(selectOccupiedSpaces, equal);
/*
* If the parent has auto height enabled, or if the current widget is the MAIN_CONTAINER_WIDGET_ID
*/
const isParentAutoHeightEnabled = useSelector((state: AppState) => {
return parentId
? !isAutoHeightEnabledForWidgetWithLimits(
state.entities.canvasWidgets[parentId],
) &&
isAutoHeightEnabledForWidget(state.entities.canvasWidgets[parentId])
: false;
});
const dispatch = useDispatch();
// Function which computes and updates the height of the dropTarget
// This is used in a context and hence in one of the children of this dropTarget
const updateDropTargetRows = (
widgetIdsToExclude: string[],
widgetBottomRow: number,
) => {
// Compute expected number of rows this drop target must have
const newRows = calculateDropTargetRows(
widgetIdsToExclude,
widgetBottomRow,
occupiedSpacesByChildren,
widgetId,
);
// If the current number of rows in the drop target is less
// than the expected number of rows in the drop target
if (rowRef.current < newRows) {
// Set the new value locally
rowRef.current = newRows;
// If the parent container like widget has auto height enabled
// We'd like to immediately update the parent's height
// based on the auto height computations
// This also updates any "dropTargets" that need to change height
// hence, this and the `updateHeight` function are mutually exclusive.
if (isParentAutoHeightEnabled && parentId) {
dispatch(updateDOMDirectlyBasedOnAutoHeightAction(parentId, newRows));
} else {
// Basically, we don't have auto height triggering, so the dropTarget height should be updated using
// the `updateHeight` function
// The difference here is that the `updateHeight` function only updates the "canvas" or the "dropTarget"
// and doesn't effect the parent container
// We can't update the height of the "Canvas" or "dropTarget" using this function
// in the previous if clause, because, there could be more "dropTargets" updating
// and this information can only be computed using auto height
if (!isAutoLayoutActive || !isListWidgetCanvas) {
updateHeight(dropTargetRef, rowRef.current);
}
}
return newRows;
}
return false;
};
// memoizing context values
const contextValue = useMemo(() => {
return {
updateDropTargetRows,
};
}, [updateDropTargetRows, occupiedSpacesByChildren]);
/** EO PREPARE CONTEXT */
return { contextValue, dropTargetRef, rowRef };
}
export function DropTargetComponent(props: DropTargetComponentProps) {
// Get if this is in preview mode.
const isPreviewMode = useSelector(combinedPreviewModeSelector);
const isAppSettingsPaneWithNavigationTabOpen = useSelector(
getIsAppSettingsPaneWithNavigationTabOpen,
);
const layoutSystemType: LayoutSystemTypes = useSelector(getLayoutSystemType);
const isAutoLayoutActive = layoutSystemType === LayoutSystemTypes.AUTO;
const { contextValue, dropTargetRef, rowRef } = useUpdateRows(
props.bottomRow,
props.widgetId,
props.parentId,
props.mobileBottomRow,
props.isMobile,
isAutoLayoutActive,
props.isListWidgetCanvas,
);
// Are we currently resizing?
const isResizing = useSelector(
(state: AppState) => state.ui.widgetDragResize.isResizing,
);
// Are we currently dragging?
const isDragging = useSelector(
(state: AppState) => state.ui.widgetDragResize.isDragging,
);
// Are we changing the auto height limits by dragging the signifiers?
const { isAutoHeightWithLimitsChanging } = useAutoHeightUIState();
const dispatch = useDispatch();
// dragDetails contains of info needed for a container jump:
// which parent the dragging widget belongs,
// which canvas is active(being dragged on),
// which widget is grabbed while dragging started,
// relative position of mouse pointer wrt to the last grabbed widget.
const dragDetails = useSelector(getDragDetails);
const { draggedOn } = dragDetails;
// All the widgets in this canvas
const childWidgets: string[] | undefined = useSelector(
(state: AppState) => state.entities.canvasWidgets[props.widgetId]?.children,
);
// This shows the property pane
const showPropertyPane = useShowPropertyPane();
const { deselectAll, focusWidget } = useWidgetSelection();
// Everytime we get a new bottomRow, or we toggle shouldScrollContents
// we call this effect
useEffect(() => {
const snapRows = getCanvasSnapRows(
props.bottomRow,
props.mobileBottomRow,
props.isMobile,
isAutoLayoutActive,
);
// If the current ref is not set to the new snaprows we've received (based on bottomRow)
if (rowRef.current !== snapRows && !isDragging && !isResizing) {
rowRef.current = snapRows;
if (!isAutoLayoutActive || !props.isListWidgetCanvas) {
updateHeight(dropTargetRef, snapRows);
}
// If we're done dragging, and the parent has auto height enabled
// It is possible that the auto height has not triggered yet
// because the user has released the mouse button but not placed the widget
// In these scenarios, the parent's height needs to be updated
// in the same way as the auto height would have done
if (props.parentId) {
dispatch(checkContainersForAutoHeightAction());
}
}
}, [
props.widgetId,
props.bottomRow,
props.mobileBottomRow,
props.isMobile,
props.parentId,
props.isListWidgetCanvas,
isDragging,
isResizing,
]);
const handleFocus = (e: React.MouseEvent<HTMLElement>) => {
// Making sure that we don't deselect the widget
// after we are done dragging the limits in auto height with limits
const selectionDiv = `div-selection-${MAIN_CONTAINER_WIDGET_ID}`;
const mainCanvasId = `canvas-selection-${MAIN_CONTAINER_WIDGET_ID}`;
const isTargetMainCanvas =
(e.target as HTMLDivElement).dataset.testid === selectionDiv ||
(e.target as HTMLDivElement).dataset.testid === mainCanvasId;
if (!isResizing && !isDragging && !isAutoHeightWithLimitsChanging) {
// Check if Target is the MainCanvas
if (isTargetMainCanvas) {
deselectAll();
focusWidget && focusWidget(props.widgetId);
showPropertyPane && showPropertyPane();
e.preventDefault();
} else {
// Prevent onClick from Bubbling out of the Canvas to the WidgetEditor for any other widget except the MainCanvas
e.stopPropagation();
}
}
};
// Get the height for the drop target
const height = `${rowRef.current * GridDefaults.DEFAULT_GRID_ROW_HEIGHT}px`;
const dropTargetStyles = {
height: props.isListWidgetCanvas ? (isDragging ? "100%" : "auto") : height,
};
const shouldOnboard =
!(childWidgets && childWidgets.length) && !isDragging && !props.parentId;
// The drag layer is the one with the grid dots.
// They need to show in certain scenarios
const showDragLayer =
((isDragging && draggedOn === props.widgetId) ||
isResizing ||
isAutoHeightWithLimitsChanging) &&
!isPreviewMode &&
!isAppSettingsPaneWithNavigationTabOpen &&
!props.useAutoLayout;
const isMainContainer = props.widgetId === MAIN_CONTAINER_WIDGET_ID;
return (
<DropTargetContext.Provider value={contextValue}>
<StyledDropTarget
className={`t--drop-target drop-target-${
props.parentId || MAIN_CONTAINER_WIDGET_ID
}`}
onClick={isMainContainer ? handleFocus : undefined}
ref={dropTargetRef}
style={dropTargetStyles}
>
{props.children}
{shouldOnboard && <Onboarding />}
{showDragLayer && (
<DragLayerComponent
noPad={props.noPad || false}
parentColumnWidth={props.snapColumnSpace}
/>
)}
</StyledDropTarget>
</DropTargetContext.Provider>
);
}
export default DropTargetComponent;