👀 Our most exciting product launch yet 🚀 Join us May 8th for Sanity Connect

Unlimited nesting

By René Hasert

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.

sanity.config.ts

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;
      }
    },
})

desk-content.ts

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),
    ]);
  },
});

set-slug-and-publish-action.ts


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);
      }
    },
  };
}

slug-prefix-template.ts

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 + "/" },
      };
    },
  };
};

parent-child.ts

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

Contributor