Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/constrain parent pages #21

Merged
merged 8 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
![sanity-plugin-page-tree-studio](https://github.com/Q42/sanity-plugin-page-tree/assets/15087372/45ba349c-13f5-482d-8490-44183b7b448d)

## Why?

In many example projects for headless CMSs in general, they typically create a post content type with a property like "slug" and serve the post on a route such as `/posts/:slug`. This becomes more complex when dealing with multiple page types and a desire to establish a dynamic page tree.

Consider having three different content types: a home page, an overview page, and a content page, and aiming to create the following URL structure:
Expand All @@ -27,6 +28,7 @@ npm i @q42/sanity-plugin-page-tree
## Usage in Sanity Studio

### Create a page tree config

Create a shared page tree config file and constant to use in your page types and desk structure.

```ts
Expand All @@ -37,21 +39,29 @@ export const pageTreeConfig: PageTreeConfig = {
/* Root page schema type name */
rootSchemaType: 'homePage',
/* Array of all page schema type names */
pageSchemaTypes: ['homePage', 'contentPage'],
pageSchemaTypes: ['homePage', 'contentPage', 'contentChildPage'],
/* Optionally specify which document types can be the parent of a document type.
If no allowed parents are specified for a type, all document types are allowed as a parent for that type.
This config can also be used to prevent certain document types from having any children.*/
allowedParents: {
contentChildPage: ['contentPage'],
},
/* Api version to be used in all underlying Sanity client use */
apiVersion: '2023-12-08',
/* Optionally provide the field name of the title field of your page documents, to be used to generate a slug automatically for example. */
titleFieldName: 'title',
/* Used for showing the full url for a document and linking to it. */
/* optional, otherwise the path is shown */
baseUrl: "https://example.com"
baseUrl: 'https://example.com',
};
```

### Create a page type

The `definePageType` function can be used to wrap your page schema types with the necessary fields (parent (page reference) and slug) for the page tree.

#### Root page (e.g. home page)

Provide the `isRoot` option to the definePageType function to mark the page as a root page. This page won't have a parent and slug field.

```ts
Expand All @@ -68,7 +78,7 @@ const _homePageType = defineType({
});

export const homePageType = definePageType(_homePageType, pageTreeConfig, {
isRoot: true
isRoot: true,
});
```

Expand All @@ -91,6 +101,7 @@ export const contentPageType = definePageType(_contentPageType, pageTreeConfig);
```

### Add page tree to desk structure

Instead of using the default document list for creating and editing pages, you can use the `createPageTreeDocumentList` function to create a custom page tree document list view and add it to your desk structure.

```ts
Expand All @@ -101,19 +112,19 @@ export const structure = (S: StructureBuilder) =>
S.list()
.title('Website')
.items([
S.listItem()
.title('Pages')
.child(
createPageTreeDocumentList(S, {
config: pageTreeConfig,
extendDocumentList: builder => builder.id('pages').title('Pages').apiVersion(pageTreeConfig.apiVersion),
}),
)
]
)
S.listItem()
.title('Pages')
.child(
createPageTreeDocumentList(S, {
config: pageTreeConfig,
extendDocumentList: builder => builder.id('pages').title('Pages').apiVersion(pageTreeConfig.apiVersion),
}),
),
]);
```

### Create internal page links

A link to an internal page is a reference to a page document. The `PageTreeField` component can be used to render a custom page tree input in the reference field.

```ts
Expand All @@ -131,6 +142,7 @@ const linkField = defineField({
```

### Document internationalization

This plugin supports the [@sanity/document-internationalization](https://github.com/sanity-io/document-internationalization) plugin. To enable this, do the setup as documented in the plugin and additionally provide the `documentInternationalization` option to the page tree configuration file.

```ts
Expand All @@ -149,14 +161,14 @@ export const pageTreeConfig: PageTreeConfig = {
};
```


## Usage on the frontend

In order to retrieve the right content for a specifc route, you need to make "catch all" route. How this is implemented depends on the framework you are using. Below are some examples for a Next.JS and React single page appication using react router.

### Regular client

In order to get the page data for the requested path you have to creat a client. Afterwards you can retrieve a list of all the pages with the resolved path and find the correct page metadata.
With this metadata you can retrieve the data of the document yourself.
With this metadata you can retrieve the data of the document yourself.

```ts
import { createPageTreeClient } from '@q42/sanity-plugin-page-tree/client';
Expand All @@ -177,23 +189,24 @@ async function getPageForPath(path: string) {

return page;
}

```


### Next.JS Client
For users using the App Router of Next.JS with Server Components, we can benefit from the "Request Memoization" feature.

For users using the App Router of Next.JS with Server Components, we can benefit from the "Request Memoization" feature.
You can import the dedicated next client:

```ts
import { createNextPageTreeClient } from '@q42/sanity-plugin-page-tree/next';
```
```

This client provides you with some additional helper methods:

This client provides you with some additional helper methods:
- `getPageMetadataById` useful for retrieving the url when you have a reference to a page
- `getPageMetadataByUrl` useful for retrieving the id when you have the path

## Examples

For full examples, see the following projects:

- [Clean studio](./examples/studio)
Expand All @@ -206,14 +219,17 @@ For full examples, see the following projects:
[MIT](LICENSE) © Q42

## Develop & test

For local development and testing you need to link and watch the library and link it to any of the studio example projects.
The basic studio example project, located in `examples/studio`, is a good starting point.

### Link & watch

- Run `npm install` in the root directory to install the dependencies.
- Run `npm run link-watch` in the root directory.

### Example studio

- To run the example studio, go to the `examples/studio` directory.
- Run `npx yalc add @q42/sanity-plugin-page-tree && npx yalc link @q42/sanity-plugin-page-tree && npm install`
- Run `npm run dev`
Expand Down
60 changes: 41 additions & 19 deletions src/components/PageTreeViewItemActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AddIcon } from '@sanity/icons';
import { Button, Flex, Menu, MenuButton, MenuItem } from '@sanity/ui';
import { Box, Button, Flex, Menu, MenuButton, MenuItem, Tooltip, Text } from '@sanity/ui';
import { useEffect, useState } from 'react';
import { useClient, useSchema } from 'sanity';
import { useRouter } from 'sanity/router';
Expand Down Expand Up @@ -48,24 +48,46 @@ export const PageTreeViewItemActions = ({ page, onActionOpen, onActionClose }: P
}
}, [newPage, navigateUrl, resolveIntentLink]);

const menuButtons = config.pageSchemaTypes
.filter(
type =>
type !== config.rootSchemaType &&
(config.allowedParents?.[type] === undefined || config.allowedParents?.[type]?.includes(page._type)),
)
.map(type => (
<MenuItem key={type} onClick={() => onAdd(type)} text={schema.get(type)?.title ?? type} value={type} />
));

const isAddPageButtonDisabled = menuButtons.length === 0;
const tooltipContent = isAddPageButtonDisabled ? (
<Box padding={1}>
<Text muted size={1}>
This page cannot have any child pages.
</Text>
</Box>
) : undefined;

return (
<Flex gap={1} style={{ flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<MenuButton
id="add-page-button"
button={<Button mode="ghost" paddingX={2} paddingY={2} fontSize={1} icon={AddIcon} />}
menu={
<Menu>
{config.pageSchemaTypes
.filter(type => type !== config.rootSchemaType)
.map(type => (
<MenuItem key={type} onClick={() => onAdd(type)} text={schema.get(type)?.title ?? type} value={type} />
))}
</Menu>
}
popover={{ placement: 'bottom' }}
onOpen={onActionOpen}
onClose={onActionClose}
/>
</Flex>
<Tooltip content={tooltipContent} fallbackPlacements={['right', 'left']} placement="top" portal>
<Flex gap={1} style={{ flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<MenuButton
id="add-page-button"
button={
<Button
mode="ghost"
paddingX={2}
paddingY={2}
fontSize={1}
icon={AddIcon}
disabled={isAddPageButtonDisabled}
/>
}
menu={<Menu>{menuButtons}</Menu>}
popover={{ placement: 'bottom' }}
onOpen={onActionOpen}
onClose={onActionClose}
/>
</Flex>
</Tooltip>
);
};
4 changes: 4 additions & 0 deletions src/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ export const getRawPageMetadataQuery = (config: PageTreeConfig) => `*[_type in [
title,
${getLanguageFromConfig(config) ?? ''}
}`;

export const getDocumentTypeQuery = (documentId: string) => `*[_id == "${documentId}"]{
_type
}`;
17 changes: 13 additions & 4 deletions src/schema/definePageType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import { SlugField } from '../components/SlugField';
import { PageTreeConfig } from '../types';
import { slugValidator } from '../validators/slug-validator';

import { allowedParentValidator } from '../validators/parent-validator';

type Options = {
isRoot?: boolean;
fieldsGroupName?: string;
slugSource?: SlugOptions['source'];
};

function getPossibleParentsFromConfig(config: PageTreeConfig, ownType: DocumentDefinition): string[] {
if (config.allowedParents !== undefined && ownType.name in config.allowedParents) {
return config.allowedParents[ownType.name];
}
return config.pageSchemaTypes;
}

export const definePageType = (
type: DocumentDefinition,
config: PageTreeConfig,
Expand All @@ -29,11 +38,11 @@ export const definePageType = (
return defineType({
...type,
title: type.title,
fields: compact([slugSourceField, ...basePageFields(config, options), ...typeFields]),
fields: compact([slugSourceField, ...basePageFields(config, options, type), ...typeFields]),
});
};

const basePageFields = (config: PageTreeConfig, options: Options) => [
const basePageFields = (config: PageTreeConfig, options: Options, ownType: DocumentDefinition) => [
...(!options.isRoot
? [
defineField({
Expand All @@ -58,8 +67,8 @@ const basePageFields = (config: PageTreeConfig, options: Options) => [
name: 'parent',
title: 'Parent page',
type: 'reference',
to: config.pageSchemaTypes.map(type => ({ type })),
validation: Rule => Rule.required(),
to: getPossibleParentsFromConfig(config, ownType).map(type => ({ type })),
validation: Rule => Rule.required().custom(allowedParentValidator(config, ownType.name)),
group: options.fieldsGroupName,
components: {
field: props => PageTreeField({ ...props, config, mode: 'select-parent' }),
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export type PageTreeConfig = {
pageSchemaTypes: string[];
/* Field name of your page documents */
titleFieldName?: string;
/* Optionally specify which document types can be the parent of a document type */
allowedParents?: Record<string, string[]>;
/* Used for creating page link on the editor page */
baseUrl?: string;
/* This plugin supports the document-internationalization plugin. To use it properly, provide the supported languages. */
Expand Down
42 changes: 42 additions & 0 deletions src/validators/parent-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Reference, ValidationContext } from 'sanity';

import { getDocumentTypeQuery } from '../queries';
import { RawPageMetadata, PageTreeConfig, SanityRef } from '../types';

/**
* Validates that the slug is unique within the parent page and therefore that entire the path is unique.
*/
export const allowedParentValidator =
(config: PageTreeConfig, ownType: string) =>
async (selectedParent: Reference | undefined, context: ValidationContext) => {
const allowedParents = config.allowedParents?.[ownType];

if (allowedParents === undefined) {
return true;
}

const parentRef = context.document?.parent as SanityRef | undefined;
if (!parentRef) {
return true;
}

const parentId = parentRef._ref;

if (parentId === undefined) {
return true;
}

const client = context.getClient({ apiVersion: config.apiVersion });
const selectedParentType = (await client.fetch<Pick<RawPageMetadata, '_type'>[]>(getDocumentTypeQuery(parentId)))[0]
?._type;

if (!selectedParentType) {
return 'Unable to check the type of the selected parent.';
}

if (!allowedParents.includes(selectedParentType)) {
return `The parent of type "${selectedParentType}" is not allowed for this type of document.`;
}

return true;
};