Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Duplicate entry #647

Merged
merged 7 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 };
Loading