Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/feat-cross-collection-duplication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Adds cross-collection duplication. Duplicate now always opens a dialog — from a content row, from a selection of up to 50 entries, or from the editor's sidebar — where you pick which collection the copy lands in. Choosing a different collection reveals a field mapping, which only pairs fields with matching column types, is validated before anything is written, and can be remembered per collection pair. The dialog names everything the copy will drop: unmapped fields, taxonomies the target isn't attached to, SEO, and links pointing at the original.

Duplicating within a collection is unchanged apart from the confirmation step, and now carries the entry's taxonomy terms to the copy.
39 changes: 35 additions & 4 deletions e2e/tests/content-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { test, expect } from "../fixtures";
// ---------- regex patterns ----------

const SCHEDULE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/schedule/;
const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/duplicate/;
const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/duplicate$/;
const DISCARD_DRAFT_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/discard-draft/;
const RESTORE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/restore/;
const PERMANENT_DELETE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/permanent/;
Expand Down Expand Up @@ -240,18 +240,25 @@ test.describe("Duplicate content", () => {
const row = page.locator("tr", { hasText: "Duplicate Source Post" });
await expect(row).toBeVisible({ timeout: 5000 });

await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click();

// The action always confirms first, so nothing is copied on the click alone.
const confirm = page
.getByRole("dialog")
.getByRole("button", { name: "Duplicate", exact: true });
await expect(confirm).toBeEnabled({ timeout: 10000 });

const duplicateResponse = page.waitForResponse(
(res) =>
DUPLICATE_API_PATTERN.test(res.url()) &&
res.request().method() === "POST" &&
(res.status() === 200 || res.status() === 201),
{ timeout: 10000 },
);

await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click();
await confirm.click();
const response = await duplicateResponse;
const body = await response.json();
duplicateId = body.data?.item?.id ?? body.data?.id;
duplicateId = body.data?.results?.[0]?.targetId;

// Wait for the list to refresh
await admin.waitForLoading();
Expand All @@ -269,6 +276,30 @@ test.describe("Duplicate content", () => {
});
expect(getRes.ok).toBe(true);
});

test("duplicate a post from the editor", async ({ admin, page }) => {
await admin.goToEditContent("posts", postId);
await admin.waitForLoading();

await page.getByRole("button", { name: "Duplicate…" }).click();

const confirm = page
.getByRole("dialog")
.getByRole("button", { name: "Duplicate", exact: true });
await expect(confirm).toBeEnabled({ timeout: 10000 });

const duplicateResponse = page.waitForResponse(
(res) => DUPLICATE_API_PATTERN.test(res.url()) && res.request().method() === "POST",
{ timeout: 10000 },
);
await confirm.click();
const body = await (await duplicateResponse).json();
duplicateId = body.data?.results?.[0]?.targetId;
expect(duplicateId).toBeTruthy();

// The editor follows the copy rather than leaving the original open.
await expect(page).toHaveURL(new RegExp(`/content/posts/${duplicateId}`), { timeout: 10000 });
});
});

// ==========================================================================
Expand Down
17 changes: 17 additions & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ export interface ContentEditorProps {
onDelete?: () => void;
/** Whether delete is in progress */
isDeleting?: boolean;
/**
* Open the duplicate flow for this entry. The copy is taken from the saved
* row, so the caller is told whether the editor has unsaved changes.
*/
onDuplicate?: (context: { unsavedChanges: boolean }) => void;
/** i18n config — present when multiple locales are configured */
i18n?: { defaultLocale: string; locales: string[] };
/** Existing translations for this content item */
Expand Down Expand Up @@ -233,6 +238,7 @@ export function ContentEditor({
onQuickEditByline,
onDelete,
isDeleting,
onDuplicate,
i18n,
translations,
onTranslate,
Expand Down Expand Up @@ -405,6 +411,16 @@ export function ContentEditor({
[formData, slug, activeBylines],
);
const isDirty = isNew || currentData !== lastSavedData;

// The settings panel is memoized, so the handler it gets must be stable —
// dirtiness is read through a ref at click time instead of closed over.
const isDirtyRef = React.useRef(isDirty);
isDirtyRef.current = isDirty;
const handleDuplicate = React.useCallback(
() => onDuplicate?.({ unsavedChanges: isDirtyRef.current }),
[onDuplicate],
);

const saveFeedbackActive = isSaveFeedbackActive ?? isSaving;
const autosaveFeedbackActive = isAutosaveFeedbackActive ?? isAutosaving;
const isContentOperationPending = Boolean(isSaving);
Expand Down Expand Up @@ -865,6 +881,7 @@ export function ContentEditor({
onDiscardDraft={onDiscardDraft}
onDelete={onDelete}
isDeleting={isDeleting}
onDuplicate={onDuplicate ? handleDuplicate : undefined}
currentUser={currentUser}
users={users}
onAuthorChange={onAuthorChange}
Expand Down
19 changes: 18 additions & 1 deletion packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export interface ContentListProps {
isTrashedLoading?: boolean;
onDelete?: (id: string) => void;
onDuplicate?: (id: string) => void;
/**
* Duplicate the selected entries. Like the other bulk handlers it resolves
* with the ids that failed, so those rows stay selected.
*/
onBulkDuplicate?: BulkActionHandler;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
onLoadMore?: () => void;
Expand Down Expand Up @@ -193,6 +198,7 @@ export function ContentList({
onBulkPublish,
onBulkUnpublish,
onBulkDelete,
onBulkDuplicate,
}: ContentListProps) {
const { t } = useLingui();
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
Expand All @@ -202,7 +208,7 @@ export function ContentList({

// Bulk selection is opt-in: the checkbox column + toolbar only render when
// the parent wired at least one bulk handler.
const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete);
const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete || onBulkDuplicate);

// Server-side search mode: the caller refetches based on the (debounced)
// query, so `items`/`total` already reflect the filter and we must not
Expand Down Expand Up @@ -432,6 +438,17 @@ export function ContentList({
{t`Set to draft`}
</Button>
)}
{onBulkDuplicate && (
<Button
size="sm"
variant="secondary"
disabled={bulkBusy}
icon={<Copy />}
onClick={() => runBulk(onBulkDuplicate)}
>
{t`Duplicate…`}
</Button>
)}
{onBulkDelete && (
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
Expand Down
101 changes: 60 additions & 41 deletions packages/admin/src/components/ContentSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Text,
} from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { ArrowSquareOut, Eye, EyeSlash, Trash, Upload, X } from "@phosphor-icons/react";
import { ArrowSquareOut, Copy, Eye, EyeSlash, Trash, Upload, X } from "@phosphor-icons/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import type { Editor } from "@tiptap/react";
Expand Down Expand Up @@ -309,6 +309,8 @@ export interface ContentSettingsPanelProps {
onDiscardDraft?: () => void;
onDelete?: () => void;
isDeleting?: boolean;
/** Opens the duplicate dialog for this entry. */
onDuplicate?: () => void;
currentUser?: CurrentUserInfo;
users?: UserListItem[];
onAuthorChange?: (authorId: string | null) => void;
Expand Down Expand Up @@ -364,6 +366,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
onDiscardDraft,
onDelete,
isDeleting,
onDuplicate,
currentUser,
users,
onAuthorChange,
Expand Down Expand Up @@ -697,50 +700,66 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
)}
</SortableContentSettingsSections>

{!isNew && onDelete && (
{!isNew && (onDuplicate || onDelete) && (
<div
data-testid="content-trash-actions"
aria-hidden={isReorderingSections || undefined}
className={cn("border-t p-4", isReorderingSections && "invisible pointer-events-none")}
className={cn(
"flex flex-col gap-2 border-t p-4",
isReorderingSections && "invisible pointer-events-none",
)}
>
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
render={(p) => (
<Button
{...p}
type="button"
variant="outline"
className="w-full text-kumo-danger hover:text-kumo-danger"
disabled={isDeleting}
icon={isDeleting ? <Loader size="sm" /> : <Trash />}
>
{t`Move to Trash`}
</Button>
)}
/>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{t`Move to Trash?`}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{t`This will move the item to trash. You can restore it later from the trash.`}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button {...p} variant="destructive" onClick={onDelete}>
{t`Move to Trash`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
{onDuplicate && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={onDuplicate}
icon={<Copy />}
>
{t`Duplicate…`}
</Button>
)}
{onDelete && (
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
render={(p) => (
<Button
{...p}
type="button"
variant="outline"
className="w-full text-kumo-danger hover:text-kumo-danger"
disabled={isDeleting}
icon={isDeleting ? <Loader size="sm" /> : <Trash />}
>
{t`Move to Trash`}
</Button>
)}
/>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{t`Move to Trash?`}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{t`This will move the item to trash. You can restore it later from the trash.`}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button {...p} variant="destructive" onClick={onDelete}>
{t`Move to Trash`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
)}
</div>
)}
</div>
Expand Down
Loading
Loading