diff --git a/.changeset/fifty-mayflies-matter.md b/.changeset/fifty-mayflies-matter.md new file mode 100644 index 000000000..c008edad8 --- /dev/null +++ b/.changeset/fifty-mayflies-matter.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Added an option to duplicate an existing entry in a collection diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 702b472c9..1dfc63c6e 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -19,6 +19,7 @@ import { Breadcrumbs, Item } from '@keystar/ui/breadcrumbs'; import { Button, ButtonGroup } from '@keystar/ui/button'; import { AlertDialog, Dialog, DialogContainer } from '@keystar/ui/dialog'; import { Icon } from '@keystar/ui/icon'; +import { copyPlusIcon } from '@keystar/ui/icon/icons/copyPlusIcon'; import { externalLinkIcon } from '@keystar/ui/icon/icons/externalLinkIcon'; import { historyIcon } from '@keystar/ui/icon/icons/historyIcon'; import { trash2Icon } from '@keystar/ui/icon/icons/trash2Icon'; @@ -175,10 +176,25 @@ function ItemPage(props: ItemPageProps) { } }; + const onDuplicate = async () => { + let hasUpdated = true; + if (hasChanged) { + hasUpdated = await onUpdate(); + } + + if (hasUpdated) { + router.push( + `${props.basePath}/collection/${encodeURIComponent( + collection + )}/create?duplicate=${slug}` + ); + } + }; + const onUpdate = useCallback(async () => { if (!clientSideValidateProp(schema, state, props.slugInfo)) { setForceValidation(true); - return; + return false; } const slug = getSlugFromState(collectionConfig, state); const hasUpdated = await update(); @@ -189,6 +205,7 @@ function ItemPage(props: ItemPageProps) { )}/item/${encodeURIComponent(slug)}` ); } + return hasUpdated; }, [ collection, collectionConfig, @@ -227,6 +244,7 @@ function ItemPage(props: ItemPageProps) { isLoading={updateResult.kind === 'loading'} hasChanged={hasChanged} onDelete={onDelete} + onDuplicate={onDuplicate} onReset={onReset} onView={onView} /> @@ -347,15 +365,25 @@ function HeaderActions(props: { hasChanged: boolean; isLoading: boolean; onDelete: () => void; + onDuplicate: () => void; onReset: () => void; onView: () => void; }) { - let { config, formID, hasChanged, isLoading, onDelete, onReset, onView } = - props; + let { + config, + formID, + hasChanged, + isLoading, + onDelete, + onDuplicate, + onReset, + onView, + } = props; const isBelowTablet = useMediaQuery(breakpointQueries.below.tablet); const isGithub = isGitHubConfig(config); const stringFormatter = useLocalizedStringFormatter(l10nMessages); const [deleteAlertIsOpen, setDeleteAlertOpen] = useState(false); + const [duplicateAlertIsOpen, setDuplicateAlertOpen] = useState(false); const menuActions = useMemo(() => { type ActionType = { @@ -375,6 +403,11 @@ function HeaderActions(props: { label: 'Delete entry…', // TODO: l10n icon: trash2Icon, }, + { + key: 'duplicate', + label: 'Duplicate entry…', // TODO: l10n + icon: copyPlusIcon, + }, ]; if (isGithub) { items.push({ @@ -436,6 +469,13 @@ function HeaderActions(props: { case 'delete': setDeleteAlertOpen(true); break; + case 'duplicate': + if (hasChanged) { + setDuplicateAlertOpen(true); + } else { + onDuplicate(); + } + break; case 'view': onView(); break; @@ -471,6 +511,20 @@ function HeaderActions(props: { )} + setDuplicateAlertOpen(false)}> + {duplicateAlertIsOpen && ( + + You have unsaved changes. Save this entry to duplicate it. + + )} + ); } diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index c62649c82..0bad48654 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -26,7 +26,7 @@ import { import { CreateBranchDuringUpdateDialog } from './ItemPage'; import l10nMessages from './l10n/index.json'; import { useRouter } from './router'; -import { PageRoot, PageHeader } from './shell/page'; +import { PageRoot, PageHeader, PageBody } from './shell/page'; import { useBaseCommit, useTree } from './shell/data'; import { TreeNode } from './trees'; import { useSlugsInCollection } from './useSlugsInCollection'; @@ -34,13 +34,141 @@ import { ForkRepoDialog } from './fork-repo'; import { useUpsertItem } from './updating'; import { FormForEntry, containerWidthForEntryLayout } from './entry-form'; import { notFound } from './not-found'; +import { useItemData } from './useItemData'; const emptyMap = new Map(); -export function CreateItem(props: { +function CreateItemWrapper(props: { collection: string; config: Config; basePath: string; +}) { + const router = useRouter(); + const duplicateSlug = useMemo(() => { + const url = new URL(router.href, 'http://localhost'); + return url.searchParams.get('duplicate'); + }, [router.href]); + + const collectionConfig = props.config.collections?.[props.collection]; + if (!collectionConfig) notFound(); + const slugsArr = useSlugsInCollection(props.collection); + const format = useMemo( + () => getCollectionFormat(props.config, props.collection), + [props.config, props.collection] + ); + + const slug = useMemo(() => { + if (duplicateSlug) { + const slugs = new Set(slugsArr); + slugs.delete(duplicateSlug); + return { + field: collectionConfig.slugField, + slugs, + glob: getSlugGlobForCollection(props.config, props.collection), + slug: duplicateSlug, + }; + } + }, [ + slugsArr, + duplicateSlug, + collectionConfig.slugField, + props.collection, + props.config, + ]); + + const itemData = useItemData({ + config: props.config, + dirpath: getCollectionItemPath( + props.config, + props.collection, + duplicateSlug ?? '' + ), + schema: collectionConfig.schema, + format, + slug, + }); + + const duplicateInitalState = + duplicateSlug && itemData.kind === 'loaded' && itemData.data !== 'not-found' + ? itemData.data.initialState + : undefined; + + const duplicateInitalStateWithUpdatedSlug = useMemo(() => { + if (duplicateInitalState) { + let slugFieldValue = duplicateInitalState[collectionConfig.slugField]; + // we'll make a best effort to add something to the slug after duplicated so it's different + // but if it fails a user can change it before creating + // (e.g. potentially it's not just a text field so appending -copy might not work) + try { + const slugFieldSchema = + collectionConfig.schema[collectionConfig.slugField]; + if ( + slugFieldSchema.kind !== 'form' || + slugFieldSchema.formKind !== 'slug' + ) { + throw new Error('not slug field'); + } + const serialized = slugFieldSchema.serializeWithSlug(slugFieldValue); + slugFieldValue = slugFieldSchema.parse(serialized.value, { + slug: `${serialized.slug}-copy`, + }); + } catch {} + return { + ...duplicateInitalState, + [collectionConfig.slugField]: slugFieldValue, + }; + } + }, [ + collectionConfig.schema, + collectionConfig.slugField, + duplicateInitalState, + ]); + + if (duplicateSlug && itemData.kind === 'error') { + return ( + + {itemData.error.message} + + ); + } + if (duplicateSlug && itemData.kind === 'loading') { + return ( + + + + ); + } + if ( + duplicateSlug && + itemData.kind === 'loaded' && + itemData.data === 'not-found' + ) { + return ( + + Entry not found. + + ); + } + + return ( + + ); +} + +function CreateItem(props: { + collection: string; + config: Config; + basePath: string; + initialState?: Record; }) { const stringFormatter = useLocalizedStringFormatter(l10nMessages); const router = useRouter(); @@ -51,7 +179,10 @@ export function CreateItem(props: { () => fields.object(collectionConfig.schema), [collectionConfig.schema] ); - const [state, setState] = useState(() => getInitialPropsValue(schema)); + const [state, setState] = useState( + () => props.initialState ?? getInitialPropsValue(schema) + ); + const previewProps = useMemo( () => createGetPreviewProps(schema, setState, () => undefined), [schema] @@ -243,3 +374,5 @@ export function CreateItem(props: { ); } + +export { CreateItemWrapper as CreateItem };