From 67f7d217a1e1bddc40c3bdac5e6ce7040b4fe67c Mon Sep 17 00:00:00 2001 From: Abhinav Jha Date: Sun, 20 Nov 2022 11:42:32 +0530 Subject: [PATCH] feat: (Internal Change) Add auto height computation functions (#17962) * Add auto height reflow functions libary * Add comments in hard to understand parts * feat: auto height reflow lib (#17978) added 2 tests for boxHelper and 1 simple test for reflow computeChangeInPositionBasedOnDelta Co-authored-by: Ankur Singhal * Reduce one loop. Fix typo * Add helper functions and use them in lib * Use helper function * Add types * Fix issue where widgets don't get close to the bottom most above widget if that widget hasn't changed * fix: auto height reflow lib merge release (#18193) * feat: show number of tabs on the header (#18071) * number of tabs displayed alongside label * styling for span removed * feature added and cypress test cases written * code refactoring after review * Update top contributors * feat: Auto-height add reducers and actions (#17953) * Add reducers for auto height feature (Internal Change, No changes reflected to users) Co-authored-by: ankurrsinghal * [Bug] Incorrect count of users in workspace when adding multiple users (#17728) fix: filtering unique users by userId Co-authored-by: Anubhav * fix: Instrumentation for execution errors (#18093) * fix: Instrumentation for execution errors * added widget editor error event * fix: Sidebar heading fontSize & checkbox alignment (#18104) sidebar heading & checkbox alignment to heading * chore: added type for feature flag (#18152) add: new type for env variable * Update top contributors * feat: [Context Switching]: Change focus target and fix cursor position (#17794) Co-authored-by: rahulramesha * fix: JS Objects save failures due to AST changes (#18018) * fix: update local_testing.sh to build image for external contributor PRs (#18024) * chore: use `typography` and `getTypographyFromKey` from the design-system (#18050) Change typography imports, change function call * dummy Co-authored-by: Rishabh Kashyap Co-authored-by: Appsmith Bot <74705725+appsmith-bot@users.noreply.github.com> Co-authored-by: Abhinav Jha Co-authored-by: Ankit Srivastava <67647761+ankitsrivas14@users.noreply.github.com> Co-authored-by: Anubhav Co-authored-by: ChandanBalajiBP <104058110+ChandanBalajiBP@users.noreply.github.com> Co-authored-by: Rohit Agarwal Co-authored-by: Ayush Pahwa Co-authored-by: Hetu Nandu Co-authored-by: subratadeypappu Co-authored-by: Sumit Kumar Co-authored-by: Tanvi Bhakta Co-authored-by: Ankur Singhal Co-authored-by: ankurrsinghal Co-authored-by: Ankur Singhal Co-authored-by: Rishabh Kashyap Co-authored-by: Appsmith Bot <74705725+appsmith-bot@users.noreply.github.com> Co-authored-by: Ankit Srivastava <67647761+ankitsrivas14@users.noreply.github.com> Co-authored-by: Anubhav Co-authored-by: ChandanBalajiBP <104058110+ChandanBalajiBP@users.noreply.github.com> Co-authored-by: Rohit Agarwal Co-authored-by: Ayush Pahwa Co-authored-by: Hetu Nandu Co-authored-by: subratadeypappu Co-authored-by: Sumit Kumar Co-authored-by: Tanvi Bhakta --- app/client/src/reflow/reflowUtils.ts | 2 +- .../sagas/CanvasSagas/SelectionCanvasSagas.ts | 2 +- app/client/src/sagas/WidgetOperationUtils.ts | 2 +- app/client/src/utils/WidgetPropsUtils.tsx | 17 +- app/client/src/utils/autoHeight/constants.ts | 10 + .../src/utils/autoHeight/generateTree.test.ts | 162 +++++++++++++ .../src/utils/autoHeight/generateTree.ts | 83 +++++++ .../src/utils/autoHeight/reflow.test.ts | 221 ++++++++++++++++++ app/client/src/utils/autoHeight/reflow.ts | 204 ++++++++++++++++ app/client/src/utils/boxHelpers.test.ts | 45 ++++ app/client/src/utils/boxHelpers.ts | 15 ++ app/client/src/utils/helpers.test.ts | 48 ++++ app/client/src/utils/helpers.tsx | 41 +++- app/client/src/utils/hooks/useReflow.ts | 2 +- app/client/src/utils/reflowHookUtils.ts | 2 + 15 files changed, 835 insertions(+), 21 deletions(-) create mode 100644 app/client/src/utils/autoHeight/generateTree.test.ts create mode 100644 app/client/src/utils/autoHeight/generateTree.ts create mode 100644 app/client/src/utils/autoHeight/reflow.test.ts create mode 100644 app/client/src/utils/autoHeight/reflow.ts create mode 100644 app/client/src/utils/boxHelpers.test.ts create mode 100644 app/client/src/utils/boxHelpers.ts diff --git a/app/client/src/reflow/reflowUtils.ts b/app/client/src/reflow/reflowUtils.ts index b148896593..65adf52aba 100644 --- a/app/client/src/reflow/reflowUtils.ts +++ b/app/client/src/reflow/reflowUtils.ts @@ -1,6 +1,6 @@ import { OccupiedSpace } from "constants/CanvasEditorConstants"; import { cloneDeep, isUndefined } from "lodash"; -import { Rect } from "utils/WidgetPropsUtils"; +import { Rect } from "utils/boxHelpers"; import { CollidingSpace, CollidingSpaceMap, diff --git a/app/client/src/sagas/CanvasSagas/SelectionCanvasSagas.ts b/app/client/src/sagas/CanvasSagas/SelectionCanvasSagas.ts index 63cfaaebf8..10285cbff5 100644 --- a/app/client/src/sagas/CanvasSagas/SelectionCanvasSagas.ts +++ b/app/client/src/sagas/CanvasSagas/SelectionCanvasSagas.ts @@ -12,7 +12,7 @@ import { all, cancel, put, select, take, takeLatest } from "redux-saga/effects"; import { getOccupiedSpaces } from "selectors/editorSelectors"; import { getSelectedWidgets } from "selectors/ui"; import { snapToGrid } from "utils/helpers"; -import { areIntersecting } from "utils/WidgetPropsUtils"; +import { areIntersecting } from "utils/boxHelpers"; import { WidgetProps } from "widgets/BaseWidget"; import { getWidgets } from "sagas/selectors"; import { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; diff --git a/app/client/src/sagas/WidgetOperationUtils.ts b/app/client/src/sagas/WidgetOperationUtils.ts index 6ad82a3fcc..cf2f154777 100644 --- a/app/client/src/sagas/WidgetOperationUtils.ts +++ b/app/client/src/sagas/WidgetOperationUtils.ts @@ -34,7 +34,7 @@ import { getNextEntityName } from "utils/AppsmithUtils"; import WidgetFactory from "utils/WidgetFactory"; import { getParentWithEnhancementFn } from "./WidgetEnhancementHelpers"; import { OccupiedSpace, WidgetSpace } from "constants/CanvasEditorConstants"; -import { areIntersecting } from "utils/WidgetPropsUtils"; +import { areIntersecting } from "utils/boxHelpers"; import { GridProps, PrevReflowState, diff --git a/app/client/src/utils/WidgetPropsUtils.tsx b/app/client/src/utils/WidgetPropsUtils.tsx index ab7b2226ea..8b5c1a982a 100644 --- a/app/client/src/utils/WidgetPropsUtils.tsx +++ b/app/client/src/utils/WidgetPropsUtils.tsx @@ -22,6 +22,7 @@ import { WidgetDraggingBlock } from "pages/common/CanvasArenas/hooks/useBlocksTo import { XYCord } from "pages/common/CanvasArenas/hooks/useCanvasDragging"; import { ContainerWidgetProps } from "widgets/ContainerWidget/widget"; import { GridProps } from "reflow/reflowTypes"; +import { areIntersecting, Rect } from "./boxHelpers"; export type WidgetOperationParams = { operation: WidgetOperation; @@ -29,13 +30,6 @@ export type WidgetOperationParams = { payload: any; }; -export type Rect = { - top: number; - left: number; - right: number; - bottom: number; -}; - const defaultDSL = defaultTemplate; export const extractCurrentDSL = ( @@ -124,15 +118,6 @@ export const getMousePositionsOnCanvas = ( }; }; -export const areIntersecting = (r1: Rect, r2: Rect) => { - return !( - r2.left >= r1.right || - r2.right <= r1.left || - r2.top >= r1.bottom || - r2.bottom <= r1.top - ); -}; - export const isDropZoneOccupied = ( offset: Rect, widgetId: string, diff --git a/app/client/src/utils/autoHeight/constants.ts b/app/client/src/utils/autoHeight/constants.ts index 8712c3dcf6..4c4f254a7a 100644 --- a/app/client/src/utils/autoHeight/constants.ts +++ b/app/client/src/utils/autoHeight/constants.ts @@ -6,3 +6,13 @@ export type TreeNode = { originalTopRow: number; originalBottomRow: number; }; + +export type NodeSpace = { + left: number; + right: number; + top: number; + bottom: number; + id: string; +}; + +export const MAX_BOX_SIZE = 20000; diff --git a/app/client/src/utils/autoHeight/generateTree.test.ts b/app/client/src/utils/autoHeight/generateTree.test.ts new file mode 100644 index 0000000000..1a15087973 --- /dev/null +++ b/app/client/src/utils/autoHeight/generateTree.test.ts @@ -0,0 +1,162 @@ +import { NodeSpace, TreeNode } from "./constants"; +import { generateTree } from "./generateTree"; + +describe("Generate Auto Height Layout tree", () => { + it("Does not conflict when only one horizontal edge is the same", () => { + const input: NodeSpace[] = [ + { left: 0, right: 100, top: 0, bottom: 30, id: "1" }, + { left: 100, top: 0, bottom: 30, right: 120, id: "2" }, + ]; + const previousTree: Record = {}; + const layoutUpdated = false; + const expected = { + "1": { + aboves: [], + belows: [], + topRow: 0, + bottomRow: 30, + originalBottomRow: 30, + originalTopRow: 0, + }, + "2": { + aboves: [], + belows: [], + topRow: 0, + bottomRow: 30, + originalBottomRow: 30, + originalTopRow: 0, + }, + }; + + const result = generateTree(input, layoutUpdated, previousTree); + + expect(result).toStrictEqual(expected); + }); + + it("Does conflict when part of the boxes overlap horizontally", () => { + const input: NodeSpace[] = [ + { left: 0, right: 100, top: 0, bottom: 30, id: "1" }, + { left: 80, top: 30, bottom: 40, right: 120, id: "2" }, + ]; + const previousTree: Record = {}; + const layoutUpdated = false; + const expected = { + "1": { + aboves: [], + belows: ["2"], + topRow: 0, + bottomRow: 30, + originalBottomRow: 30, + originalTopRow: 0, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: 30, + bottomRow: 40, + originalBottomRow: 40, + originalTopRow: 30, + }, + }; + + const result = generateTree(input, layoutUpdated, previousTree); + + expect(result).toStrictEqual(expected); + }); + + it("Uses existing originals if available in prevTree when layout hasn't updated", () => { + const input: NodeSpace[] = [ + { left: 0, right: 100, top: 0, bottom: 30, id: "1" }, + { left: 80, top: 30, bottom: 40, right: 120, id: "2" }, + ]; + const previousTree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: 0, + bottomRow: 30, + originalBottomRow: 20, + originalTopRow: 0, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: 30, + bottomRow: 40, + originalBottomRow: 30, + originalTopRow: 20, + }, + }; + const layoutUpdated = false; + const expected = { + "1": { + aboves: [], + belows: ["2"], + topRow: 0, + bottomRow: 30, + originalBottomRow: 20, + originalTopRow: 0, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: 30, + bottomRow: 40, + originalBottomRow: 30, + originalTopRow: 20, + }, + }; + + const result = generateTree(input, layoutUpdated, previousTree); + + expect(result).toStrictEqual(expected); + }); + + it("Ignores existing originals if available in prevTree when layout has updated", () => { + const input: NodeSpace[] = [ + { left: 0, right: 100, top: 0, bottom: 30, id: "1" }, + { left: 80, top: 30, bottom: 40, right: 120, id: "2" }, + ]; + const previousTree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: 0, + bottomRow: 30, + originalBottomRow: 20, + originalTopRow: 0, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: 30, + bottomRow: 40, + originalBottomRow: 30, + originalTopRow: 20, + }, + }; + const layoutUpdated = true; + const expected = { + "1": { + aboves: [], + belows: ["2"], + topRow: 0, + bottomRow: 30, + originalBottomRow: 30, + originalTopRow: 0, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: 30, + bottomRow: 40, + originalBottomRow: 40, + originalTopRow: 30, + }, + }; + + const result = generateTree(input, layoutUpdated, previousTree); + + expect(result).toStrictEqual(expected); + }); +}); diff --git a/app/client/src/utils/autoHeight/generateTree.ts b/app/client/src/utils/autoHeight/generateTree.ts new file mode 100644 index 0000000000..e18eabf5c3 --- /dev/null +++ b/app/client/src/utils/autoHeight/generateTree.ts @@ -0,0 +1,83 @@ +import { areIntersecting } from "utils/boxHelpers"; +import { pushToArray } from "utils/helpers"; +import { MAX_BOX_SIZE, NodeSpace, TreeNode } from "./constants"; + +// This function uses the spaces occupied by sibling boxes and provides us with +// a data structure which defines the relative vertical positioning of the boxes +// in the form of "aboves" and "belows" for each box, which are array of box ids +export function generateTree( + spaces: NodeSpace[], + layoutUpdated: boolean, + previousTree: Record, +): Record { + // If widget doesn't exist in this DS, this means that its height changes does not effect any other sibling + spaces.sort((a, b) => a.top - b.top); // Sort based on position, top to bottom, so that we know which is above the other + const _spaces = [...spaces]; + + const aboveMap: Record = {}; + const belowMap: Record = {}; + + const tree: Record = {}; + + // For each of the sibling boxes + for (let i = 0; i < spaces.length; i++) { + // Get the left most box in the array (Remember: we sorted from top to bottom, so the leftmost will be the top most) + const _curr = _spaces.shift(); + if (_curr) { + // Create a reference copy as we need to override the bottom value + const currentSpace = { ..._curr }; + // Add a randomly large value to the bottom; this will help us know if any box is below this box + currentSpace.bottom += MAX_BOX_SIZE; + // For each of the remaining sibling widgets + for (let j = 0; j < _spaces.length; j++) { + // Create a reference copy as we need to override the bottom value + const comparisionSpace = { ..._spaces[j] }; + // Add a randomly large value to the bottom; this will help us know if any box is below this box + // TODO(abhinav): This addition may not be necessary, as we're only looking to see if these boxes + // are below the currentSpace + comparisionSpace.bottom += MAX_BOX_SIZE; + // Check if comparison space has an overlap with current space + if (areIntersecting(currentSpace, comparisionSpace)) { + // If there is an overlap, comparisonSpace is below the current space + // so, we update the aboveMap and belowMap accordingly + aboveMap[comparisionSpace.id] = pushToArray( + currentSpace.id, + aboveMap[comparisionSpace.id], + ) as string[]; + belowMap[currentSpace.id] = pushToArray( + comparisionSpace.id, + belowMap[currentSpace.id], + ) as string[]; + } + } + // Get the originalTop and originalBottom from the previous tree. + // This is so that we can get close to the original (user defined) positions of the boxes + // For example, if box1 increases in size and pushes box2 by 100 rows, while box3 is also above box2 + // When the box1 subsequently decrease by 50 rows, we need to maintain spacing between box3 and box2 + // Otherwise, if box1 happens to go below the bottomRow of box3, box2 will tend to overlap with box3. + let originalTopRow = previousTree[currentSpace.id]?.originalTopRow; + let originalBottomRow = previousTree[currentSpace.id]?.originalBottomRow; + // We also udpate the original if the layout is being updated + // This happens when the user repositions/resizes boxes + // If the previousTree doesn't have any originals, we can assume that this is the + // first time we're generating the tree, hence we need to keep the current top and bottom + // for subsequent tree generation + if (originalTopRow === undefined || layoutUpdated) { + originalTopRow = currentSpace.top; + } + if (originalBottomRow === undefined || layoutUpdated) { + originalBottomRow = currentSpace.bottom - MAX_BOX_SIZE; + } + tree[currentSpace.id] = { + aboves: aboveMap[currentSpace.id] || [], + belows: belowMap[currentSpace.id] || [], + topRow: currentSpace.top, + bottomRow: currentSpace.bottom - MAX_BOX_SIZE, + originalTopRow, + originalBottomRow, + }; + } + } + + return tree; +} diff --git a/app/client/src/utils/autoHeight/reflow.test.ts b/app/client/src/utils/autoHeight/reflow.test.ts new file mode 100644 index 0000000000..21190ae337 --- /dev/null +++ b/app/client/src/utils/autoHeight/reflow.test.ts @@ -0,0 +1,221 @@ +import { TreeNode } from "./constants"; +import { computeChangeInPositionBasedOnDelta } from "./reflow"; + +describe("reflow", () => { + describe("computeChangeInPositionBasedOnDelta (should compute new positions for boxes based on boxes which has changed heights)", () => { + it("simple 2 boxes test where the top grows by 5 rows and should shifts the bottom one by 5 rows", () => { + const box1TopRow = 10; + const box1BottomRow = 20; + + const box2TopRow = 30; + const box2BottomRow = 40; + + const tree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: box1TopRow, + bottomRow: box1BottomRow, + originalTopRow: box1TopRow, + originalBottomRow: box1BottomRow, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: box2TopRow, + bottomRow: box2BottomRow, + originalTopRow: box2TopRow, + originalBottomRow: box2BottomRow, + }, + }; + + const box1DeltaHeightIncrease = 5; + + const delta: Record = { + "1": box1DeltaHeightIncrease, + }; + + const expectedChanges = { + "1": { + topRow: box1TopRow, + bottomRow: box1BottomRow + box1DeltaHeightIncrease, + }, + "2": { + topRow: box2TopRow + box1DeltaHeightIncrease, + bottomRow: box2BottomRow + box1DeltaHeightIncrease, + }, + }; + + const changes = computeChangeInPositionBasedOnDelta(tree, delta); + + expect(expectedChanges).toMatchObject(changes); + }); + + it("When delta is negative, the original spacing is maintained", () => { + const box1TopRow = 10; + const box1BottomRow = 100; + const box1OriginalTopRow = 10; + const box1OriginalBottomRow = 20; + + const box2TopRow = 110; + const box2BottomRow = 200; + const box2OriginalTopRow = 30; + const box2OriginalBottomRow = 40; + + const tree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: box1TopRow, + bottomRow: box1BottomRow, + originalTopRow: box1OriginalTopRow, + originalBottomRow: box1OriginalBottomRow, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: box2TopRow, + bottomRow: box2BottomRow, + originalTopRow: box2OriginalTopRow, + originalBottomRow: box2OriginalBottomRow, + }, + }; + + const box1DeltaHeight = -50; + + const delta: Record = { + "1": box1DeltaHeight, + "2": box1DeltaHeight, + }; + + const expectedChanges = { + "1": { + topRow: box1TopRow, + bottomRow: box1BottomRow + box1DeltaHeight, + }, + "2": { + topRow: 60, + bottomRow: 100, + }, + }; + + const changes = computeChangeInPositionBasedOnDelta(tree, delta); + + expect(expectedChanges).toMatchObject(changes); + }); + it("When delta is negative, bottom box moves up", () => { + const box1TopRow = 10; + const box1BottomRow = 100; + const box1OriginalTopRow = 10; + const box1OriginalBottomRow = 15; + + const box2TopRow = 140; + const box2BottomRow = 230; + const box2OriginalTopRow = 30; + const box2OriginalBottomRow = 40; + + const tree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: box1TopRow, + bottomRow: box1BottomRow, + originalTopRow: box1OriginalTopRow, + originalBottomRow: box1OriginalBottomRow, + }, + "2": { + aboves: ["1"], + belows: [], + topRow: box2TopRow, + bottomRow: box2BottomRow, + originalTopRow: box2OriginalTopRow, + originalBottomRow: box2OriginalBottomRow, + }, + }; + + const box1DeltaHeight = -50; + + const delta: Record = { + "1": box1DeltaHeight, + "2": box1DeltaHeight, + }; + + const expectedChanges = { + "1": { + topRow: box1TopRow, + bottomRow: box1BottomRow + box1DeltaHeight, + }, + "2": { + topRow: 90, + bottomRow: 130, + }, + }; + + const changes = computeChangeInPositionBasedOnDelta(tree, delta); + + expect(expectedChanges).toMatchObject(changes); + }); + it("When a widget is blocking and delta is negative, bottom box moves up to the original spacing between the blocking box and the bottom box", () => { + const box1TopRow = 10; + const box1BottomRow = 100; + const box1OriginalTopRow = 10; + const box1OriginalBottomRow = 15; + + const box2TopRow = 140; + const box2BottomRow = 230; + const box2OriginalTopRow = 30; + const box2OriginalBottomRow = 40; + + const box3 = { + aboves: [], + belows: ["2"], + topRow: 50, + bottomRow: 120, + originalBottomRow: 20, + originalTopRow: 10, + }; + + const tree: Record = { + "1": { + aboves: [], + belows: ["2"], + topRow: box1TopRow, + bottomRow: box1BottomRow, + originalTopRow: box1OriginalTopRow, + originalBottomRow: box1OriginalBottomRow, + }, + "2": { + aboves: ["1", "3"], + belows: [], + topRow: box2TopRow, + bottomRow: box2BottomRow, + originalTopRow: box2OriginalTopRow, + originalBottomRow: box2OriginalBottomRow, + }, + "3": box3, + }; + + const box1DeltaHeight = -50; + + const delta: Record = { + "1": box1DeltaHeight, + "2": box1DeltaHeight, + }; + + const expectedChanges = { + "1": { + topRow: box1TopRow, + bottomRow: box1BottomRow + box1DeltaHeight, + }, + "2": { + topRow: 130, + bottomRow: 170, + }, + }; + + const changes = computeChangeInPositionBasedOnDelta(tree, delta); + + expect(expectedChanges).toMatchObject(changes); + }); + }); +}); diff --git a/app/client/src/utils/autoHeight/reflow.ts b/app/client/src/utils/autoHeight/reflow.ts new file mode 100644 index 0000000000..15c421dffb --- /dev/null +++ b/app/client/src/utils/autoHeight/reflow.ts @@ -0,0 +1,204 @@ +import { pushToArray } from "utils/helpers"; +import { TreeNode } from "./constants"; + +/** + * + * @param tree : Auto Height Layout Tree + * @param effectedBoxId : Current box in consideration + * @param aboveId : Above box which may or maynot have changed + * @param offsetSoFar : Offset of the above box, or changes to be applied so far + * @returns : The offset expected to be applied to the effectedBoxId. This is how much this box should move + */ +export function getNegativeOffset( + tree: Record, + effectedBoxId: string, + aboveId: string, + offsetSoFar = 0, +): number { + if (offsetSoFar <= 0) { + // Let's take in to account the old spacing between the effected box and bottom most above box + // when the layout was last updated. + const oldSpacing = + tree[effectedBoxId].originalTopRow - tree[aboveId].originalBottomRow; + // Let's compute the spacing between the effected box and bottom most above box + const currentSpacing = tree[effectedBoxId].topRow - tree[aboveId].bottomRow; + // If the old spacing is less than current spacing and the offset of the bottom most above, + // we need to make sure that we're sticking to the original spacing between the bottom most above + // and the current effected box. + // Note: This applies only if the offset is negative, which is to say that the box is to move up + if (oldSpacing < currentSpacing + offsetSoFar) { + return oldSpacing + offsetSoFar - currentSpacing; + } + } + return offsetSoFar; +} +/** + * Gets the nearest above box for the current box. Including the aboves which have changes so far. + * + * @param tree: Auto Height Layout Tree + * @param effectedBoxId: Current box in consideration + * @param repositionedBoxes: Boxes repositioned so far + * @returns An array of boxIds which are above and nearest the effectedBoxId + */ +export function getNearestAbove( + tree: Record, + effectedBoxId: string, + repositionedBoxes: Record, +) { + // Get all the above boxes + const aboves = tree[effectedBoxId].aboves; + // We're trying to find the nearest boxes above this box + + return aboves.reduce((prev: string[], next: string) => { + if (!prev[0]) return [next]; + // Get the bottomRow of the above box + let nextBottomRow = tree[next].bottomRow; + let prevBottomRow = tree[prev[0]].bottomRow; + // If we've already repositioned this, use the new bottomRow of the box + if (repositionedBoxes[next]) { + nextBottomRow = repositionedBoxes[next].bottomRow; + } + if (repositionedBoxes[prev[0]]) { + prevBottomRow = repositionedBoxes[prev[0]].bottomRow; + } + + // If the current box's (next) bottomRow is larger than the previous + // This (next) box is the bottom most above so far + if (nextBottomRow > prevBottomRow) return [next]; + // If this (next) box's bottom row is the same as the previous + // We have two bottom most boxes + else if (nextBottomRow === prevBottomRow) { + if ( + repositionedBoxes[prev[0]] && + repositionedBoxes[prev[0]].bottomRow === + repositionedBoxes[prev[0]].topRow + ) { + return prev; + } + if ( + repositionedBoxes[next] && + repositionedBoxes[next].bottomRow === repositionedBoxes[next].topRow + ) { + return [next]; + } + return [...prev, next]; + } + // This (next) box's bottom row is lower than the boxes selected so far + // so, we ignore it. + else return prev; + }, []); +} + +// This function computes the new positions for boxes based on the boxes which have changed height +// delta: a map of boxes with change in heights +// tree: a layout tree which contains the current state of the boxes. +export function computeChangeInPositionBasedOnDelta( + tree: Record, + delta: Record, +): Record { + const repositionedBoxes: Record< + string, + { topRow: number; bottomRow: number } + > = {}; + + const effectedBoxMap: Record = {}; + + // For each box which has changed height (box delta) + for (const boxId in delta) { + // Create an effectedBoxMap, which contains the changes for each of the boxes effected by the delta of this box + // This is a map, because multiple box deltas can effect one box + + // We simply take all the boxes which are below this box from the tree + // and add the delta to the effectedBoxMap where the key is the below boxId from the tree + tree[boxId].belows.forEach((effectedId) => { + effectedBoxMap[effectedId] = pushToArray( + delta[boxId], + effectedBoxMap[effectedId], + ) as number[]; + }); + + // Add this box's delta to the repositioning, as this won't show up in the effectedBoxMap + repositionedBoxes[boxId] = { + topRow: tree[boxId].topRow, + bottomRow: tree[boxId].bottomRow + delta[boxId], + }; + } + + // Sort the effected box ids, this is to make sure we compute from top to bottom. + const sortedEffectedBoxIds = Object.keys(effectedBoxMap).sort( + (a, b) => tree[a].topRow - tree[b].topRow, + ); + + // For each of the boxes which have been effected + for (const effectedBoxId of sortedEffectedBoxIds) { + let _offset; + const bottomMostAboves = getNearestAbove( + tree, + effectedBoxId, + repositionedBoxes, + ); + // for each of the bottom most above boxes. + // Note: There can be more than one if two above widgets have the same bottomrow + for (const aboveId of bottomMostAboves) { + // If the above box has been effected by another box change height + // Or, if this above box itself has changed height + if (Array.isArray(effectedBoxMap[aboveId]) || delta[aboveId]) { + // In case the above box has changed heights + const _aboveOffset = repositionedBoxes[aboveId] + ? repositionedBoxes[aboveId].bottomRow - tree[aboveId].bottomRow + : 0; + + // If so far, we haven't got any _offset updates + // This can happen if this is the first aboveId we're checking + if (_offset === undefined) _offset = _aboveOffset; + + const negativeOffset = getNegativeOffset( + tree, + effectedBoxId, + aboveId, + _aboveOffset, + ); + + // If the bottom most above (_aboveOffset), has moved down (either by increasing height and/or due to its above) + // Let's take the effected boxs' change to be the max of _offset and _aboveOffset + // The _offset so far will be due to other bottomMostAbove effecting this effected box. + if (_aboveOffset > 0) _offset = Math.max(_aboveOffset, _offset); + // If the bottom most above (_aboveOffset) has moved up (either by decreasing height and/or due to its above) + // Let's take the Min (negative values, so max offset in the upward direction) of the _aboveOffset, _offset, negativeOffset. + else if (_aboveOffset < 0) { + _offset = Math.min(_aboveOffset, _offset, negativeOffset); + } + } else { + // Stick to the widget above if the bottomMost above box hasn't changed + // TODO(abhinav): Here we may want to use the same logic as negativeOffset using originals as done previously. + // Test this. + // Let's take in to account the old spacing between the effected box and bottom most above box + // when the layout was last updated. + const negativeOffset = getNegativeOffset(tree, effectedBoxId, aboveId); + _offset = negativeOffset; + } + } + + // If _offset is not defined, this means that this box is the topmost box + if (_offset === undefined) { + // The effectedBoxId is the topmost box, so the _offset will most likely always be 0 + _offset = effectedBoxMap[effectedBoxId].reduce( + (prev, next) => prev + next, + 0, + ); + } + + // Finally update the repositioned box with the _offset. + if (repositionedBoxes[effectedBoxId]) { + repositionedBoxes[effectedBoxId].bottomRow += _offset; + repositionedBoxes[effectedBoxId].topRow += _offset; + } else { + repositionedBoxes[effectedBoxId] = { + topRow: tree[effectedBoxId].topRow + _offset, + bottomRow: tree[effectedBoxId].bottomRow + _offset, + }; + } + } + + return repositionedBoxes; +} diff --git a/app/client/src/utils/boxHelpers.test.ts b/app/client/src/utils/boxHelpers.test.ts new file mode 100644 index 0000000000..4fb5885a2e --- /dev/null +++ b/app/client/src/utils/boxHelpers.test.ts @@ -0,0 +1,45 @@ +import { areIntersecting, Rect } from "./boxHelpers"; + +describe("boxHelpers", () => { + describe("areIntersecting", () => { + it("should return true when rect 1 and rect 2 are intersecting", () => { + const rect1: Rect = { + top: 0, + bottom: 2, + left: 0, + right: 2, + }; + + const rect2: Rect = { + top: 0, + bottom: 3, + left: 1, + right: 3, + }; + + const isIntersecting = areIntersecting(rect1, rect2); + + expect(isIntersecting).toBe(true); + }); + + it("should return false when rect 1 and rect 2 are not intersecting", () => { + const rect1: Rect = { + top: 0, + bottom: 2, + left: 0, + right: 2, + }; + + const rect2: Rect = { + top: 3, + bottom: 6, + left: 3, + right: 6, + }; + + const isIntersecting = areIntersecting(rect1, rect2); + + expect(isIntersecting).toBe(false); + }); + }); +}); diff --git a/app/client/src/utils/boxHelpers.ts b/app/client/src/utils/boxHelpers.ts new file mode 100644 index 0000000000..5f051ca3ec --- /dev/null +++ b/app/client/src/utils/boxHelpers.ts @@ -0,0 +1,15 @@ +export type Rect = { + top: number; + left: number; + right: number; + bottom: number; +}; + +export const areIntersecting = (r1: Rect, r2: Rect) => { + return !( + r2.left >= r1.right || + r2.right <= r1.left || + r2.top >= r1.bottom || + r2.bottom <= r1.top + ); +}; diff --git a/app/client/src/utils/helpers.test.ts b/app/client/src/utils/helpers.test.ts index 1f07f2f3bb..60668521b4 100644 --- a/app/client/src/utils/helpers.test.ts +++ b/app/client/src/utils/helpers.test.ts @@ -10,6 +10,8 @@ import { mergeWidgetConfig, extractColorsFromString, isNameValid, + pushToArray, + concatWithArray, } from "./helpers"; import WidgetFactory from "./WidgetFactory"; import * as Sentry from "@sentry/react"; @@ -597,3 +599,49 @@ describe("isNameValid()", () => { } }); }); + +describe("pushToArray", () => { + it("adds to an undefined array", () => { + const item = "something"; + const expected = ["something"]; + const result = pushToArray(item); + expect(result).toStrictEqual(expected); + }); + it("adds to an existing array", () => { + const item = "something"; + const arr1 = ["another"]; + const expected = ["another", "something"]; + const result = pushToArray(item, arr1); + expect(result).toStrictEqual(expected); + }); + it("adds to an existing array and make unique", () => { + const item = "something"; + const arr1 = ["another", "another"]; + const expected = ["another", "something"]; + const result = pushToArray(item, arr1, true); + expect(result).toStrictEqual(expected); + }); +}); + +describe("concatWithArray", () => { + it("adds to an undefined array", () => { + const items = ["something"]; + const expected = ["something"]; + const result = concatWithArray(items); + expect(result).toStrictEqual(expected); + }); + it("adds to an existing array", () => { + const items = ["something"]; + const arr1 = ["another"]; + const expected = ["another", "something"]; + const result = concatWithArray(items, arr1); + expect(result).toStrictEqual(expected); + }); + it("adds to an existing array and make unique", () => { + const items = ["something"]; + const arr1 = ["another", "another"]; + const expected = ["another", "something"]; + const result = concatWithArray(items, arr1, true); + expect(result).toStrictEqual(expected); + }); +}); diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx index 3212b55ee5..aa346748a2 100644 --- a/app/client/src/utils/helpers.tsx +++ b/app/client/src/utils/helpers.tsx @@ -9,7 +9,7 @@ import { DEDICATED_WORKER_GLOBAL_SCOPE_IDENTIFIERS, JAVASCRIPT_KEYWORDS, } from "constants/WidgetValidation"; -import { get, set, isNil, has } from "lodash"; +import { get, set, isNil, has, uniq } from "lodash"; import { Workspace } from "@appsmith/constants/workspaceConstants"; import { isPermitted, @@ -936,3 +936,42 @@ export function AutoBind(target: any, _: string, descriptor: any) { descriptor.value = descriptor.value.bind(target); return descriptor; } + +/** + * Add item to an array which could be undefined + * @param arr1 Base Array (could be undefined) + * @param item Item to add to array + * @param makeUnique Should make sure array has unique entries + * @returns array which includes items from arr1 and item + */ +export function pushToArray( + item: unknown, + arr1?: unknown[], + makeUnique = false, +) { + if (Array.isArray(arr1)) arr1.push(item); + else return [item]; + + if (makeUnique) return uniq(arr1); + return arr1; +} + +/** + * Add items to array which could be undefined + * @param arr1 Base Array (could be undefined) + * @param items Items to add to arr1 + * @param makeUnique Should make sure array has unique entries + * @returns array which contains items from arr1 and items + */ +export function concatWithArray( + items: unknown[], + arr1?: unknown[], + makeUnique = false, +) { + let finalArr: unknown[] = []; + if (Array.isArray(arr1)) finalArr = arr1.concat(items); + else finalArr = finalArr.concat(items); + + if (makeUnique) return uniq(finalArr); + return finalArr; +} diff --git a/app/client/src/utils/hooks/useReflow.ts b/app/client/src/utils/hooks/useReflow.ts index 94979b3fce..9fab88c4db 100644 --- a/app/client/src/utils/hooks/useReflow.ts +++ b/app/client/src/utils/hooks/useReflow.ts @@ -24,7 +24,7 @@ import { getBottomRowAfterReflow } from "utils/reflowHookUtils"; import { checkIsDropTarget } from "components/designSystems/appsmith/PositionedContainer"; import { getIsReflowing } from "selectors/widgetReflowSelectors"; import { AppState } from "@appsmith/reducers"; -import { areIntersecting } from "utils/WidgetPropsUtils"; +import { areIntersecting } from "utils/boxHelpers"; type WidgetCollidingSpace = CollidingSpace & { type: string; diff --git a/app/client/src/utils/reflowHookUtils.ts b/app/client/src/utils/reflowHookUtils.ts index 1fe708e839..1cbe6f2665 100644 --- a/app/client/src/utils/reflowHookUtils.ts +++ b/app/client/src/utils/reflowHookUtils.ts @@ -39,6 +39,8 @@ export function collisionCheckPostReflow( return true; } +// TODO(ashok): There is a name clash here. Fine for now, but might get confusing in the future. +// maybe we should create a task for this. function areIntersecting(r1: FlattenedWidgetProps, r2: FlattenedWidgetProps) { if (r1.widgetId === r2.widgetId) return false;