feat: Folders select in pages (#2763)

Ref  https://github.com/webstudio-is/webstudio/issues/404
## Description

- Folders select in pages
- Path rendered in page contains all slugs from all parent folders

## Steps for reproduction

1. open page settings
2. select a folder
3. see page was moved to a folder
4. see url preview contains the right folder slugs

## Code Review

- [ ] hi @TrySound , I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env.example`
and the `builder/env-check.js` if mandatory
This commit is contained in:
Oleg Isonen authored and GitHub committed 2024-01-16 14:12:01 +01:00
1 parent c0702ba10a
commit febb02e2df
5 files changed
+332 -119

No files matched your search

@@ -4,7 +4,7 @@ import { useStore } from "@nanostores/react";
import { useDebouncedCallback } from "use-debounce";
import { useUnmount } from "react-use";
import slugify from "slugify";
import { Folder, FolderName, FolderSlug, Pages } from "@webstudio-is/sdk";
import { Folder, Pages } from "@webstudio-is/sdk";
import {
theme,
Button,
@@ -35,38 +35,36 @@ import {
findParentFolderByChildId,
cleanupChildRefsMutable,
isSlugUsed,
registerFolderChildMutable,
} from "./page-utils";
import { createRootFolder } from "@webstudio-is/project-build";
import { ROOT_FOLDER_ID, createRootFolder } from "@webstudio-is/project-build";
const fieldDefaultValues = {
name: "Untitled",
slug: "untitled",
parentFolderId: createRootFolder().id,
};
const Values = Folder.pick({ name: true, slug: true }).extend({
parentFolderId: z.string(),
});
const fieldNames = Object.keys(
fieldDefaultValues
) as (keyof typeof fieldDefaultValues)[];
type Values = z.infer<typeof Values>;
type FieldName = (typeof fieldNames)[number];
type Values = typeof fieldDefaultValues;
type FieldName = keyof Values;
type Errors = {
[fieldName in FieldName]?: string[];
};
const FolderValues = z.object({
name: FolderName,
slug: FolderSlug,
});
const fieldDefaultValues = {
name: "Untitled",
slug: "untitled",
parentFolderId: createRootFolder().id,
} satisfies Values;
const fieldNames = Object.keys(fieldDefaultValues) as Array<FieldName>;
const validateValues = (
pages: undefined | Pages,
values: Values,
folderId?: Folder["id"]
): Errors => {
const parsedResult = FolderValues.safeParse(values);
const parsedResult = Values.safeParse(values);
const errors: Errors = {};
if (parsedResult.success === false) {
return parsedResult.error.formErrors.fieldErrors;
@@ -96,7 +94,7 @@ const toFormValues = (
return {
name: folder?.name ?? "",
slug: folder?.slug ?? "",
parentFolderId: parentFolder?.id ?? "root",
parentFolderId: parentFolder?.id ?? ROOT_FOLDER_ID,
};
};
@@ -163,28 +161,26 @@ const FormFields = ({
</Grid>
<Grid gap={1}>
<Label htmlFor={fieldIds.name}>Parent Folder</Label>
<InputErrorsTooltip errors={errors.name}>
<Select
tabIndex={1}
css={{ zIndex: theme.zIndices[1] }}
options={pages.folders.filter(
// Prevent selecting yourself as a parent
({ id }) => folderId !== id
)}
getValue={(folder) => folder.id}
getLabel={(folder) => folder.name}
value={pages.folders.find(
({ id }) => id === values.parentFolderId
)}
onChange={(folder) => {
onChange({
field: "parentFolderId",
value: folder.id,
});
}}
/>
</InputErrorsTooltip>
<Label htmlFor={fieldIds.parentFolderId}>Parent Folder</Label>
<Select
tabIndex={1}
css={{ zIndex: theme.zIndices[1] }}
options={pages.folders.filter(
// Prevent selecting yourself as a parent
({ id }) => folderId !== id
)}
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}>
@@ -254,23 +250,7 @@ export const NewFolderSettings = ({
const handleSubmit = () => {
if (Object.keys(errors).length === 0) {
const folderId = nanoid();
// @todo move to a function
serverSyncStore.createTransaction([$pages], (pages) => {
if (pages === undefined) {
return;
}
pages.folders.push({
id: folderId,
name: values.name,
slug: values.slug,
children: [],
} satisfies Folder);
const parentFolder = pages.folders.find(
({ id }) => id === values.parentFolderId
);
parentFolder?.children.push(folderId);
});
createFolder(folderId, values);
onSuccess(folderId);
}
};
@@ -358,6 +338,24 @@ const NewFolderSettingsView = ({
);
};
const createFolder = (folderId: Folder["id"], values: Values) => {
serverSyncStore.createTransaction([$pages], (pages) => {
if (pages === undefined) {
return;
}
pages.folders.push({
id: folderId,
name: values.name,
slug: values.slug,
children: [],
} satisfies Folder);
const parentFolder = pages.folders.find(
({ id }) => id === values.parentFolderId
);
parentFolder?.children.push(folderId);
});
};
const updateFolder = (folderId: Folder["id"], values: Partial<Values>) => {
serverSyncStore.createTransaction([$pages], (pages) => {
if (pages === undefined) {
@@ -374,13 +372,11 @@ const updateFolder = (folderId: Folder["id"], values: Partial<Values>) => {
folder.slug = values.slug;
}
if (values.parentFolderId !== undefined) {
const newParentFolder = pages.folders.find(
({ id }) => id === values.parentFolderId
registerFolderChildMutable(
pages.folders,
folderId,
values.parentFolderId
);
if (newParentFolder) {
cleanupChildRefsMutable(folderId, pages);
newParentFolder?.children.push(folderId);
}
}
});
};
@@ -391,7 +387,7 @@ const deleteFolder = (folderId: Folder["id"]) => {
if (pages === undefined) {
return;
}
cleanupChildRefsMutable(folderId, pages);
cleanupChildRefsMutable(folderId, pages.folders);
removeByMutable(pages.folders, (folder) => folder.id === folderId);
});
};
@@ -13,8 +13,13 @@ import {
PageTitle,
PagePath,
DataSource,
Folder,
} from "@webstudio-is/sdk";
import { findPageByIdOrPath } from "@webstudio-is/project-build";
import {
ROOT_FOLDER_ID,
createRootFolder,
findPageByIdOrPath,
} from "@webstudio-is/project-build";
import {
theme,
Button,
@@ -31,6 +36,7 @@ import {
ScrollArea,
rawTheme,
Flex,
Select,
} from "@webstudio-is/design-system";
import {
ChevronDoubleLeftIcon,
@@ -74,10 +80,16 @@ import {
parsePathnamePattern,
validatePathnamePattern,
} from "./url-pattern";
import { isRoot } from "./page-utils";
import {
cleanupChildRefsMutable,
compileSlugPath,
findParentFolderByChildId,
registerFolderChildMutable,
} from "./page-utils";
const fieldDefaultValues = {
name: "Untitled",
parentFolderId: createRootFolder().id,
path: "/untitled",
title: "Untitled",
description: "",
@@ -188,9 +200,15 @@ const validateValues = (
return errors;
};
const toFormPage = (page: Page, isHomePage: boolean): Values => {
const toFormValues = (
page: Page,
pages: Pages,
isHomePage: boolean
): Values => {
const parentFolder = findParentFolderByChildId(page.id, pages.folders);
return {
name: page.name,
parentFolderId: parentFolder?.id ?? ROOT_FOLDER_ID,
path: page.path,
title: page.title,
description: page.meta.description ?? fieldDefaultValues.description,
@@ -281,8 +299,14 @@ const FormFields = ({
const fieldIds = useIds(fieldNames);
const assets = useStore($assets);
const pages = useStore($pages);
const dataSourceVariables = useStore($dataSourceVariables);
if (pages === undefined) {
return;
}
const socialImageAsset = assets.get(values.socialImageAssetId);
const faviconAsset = assets.get(pages?.meta?.faviconAssetId ?? "");
const faviconAsset = assets.get(pages.meta?.faviconAssetId ?? "");
const faviconUrl = faviconAsset?.type === "image" ? faviconAsset.name : "";
@@ -296,14 +320,15 @@ const FormFields = ({
const publishedUrl = new URL(`https://${domain}`);
const pathParamNames = parsePathnamePattern(values.path);
const dataSourceVariables = useStore($dataSourceVariables);
const params =
pathVariableId !== undefined
? (dataSourceVariables.get(pathVariableId) as Record<string, string>)
: undefined;
const compiledPath = compilePathnamePattern(values.path ?? "", params ?? {});
const pageDomainAndPath = [publishedUrl.host, compiledPath]
const slug = compileSlugPath(pages.folders, values.parentFolderId);
const pageDomainAndPath = [publishedUrl.host, slug, compiledPath]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/");
@@ -378,6 +403,27 @@ const FormFields = ({
</>
)}
</Grid>
{isFeatureEnabled("folders") && values.isHomePage === false && (
<Grid gap={1}>
<Label htmlFor={fieldIds.parentFolderId}>Parent Folder</Label>
<Select
tabIndex={1}
css={{ zIndex: theme.zIndices[1] }}
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 && (
<Grid gap={1}>
@@ -661,39 +707,8 @@ export const NewPageSettings = ({
const handleSubmit = () => {
if (Object.keys(errors).length === 0) {
const pageId = nanoid();
serverSyncStore.createTransaction(
[$pages, $instances],
(pages, instances) => {
if (pages === undefined) {
return;
}
const rootInstanceId = nanoid();
pages.pages.push({
id: pageId,
name: values.name,
path: values.path,
title: values.title,
rootInstanceId,
meta: {},
});
instances.set(rootInstanceId, {
type: "instance",
id: rootInstanceId,
component: "Body",
children: [],
});
// @todo add parent folder selection
pages.folders.find(isRoot)?.children.push(pageId);
$selectedInstanceSelector.set(undefined);
}
);
createPage(pageId, values);
updatePage(pageId, values);
onSuccess(pageId);
}
};
@@ -783,8 +798,42 @@ const NewPageSettingsView = ({
);
};
const createPage = (pageId: Page["id"], values: Values) => {
serverSyncStore.createTransaction(
[$pages, $instances],
(pages, instances) => {
if (pages === undefined) {
return;
}
const rootInstanceId = nanoid();
pages.pages.push({
id: pageId,
name: values.name,
path: values.path,
title: values.title,
rootInstanceId,
meta: {},
});
instances.set(rootInstanceId, {
type: "instance",
id: rootInstanceId,
component: "Body",
children: [],
});
registerFolderChildMutable(pages.folders, pageId, values.parentFolderId);
$selectedInstanceSelector.set(undefined);
}
);
};
const updatePage = (pageId: Page["id"], values: Partial<Values>) => {
const updatePageMutable = (page: Page, values: Partial<Values>) => {
const updatePageMutable = (
page: Page,
values: Partial<Values>,
folders: Array<Folder>
) => {
if (values.name !== undefined) {
page.name = values.name;
}
@@ -810,6 +859,10 @@ const updatePage = (pageId: Page["id"], values: Partial<Values>) => {
if (values.customMetas !== undefined) {
page.meta.custom = values.customMetas;
}
if (values.parentFolderId !== undefined) {
registerFolderChildMutable(folders, page.id, values.parentFolderId);
}
};
serverSyncStore.createTransaction(
@@ -839,7 +892,7 @@ const updatePage = (pageId: Page["id"], values: Partial<Values>) => {
}
if (pages.homePage.id === pageId) {
updatePageMutable(pages.homePage, values);
updatePageMutable(pages.homePage, values, pages.folders);
}
for (const page of pages.pages) {
@@ -856,7 +909,7 @@ const updatePage = (pageId: Page["id"], values: Partial<Values>) => {
name: "Page params",
});
}
updatePageMutable(page, values);
updatePageMutable(page, values, pages.folders);
}
}
}
@@ -881,6 +934,7 @@ const deletePage = (pageId: Page["id"]) => {
return;
}
removeByMutable(pages.pages, (page) => page.id === pageId);
cleanupChildRefsMutable(pageId, pages.folders);
});
};
@@ -905,14 +959,26 @@ const duplicatePage = (pageId: Page["id"]) => {
slice,
availableDataSources: new Set(),
beforeTransactionEnd: (rootInstanceId, draft) => {
if (draft.pages === undefined) {
return;
}
const newPage = {
...page,
id: newPageId,
rootInstanceId,
name: newName,
path: nameToPath(pages, newName),
};
draft.pages?.pages.push(newPage);
} satisfies Page;
draft.pages.pages.push(newPage);
const currentFolder = findParentFolderByChildId(
pageId,
draft.pages.folders
);
registerFolderChildMutable(
draft.pages.folders,
newPageId,
currentFolder?.id
);
},
});
return newPageId;
@@ -937,7 +1003,7 @@ export const PageSettings = ({
const [unsavedValues, setUnsavedValues] = useState<Partial<Values>>({});
const values: Values = {
...(page ? toFormPage(page, isHomePage) : fieldDefaultValues),
...(page ? toFormValues(page, pages, isHomePage) : fieldDefaultValues),
...unsavedValues,
};
@@ -1,9 +1,11 @@
import { describe, expect, test } from "@jest/globals";
import {
cleanupChildRefsMutable,
compileSlugPath,
findParentFolderByChildId,
isRoot,
isSlugUsed,
registerFolderChildMutable,
reparentOrphansMutable,
toTreeData,
} from "./page-utils";
@@ -239,19 +241,19 @@ describe("reparentOrphansMutable", () => {
describe("cleanupChildRefsMutable", () => {
test("cleanup refs", () => {
const pages = createDefaultPages({
const { folders } = createDefaultPages({
rootInstanceId: "rootInstanceId",
homePageId: "homePageId",
});
pages.folders.push({
folders.push({
id: "folderId",
name: "Folder",
slug: "folder",
children: [],
});
const rootFolder = pages.folders.find(isRoot);
const rootFolder = folders.find(isRoot);
rootFolder?.children.push("folderId");
cleanupChildRefsMutable("folderId", pages);
cleanupChildRefsMutable("folderId", folders);
expect(rootFolder).toEqual({
id: "root",
name: "Root",
@@ -337,3 +339,107 @@ describe("isSlugUsed", () => {
);
});
});
describe("registerFolderChildMutable", () => {
test("register a folder child in the root via fallback", () => {
const { folders } = createDefaultPages({
rootInstanceId: "rootInstanceId",
homePageId: "homePageId",
});
registerFolderChildMutable(folders, "folderId");
const rootFolder = folders.find(isRoot);
expect(rootFolder?.children).toEqual(["homePageId", "folderId"]);
});
test("register a folder child in a provided folder", () => {
const { folders } = createDefaultPages({
rootInstanceId: "rootInstanceId",
homePageId: "homePageId",
});
const folder = {
id: "folderId",
name: "Folder",
slug: "folder",
children: [],
};
folders.push(folder);
registerFolderChildMutable(folders, "folderId2", "folderId");
expect(folder.children).toEqual(["folderId2"]);
});
test("register in a provided folder & cleanup old refs", () => {
const { folders } = createDefaultPages({
rootInstanceId: "rootInstanceId",
homePageId: "homePageId",
});
const folder = {
id: "folderId",
name: "Folder",
slug: "folder",
children: [],
};
folders.push(folder);
const rootFolder = folders.find(isRoot);
registerFolderChildMutable(folders, "folderId", "root");
registerFolderChildMutable(folders, "folderId2", "root");
expect(rootFolder?.children).toEqual([
"homePageId",
"folderId",
"folderId2",
]);
// Moving folderId from root to folderId
registerFolderChildMutable(folders, "folderId2", "folderId");
expect(rootFolder?.children).toEqual(["homePageId", "folderId"]);
expect(folder.children).toEqual(["folderId2"]);
});
});
describe("compileSlugPath", () => {
const { folders } = createDefaultPages({
rootInstanceId: "rootInstanceId",
homePageId: "homePageId",
});
folders.push({
id: "folderId-1",
name: "Folder 1",
slug: "folder-1",
children: ["folderId-1-1"],
});
const rootFolder = folders.find(isRoot);
rootFolder?.children.push("folderId-1");
folders.push({
id: "folderId-1-1",
name: "Folder 1-1",
slug: "folder-1-1",
children: ["folderId-1-1-1"],
});
folders.push({
id: "folderId-1-1-1",
name: "Folder 1-1-1",
slug: "folder-1-1-1",
children: [],
});
test("nesting level 0", () => {
expect(compileSlugPath(folders, "root")).toEqual("");
});
test("nesting level 1", () => {
expect(compileSlugPath(folders, "folderId-1")).toEqual("/folder-1");
});
test("nesting level 2", () => {
expect(compileSlugPath(folders, "folderId-1-1")).toEqual(
"/folder-1/folder-1-1"
);
});
test("nesting level 3", () => {
expect(compileSlugPath(folders, "folderId-1-1-1")).toEqual(
"/folder-1/folder-1-1/folder-1-1-1"
);
});
});
@@ -98,9 +98,9 @@ export const findParentFolderByChildId = (
*/
export const cleanupChildRefsMutable = (
id: Folder["id"] | Page["id"],
pages: Pages
folders: Array<Folder>
) => {
for (const folder of pages.folders) {
for (const folder of folders) {
const index = folder.children.indexOf(id);
if (index !== -1) {
// Not exiting here just to be safe and check all folders even though it should be impossible
@@ -171,3 +171,50 @@ export const isSlugUsed = (
) === false
);
};
/**
* - Register a folder or a page inside children of a given parent folder.
* - Fallback to a root folder.
* - Cleanup any potential references in other folders.
*/
export const registerFolderChildMutable = (
folders: Array<Folder>,
id: Page["id"] | Folder["id"],
// In case we couldn't find the current folder during update for any reason,
// we will always fall back to the root folder.
parentFolderId?: Folder["id"]
) => {
const parentFolder =
folders.find((folder) => folder.id === parentFolderId) ??
folders.find(isRoot);
cleanupChildRefsMutable(id, folders);
parentFolder?.children.push(id);
};
/**
* Get a path from all folder slugs from root to the current folder.
*/
export const compileSlugPath = (
folders: Array<Folder>,
folderId: Folder["id"]
) => {
const foldersMap = new Map<Folder["id"], Folder>();
const childParentMap = new Map<Folder["id"], Folder["id"]>();
for (const folder of folders) {
foldersMap.set(folder.id, folder);
for (const childId of folder.children) {
childParentMap.set(childId, folder.id);
}
}
const slugs = [];
let currentFolderId: undefined | string = folderId;
while (currentFolderId) {
const folder = foldersMap.get(currentFolderId);
if (folder === undefined) {
break;
}
slugs.push(folder.slug);
currentFolderId = childParentMap.get(currentFolderId);
}
return slugs.reverse().join("/");
};
+2 -4
View File
@@ -9,10 +9,8 @@ export const FolderName = z
.string()
.refine((value) => value.trim() !== "", "Can't be empty");
export const FolderSlug = z
const Slug = z
.string()
.refine((slug) => slug !== "", "Can't be empty")
.refine((slug) => slug.includes("/") === false, "Can't contain a /")
.refine(
(path) => /^[-a-z0-9]*$/.test(path),
"Only a-z, 0-9 and - are allowed"
@@ -21,7 +19,7 @@ export const FolderSlug = z
export const Folder = z.object({
id: FolderId,
name: FolderName,
slug: z.string(),
slug: Slug,
children: z.array(z.union([FolderId, PageId])),
});