René Hasert
Fullstack JS/TS developer at Eight Media
This snippet is useful if you want a desk structure that allows columns with a parent page and children pages underneath it. As deep as you would like.
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
export default defineConfig((
// ...
schema: {
types: [
// ...
],
templates: (prev: Template<any, any>[]) => {
return [
...prev,
slugPrefixTpl("yourSchemaType"),
]
},
plugins: [
deskContent
],
actions: (prev, context) => {
switch (context.schemaType) {
case "yourSchemaType":
return [SetSlugAndPublishAction, ...prev];
default:
return prev;
}
},
})
export const deskContent = structureTool({
name: "content",
title: "Content",
defaultDocumentNode,
structure: (S: StructureBuilder, context: StructureResolverContext) => {
return S.list()
.title("Website")
.id("website-id")
.items([
parentChild("yourSchemaType" S, context.documentStore),
]);
},
});
const getAncestorSlugs = async (parentId: string) => {
if (!parentId) return "";
let id: string | null = parentId,
slugs: string[][] = [];
while (Boolean(id)) {
const parent: any = await client.fetch(
groq`*[_id == "${id}"][0]{slug, parent}`,
);
const parentSlug = parent?.slug?.current as string;
const grandParentRef = parent?.parent?._ref;
if (parentSlug) {
slugs.unshift(parentSlug.split("/").filter(Boolean));
}
if (grandParentRef) {
id = grandParentRef;
} else {
return [...new Set(slugs.flat())].join("/");
}
}
};
const getChildren = async (
id: string,
schemaType: string,
): Promise<string[] | []> => {
if (!id) return [];
const children: any = await client.fetch(
groq`*[_type == "${schemaType}" && parent._ref == "${id}"]{_id}`,
);
return children.map((child: { _id: string }) => child._id);
};
const updateParentsChildren = async (
parentId: string,
childId: string,
schemaType: string,
) => {
if (!parentId || !childId) return null;
const parent: any = await client.fetch(
groq`*[_type == "${schemaType}" && _id == "${parentId}"][0]{_id, children}`,
);
if (!parent) return null;
const childIsPresent = parent.children?.some((id: string) => id === childId);
if (childIsPresent) return null;
const oldChildren = parent.children?.filter(Boolean);
const newChildren = parent.children?.length
? [...oldChildren, childId]
: [childId];
const patch = client.patch(parent._id).set({ children: newChildren });
return await patch.commit().then(console.log).catch(console.error);
};
const setNewSlugForChild = async (
id: string,
slugifiedDraftTitle: string,
slugifiedPublishedTitle: string,
schemaType: string,
) => {
if (!id) return;
const children: any = await client.fetch(
groq`*[_type == "${schemaType}" && (_id == "${id}" || _id == "drafts.${id}")]{_id, slug, children}`,
);
if (!children.length) return;
children.forEach(
async (child: {
_id: string;
slug: { current: string };
children: string[];
}) => {
let newSlug = child.slug.current.replace(
slugifiedPublishedTitle,
slugifiedDraftTitle,
);
const patch = client.patch(id).set({
slug: {
current: newSlug,
},
});
if (child.children?.length) {
child.children.forEach((childId: string) =>
setNewSlugForChild(
childId,
slugifiedDraftTitle,
slugifiedPublishedTitle,
schemaType,
),
);
}
return await patch.commit().then(console.log).catch(console.error);
},
);
};
export function SetSlugAndPublishAction(
props: DocumentActionProps,
): DocumentActionDescription {
const doc = props.draft || props.published;
const { patch, publish } = useDocumentOperation(props.id, props.type);
const [isPublishing, setIsPublishing] = useState(false);
useEffect(() => {
// if the isPublishing state was set to true and the draft has changed
// to become `null` the document has been published
if (isPublishing && !props.draft) {
setIsPublishing(false);
}
}, [isPublishing, props.draft]);
return {
disabled: Boolean(publish.disabled),
label: isPublishing ? "Publishing…" : "Publish & Update",
onHandle: async () => {
try {
// This will update the button text
setIsPublishing(true);
// Set slug
// @ts-ignore
let parentsSlug = doc?.slug?.current || "";
// @ts-ignore
const parentId = doc?.parent?._ref || "";
const ancestors = await getAncestorSlugs(parentId);
const schemaType = doc?._type;
parentsSlug = ancestors;
if (parentsSlug) {
parentsSlug += "/";
}
const newSlug = `${parentsSlug}${slugify(doc!.title as string)}`;
patch.execute([
{
set: {
slug: {
_type: "slug",
current: newSlug,
},
},
},
]);
if (parentId && doc?._id && schemaType) {
const childId = doc._id.replace("drafts.", "");
updateParentsChildren(parentId, childId, schemaType);
}
if (doc?._id && schemaType) {
const id = doc._id.replace("drafts.", "");
const children = await getChildren(id, schemaType);
if (children?.length) {
// For each child set new slug, if title has changed
const slugifiedDraftTitle = slugify(props.draft?.title as string);
const slugifiedPublishedTitle = slugify(
props.published?.title as string,
);
if (!isEqual(slugifiedDraftTitle, slugifiedPublishedTitle)) {
children.forEach((childId) =>
setNewSlugForChild(
childId,
slugifiedDraftTitle,
slugifiedPublishedTitle,
schemaType,
),
);
}
// Set children IDs, if new children array is not equal to old one
if (!isEqual(props.draft?.children, children))
patch.execute([
{
set: {
children,
},
},
]);
}
}
// Perform the publish
publish.execute();
// Signal that the action is completed
props.onComplete();
} catch (e) {
console.error(e);
}
},
};
}
export const slugPrefixTpl = (
schemaType: string,
title?: string,
): Template<any, any> => {
return {
id: `${schemaType}-with-initial-slug`,
title: title || `create new ${schemaType}`,
schemaType: schemaType,
parameters: [
{ name: `parentId`, title: `Parent ID`, type: `string` },
{ name: "parentSlug", title: "Parent Slug", type: "string" },
],
value: ({
parentId,
parentSlug,
}: {
parentId: string;
parentSlug: string;
}) => {
return {
parent: { _type: "reference", _ref: parentId },
slug: { _type: "string", current: parentSlug + "/" },
};
},
};
};
export default function parentChild(
schemaType: string = "yourSchemaType",
S: StructureBuilder,
documentStore: DocumentStore,
) {
const filterWithoutParent = `_type == "${schemaType}" && !defined(parent) && !(_id in path("drafts.**"))`;
const filterAll = `_type == "${schemaType}" && !(_id in path("drafts.**"))`;
const query = `*[${filterWithoutParent}]{ _id, title, slug }`;
const queryId = (id: string) =>
`*[${filterAll} && _id == "${id}"][0]{ _id, title, slug, parent, children }`;
const queryGetChildren = (id: string, schemaType: string) =>
`*[_type == "${schemaType}" && (_id == "${id}" || parent._ref == "${id}") && !(_id in path("drafts.**"))]{ _id, title, slug, parent, children }`;
const options: ListenQueryOptions = { apiVersion: `2023-01-01` };
const getChildrenFn = (
id: string,
S: StructureBuilder,
fn: any,
): Observable<ListBuilder | ItemChild> => {
return documentStore
.listenQuery(queryGetChildren(id, schemaType), {}, options)
.pipe(
distinctUntilChanged(isEqual),
switchMap((children) => {
return documentStore.listenQuery(queryId(id), {}, options).pipe(
distinctUntilChanged(isEqual),
map((parent) => {
return S.list()
.menuItems([
parent &&
S.menuItem()
.title("Create new page")
.icon(LuPlus)
.intent({
type: "create",
params: [
{
type: schemaType,
template: `${schemaType}-with-initial-slug`,
},
{
parentId: parent?._id,
parentSlug: parent?.slug?.current,
},
],
}),
])
.title(parent.title)
.items([
parent?._id === id &&
S.listItem()
.id(parent._id)
.title(parent.title)
.icon(icons.document)
.child(
S.document()
.documentId(parent._id)
.schemaType(schemaType)
.views(viewsWithPreview(S, schemaType)),
),
S.divider(),
...children
.filter(({ _id }: { _id: string }) => id !== _id)
.map((child: any) => {
return S.listItem()
.id(child._id)
.title(child.title)
.icon(icons.folder)
.showIcon(true)
.schemaType(schemaType)
.child(
(_id) => fn(_id, S, fn),
);
})
]);
})
);
})
);
};
return S.listItem()
.title("Pagews")
.child(() =>
documentStore.listenQuery(query, {}, options).pipe(
distinctUntilChanged(isEqual),
map((parents) =>
S.list()
.title("Pages")
.menuItems([
S.menuItem()
.title("Add")
.icon(LuPlus)
.intent({ type: "create", params: { type: schemaType } }),
])
.items([
// Create a List Item for all documents
// Useful for searching
S.listItem()
.title("All")
.schemaType(schemaType)
.child(() =>
S.documentList()
.schemaType(schemaType)
.title("All")
.apiVersion(apiVersion)
.filter(filterAll)
// Use this list for displaying from search results
.canHandleIntent(
(intentName, params) =>
intentName === "edit" && params.type === schemaType,
)
.child((id) =>
S.document()
.documentId(id)
.schemaType(schemaType)
.views(viewsWithPreview(S, schemaType)),
),
),
S.divider(),
...parents.map((parent: SanityDocument) => {
return S.listItem()
.id(parent._id)
.title(parent.title)
.schemaType(schemaType)
.child((id) => getChildrenFn(id, S, getChildrenFn));
}),
]),
),
),
);
}
This is a basic desk structure that uses the listenQuery observable to display documents by one schema type. It recursively loops through all the documents and checks if a document has children.
The idea is that all parent pages have their own page, say About us, with a slug: /about-us. Now you want to create a page for a specific person, say James, so you create the page and this will automatically set the slug /about-us/james (upon creation: "Add") and in the desk structure it will be shown underneath the About us page, in a column.
Now, James can also have children pages: /about-us/james/hobbies, /about-us/james/hobbies/cooking and so on... All routes have their own column. How wonderful.
Prerequisites:
- The document schema should have a parent reference field (named "parent"), so the function knows which document is the (first) parent;
- The document schema should also have a hidden field, named "children", which is an array of strings. Those strings are children _ids. Upon publishing that array should be set, as is shown in set-slug-and-publish-action.ts.
- The document schema should also have a slug field, which will be predefined when creating a new page (with the template slugPrefixTemplate);
- It is advisable to use a script that defines a new slug based on the title upon publishing, otherwise you'll have to manually create the slugs and that may be prone to bugs. It is shown in set-slug-and-publish-action.ts
Fullstack JS/TS developer at Eight Media