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