From a1a8cb6b3a9b0813ead8e257d38cf232de6fa989 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:58:29 -0400 Subject: [PATCH 1/2] fix(admin): add publish date editing --- .changeset/add-publish-date-editor.md | 5 ++ .../admin/src/components/ContentEditor.tsx | 8 +++ .../src/components/ContentSettingsPanel.tsx | 45 ++++++++++++++++ packages/admin/src/lib/api/content.ts | 1 + packages/admin/src/router.tsx | 16 ++++++ .../components/ContentSettingsPanel.test.tsx | 52 +++++++++++++++++++ packages/admin/tests/router.test.tsx | 39 ++++++++++++++ 7 files changed, 166 insertions(+) create mode 100644 .changeset/add-publish-date-editor.md diff --git a/.changeset/add-publish-date-editor.md b/.changeset/add-publish-date-editor.md new file mode 100644 index 0000000000..731e77a112 --- /dev/null +++ b/.changeset/add-publish-date-editor.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Adds a publish-date field for editors to backdate existing published content. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index f2025f4b6e..8607665641 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -143,6 +143,10 @@ export interface ContentEditorProps { onUnschedule?: () => void; /** Whether scheduling is in progress */ isScheduling?: boolean; + /** Callback to change the timestamp of published content */ + onPublishedAtChange?: (publishedAt: string) => void; + /** Whether the publish timestamp is being updated */ + isUpdatingPublishedAt?: boolean; /** Whether this collection supports drafts */ supportsDrafts?: boolean; /** Whether this collection supports revisions */ @@ -213,6 +217,8 @@ export function ContentEditor({ onSchedule, onUnschedule, isScheduling, + onPublishedAtChange, + isUpdatingPublishedAt, supportsDrafts = false, supportsRevisions = false, supportsPreview = false, @@ -854,6 +860,8 @@ export function ContentEditor({ onSchedule={onSchedule} onUnschedule={onUnschedule} isScheduling={isScheduling} + onPublishedAtChange={onPublishedAtChange} + isUpdatingPublishedAt={isUpdatingPublishedAt} onDiscardDraft={onDiscardDraft} onDelete={onDelete} isDeleting={isDeleting} diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index f108f9fffe..d90b5df563 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -25,6 +25,7 @@ import type { UserListItem, } from "../lib/api"; import { fetchBylines } from "../lib/api"; +import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { useDebouncedValue } from "../lib/hooks.js"; import { cn, slugify } from "../lib/utils"; import type { CurrentUserInfo } from "./ContentEditor.js"; @@ -302,6 +303,8 @@ export interface ContentSettingsPanelProps { onSchedule?: (scheduledAt: string) => void; onUnschedule?: () => void; isScheduling?: boolean; + onPublishedAtChange?: (publishedAt: string) => void; + isUpdatingPublishedAt?: boolean; onDiscardDraft?: () => void; onDelete?: () => void; isDeleting?: boolean; @@ -355,6 +358,8 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ onSchedule, onUnschedule, isScheduling, + onPublishedAtChange, + isUpdatingPublishedAt, onDiscardDraft, onDelete, isDeleting, @@ -382,9 +387,17 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); + const storedPublishedDate = toDatetimeLocalInputValue(item?.publishedAt); + const [publishedDate, setPublishedDate] = React.useState(storedPublishedDate); const [isReorderingSections, setIsReorderingSections] = React.useState(false); const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft; const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection); + const canUpdatePublishedDate = + item?.publishedAt != null && (currentUser?.role ?? 0) >= ROLE_EDITOR && !!onPublishedAtChange; + + React.useEffect(() => { + setPublishedDate(storedPublishedDate); + }, [item?.id, storedPublishedDate]); const handleScheduleSubmit = () => { if (scheduleDate && onSchedule) { @@ -395,6 +408,12 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ } }; + const handlePublishedDateSubmit = () => { + if (publishedDate && onPublishedAtChange) { + onPublishedAtChange(fromDatetimeLocalInputValue(publishedDate)); + } + }; + if (blockSidebarPanel) { // A block requesting the sidebar replaces the default sections. return blockSidebarPanel.type === "image" ? ( @@ -526,6 +545,32 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ )} )} + + {canUpdatePublishedDate && ( +
+ setPublishedDate(event.target.value)} + disabled={isUpdatingPublishedAt} + /> + +
+ )} {item && ( diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 23030c2e97..49ab6b2a5c 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -107,6 +107,7 @@ export interface UpdateContentInput { data?: Record; slug?: string; status?: string; + publishedAt?: string | null; authorId?: string | null; bylines?: BylineCreditInput[]; /** Skip revision creation (used by autosave) */ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 4b8fb9530f..61bd711354 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -137,6 +137,7 @@ interface RouterContext { interface ContentUpdateChanges { data?: Record; slug?: string; + publishedAt?: string | null; authorId?: string | null; bylines?: BylineCreditInput[]; skipRevision?: boolean; @@ -1188,6 +1189,17 @@ function ContentEditPage() { }, [activeLocale, id, rawItem?.locale, updateMutation.mutate], ); + const handlePublishedAtChange = React.useCallback( + (publishedAt: string) => { + updateMutation.mutate({ + targetId: id, + targetLocale: rawItem?.locale ?? activeLocale, + source: "auxiliary", + changes: { publishedAt }, + }); + }, + [activeLocale, id, rawItem?.locale, updateMutation.mutate], + ); const handleSeoChange = React.useCallback( (seo: ContentSeoInput) => { @@ -1268,6 +1280,10 @@ function ContentEditPage() { onSchedule={handleSchedule} onUnschedule={handleUnschedule} isScheduling={scheduleMutation.isPending} + onPublishedAtChange={handlePublishedAtChange} + isUpdatingPublishedAt={ + updateMutation.isPending && updateMutation.variables?.changes.publishedAt !== undefined + } onDelete={handleDelete} isDeleting={deleteMutation.isPending} supportsDrafts={collectionConfig.supports.includes("drafts")} diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx index 6fa76bb137..af576ae88e 100644 --- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx +++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx @@ -1,3 +1,4 @@ +import { i18n } from "@lingui/core"; import { act, fireEvent } from "@testing-library/react"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -144,6 +145,57 @@ describe("ContentSettingsPanel", () => { expect(screen.container.textContent).not.toContain("Bylines"); }); + it("lets editors update the publish date of published content", async () => { + const onPublishedAtChange = vi.fn(); + const previousLocale = i18n.locale; + i18n.load("ar", {}); + i18n.activate("ar"); + try { + const screen = await render( +
+ +
, + ); + + const input = screen.getByLabelText("Publish date"); + await expect.element(input).toHaveValue("2025-01-15T10:30"); + await input.fill("2020-06-01T08:45"); + await screen.getByRole("button", { name: "Update publish date" }).click(); + + expect(onPublishedAtChange).toHaveBeenCalledWith("2020-06-01T08:45:00.000Z"); + } finally { + i18n.activate(previousLocale); + } + }); + + it("does not expose publish-date editing below the editor role", async () => { + const screen = await render( + , + ); + + expect(screen.container.querySelector('input[type="datetime-local"]')).toBeNull(); + expect(screen.container.textContent).not.toContain("Update publish date"); + }); + it("hides capability-gated sections when their flags are off", async () => { const screen = await render( ({ onSave, onAutosave, onSeoChange, + onPublishedAtChange, isSaving, isAutosaving, isSaveFeedbackActive, @@ -57,6 +58,7 @@ vi.mock("../src/components/ContentEditor", () => ({ onSave?: (payload: { data: Record }) => void; onAutosave?: (payload: { data: Record; slug?: string }) => void; onSeoChange?: (seo: { title: string }) => void; + onPublishedAtChange?: (publishedAt: string) => void; isSaving?: boolean; isAutosaving?: boolean; isSaveFeedbackActive?: boolean; @@ -94,6 +96,9 @@ vi.mock("../src/components/ContentEditor", () => ({ + ), })); @@ -529,6 +534,40 @@ describe("ContentEditPage – autosave cache patching", () => { }); }); + it("sends publish-date changes through the auxiliary update payload", async () => { + const fetchWithMocks = globalThis.fetch; + let updateBody: Record | undefined; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (init?.method === "PUT" && url.includes("/content/posts/post_1")) { + if (typeof init.body !== "string") throw new TypeError("Expected a JSON request body"); + updateBody = JSON.parse(init.body) as Record; + } + return fetchWithMocks(input, init); + }) as typeof fetch; + + try { + const { router, TestApp } = buildRouter(); + await router.navigate({ + to: "/content/$collection/$id", + params: { collection: "posts", id: "post_1" }, + }); + const screen = await render(); + await waitFor(() => { + expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title"); + }); + + await screen.getByRole("button", { name: "Trigger Publish Date Sync" }).click(); + + await waitFor(() => { + expect(updateBody).toEqual({ publishedAt: "2020-06-01T08:45:00.000Z" }); + }); + } finally { + globalThis.fetch = fetchWithMocks; + } + }); + it("does not report auxiliary writes as saving; editor saves still do", async () => { const { router, TestApp } = buildRouter(); await router.navigate({ From 37afcff849392a854cc2a7d3f0b5d75007184d44 Mon Sep 17 00:00:00 2001 From: Mason James <297610+masonjames@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:51:20 -0400 Subject: [PATCH 2/2] fix(admin): isolate publish date pending state --- packages/admin/src/router.tsx | 23 +++++------ packages/admin/tests/router.test.tsx | 58 +++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 61bd711354..168a5c7458 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -966,6 +966,14 @@ function ContentEditPage() { } }, }); + const publishedAtMutation = useMutation({ + mutationFn: (publishedAt: string) => + updateContent(collection, id, { publishedAt }, { locale: rawItem?.locale ?? activeLocale }), + onSuccess: () => { + handleContentUpdateSuccess(id); + }, + onError: handleContentUpdateError, + }); // Autosave mutation - skips revision creation const autosaveMutation = useMutation({ @@ -1191,14 +1199,9 @@ function ContentEditPage() { ); const handlePublishedAtChange = React.useCallback( (publishedAt: string) => { - updateMutation.mutate({ - targetId: id, - targetLocale: rawItem?.locale ?? activeLocale, - source: "auxiliary", - changes: { publishedAt }, - }); + publishedAtMutation.mutate(publishedAt); }, - [activeLocale, id, rawItem?.locale, updateMutation.mutate], + [publishedAtMutation.mutate], ); const handleSeoChange = React.useCallback( @@ -1265,7 +1268,7 @@ function ContentEditPage() { collectionLabel={collectionConfig.labelSingular || collectionConfig.label} item={item} fields={collectionConfig.fields} - isSaving={updateMutation.isPending} + isSaving={updateMutation.isPending || publishedAtMutation.isPending} isSaveFeedbackActive={(editorSavePendingCounts.get(id) ?? 0) > 0} onSave={handleSave} onAutosave={handleAutosave} @@ -1281,9 +1284,7 @@ function ContentEditPage() { onUnschedule={handleUnschedule} isScheduling={scheduleMutation.isPending} onPublishedAtChange={handlePublishedAtChange} - isUpdatingPublishedAt={ - updateMutation.isPending && updateMutation.variables?.changes.publishedAt !== undefined - } + isUpdatingPublishedAt={publishedAtMutation.isPending} onDelete={handleDelete} isDeleting={deleteMutation.isPending} supportsDrafts={collectionConfig.supports.includes("drafts")} diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 45e4472aa5..4434343f90 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -52,6 +52,7 @@ vi.mock("../src/components/ContentEditor", () => ({ isSaving, isAutosaving, isSaveFeedbackActive, + isUpdatingPublishedAt, autosaveCompletionToken, }: { item?: { data?: { title?: string }; slug?: string | null }; @@ -62,6 +63,7 @@ vi.mock("../src/components/ContentEditor", () => ({ isSaving?: boolean; isAutosaving?: boolean; isSaveFeedbackActive?: boolean; + isUpdatingPublishedAt?: boolean; autosaveCompletionToken?: number; }) => (
@@ -96,7 +98,11 @@ vi.mock("../src/components/ContentEditor", () => ({ -
@@ -568,6 +574,56 @@ describe("ContentEditPage – autosave cache patching", () => { } }); + it("keeps publish-date editing disabled while its update remains pending", async () => { + const fetchWithMocks = globalThis.fetch; + let releasePublishedAt: (() => void) | undefined; + let seoRequestSeen = false; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (init?.method !== "PUT" || !url.includes("/content/posts/post_1")) { + return fetchWithMocks(input, init); + } + if (typeof init.body !== "string") throw new TypeError("Expected a JSON request body"); + const body = JSON.parse(init.body) as Record; + const response = fetchWithMocks(input, init); + if (body.publishedAt !== undefined) { + return new Promise((resolve) => { + releasePublishedAt = () => void response.then(resolve); + }); + } + seoRequestSeen = body.seo !== undefined; + return response; + }) as typeof fetch; + + try { + const { router, TestApp } = buildRouter(); + await router.navigate({ + to: "/content/$collection/$id", + params: { collection: "posts", id: "post_1" }, + }); + const screen = await render(); + await waitFor(() => { + expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title"); + }); + + const publishDateButton = screen.getByRole("button", { + name: "Trigger Publish Date Sync", + }); + await publishDateButton.click(); + await expect.element(publishDateButton).toBeDisabled(); + + await screen.getByRole("button", { name: "Trigger SEO Sync" }).click(); + await waitFor(() => { + expect(seoRequestSeen).toBe(true); + }); + await expect.element(publishDateButton).toBeDisabled(); + } finally { + releasePublishedAt?.(); + globalThis.fetch = fetchWithMocks; + } + }); + it("does not report auxiliary writes as saving; editor saves still do", async () => { const { router, TestApp } = buildRouter(); await router.navigate({