Skip to content

Commit

Permalink
feat(pathname): implement "source" option and "Generate" button (#65)
Browse files Browse the repository at this point in the history
* feat(pathname): implement "source" option and "Generate" button

* refactor(pathname): wrap i18nOptions in useMemo
  • Loading branch information
marcusforsberg authored Jun 11, 2024
1 parent 7dd9046 commit a09d24f
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/metal-experts-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tinloof/sanity-studio": minor
---

Add support for the `source` field, which when set will render a button to generate the pathname similar to Sanity's `Slug` type. Thanks @marcusforsberg!
21 changes: 21 additions & 0 deletions packages/sanity-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ export default defineType({

Documents with a defined `pathname` field value are now recognized as pages and are automatically grouped into directories in the pages navigator.

Like Sanity's native `slug` type, the `pathname` supports a `source` option which can be used to generate the pathname from another field on the document, eg. the title:

```tsx
import { definePathname } from "@tinloof/sanity-studio";

export default defineType({
type: "document",
name: "modularPage",
fields: [
definePathname({
name: "pathname",
options: {
source: "title",
},
}),
],
});
```

The `source` can also be a function (which can be asynchronous), returning the generated pathname.

### Enabling page creation

Use the `creatablePages` option to define which schema types can be used to create pages.
Expand Down
1 change: 1 addition & 0 deletions packages/sanity-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@sanity/incompatible-plugin": "^1.0.4",
"@sanity/presentation": "^1.12.8",
"@sanity/ui": "^2.1.4",
"@sanity/util": "^3.45.0",
"@tanstack/react-virtual": "^3.4.0",
"@tinloof/sanity-web": "workspace:*",
"lodash": "^4.17.21",
Expand Down
187 changes: 172 additions & 15 deletions packages/sanity-studio/src/components/PathnameFieldComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
import { EditIcon, EyeOpenIcon, FolderIcon, LockIcon } from "@sanity/icons";
import {
EditIcon,
EyeOpenIcon,
FolderIcon,
LockIcon,
RefreshIcon,
} from "@sanity/icons";
import {
PresentationNavigateContextValue,
usePresentationNavigate,
usePresentationParams,
} from "@sanity/presentation";
import { Box, Button, Card, Flex, Stack, Text, TextInput } from "@sanity/ui";
import {
Box,
Button,
Card,
Flex,
Spinner,
Stack,
Text,
TextInput,
} from "@sanity/ui";
import * as PathUtils from "@sanity/util/paths";
import { getDocumentPath, stringToPathname } from "@tinloof/sanity-web";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { FormFieldValidationStatus, set, unset, useFormValue } from "sanity";
import {
FormFieldValidationStatus,
FormPatch,
PatchEvent,
Path,
SanityDocument,
set,
unset,
useFormValue,
} from "sanity";
import { styled } from "styled-components";
import { useDebounce, useDebouncedCallback } from "use-debounce";

import { useAsync } from "../hooks/useAsync";
import { SlugContext, usePathnameContext } from "../hooks/usePathnameContext";
import { usePathnamePrefix } from "../hooks/usePathnamePrefix";
import {
DocumentWithLocale,
PathnameInputProps,
PathnameOptions,
PathnameSourceFn,
} from "../types";

const UnlockButton = styled(Button)`
Expand Down Expand Up @@ -44,11 +72,17 @@ export function PathnameFieldComponent(props: PathnameInputProps): JSX.Element {
const fieldOptions = props.schemaType.options as PathnameOptions | undefined;
const { prefix } = usePathnamePrefix(props);
const folderOptions = fieldOptions?.folder ?? { canUnlock: true };
const i18nOptions = fieldOptions?.i18n ?? {
enabled: false,
defaultLocaleId: undefined,
localizePathname: undefined,
};

const i18nOptions = useMemo(
() =>
fieldOptions?.i18n ?? {
enabled: false,
defaultLocaleId: undefined,
localizePathname: undefined,
},
[fieldOptions]
);

const autoNavigate = fieldOptions?.autoNavigate ?? false;
const document = useFormValue([]) as DocumentWithLocale;
const {
Expand All @@ -62,6 +96,7 @@ export function PathnameFieldComponent(props: PathnameInputProps): JSX.Element {
const slug = segments?.slice(-1)[0] || "";
const [folderLocked, setFolderLocked] = useState(!!folder);
const folderCanUnlock = !readOnly && folderOptions.canUnlock;
const sourceField = fieldOptions?.source;

const navigate = useSafeNavigate();
const preview = useSafePreview();
Expand Down Expand Up @@ -217,6 +252,19 @@ export function PathnameFieldComponent(props: PathnameInputProps): JSX.Element {
{...inputValidationProps}
/>
</Box>
{sourceField && (
<GenerateButton
sourceField={sourceField}
document={document}
onChange={onChange}
folder={folder}
disabled={readOnly}
localizedPathname={localizedPathname}
i18nOptions={i18nOptions}
preview={preview}
navigate={autoNavigate ? debouncedNavigate : undefined}
/>
)}
{!autoNavigate && (
<PreviewButton localizedPathname={localizedPathname || ""} />
)}
Expand All @@ -238,25 +286,44 @@ export function PathnameFieldComponent(props: PathnameInputProps): JSX.Element {
{...inputValidationProps}
/>
</Box>
{sourceField && (
<GenerateButton
sourceField={sourceField}
document={document}
onChange={onChange}
folder={folder}
disabled={readOnly}
localizedPathname={localizedPathname}
i18nOptions={i18nOptions}
preview={preview}
navigate={autoNavigate ? debouncedNavigate : undefined}
/>
)}
{!autoNavigate && (
<PreviewButton localizedPathname={localizedPathname || ""} />
)}
</Flex>
);
}, [
autoNavigate,
debouncedNavigate,
document,
folder,
folderCanUnlock,
folderLocked,
slug,
handleBlur,
i18nOptions,
inputValidationProps,
localizedPathname,
onChange,
preview,
readOnly,
slug,
sourceField,
unlockFolder,
updateFullPath,
updateFinalSegment,
handleBlur,
updateFullPath,
value,
inputValidationProps,
localizedPathname,
folderCanUnlock,
autoNavigate,
]);

return (
Expand Down Expand Up @@ -374,6 +441,96 @@ function PreviewButton({ localizedPathname }: { localizedPathname: string }) {
);
}

function GenerateButton({
sourceField,
document,
onChange,
folder,
disabled,
localizedPathname,
i18nOptions,
preview,
navigate,
}: {
sourceField: string | Path | PathnameSourceFn;
document: DocumentWithLocale;
onChange: (patch: FormPatch | PatchEvent | FormPatch[]) => void;
folder?: string;
disabled?: boolean;
localizedPathname?: string;
i18nOptions?: PathnameOptions["i18n"];
preview?: string | null;
navigate?: PresentationNavigateContextValue;
}) {
const pathnameContext = usePathnameContext();

const updatePathname = useCallback(
(nextPathname: string) => {
const finalValue = [
...(folder ? [folder] : []),
stringToPathname(nextPathname),
]
.filter((part) => typeof part === "string")
.join("/");

runChange({
onChange,
value: finalValue,
document,
i18nOptions,
prevLocalizedPathname: localizedPathname,
preview,
navigate,
});
},
[
document,
folder,
i18nOptions,
localizedPathname,
navigate,
onChange,
preview,
]
);

const [generateState, handleGenerateSlug] = useAsync(() => {
return getNewFromSource(sourceField, document, pathnameContext).then(
(newFromSource) =>
updatePathname(
stringToPathname(newFromSource?.trim() || "", {
allowTrailingSlash: true,
})
)
);
}, [sourceField, pathnameContext, updatePathname]);

const isUpdating = generateState?.status === "pending";

return (
<Button
fontSize={1}
height="100%"
mode="ghost"
tone="default"
icon={isUpdating ? Spinner : RefreshIcon}
aria-label="Generate"
onClick={handleGenerateSlug}
disabled={disabled || isUpdating}
/>
);
}

async function getNewFromSource(
source: string | Path | PathnameSourceFn,
document: SanityDocument,
context: SlugContext
): Promise<string | undefined> {
return typeof source === "function"
? source(document, context)
: (PathUtils.get(document, source) as string | undefined);
}

function useSafeNavigate() {
try {
const navigate = usePresentationNavigate();
Expand Down
62 changes: 62 additions & 0 deletions packages/sanity-studio/src/hooks/useAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copied from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/core/form/inputs/Slug/utils/useAsync.tsx

import { type DependencyList, useCallback, useRef, useState } from "react";

export type AsyncCompleteState<T> = {
status: "complete";
result: T;
};
export type AsyncPendingState = {
status: "pending";
};
export type AsyncErrorState = {
status: "error";
error: Error;
};

export type AsyncState<T> =
| AsyncPendingState
| AsyncCompleteState<T>
| AsyncErrorState;

/**
* Takes an async function and returns a [AsyncState<value>, callback] pair.
* Whenever the callback is invoked, a new AsyncState is returned.
* If the returned callback is called again before the previous callback has settled, the resolution of the previous one will be ignored, thus preventing race conditions.
* @param fn - an async function that returns a value
* @param dependencies - list of dependencies that will return a new [value, callback] pair
*/
export function useAsync<T, U>(
fn: (arg: U) => Promise<T>,
dependencies: DependencyList
): [null | AsyncState<T>, (arg: U) => void] {
const [state, setState] = useState<AsyncState<T> | null>(null);

const lastId = useRef(0);

const wrappedCallback = useCallback(
(arg: U) => {
const asyncId = ++lastId.current;
setState({ status: "pending" });

Promise.resolve()
.then(() => fn(arg))
.then(
(res) => {
if (asyncId === lastId.current) {
setState({ status: "complete", result: res });
}
},
(err) => {
if (asyncId === lastId.current) {
setState({ status: "error", error: err });
}
}
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- this is under control, and enforced by our linter setup
[fn, ...dependencies]
);

return [state, wrappedCallback];
}
10 changes: 9 additions & 1 deletion packages/sanity-studio/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
ObjectDefinition,
ObjectOptions,
ObjectSchemaType,
Path,
SanityDocument,
SlugDefinition,
SlugOptions,
SlugSourceFn,
} from "sanity";
import { ObjectFieldProps, SlugValue } from "sanity";

Expand Down Expand Up @@ -171,7 +173,13 @@ export type PathnamePrefix =
| string
| ((doc: SanityDocument, context: SlugContext) => Promise<string> | string);

export type PathnameOptions = SlugOptions & {
export type PathnameSourceFn = (
document: SanityDocument,
context: SlugContext
) => string | Promise<string>;

export type PathnameOptions = Pick<SlugOptions, "isUnique"> & {
source?: string | Path | PathnameSourceFn;
prefix?: PathnamePrefix;
folder?: {
canUnlock?: boolean;
Expand Down
Loading

0 comments on commit a09d24f

Please sign in to comment.