Skip to content

Commit

Permalink
Duplicate entry (#647)
Browse files Browse the repository at this point in the history
Co-authored-by: Emma Hamilton <[email protected]>
  • Loading branch information
valery-tm and emmatown authored Sep 21, 2023
1 parent 53f2a7e commit 183e129
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-mayflies-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystatic/core': patch
---

Added an option to duplicate an existing entry in a collection
60 changes: 57 additions & 3 deletions packages/keystatic/src/app/ItemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -189,6 +205,7 @@ function ItemPage(props: ItemPageProps) {
)}/item/${encodeURIComponent(slug)}`
);
}
return hasUpdated;
}, [
collection,
collectionConfig,
Expand Down Expand Up @@ -227,6 +244,7 @@ function ItemPage(props: ItemPageProps) {
isLoading={updateResult.kind === 'loading'}
hasChanged={hasChanged}
onDelete={onDelete}
onDuplicate={onDuplicate}
onReset={onReset}
onView={onView}
/>
Expand Down Expand Up @@ -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 = {
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -471,6 +511,20 @@ function HeaderActions(props: {
</AlertDialog>
)}
</DialogContainer>
<DialogContainer onDismiss={() => setDuplicateAlertOpen(false)}>
{duplicateAlertIsOpen && (
<AlertDialog
title="Save and duplicate entry"
tone="neutral"
cancelLabel="Cancel"
primaryActionLabel="Save and duplicate"
autoFocusButton="primary"
onPrimaryAction={onDuplicate}
>
You have unsaved changes. Save this entry to duplicate it.
</AlertDialog>
)}
</DialogContainer>
</>
);
}
Expand Down
139 changes: 136 additions & 3 deletions packages/keystatic/src/app/create-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,149 @@ 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';
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<string, TreeNode>();

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 (
<PageBody>
<Notice tone="critical">{itemData.error.message}</Notice>
</PageBody>
);
}
if (duplicateSlug && itemData.kind === 'loading') {
return (
<Flex alignItems="center" justifyContent="center" minHeight="scale.3000">
<ProgressCircle
aria-label="Loading Item"
isIndeterminate
size="large"
/>
</Flex>
);
}
if (
duplicateSlug &&
itemData.kind === 'loaded' &&
itemData.data === 'not-found'
) {
return (
<PageBody>
<Notice tone="caution">Entry not found.</Notice>
</PageBody>
);
}

return (
<CreateItem
collection={props.collection}
config={props.config}
basePath={props.basePath}
initialState={duplicateInitalStateWithUpdatedSlug}
/>
);
}

function CreateItem(props: {
collection: string;
config: Config;
basePath: string;
initialState?: Record<string, unknown>;
}) {
const stringFormatter = useLocalizedStringFormatter(l10nMessages);
const router = useRouter();
Expand All @@ -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]
Expand Down Expand Up @@ -243,3 +374,5 @@ export function CreateItem(props: {
</>
);
}

export { CreateItemWrapper as CreateItem };

3 comments on commit 183e129

@vercel
Copy link

@vercel vercel bot commented on 183e129 Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

keystar-ui – ./design-system/docs

keystar-ui.vercel.app
keystar-ui-git-main-thinkmill-labs.vercel.app
keystar-ui-thinkmill-labs.vercel.app
voussoir.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 183e129 Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

keystatic – ./dev-projects/next-app

keystatic-git-main-thinkmill-labs.vercel.app
keystatic-thinkmill-labs.vercel.app
keystatic.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 183e129 Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.