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
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2024-11-04 10:02:29 +04:00
1 parent 65a58de7c0
commit 57fc3b9e73
6 files changed
+304 -178

No files matched your search

@@ -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 = ({
</InputErrorsTooltip>
</Grid>
<Grid gap={1}>
<Label htmlFor={fieldIds.parentFolderId}>Parent Folder</Label>
<Select
tabIndex={1}
options={filterSelfAndChildren(folderId, pages.folders)}
getValue={(folder) => folder.id}
getLabel={(folder) => folder.name}
value={pages.folders.find(
({ id }) => id === values.parentFolderId
)}
onChange={(folder) => {
onChange({
field: "parentFolderId",
value: folder.id,
});
}}
/>
</Grid>
<Grid gap={1}>
<Flex align="center" css={{ gap: theme.spacing[3] }}>
<Label htmlFor={fieldIds.slug}>Slug</Label>
@@ -823,26 +823,6 @@ const FormFields = ({
</Grid>
</Grid>
{values.isHomePage === false && (
<Grid gap={1}>
<Label htmlFor={fieldIds.parentFolderId}>Parent Folder</Label>
<Select
options={pages.folders}
getValue={(folder) => folder.id}
getLabel={(folder) => folder.name}
value={pages.folders.find(
({ id }) => id === values.parentFolderId
)}
onChange={(folder) => {
onChange({
field: "parentFolderId",
value: folder.id,
});
}}
/>
</Grid>
)}
{values.isHomePage === false && (
<PathField
errors={errors.path}
@@ -13,9 +13,9 @@ import {
isSlugAvailable,
registerFolderChildMutable,
reparentOrphansMutable,
filterSelfAndChildren,
$pageRootScope,
isPathAvailable,
reparentPageOrFolderMutable,
} from "./page-utils";
import {
$dataSourceVariables,
@@ -284,6 +284,49 @@ describe("registerFolderChildMutable", () => {
});
});
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<Folder> = [
{
@@ -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",
@@ -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<Folder>
) => {
const folderIds = getAllChildrenAndSelf(folderId, folders, "folder");
return folders.filter((folder) => {
return folderIds.includes(folder.id) === false;
});
};
const $editingPage = computed(
[$editingPageId, $pages],
(editingPageId, pages) => {
+237 -92
View File
@@ -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<string>());
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<undefined | DropTarget>();
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 (
<>
<PanelTitle
suffix={
<>
<Tooltip content="New folder" side="bottom">
<Button
onClick={() => onCreateNewFolder()}
aria-label="New folder"
prefix={<NewFolderIcon />}
color="ghost"
/>
</Tooltip>
<Tooltip content="New page" side="bottom">
<Button
onClick={() => onCreateNewPage()}
aria-label="New page"
prefix={<NewPageIcon />}
color="ghost"
/>
</Tooltip>
<Tooltip content="Close panel" side="bottom">
<Button
color="ghost"
prefix={<CrossIcon />}
aria-label="Close panel"
onClick={onClose}
/>
</Tooltip>
</>
}
>
Pages
</PanelTitle>
<Separator />
<Box css={{ overflowY: "auto", flexBasis: 0, flexGrow: 1 }}>
<TreeRoot>
{flatPagesTree.map((item, index) => {
const handleExpand = (isExpanded: boolean, all: boolean) => {
const expandedItems = new Set($expandedItems.get());
const items = all
? getAllChildrenAndSelf(item.id, pages.folders, "folder")
: [item.id];
for (const itemId of items) {
if (isExpanded) {
expandedItems.add(itemId);
} else {
expandedItems.delete(itemId);
}
<Box css={{ overflowY: "auto", flexBasis: 0, flexGrow: 1 }}>
<TreeRoot>
{flatPagesTree.map((item, index) => {
const handleExpand = (isExpanded: boolean, all: boolean) => {
const expandedItems = new Set($expandedItems.get());
const items = all
? getAllChildrenAndSelf(item.id, pages.folders, "folder")
: [item.id];
for (const itemId of items) {
if (isExpanded) {
expandedItems.add(itemId);
} else {
expandedItems.delete(itemId);
}
$expandedItems.set(expandedItems);
};
}
$expandedItems.set(expandedItems);
};
return (
return (
<TreeSortableItem
key={item.id}
level={item.level}
isExpanded={item.isExpanded}
isLastChild={item.isLastChild}
data={item}
canDrag={() => {
// forbid dragging home page
if (item.id === pages.homePage.id) {
toast.error("Home page cannot be moved");
return false;
}
return true;
}}
onExpand={(isExpanded) => handleExpand(isExpanded, false)}
dropTarget={item.dropTarget}
onDropTargetChange={(dropTarget) => {
if (dropTarget) {
const storedDropTarget = getStoredDropTarget(
item.selector,
dropTarget
);
if (
storedDropTarget &&
canDrop(storedDropTarget, pages.folders)
) {
$dropTarget.set(storedDropTarget);
}
} else {
$dropTarget.set(undefined);
}
}}
onDrop={(item) => {
if (dropTarget === undefined) {
return;
}
updateWebstudioData((data) => {
reparentPageOrFolderMutable(
data.pages.folders,
item.id,
dropTarget.parentId,
dropTarget.indexWithinChildren
);
});
$dropTarget.set(undefined);
}}
>
<TreeNode
key={item.id}
level={item.level}
tabbable={index === 0}
isSelected={item.id === selectedPageId}
isHighlighted={dropTarget?.parentId === item.id}
isExpanded={item.isExpanded}
onExpand={handleExpand}
buttonProps={{
@@ -305,11 +422,11 @@ const PagesTree = ({
</TreeNodeLabel>
)}
</TreeNode>
);
})}
</TreeRoot>
</Box>
</>
</TreeSortableItem>
);
})}
</TreeRoot>
</Box>
);
};
@@ -401,28 +518,56 @@ export const PagesPanel = ({ onClose }: { onClose: () => void }) => {
return (
<>
<PagesTree
onClose={onClose}
onCreateNewFolder={() => {
$editingPageId.set(
editingItemId === newFolderId ? undefined : newFolderId
);
}}
onCreateNewPage={() =>
$editingPageId.set(
editingItemId === newPageId ? undefined : newPageId
)
<PanelTitle
suffix={
<>
<Tooltip content="New folder" side="bottom">
<Button
onClick={() => {
$editingPageId.set(
editingItemId === newFolderId ? undefined : newFolderId
);
}}
aria-label="New folder"
prefix={<NewFolderIcon />}
color="ghost"
/>
</Tooltip>
<Tooltip content="New page" side="bottom">
<Button
onClick={() => {
$editingPageId.set(
editingItemId === newPageId ? undefined : newPageId
);
}}
aria-label="New page"
prefix={<NewPageIcon />}
color="ghost"
/>
</Tooltip>
<Tooltip content="Close panel" side="bottom">
<Button
color="ghost"
prefix={<CrossIcon />}
aria-label="Close panel"
onClick={onClose}
/>
</Tooltip>
</>
}
>
Pages
</PanelTitle>
<Separator />
<PagesTree
selectedPageId={currentPageId}
onSelect={(itemId) => {
if (isFolder(itemId, pages.folders)) {
return;
}
switchPage(itemId);
onClose();
}}
selectedPageId={currentPageId}
onEdit={$editingPageId.set}
editingItemId={editingItemId}
onEdit={$editingPageId.set}
/>
<ExtendedPanel isOpen={editingItemId !== undefined}>
+2 -2
View File
@@ -10,8 +10,8 @@
"css-to-ws": "NODE_OPTIONS=--conditions=webstudio css-to-ws",
"build:webflow-presets": "pnpm css-to-ws ./app/shared/copy-paste/plugin-webflow/style-presets.css ./app/shared/copy-paste/plugin-webflow/__generated__/style-presets.ts",
"start": "remix-serve build/server/index.js",
"dev": "remix vite:dev",
"dev:auth": "DEBUG=OAuth2Strategy,ws:* remix vite:dev",
"dev": "pnpm --filter=@webstudio-is/http-client build && remix vite:dev",
"dev:auth": "pnpm --filter=@webstudio-is/http-client build && DEBUG=OAuth2Strategy,ws:* remix vite:dev",
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
},