mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-07 20:44:05 +09:00
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:
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("/");
|
||||
};
|
||||
@@ -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])),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user