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 = ({
-
-
-
-
diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx
index 6ea80400e1..a1dc36800b 100644
--- a/apps/builder/app/builder/features/pages/page-settings.tsx
+++ b/apps/builder/app/builder/features/pages/page-settings.tsx
@@ -823,26 +823,6 @@ const FormFields = ({
- {values.isHomePage === false && (
-
-
-
- )}
-
{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 (
- <>
-
-
-
-
-
-
- }
- aria-label="Close panel"
- onClick={onClose}
- />
-
- >
- }
- >
- Pages
-
-
-
-
-
- {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);
- }
+
+
+ {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 (
+ {
+ // 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);
+ }}
+ >
)}
- );
- })}
-
-
- >
+
+ );
+ })}
+
+
);
};
@@ -401,28 +518,56 @@ export const PagesPanel = ({ onClose }: { onClose: () => void }) => {
return (
<>
- {
- $editingPageId.set(
- editingItemId === newFolderId ? undefined : newFolderId
- );
- }}
- onCreateNewPage={() =>
- $editingPageId.set(
- editingItemId === newPageId ? undefined : newPageId
- )
+
+
+
+
+
+
+ }
+ aria-label="Close panel"
+ onClick={onClose}
+ />
+
+ >
}
+ >
+ Pages
+
+
+
+ {
- if (isFolder(itemId, pages.folders)) {
- return;
- }
switchPage(itemId);
onClose();
}}
- selectedPageId={currentPageId}
- onEdit={$editingPageId.set}
editingItemId={editingItemId}
+ onEdit={$editingPageId.set}
/>
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 8c64fc8dc0..117b4c4200 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -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"
},