Skip to content
Open
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/add-publish-date-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Adds a publish-date field for editors to backdate existing published content.
8 changes: 8 additions & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -213,6 +217,8 @@ export function ContentEditor({
onSchedule,
onUnschedule,
isScheduling,
onPublishedAtChange,
isUpdatingPublishedAt,
supportsDrafts = false,
supportsRevisions = false,
supportsPreview = false,
Expand Down Expand Up @@ -854,6 +860,8 @@ export function ContentEditor({
onSchedule={onSchedule}
onUnschedule={onUnschedule}
isScheduling={isScheduling}
onPublishedAtChange={onPublishedAtChange}
isUpdatingPublishedAt={isUpdatingPublishedAt}
onDiscardDraft={onDiscardDraft}
onDelete={onDelete}
isDeleting={isDeleting}
Expand Down
45 changes: 45 additions & 0 deletions packages/admin/src/components/ContentSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -355,6 +358,8 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
onSchedule,
onUnschedule,
isScheduling,
onPublishedAtChange,
isUpdatingPublishedAt,
onDiscardDraft,
onDelete,
isDeleting,
Expand Down Expand Up @@ -382,9 +387,17 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({

const [scheduleDate, setScheduleDate] = React.useState<string>("");
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) {
Expand All @@ -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" ? (
Expand Down Expand Up @@ -526,6 +545,32 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
)}
</div>
)}

{canUpdatePublishedDate && (
<div className="space-y-2 pt-2">
<Input
label={t`Publish date`}
type="datetime-local"
value={publishedDate}
onChange={(event) => setPublishedDate(event.target.value)}
disabled={isUpdatingPublishedAt}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePublishedDateSubmit}
disabled={
!publishedDate ||
publishedDate === storedPublishedDate ||
isUpdatingPublishedAt
}
icon={isUpdatingPublishedAt ? <Loader size="sm" /> : undefined}
>
{t`Update publish date`}
</Button>
</div>
)}
</div>

{item && (
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface UpdateContentInput {
data?: Record<string, unknown>;
slug?: string;
status?: string;
publishedAt?: string | null;
authorId?: string | null;
bylines?: BylineCreditInput[];
/** Skip revision creation (used by autosave) */
Expand Down
19 changes: 18 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ interface RouterContext {
interface ContentUpdateChanges {
data?: Record<string, unknown>;
slug?: string;
publishedAt?: string | null;
authorId?: string | null;
bylines?: BylineCreditInput[];
skipRevision?: boolean;
Expand Down Expand Up @@ -965,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({
Expand Down Expand Up @@ -1188,6 +1197,12 @@ function ContentEditPage() {
},
[activeLocale, id, rawItem?.locale, updateMutation.mutate],
);
const handlePublishedAtChange = React.useCallback(
(publishedAt: string) => {
publishedAtMutation.mutate(publishedAt);
},
[publishedAtMutation.mutate],
);

const handleSeoChange = React.useCallback(
(seo: ContentSeoInput) => {
Expand Down Expand Up @@ -1253,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}
Expand All @@ -1268,6 +1283,8 @@ function ContentEditPage() {
onSchedule={handleSchedule}
onUnschedule={handleUnschedule}
isScheduling={scheduleMutation.isPending}
onPublishedAtChange={handlePublishedAtChange}
isUpdatingPublishedAt={publishedAtMutation.isPending}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
supportsDrafts={collectionConfig.supports.includes("drafts")}
Expand Down
52 changes: 52 additions & 0 deletions packages/admin/tests/components/ContentSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<div dir="rtl">
<ContentSettingsPanel
{...makePanelProps({
item: makeItem({
status: "published",
publishedAt: "2025-01-15T10:30:00.000Z",
}),
isLive: true,
onPublishedAtChange,
})}
/>
</div>,
);

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(
<ContentSettingsPanel
{...makePanelProps({
item: makeItem({
status: "published",
publishedAt: "2025-01-15T10:30:00.000Z",
}),
isLive: true,
currentUser: AUTHOR_ROLE,
onPublishedAtChange: vi.fn(),
})}
/>,
);

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(
<ContentSettingsPanel
Expand Down
95 changes: 95 additions & 0 deletions packages/admin/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,22 @@ vi.mock("../src/components/ContentEditor", () => ({
onSave,
onAutosave,
onSeoChange,
onPublishedAtChange,
isSaving,
isAutosaving,
isSaveFeedbackActive,
isUpdatingPublishedAt,
autosaveCompletionToken,
}: {
item?: { data?: { title?: string }; slug?: string | null };
onSave?: (payload: { data: Record<string, unknown> }) => void;
onAutosave?: (payload: { data: Record<string, unknown>; slug?: string }) => void;
onSeoChange?: (seo: { title: string }) => void;
onPublishedAtChange?: (publishedAt: string) => void;
isSaving?: boolean;
isAutosaving?: boolean;
isSaveFeedbackActive?: boolean;
isUpdatingPublishedAt?: boolean;
autosaveCompletionToken?: number;
}) => (
<div data-testid="content-editor">
Expand Down Expand Up @@ -94,6 +98,13 @@ vi.mock("../src/components/ContentEditor", () => ({
<button type="button" onClick={() => onSeoChange?.({ title: "Search title" })}>
Trigger SEO Sync
</button>
<button
type="button"
disabled={isUpdatingPublishedAt}
onClick={() => onPublishedAtChange?.("2020-06-01T08:45:00.000Z")}
>
Trigger Publish Date Sync
</button>
</div>
),
}));
Expand Down Expand Up @@ -529,6 +540,90 @@ describe("ContentEditPage – autosave cache patching", () => {
});
});

it("sends publish-date changes through the auxiliary update payload", async () => {
const fetchWithMocks = globalThis.fetch;
let updateBody: Record<string, unknown> | 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<string, unknown>;
}
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(<TestApp />);
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("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<string, unknown>;
const response = fetchWithMocks(input, init);
if (body.publishedAt !== undefined) {
return new Promise<Response>((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(<TestApp />);
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({
Expand Down
Loading