diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index 24adc2bed..6bea82773 100644 --- a/docs/src/content/pages/collections.mdoc +++ b/docs/src/content/pages/collections.mdoc @@ -107,6 +107,160 @@ testimonials: collection({ `template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries. +### Reader + +`reader` — allows you to provide a custom reader for the collection. +This can be useful when you need to fetch data from an external source or perform custom logic before returning the data. + +### Hook: before save + +`beforeSave` — allows you to execute a function before an entry is saved by Keystatic. This hook can be useful when you need +to perform complex validations, transformations, or other logic before the data is stored. + +```typescript +beforeSave(args: BeforeSaveCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeSaveCallbackArgs that contains the following information: + +```typescript +type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; +``` + +`item`: Contains the information of the entry being saved.\ +`action`: Indicates the type of operation being performed.\ +`keystaticSave`: Function that executes the normal saving of Keystatic. This function handles success and error by +default and returns the result; therefore, it's not necessary to throw an error when `keystaticSave` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the changes were saved or not:\ +`true`: Indicates that the save was successful. It can return true without using `keystaticSave()` to simulate a valid +save without the keystatic actually storing the changes.\ +`false`: Indicates that the save process failed. Ideally, it should only return false if `keystaticSave()` was used and +returned false, since `keystaticSave()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +testimonials: collection({ + label: 'Testimonials', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + }, + slugField: 'title', + beforeSave: async ({item, keystaticSave, action}: BeforeSaveCallbackArgs) => { + // Send data to external service + const res = await fetch(`https://api.example.com/${action}/testimonial`, { + method: 'POST', + body: JSON.stringify(item), + }); + + if (res.status !== 200) + throw new Error('Save failed: Failed to send data to external service'); + + const saveResult = await keystaticSave(); + + if (!saveResult) { + const rollback_action = action === 'create' ? 'delete' : 'update'; + await fetch(`https://api.example.com/${rollback_action}/testimonial`, { + method: 'POST', + body: JSON.stringify({ + ...item, + action: 'rollback' + }), + }); + // keystaticSave() function already handled the error, + // so is not needed to throw an error here + } + + return saveResult; + }, +}), +``` + +### Hook: before delete + +`beforeDelete` — allows you to execute a function before an entry is deleted by Keystatic. This hook can be useful when you need +to perform validations or other logic before the data is deleted. + +```typescript +beforeDelete(args: BeforeDeleteCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeDeleteCallbackArgs that contains the following information: + +```typescript +type BeforeDeleteCallbackArgs = { + basePath: string; + initialFiles: string[]; + keystaticDelete: () => Promise; +}; +``` + +`basePath`: The base path of the collection.\ +`initialFiles`: An array of file paths that will be deleted.\ +`keystaticDelete`: Function that executes the normal deletion of Keystatic. This function handles success and error +by default and returns the result; therefore, it's not necessary to throw an error when `keystaticDelete` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the deletion was successful or not:\ +`true`: Indicates that the deletion was successful. You can return true without using `keystaticDelete()` to simulate a valid +deletion without the keystatic actually deleting the entry.\ +`false`: Indicates that the deletion process failed. Ideally, it should only return false if `keystaticDelete()` was used and +returned false, since `keystaticDelete()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +testimonials: collection({ + label: 'Testimonials', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + }, + slugField: 'title', + beforeDelete: async ({basePath, initialFiles, keystaticDelete}: BeforeDeleteCallbackArgs) => { + // Send data to external service + const res = await fetch('https://api.example.com/delete/testimonial', { + method: 'POST', + body: JSON.stringify({basePath}), + }); + + if (res.status !== 200) + throw new Error('Delete failed: Failed to delete data from external service'); + + const deleteResult = await keystaticDelete(); + + if (!deleteResult) { + await fetch('https://api.example.com/delete/testimonial', { + method: 'POST', + body: JSON.stringify({ + basePath, + action: 'rollback' + }), + }); + // keystaticDelete() function already handled the error, + // so is not needed to throw an error here + } + + return deleteResult; + }, +}), +``` + --- ## Type signature diff --git a/docs/src/content/pages/singletons.mdoc b/docs/src/content/pages/singletons.mdoc index 692073c89..2af55e5b4 100644 --- a/docs/src/content/pages/singletons.mdoc +++ b/docs/src/content/pages/singletons.mdoc @@ -64,6 +64,69 @@ Learn more about the `path` option on the [Content Organisation](/docs/content-o `schema` — defines the fields that the singleton should have. +### Reader + +`reader` — allows you to provide a custom reader for the singleton. +This can be useful when you need to fetch data from an external source or perform custom logic before returning the data. + +### Hook: before save + +`beforeSave` — allows you to execute a function before a singleton is saved by Keystatic. This hook can be useful when you need +to perform complex validations, transformations, or other logic before the data is stored. + +```typescript +beforeSave(args: BeforeSaveCallbackArgs): Promise; +``` + +**Parameters** + +The hook receives a single object of type BeforeSaveCallbackArgs that contains the following information: + +```typescript +type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; +``` + +`item`: Contains the information of the entry being saved.\ +`action`: Indicates the type of operation being performed.\ +`keystaticSave`: Function that executes the normal saving of Keystatic. This function handles success and error by +default and returns the result; therefore, it's not necessary to throw an error when `keystaticSave` has been invoked. + +**Return** + +The return value must be of type `Promise`, and will indicate whether the changes were saved or not:\ +`true`: Indicates that the save was successful. It can return true without using `keystaticSave()` to simulate a valid +save without the keystatic actually storing the changes.\ +`false`: Indicates that the save process failed. Ideally, it should only return false if `keystaticSave()` was used and +returned false, since `keystaticSave()` will handle the error internally. In any other case, the correct approach would +be throw an Error instance to display the correct error message to the user, if no error is thrown, the default error message +will be displayed. + +**Example** + +```typescript +settings: singleton({ + label: 'Settings', + schema: { + weatherApiKey: fields.text({ label: 'Weather API Key' }), + }, + beforeSave: async ({item, keystaticSave, action}: BeforeSaveCallbackArgs) => { + // Check if the API key is valid + const res = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${item.weatherApiKey}`); + + if (res.status === 401) + throw new Error('Save failed: Invalid API key'); + else if (res.status !== 200) + throw new Error('Save failed: Failed to validate API key'); + + return keystaticSave(); + }, +}), +``` + --- ## Type signature diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 2d086dd03..3f30e8f0a 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -63,7 +63,7 @@ import { notFound } from './not-found'; import { fetchBlob } from './useItemData'; import { loadDataFile } from './required-files'; import { parseProps } from '../form/parse-props'; -import { useData } from './useData'; +import { DataState, useData } from './useData'; type CollectionPageProps = { collection: string; @@ -218,6 +218,8 @@ function CollectionPageHeader(props: { type CollectionPageContentProps = CollectionPageProps & { searchTerm: string }; function CollectionPageContent(props: CollectionPageContentProps) { const trees = useTree(); + const collectionConfig = props.config.collections?.[props.collection]; + const hasCustomReader = collectionConfig?.reader != undefined; const tree = trees.merged.kind === 'loaded' @@ -253,7 +255,7 @@ function CollectionPageContent(props: CollectionPageContentProps) { ); } - if (!tree) { + if (!tree && !hasCustomReader) { return ( ; } +function CustomReaderEmptyState({ + basePath, + collection, + readerEntries, +}: { + basePath: string; + collection: string; + readerEntries: DataState<[]>; +}) { + if (readerEntries.kind === 'loading') { + return ( + + + + ); + } + + if ( + readerEntries.kind === 'loaded' && + readerEntries.data && + readerEntries.data.length === 0 + ) { + return ( + + There aren't any entries yet.{' '} + + Create the first entry + {' '} + to see it here. + + } + /> + ); + } + + return ( + + ); +} + const SLUG = '@@slug'; const STATUS = '@@status'; @@ -299,14 +361,41 @@ function CollectionTable( column: SLUG, direction: 'ascending', }); + + const collection = props.config.collections![props.collection]!; + let hideStatusColumn = - isLocalMode || currentBranch === repoInfo?.defaultBranch; + isLocalMode || + currentBranch === repoInfo?.defaultBranch || + !!collection.reader; const baseCommit = useBaseCommit(); - const collection = props.config.collections![props.collection]!; + const readerEntries = useData( + useCallback(async () => { + if (!collection.reader) + return null; + + let entriesSlug: string[] = []; + + try { + entriesSlug = await collection.reader.list(); + } catch { + return null; + } + + return entriesSlug.map((slug: string) => ({ + name: slug, + status: 'Unchanged' as const, + sha: '', + })); + }, [collection.reader]) + ); const entriesWithStatus = useMemo(() => { + if (readerEntries.kind === 'loaded' && readerEntries.data) { + return readerEntries.data; + } const defaultEntries = new Map( getEntriesInCollectionWithTreeKey( props.config, @@ -329,10 +418,18 @@ function CollectionTable( sha: entry.sha, }; }); - }, [props.collection, props.config, props.trees]); + }, [props.collection, props.config, props.trees, readerEntries]); const mainFiles = useData( useCallback(async () => { + if (collection.reader) { + const entries = await collection.reader.all(); + const parsedEntries = new Map>(); + for (const item of entries) { + parsedEntries.set(item.slug, item.entry as Record); + } + return parsedEntries; + } if (!collection.columns?.length) return undefined; const formatInfo = getCollectionFormat(props.config, props.collection); const entries = await Promise.all( @@ -487,6 +584,17 @@ function CollectionTable( ]; }, [collection, hideStatusColumn]); + if ( + collection.reader && + (readerEntries.kind !== 'loaded' || readerEntries.data?.length === 0) + ) { + return CustomReaderEmptyState({ + readerEntries: readerEntries as DataState<[]>, + basePath: props.basePath, + collection: props.collection, + }); + } + return ( { @@ -432,6 +433,7 @@ function LocalItemPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); useEffect(() => { @@ -885,17 +887,52 @@ function ItemPageOuterWrapper(props: ItemPageWrapperProps) { }, [collectionConfig, props.collection, props.config, props.itemSlug]) ); - const itemData = useItemData({ - config: props.config, - dirpath: getCollectionItemPath( - props.config, - props.collection, - props.itemSlug - ), - schema: collectionConfig.schema, - format, - slug: slugInfo, - }); + const itemData = collectionConfig.reader + ? useData( + useCallback(async () => { + let entryData: Record | null = null; + + try { + entryData = await collectionConfig.reader!.read(props.itemSlug); + } catch { + return 'not-found' as const; + } + + if (!entryData) { + return 'not-found' as const; + } + + const initialFiles: string[] = []; + const initialState: Record = {}; + + initialState[collectionConfig.slugField] = { + name: props.itemSlug, + slug: props.itemSlug, + }; + + for (const [key, value] of Object.entries(entryData)) { + if (key !== collectionConfig.slugField) { + initialState[key] = value; + } + } + return { + initialState, + initialFiles, + localTreeKey: '', + }; + }, [collectionConfig, props.itemSlug]) + ) + : useItemData({ + config: props.config, + dirpath: getCollectionItemPath( + props.config, + props.collection, + props.itemSlug + ), + schema: collectionConfig.schema, + format, + slug: slugInfo, + }); const currentBranch = useCurrentBranch(); diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index e64eef51e..fc3835cb4 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -436,6 +436,7 @@ function LocalSingletonPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: undefined, + beforeSave: singletonConfig.beforeSave, }); const update = useEventCallback(_update); @@ -490,6 +491,7 @@ function CollabSingletonPage( format: formatInfo, currentLocalTreeKey: localTreeKey, slug: undefined, + beforeSave: singletonConfig.beforeSave, }); const update = useEventCallback(_update); @@ -564,13 +566,42 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) { }, [dirpath, format, props.singleton, singletonConfig.schema]) ); - const itemData = useItemData({ - config: props.config, - dirpath, - schema: singletonConfig.schema, - format, - slug: undefined, - }); + const itemData = singletonConfig.reader + ? useData( + useCallback(async () => { + let data: any; + + try { + data = await singletonConfig.reader!.read(); + } catch { + return 'not-found' as const; + } + + if (!data) { + return 'not-found' as const; + } + + const initialFiles: string[] = []; + const initialState: Record = {}; + + for (const [key, value] of Object.entries(data)) { + initialState[key] = value; + } + + return { + initialState, + initialFiles, + localTreeKey: '', + }; + }, [singletonConfig]) + ) + : useItemData({ + config: props.config, + dirpath, + schema: singletonConfig.schema, + format, + slug: undefined, + }); const currentBranch = useCurrentBranch(); const key = `${currentBranch}/${props.singleton}`; diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 0c2a9d8a8..3417b347f 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -289,6 +289,7 @@ function CreateItemLocal(props: { format: formatInfo, currentLocalTreeKey: undefined, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); const createItem = useEventCallback(_createItem); @@ -382,6 +383,7 @@ function CreateItemCollab(props: { format: formatInfo, currentLocalTreeKey: undefined, slug: { field: collectionConfig.slugField, value: slug }, + beforeSave: collectionConfig.beforeSave, }); const createItem = useEventCallback(_createItem); diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 4ca1ee7b9..91ee2a82c 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -1,6 +1,12 @@ import { gql } from '@ts-gql/tag/no-transform'; import { assert } from 'emery'; -import { useContext, useState } from 'react'; +import { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { ComponentSchema, fields } from '../form/api'; import { dump } from 'js-yaml'; @@ -29,6 +35,7 @@ import { createUrqlClient } from './provider'; import { serializeProps } from '../form/serialize-props'; import { scopeEntriesWithPathPrefix } from './shell/path-prefix'; import { base64Encode } from '#base64'; +import { BeforeDeleteCallback, BeforeSaveCallback } from '../config'; const textEncoder = new TextEncoder(); @@ -115,6 +122,7 @@ export function useUpsertItem(args: { currentLocalTreeKey: string | undefined; basePath: string; slug: { value: string; field: string } | undefined; + beforeSave?: BeforeSaveCallback; }) { const [state, setState] = useState< | { kind: 'idle' } @@ -126,6 +134,7 @@ export function useUpsertItem(args: { >({ kind: 'idle', }); + const stateRef = useRef(state); const baseCommit = useBaseCommit(); const currentBranch = useCurrentBranch(); const setTreeSha = useSetTreeSha(); @@ -134,8 +143,11 @@ export function useUpsertItem(args: { const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); - return [ - state, + useEffect(() => { + stateRef.current = state; + }, [state]); + + const doSave = useCallback( async (override?: { sha: string; branch: string }): Promise => { try { const unscopedTree = @@ -237,8 +249,6 @@ export function useUpsertItem(args: { return false; } if (gqlError.type === 'STALE_DATA') { - // we don't want this to go into the cache yet - // so we create a new client just for this const refData = await createUrqlClient(args.config) .query(FetchRef, { owner: repoInfo.owner, @@ -332,6 +342,50 @@ export function useUpsertItem(args: { return false; } }, + [ + args.config, + args.schema, + args.format, + args.state, + args.slug, + args.basePath, + args.initialFiles, + args.currentLocalTreeKey, + unscopedTreeData, + repoInfo, + appSlug, + currentBranch, + baseCommit, + mutate, + setTreeSha, + ] + ); + + return [ + state, + async (override?: { sha: string; branch: string }): Promise => { + if (args.beforeSave) { + try { + setState({ kind: 'loading' }); + const hasUpdated = await args.beforeSave({ + item: args.state as Record, + action: args.initialFiles === undefined ? 'create' : 'update', + keystaticSave: async () => doSave(override), + }); + if (hasUpdated) { + stateRef.current.kind === 'loading' && setState({ kind: 'updated' }); + return true; + } else { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: new Error('Save failed') }); + return false; + } + } catch (error) { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: error as Error }); + return false; + } + } + return doSave(override); + }, () => { setState({ kind: 'idle' }); }, @@ -362,6 +416,8 @@ export function useDeleteItem(args: { basePath: string; initialFiles: string[]; storage: Config['storage']; + beforeDelete?: BeforeDeleteCallback; + collectionName?: string; }) { const [state, setState] = useState< | { kind: 'idle' } @@ -372,6 +428,7 @@ export function useDeleteItem(args: { >({ kind: 'idle', }); + const stateRef = useRef(state); const baseCommit = useBaseCommit(); const currentBranch = useCurrentBranch(); @@ -380,92 +437,132 @@ export function useDeleteItem(args: { const repoInfo = useRepoInfo(); const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); + + useEffect(() => { + stateRef.current = state; + }, [state]); - return [ - state, - async () => { - try { - const unscopedTree = - unscopedTreeData.kind === 'loaded' - ? unscopedTreeData.data.tree - : undefined; - if (!unscopedTree) return false; + const doDelete = useCallback(async (): Promise => { + try { + const unscopedTree = + unscopedTreeData.kind === 'loaded' + ? unscopedTreeData.data.tree + : undefined; + if (!unscopedTree) return false; + if ( + args.storage.kind === 'github' && + repoInfo && + !repoInfo.hasWritePermission && + appSlug?.value + ) { + setState({ kind: 'needs-fork' }); + return false; + } + setState({ kind: 'loading' }); + const deletions = args.initialFiles.map( + x => (getPathPrefix(args.storage) ?? '') + x + ); + const updatedTree = await updateTreeWithChanges(unscopedTree, { + additions: [], + deletions, + }); + await hydrateTreeCacheWithEntries(updatedTree.entries); + if (args.storage.kind === 'github' || args.storage.kind === 'cloud') { + if (!repoInfo) { + throw new Error('Repo info not loaded'); + } + const { error } = await mutate({ + input: { + branch: { + repositoryNameWithOwner: `${repoInfo.owner}/${repoInfo.name}`, + branchName: currentBranch, + }, + message: { headline: `Delete ${args.basePath}` }, + expectedHeadOid: baseCommit, + fileChanges: { + deletions: deletions.map(path => ({ path })), + }, + }, + }); if ( - args.storage.kind === 'github' && - repoInfo && - !repoInfo.hasWritePermission && - appSlug?.value + error?.graphQLErrors.some( + err => + 'type' in err && + err.type === 'FORBIDDEN' && + err.message === 'Resource not accessible by integration' + ) ) { - setState({ kind: 'needs-fork' }); - return false; + throw new Error( + `The GitHub App is unable to commit to the repository. Please ensure that the Keystatic GitHub App is installed in the GitHub repository ${repoInfo.owner}/${repoInfo.name}` + ); } - setState({ kind: 'loading' }); - const deletions = args.initialFiles.map( - x => (getPathPrefix(args.storage) ?? '') + x - ); - const updatedTree = await updateTreeWithChanges(unscopedTree, { - additions: [], - deletions, + if (error) { + throw error; + } + setState({ kind: 'updated' }); + return true; + } else { + const res = await fetch('/api/keystatic/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'no-cors': '1', + }, + body: JSON.stringify({ + additions: [], + deletions: deletions.map(path => ({ path })), + }), }); - await hydrateTreeCacheWithEntries(updatedTree.entries); - if (args.storage.kind === 'github' || args.storage.kind === 'cloud') { - if (!repoInfo) { - throw new Error('Repo info not loaded'); - } - const { error } = await mutate({ - input: { - branch: { - repositoryNameWithOwner: `${repoInfo.owner}/${repoInfo.name}`, - branchName: currentBranch, - }, - message: { headline: `Delete ${args.basePath}` }, - expectedHeadOid: baseCommit, - fileChanges: { - deletions: deletions.map(path => ({ path })), - }, - }, - }); - if ( - error?.graphQLErrors.some( - err => - 'type' in err && - err.type === 'FORBIDDEN' && - err.message === 'Resource not accessible by integration' - ) - ) { - throw new Error( - `The GitHub App is unable to commit to the repository. Please ensure that the Keystatic GitHub App is installed in the GitHub repository ${repoInfo.owner}/${repoInfo.name}` - ); - } - if (error) { - throw error; - } - setState({ kind: 'updated' }); - return true; - } else { - const res = await fetch('/api/keystatic/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'no-cors': '1', - }, - body: JSON.stringify({ - additions: [], - deletions: deletions.map(path => ({ path })), - }), + if (!res.ok) { + throw new Error(await res.text()); + } + const newTree: TreeEntry[] = await res.json(); + const { tree } = await hydrateTreeCacheWithEntries(newTree); + setTreeSha(await treeSha(tree)); + setState({ kind: 'updated' }); + return true; + } + } catch (err) { + setState({ kind: 'error', error: err as Error }); + return false; + } + }, [ + args.storage, + args.basePath, + args.initialFiles, + unscopedTreeData, + repoInfo, + appSlug, + currentBranch, + baseCommit, + mutate, + setTreeSha, + ]); + + return [ + state, + async () => { + if (args.beforeDelete) { + try { + setState({ kind: 'loading' }); + const wasDeleted = await args.beforeDelete({ + basePath: args.basePath, + initialFiles: args.initialFiles, + keystaticDelete: async () => doDelete(), }); - if (!res.ok) { - throw new Error(await res.text()); + if (wasDeleted) { + stateRef.current.kind === 'loading' && setState({ kind: 'updated' }); + return true; + } else { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: new Error('Delete failed') }); + return false; } - const newTree: TreeEntry[] = await res.json(); - const { tree } = await hydrateTreeCacheWithEntries(newTree); - setTreeSha(await treeSha(tree)); - setState({ kind: 'updated' }); - return true; + } catch (error) { + stateRef.current.kind === 'loading' && setState({ kind: 'error', error: error as Error }); + return false; } - } catch (err) { - setState({ kind: 'error', error: err as Error }); } + return doDelete(); }, () => { setState({ kind: 'idle' }); diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..d550e71f8 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -4,6 +4,7 @@ import { ReactElement } from 'react'; import { ComponentSchema, FormField, SlugFormField } from './form/api'; import type { Locale } from './app/l10n/locales'; import { RepoConfig } from './app/repo-config'; +import { CollectionReader, SingletonReader } from './reader/generic'; // Common // ---------------------------------------------------------------------------- @@ -17,6 +18,46 @@ export type Format = }; export type EntryLayout = 'content' | 'form'; export type Glob = '*' | '**'; + +export type UseUpsertItemArgs = { + state: unknown; + initialFiles: string[] | undefined; + schema: Record; + config: Config; + format: FormatInfo; + currentLocalTreeKey: string | undefined; + basePath: string; + slug: { value: string; field: string } | undefined; +}; + +export type FormatInfo = { + data: DataFormat; + contentField?: { + path: string[]; + contentExtension: string; + }; +}; + +export type BeforeSaveCallbackArgs = { + item: Record; + action: 'create' | 'update'; + keystaticSave: () => Promise; +}; + +export type BeforeDeleteCallbackArgs = { + basePath: string; + initialFiles: string[]; + keystaticDelete: () => Promise; +}; + +export type BeforeSaveCallback = ( + args: BeforeSaveCallbackArgs +) => Promise; + +export type BeforeDeleteCallback = ( + args: BeforeDeleteCallbackArgs +) => Promise; + export type Collection< Schema extends Record, SlugField extends string, @@ -31,6 +72,9 @@ export type Collection< parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; schema: Schema; + beforeSave?: BeforeSaveCallback; + beforeDelete?: BeforeDeleteCallback; + reader?: CollectionReader; }; export type Singleton> = { @@ -40,6 +84,8 @@ export type Singleton> = { format?: Format; previewUrl?: string; schema: Schema; + beforeSave?: BeforeSaveCallback; + reader?: SingletonReader; }; type CommonConfig = { diff --git a/packages/keystatic/src/reader/generic.ts b/packages/keystatic/src/reader/generic.ts index 9d6d4ba39..5733d9570 100644 --- a/packages/keystatic/src/reader/generic.ts +++ b/packages/keystatic/src/reader/generic.ts @@ -261,6 +261,10 @@ export function collectionReader( config: Config, fsReader: MinimalFs ): CollectionReader { + if (config.collections![collection].reader) { + return config.collections![collection].reader; + } + const formatInfo = getCollectionFormat(config, collection); const collectionPath = getCollectionPath(config, collection); const collectionConfig = config.collections![collection]; @@ -405,6 +409,10 @@ export function singletonReader( config: Config, fsReader: MinimalFs ): SingletonReader { + if (config.singletons![singleton].reader) { + return config.singletons![singleton].reader; + } + const formatInfo = getSingletonFormat(config, singleton); const singletonPath = getSingletonPath(config, singleton); const schema = fields.object(config.singletons![singleton].schema); diff --git a/packages/keystatic/test/reader.test.tsx b/packages/keystatic/test/reader.test.tsx index c5a674ddf..f49a07c55 100644 --- a/packages/keystatic/test/reader.test.tsx +++ b/packages/keystatic/test/reader.test.tsx @@ -307,3 +307,137 @@ test('errors', async () => { text: Must be a string] `); }); + +const customReaderPosts = [ + { + slug: 'custom-post-1', + entry: { + title: 'Custom Post 1', + publishDate: '2024-01-01', + heroImage: 'image1.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 1' }] }], + authors: [ + { + name: 'Author 1', + bio: [{ type: 'paragraph', children: [{ text: 'Bio 1' }] }], + }, + ], + }, + }, + { + slug: 'custom-post-2', + entry: { + title: 'Custom Post 2', + publishDate: '2024-02-01', + heroImage: 'image2.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 2' }] }], + authors: [], + }, + }, +]; + +const customReaderConfig = config({ + storage: { kind: 'local' }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: 'posts/*', + reader: (() => { + const data = customReaderPosts; + return { + read: async (slug: string, ..._: any[]) => { + const entry = data.find((item: any) => item.slug === slug); + return entry?.entry ?? null; + }, + readOrThrow: async (slug: string, ..._: any[]) => { + const entry = data.find((item: any) => item.slug === slug); + if (!entry) { + throw new Error( + `Entry "${slug}" not found in collection "posts"` + ); + } + return entry.entry; + }, + all: async () => data, + list: async () => data.map((item: any) => item.slug), + }; + })() as any, + schema: { + title: fields.slug({ name: { label: 'Title' } }), + publishDate: fields.date({ label: 'Publish Date' }), + heroImage: fields.image({ label: 'Hero Image' }), + content: fields.document({ + label: 'Content', + formatting: true, + dividers: true, + links: true, + }), + authors: fields.array( + fields.object({ + name: fields.text({ label: 'Name' }), + bio: fields.document({ + label: 'Bio', + formatting: true, + dividers: true, + links: true, + }), + }), + { label: 'Authors', itemLabel: props => props.fields.name.value } + ), + }, + }), + }, +}); + +test('custom reader list', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.list(); + expect(result).toEqual(['custom-post-1', 'custom-post-2']); +}); + +test('custom reader read', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.read('custom-post-1'); + expect(result).toEqual({ + title: 'Custom Post 1', + publishDate: '2024-01-01', + heroImage: 'image1.png', + content: [{ type: 'paragraph', children: [{ text: 'Content 1' }] }], + authors: [{ name: 'Author 1', bio: [{ type: 'paragraph', children: [{ text: 'Bio 1' }] }] }], + }); +}); + +test('custom reader readOrThrow throws for non-existent', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + await expect( + reader.collections.posts.readOrThrow('non-existent') + ).rejects.toThrow('Entry "non-existent" not found in collection "posts"'); +}); + +test('custom reader all', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.all(); + expect(result).toEqual(customReaderPosts); +}); + +test('custom reader read returns null for non-existent', async () => { + const reader = createReader( + path.join(pkgDir, 'test-data'), + customReaderConfig + ); + const result = await reader.collections.posts.read('non-existent'); + expect(result).toBeNull(); +});