From 57fc3b9e73a23b60b0ea06a8c42e6f732d54ea1b Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 4 Nov 2024 10:02:29 +0400 Subject: [PATCH] feat: allow drag and drop in pages (#4358) Closes https://github.com/webstudio-is/webstudio/issues/403 Now pages and folders can be maintained with drag and drop. https://github.com/user-attachments/assets/108230ea-95fa-40ac-bbcb-5d0785c5554f --- .../features/pages/folder-settings.tsx | 22 -- .../builder/features/pages/page-settings.tsx | 20 -- .../builder/features/pages/page-utils.test.ts | 73 ++-- .../app/builder/features/pages/page-utils.ts | 34 +- .../app/builder/features/pages/pages.tsx | 329 +++++++++++++----- apps/builder/package.json | 4 +- 6 files changed, 304 insertions(+), 178 deletions(-) diff --git a/apps/builder/app/builder/features/pages/folder-settings.tsx b/apps/builder/app/builder/features/pages/folder-settings.tsx index e74939bebf..fb1f17e047 100644 --- a/apps/builder/app/builder/features/pages/folder-settings.tsx +++ b/apps/builder/app/builder/features/pages/folder-settings.tsx @@ -20,7 +20,6 @@ import { ScrollArea, rawTheme, Flex, - Select, Dialog, DialogContent, Text, @@ -45,7 +44,6 @@ import { registerFolderChildMutable, deleteFolderWithChildrenMutable, deletePageMutable, - filterSelfAndChildren, } from "./page-utils"; import { Form } from "./form"; import { updateWebstudioData } from "~/shared/instance-utils"; @@ -118,7 +116,6 @@ const FormFields = ({ autoSelect, errors, values, - folderId, onChange, }: { disabled?: boolean; @@ -166,25 +163,6 @@ const FormFields = ({ - - - folder.id} - getLabel={(folder) => folder.name} - value={pages.folders.find( - ({ id }) => id === values.parentFolderId - )} - onChange={(folder) => { - onChange({ - field: "parentFolderId", - value: folder.id, - }); - }} - /> - - )} - {values.isHomePage === false && ( { }); }); +describe("reparent pages and folders", () => { + test("move page up within single parent", () => { + const { f, p, register, pages } = createPages(); + register([ + f("folder", [ + p("page1", "/page1"), + p("page2", "/page2"), + p("page3", "/page3"), + ]), + ]); + reparentPageOrFolderMutable(pages.folders, "page3", "folder", 1); + const folder = pages.folders.find((folder) => folder.id === "folder"); + expect(folder?.children).toEqual(["page1", "page3", "page2"]); + }); + + test("move page down within single parent", () => { + const { f, p, register, pages } = createPages(); + register([ + f("folder", [ + p("page1", "/page1"), + p("page2", "/page2"), + p("page3", "/page3"), + ]), + ]); + reparentPageOrFolderMutable(pages.folders, "page1", "folder", 2); + const folder = pages.folders.find((folder) => folder.id === "folder"); + expect(folder?.children).toEqual(["page2", "page1", "page3"]); + }); + + test("move page into another folder", () => { + const { f, p, register, pages } = createPages(); + register([ + f("folder1", [p("page1", "/page1"), p("page2", "/page2")]), + f("folder2", [p("page3", "/page3")]), + ]); + reparentPageOrFolderMutable(pages.folders, "page1", "folder2", 1); + const folder1 = pages.folders.find((folder) => folder.id === "folder1"); + const folder2 = pages.folders.find((folder) => folder.id === "folder2"); + expect(folder1?.children).toEqual(["page2"]); + expect(folder2?.children).toEqual(["page3", "page1"]); + }); +}); + describe("getAllChildrenAndSelf", () => { const folders: Array = [ { @@ -353,34 +396,6 @@ describe("deleteFolderWithChildrenMutable", () => { }); }); -describe("filterSelfAndChildren", () => { - const folders = [ - { - id: "1", - name: "1", - slug: "1", - children: ["2"], - }, - { - id: "2", - name: "2", - slug: "2", - children: ["page1"], - }, - { - id: "3", - name: "3", - slug: "3", - children: [], - }, - ]; - - test("filter self and child folders", () => { - const result = filterSelfAndChildren("1", folders); - expect(result).toEqual([folders[2]]); - }); -}); - test("page root scope should rely on editing page", () => { const pages = createDefaultPages({ rootInstanceId: "homeRootId", diff --git a/apps/builder/app/builder/features/pages/page-utils.ts b/apps/builder/app/builder/features/pages/page-utils.ts index 117a9156e1..77e2485444 100644 --- a/apps/builder/app/builder/features/pages/page-utils.ts +++ b/apps/builder/app/builder/features/pages/page-utils.ts @@ -160,6 +160,27 @@ export const registerFolderChildMutable = ( parentFolder?.children.push(id); }; +export const reparentPageOrFolderMutable = ( + folders: Folder[], + pageOrFolderId: string, + newFolderId: string, + newPosition: number +) => { + const prevParent = findParentFolderByChildId(pageOrFolderId, folders); + const nextParent = folders.find((folder) => folder.id === newFolderId); + if (prevParent === undefined || nextParent === undefined) { + return; + } + // if parent is the same, we need to adjust the position + // to account for the removal of the instance. + const prevPosition = prevParent.children.indexOf(pageOrFolderId); + if (prevParent.id === nextParent.id && prevPosition < newPosition) { + newPosition -= 1; + } + prevParent.children.splice(prevPosition, 1); + nextParent.children.splice(newPosition, 0, pageOrFolderId); +}; + /** * Get all child folder ids of the current folder including itself. */ @@ -223,19 +244,6 @@ export const deleteFolderWithChildrenMutable = ( }; }; -/** - * Filter out folders that are children of the current folder or the current folder itself. - */ -export const filterSelfAndChildren = ( - folderId: Folder["id"], - folders: Array -) => { - const folderIds = getAllChildrenAndSelf(folderId, folders, "folder"); - return folders.filter((folder) => { - return folderIds.includes(folder.id) === false; - }); -}; - const $editingPage = computed( [$editingPageId, $pages], (editingPageId, pages) => { diff --git a/apps/builder/app/builder/features/pages/pages.tsx b/apps/builder/app/builder/features/pages/pages.tsx index 3f08f36bcb..4f2d566336 100644 --- a/apps/builder/app/builder/features/pages/pages.tsx +++ b/apps/builder/app/builder/features/pages/pages.tsx @@ -10,6 +10,9 @@ import { TreeNodeLabel, PanelTitle, Separator, + TreeSortableItem, + type TreeDropTarget, + toast, } from "@webstudio-is/design-system"; import { ChevronRightIcon, @@ -26,7 +29,11 @@ import { ExtendedPanel } from "../../shared/extended-sidebar-panel"; import { NewPageSettings, PageSettings } from "./page-settings"; import { $editingPageId, $pages, $selectedPageId } from "~/shared/nano-states"; import { switchPage } from "~/shared/pages"; -import { getAllChildrenAndSelf, reparentOrphansMutable } from "./page-utils"; +import { + getAllChildrenAndSelf, + reparentOrphansMutable, + reparentPageOrFolderMutable, +} from "./page-utils"; import { FolderSettings, NewFolderSettings, @@ -34,9 +41,15 @@ import { } from "./folder-settings"; import { serverSyncStore } from "~/shared/sync"; import { useMount } from "~/shared/hook-utils/use-mount"; -import { ROOT_FOLDER_ID, type Folder, type Page } from "@webstudio-is/sdk"; +import { + isRootFolder, + ROOT_FOLDER_ID, + type Folder, + type Page, +} from "@webstudio-is/sdk"; import { atom, computed } from "nanostores"; import { isPathnamePattern } from "~/builder/shared/url-pattern"; +import { updateWebstudioData } from "~/shared/instance-utils"; const ItemSuffix = ({ isParentSelected, @@ -119,22 +132,67 @@ const $expandedItems = atom(new Set()); type PagesTreeItem = | { id: string; + selector: string[]; level: number; isExpanded?: boolean; type: "page"; page: Page; + isLastChild: boolean; + dropTarget?: TreeDropTarget; } | { id: string; + selector: string[]; level: number; isExpanded?: boolean; type: "folder"; folder: Folder; + isLastChild: boolean; + dropTarget?: TreeDropTarget; }; +type DropTarget = { + parentId: string; + beforeId?: string; + afterId?: string; + indexWithinChildren: number; +}; + +const $dropTarget = atom(); + +const getStoredDropTarget = ( + selector: string[], + dropTarget: TreeDropTarget +): undefined | DropTarget => { + const parentId = selector.at(-dropTarget.parentLevel - 1); + const beforeId = + dropTarget.beforeLevel === undefined + ? undefined + : selector.at(-dropTarget.beforeLevel - 1); + const afterId = + dropTarget.afterLevel === undefined + ? undefined + : selector.at(-dropTarget.afterLevel - 1); + const pages = $pages.get(); + const parentFolder = pages?.folders.find((item) => item.id === parentId); + let indexWithinChildren = 0; + if (parentFolder) { + const beforeIndex = parentFolder.children.indexOf(beforeId ?? ""); + const afterIndex = parentFolder.children.indexOf(afterId ?? ""); + if (beforeIndex > -1) { + indexWithinChildren = beforeIndex; + } else if (afterIndex > -1) { + indexWithinChildren = afterIndex + 1; + } + } + if (parentId) { + return { parentId, beforeId, afterId, indexWithinChildren }; + } +}; + const $flatPagesTree = computed( - [$pages, $expandedItems], - (pagesData, expandedItems) => { + [$pages, $expandedItems, $dropTarget], + (pagesData, expandedItems, dropTarget) => { const flatPagesTree: PagesTreeItem[] = []; if (pagesData === undefined) { return flatPagesTree; @@ -144,9 +202,24 @@ const $flatPagesTree = computed( ); const pages = new Map(pagesData.pages.map((page) => [page.id, page])); pages.set(pagesData.homePage.id, pagesData.homePage); - const traverse = (itemId: string, level = 0) => { + const traverse = (selector: string[], level = 0, isLastChild = false) => { + const [itemId] = selector; + let treeItem: undefined | PagesTreeItem; + let lastTreeItem: undefined | PagesTreeItem; const folder = folders.get(itemId); const page = pages.get(itemId); + if (page) { + treeItem = { + id: itemId, + selector, + level, + type: "page", + page, + isLastChild, + }; + lastTreeItem = treeItem; + flatPagesTree.push(treeItem); + } if (folder) { let isExpanded: undefined | boolean; if (level > 0 && folder.children.length > 0) { @@ -154,41 +227,72 @@ const $flatPagesTree = computed( } // hide root folder if (itemId !== ROOT_FOLDER_ID) { - flatPagesTree.push({ + treeItem = { id: itemId, + selector, level, isExpanded, type: "folder", folder, - }); + isLastChild, + }; + lastTreeItem = treeItem; + flatPagesTree.push(treeItem); } if (level === 0 || isExpanded) { - for (const childId of folder.children) { - traverse(childId, level + 1); + for (let index = 0; index < folder.children.length; index += 1) { + const childId = folder.children[index]; + const isLastChild = index === folder.children.length - 1; + lastTreeItem = traverse( + [childId, ...selector], + level + 1, + isLastChild + ); } } } - if (page) { - flatPagesTree.push({ id: itemId, level, type: "page", page }); + + if (treeItem && dropTarget?.beforeId === itemId) { + treeItem.dropTarget = { + parentLevel: level - 1, + beforeLevel: level, + }; } + if (lastTreeItem && dropTarget?.afterId === itemId) { + lastTreeItem.dropTarget = { + parentLevel: level - 1, + afterLevel: level, + }; + } + return lastTreeItem; }; - traverse(ROOT_FOLDER_ID); + traverse([ROOT_FOLDER_ID]); return flatPagesTree; } ); +const canDrop = (dropTarget: DropTarget, folders: Folder[]) => { + // allow dropping only inside folders + if (isFolder(dropTarget.parentId, folders) === false) { + return false; + } + // forbid dropping in the beginning of root folder + // which is always used by home page + if ( + isRootFolder({ id: dropTarget.parentId }) && + dropTarget.indexWithinChildren === 0 + ) { + return false; + } + return true; +}; + const PagesTree = ({ - onClose, - onCreateNewFolder, - onCreateNewPage, onSelect, selectedPageId, onEdit, editingItemId, }: { - onClose: () => void; - onCreateNewFolder: () => void; - onCreateNewPage: () => void; onSelect: (pageId: string) => void; selectedPageId: string; onEdit: (pageId: string | undefined) => void; @@ -196,6 +300,7 @@ const PagesTree = ({ }) => { const pages = useStore($pages); const flatPagesTree = useStore($flatPagesTree); + const dropTarget = useStore($dropTarget); useReparentOrphans(); if (pages === undefined) { @@ -203,65 +308,77 @@ const PagesTree = ({ } return ( - <> - - -