diff --git a/apps/admin/src/app-root.tsx b/apps/admin/src/app-root.tsx index 8305d5948df..69b032c6e66 100644 --- a/apps/admin/src/app-root.tsx +++ b/apps/admin/src/app-root.tsx @@ -8,10 +8,14 @@ import { import { ShadeApp } from '@tryghost/shade/app'; import App from './app.tsx'; +import { installHistoryPopGate } from './hooks/use-history-pop-navigation-guard'; import { routes, useIsEmberOwnedRoute } from './routes.tsx'; import { useThemeContext } from './providers/theme-context'; import { ThemeProvider } from './providers/theme-provider'; +// At module scope, so it is in place before `AdminAppRoot` creates the router. +installHistoryPopGate(); + function ThemedAdminApp() { const { resolvedTheme } = useThemeContext(); const { pathname } = useLocation(); diff --git a/apps/admin/src/app.tsx b/apps/admin/src/app.tsx index c5d398fb447..b8af5e922f9 100644 --- a/apps/admin/src/app.tsx +++ b/apps/admin/src/app.tsx @@ -3,6 +3,7 @@ import { useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; import { EmberProvider, EmberFallback, EmberRoot } from './ember-bridge'; import { AdminLayout } from './layout/admin-layout'; +import { useSyncEmberFullScreen } from './layout/sidebar-visibility'; import { useEmberAuthSync, useEmberDataSync, useEmberListReturnSync } from './ember-bridge'; import { DocsBotWidgetHost } from './docsbot-widget-host'; import { useAccentColorProperties } from './hooks/use-accent-color-properties'; @@ -22,6 +23,7 @@ function App() { useEmberAuthSync(); useEmberDataSync(); useEmberListReturnSync(); + useSyncEmberFullScreen(); useAuthNotice(Boolean(currentUser)); return ( diff --git a/apps/admin/src/editor/README.md b/apps/admin/src/editor/README.md index 017904ee127..35e44f8109c 100644 --- a/apps/admin/src/editor/README.md +++ b/apps/admin/src/editor/README.md @@ -2,8 +2,9 @@ `apps/admin/src/editor/` is the React post and page editor: the screen, the Koenig surface it wraps, the settings sidebar beside it, and the publish and -preview flows it opens. `api.ts` is the domain's public surface — the shell -mounts the screen lazily through it and everything else here is internal. +preview flows it opens, plus the restore screen that turns a local copy of a +lost draft back into a post. `api.ts` is the domain's public surface — the shell +mounts both screens lazily through it and everything else here is internal. ## The modules @@ -16,8 +17,10 @@ mounts the screen lazily through it and everything else here is internal. | [`preview/`](preview/README.md) | The modal that shows a post as the site renders it or as the newsletter it would be sent as | | `editor-screen.tsx` | The route: loads the post, builds the session, and lays out the header, the surface and the sidebar | | `post-editor.tsx`, `koenig-post-editor.tsx` | The title, excerpt and feature image around the Koenig instances, and the Koenig integration itself | -| `editor-header-actions.tsx`, `editor-status.tsx` | The header's publish and preview controls, and the line saying where the post stands | +| `editor-header-actions.tsx`, `editor-status.tsx` | The header's publish and preview controls, and the line saying where the post stands, with a failed send's retry | | `card-config.ts`, `use-post-card-config.ts` | What Koenig's cards are told about the site and the post they are being edited in | +| `local-revisions.ts` | Browser-local copies of drafts holding unsaved work: how they are stored, trimmed and read back | +| `restore/` | The `/restore` screen: lists this browser's local copies and creates a new draft from any of them | Two small modules are shared across all of the above. `request-options.ts` carries the editor's opt-out from the transport's session-expiry redirect, which @@ -28,6 +31,24 @@ a component reading a query key it shares with a screen outside the editor opts out too. `layering.ts` carries the z-index a confirmation dialog opened from inside another editor surface needs in order to paint above it. +## Opening the publish flow + +A draft opens the publish flow from three places: the header's Publish button, +its keyboard shortcut and the preview's Publish. A published or sent post whose +newsletter failed opens it from the status line instead, which offers "Retry +now" on an email-only send and "View details" on a published post; the flow +then starts at its email-failure step. Whether a post qualifies is decided by +the flow's own `initialEmailError()`, and the button is offered only to roles +Core lets retry an email, so an Author sees the failure without it. + +Every opener stays unavailable until the publish inputs have loaded. When they +fail to load, the header shows the error with a Retry for a draft, and for a +post whose status line offers the retry. After a retry, or a publish that +emails, a published post's status line reads "Published and sending to N +members" while the email is on its way and "Published and sent to N members" +once the flow's email confirmation finds it submitted; an email-only send reads +"Sent to N members" throughout. + ## Adding a settings section 1. Add the section's id to `SETTINGS_SECTION_ORDER` in `settings/sections.ts`, diff --git a/apps/admin/src/editor/api.ts b/apps/admin/src/editor/api.ts index fe4fcf128c6..10100c38016 100644 --- a/apps/admin/src/editor/api.ts +++ b/apps/admin/src/editor/api.ts @@ -1,9 +1,10 @@ /** * Public surface of the editor domain, consumed by the admin shell - * (apps/admin/src/routes.tsx via the editor gate). Everything else in this + * (apps/admin/src/routes.tsx and the editor gate). Everything else in this * domain is internal. */ -// Lazy entry, not a component re-export: the shell mounts this behind +// Lazy entries, not component re-exports: the shell mounts these behind // `lazy()`, so a static re-export would pull the chunk into the shell bundle. export const lazyEditorScreen = () => import('./editor-screen'); +export const lazyRestoreScreen = () => import('./restore/restore-screen'); diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index e3441871434..640649fcbc1 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -16,7 +16,7 @@ import { PostPreviewModal, type PostPreviewModalProps } from './preview/post-pre import { postPreviewUrl } from './preview/preview-url'; import { PublishFlowModal } from './publish/publish-flow-modal'; import { UpdateFlowModal } from './publish/update-flow-modal'; -import { buildPublishFlowPost, type PublishFlowPost } from './publish/flow-post'; +import type { PublishFlowPost } from './publish/flow-post'; import { describeCompletionFailure } from './publish/completion-message'; import { usePublishInputs } from './publish/use-publish-inputs'; import { usePublishLimits } from './publish/use-publish-limits'; @@ -25,7 +25,7 @@ import type { EditorSessionHandle } from './session/use-editor-session'; import type { SaveCompletion } from './engine/save-engine'; import { usePreviewShortcut, usePublishShortcut } from './use-editor-shortcuts'; -type OpenFlow = 'none' | 'publish' | 'update'; +export type OpenFlow = 'none' | 'publish' | 'update'; /** The preview's props short of Publish, which only the publish controls can supply. */ type HeaderPreviewProps = Omit; @@ -47,11 +47,18 @@ async function requireSaved(pending: Promise): Promise { export interface EditorHeaderActionsProps { session: EditorSessionHandle; + /** Built by the screen, which derives the status line's retry from it too. */ + post: PublishFlowPost; postType: PostType; currentUser?: User; siteUrl: string; /** Unresolved TK markers in the title, excerpt, body and feature image. */ tkCount: number; + /** Held by the screen, because the status line opens the publish flow too. */ + openFlow: OpenFlow; + onOpenFlow: (flow: OpenFlow) => void; + /** Whether the status line offers a failed send's retry, which needs the publish inputs. */ + offersEmailRetry: boolean; } /** @@ -60,30 +67,22 @@ export interface EditorHeaderActionsProps { */ export function EditorHeaderActions({ session, + post, postType, currentUser, siteUrl, tkCount, + openFlow, + onOpenFlow, + offersEmailRetry, }: EditorHeaderActionsProps) { const { isAdmin7 } = useShade(); - const { persistedId, publishTime, title } = session; + const { persistedId } = session; const record = session.loadedRecord; const [previewOpen, setPreviewOpen] = useState(false); - const [openFlow, setOpenFlow] = useState('none'); const openPreview = useCallback(() => setPreviewOpen(true), []); - const post = buildPublishFlowPost({ - snapshot: { - id: persistedId, - status: publishTime.status, - publishedAt: publishTime.publishedAt, - title, - }, - record, - displayName: postType, - lexical: session.getLiveLexical(), - }); // Core 301-redirects a published or sent post away from /p/:uuid/ and drops the // audience query, so Ember offers a preview only while the post is a draft. const isDraft = post.status === 'draft'; @@ -157,12 +156,13 @@ export function EditorHeaderActions({ )} @@ -176,6 +176,7 @@ interface PublishActionsProps { tkCount: number; isDraft: boolean; isSaving: boolean; + offersEmailRetry: boolean; openFlow: OpenFlow; preview: HeaderPreviewProps; onOpenFlow: (flow: OpenFlow) => void; @@ -192,6 +193,7 @@ function PublishActions({ tkCount, isDraft, isSaving, + offersEmailRetry, openFlow, preview, onOpenFlow, @@ -236,30 +238,34 @@ function PublishActions({ // button is the only way into the flow from there. usePublishShortcut(openPublishFlow, isDraft && inputs.isReady && !preview.open); + // A draft's Publish and the status line's retry stay disabled until these inputs load. + const inputsError = + (isDraft || offersEmailRetry) && inputs.error ? ( + <> + + {inputs.error.message} + + + + ) : null; + return ( <> {isDraft ? ( <> - {inputs.error ? ( - <> - - {inputs.error.message} - - - - ) : null} + {inputsError} + + ); +} + function StatusBody({ view, timezone, isHovered, + onOpenPublishFlow, }: { view: EditorStatusView; timezone: string; isHovered: boolean; + onOpenPublishFlow?: () => void; }) { switch (view.kind) { case 'problem': @@ -77,7 +100,16 @@ function StatusBody({ case 'draft': return <>{view.saved ? 'Draft - Saved' : 'Draft'}; case 'sent': - return view.failed ? <>Failed to send newsletter. : <>Sent to {members(view.count)}; + return view.failed ? ( + <> + Failed to send newsletter. + {onOpenPublishFlow ? ( + + ) : null} + + ) : ( + <>Sent to {members(view.count)} + ); case 'scheduled': return ( <> @@ -114,6 +146,9 @@ function StatusBody({ {view.email === 'sending' && ` and sending to ${members(view.count)}`} {view.email === 'sent' && ` and sent to ${members(view.count)}`} {view.email === 'failed' && ' but failed to send newsletter.'} + {view.email === 'failed' && onOpenPublishFlow ? ( + + ) : null} ); } @@ -123,10 +158,12 @@ export interface EditorStatusProps { state: SaveEngineState; record?: EditorStatusRecord; isDirty: boolean; + /** Opens the publish flow at a failed send; omitted unless the role may retry it. */ + onOpenPublishFlow?: () => void; } /** Where the post stands: its status, the newsletter, and the last save. */ -export function EditorStatus({ state, record, isDirty }: EditorStatusProps) { +export function EditorStatus({ state, record, isDirty, onOpenPublishFlow }: EditorStatusProps) { const { isAdmin7 } = useShade(); const timezone = useSiteTimezone(); const isSaving = useSavingHold(state.kind === 'saving' || state.kind === 'pending-coalesced'); @@ -168,6 +205,7 @@ export function EditorStatus({ state, record, isDirty }: EditorStatusProps) { isHovered={isHovered} timezone={timezone} view={deriveEditorStatus({ state, record, isDirty, isSaving })} + onOpenPublishFlow={onOpenPublishFlow} /> diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 13368adfd2e..0982be65328 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -17,9 +17,11 @@ import { editorHelpLink, editorLeaveDialog, editorLoadError, + editorNewsletterDetailsButton, editorPreviewButton, editorPublishButton, editorPublishInputsError, + editorRetryNewsletterButton, editorSaveButton, editorUnpublishButton, editorUnscheduleButton, @@ -138,6 +140,7 @@ export const editorScreen = { return pane; }, loadError: () => page.getByTestId(editorLoadError), + retryLoad: () => page.getByTestId(editorLoadError).getByRole('button', { name: 'Retry' }), /** The sign-in dialog a save that finds no session opens, and its two steps. */ reauthDialog: () => page.getByTestId(editorReauthDialog), reauthEmail: () => page.getByTestId(editorReauthDialog).getByLabelText('Email'), @@ -162,6 +165,11 @@ export const editorScreen = { .getByTestId(editorConflictReloadConfirm) .getByRole('button', { name: conflictCancelReloadButton }), status: () => page.getByTestId(editorStatus), + /** The status line's ways back into the publish flow once a newsletter failed. */ + retryNewsletter: () => + page.getByTestId(editorStatus).getByRole('button', { name: editorRetryNewsletterButton }), + viewNewsletterDetails: () => + page.getByTestId(editorStatus).getByRole('button', { name: editorNewsletterDetailsButton }), pendingSaveNotice: () => page.getByRole('status').filter({ hasText: 'Changes are waiting to save.' }), diff --git a/apps/admin/src/editor/local-revisions.test.ts b/apps/admin/src/editor/local-revisions.test.ts new file mode 100644 index 00000000000..f0204ab0b97 --- /dev/null +++ b/apps/admin/src/editor/local-revisions.test.ts @@ -0,0 +1,447 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createLocalRevisionWriter, + readLocalRevisions, + writeLocalRevision, + type LocalRevision, + type LocalRevisionDraft, + type StoredLocalRevision, +} from '@/editor/local-revisions'; + +/** An in-memory Storage that refuses writes past `capacity` characters, as a full browser store does. */ +class MemoryStorage implements Storage { + private readonly entries = new Map(); + private readonly capacity: number; + + constructor(capacity = Infinity) { + this.capacity = capacity; + } + + get length(): number { + return this.entries.size; + } + + key(index: number): string | null { + return [...this.entries.keys()][index] ?? null; + } + + getItem(key: string): string | null { + return this.entries.get(key) ?? null; + } + + setItem(key: string, value: string): void { + const used = [...this.entries].reduce( + (total, [k, v]) => (k === key ? total : total + k.length + v.length), + 0, + ); + if (used + key.length + value.length > this.capacity) { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + } + this.entries.set(key, value); + } + + removeItem(key: string): void { + this.entries.delete(key); + } + + clear(): void { + this.entries.clear(); + } + + keys(): string[] { + return [...this.entries.keys()]; + } +} + +const FIELDS = { + title: 'Hello', + slug: 'hello', + status: 'draft', + lexical: '{"root":{"children":[]}}', + authors: [{ id: 'user-1' }], + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }], +}; + +function revision(overrides: Partial = {}): LocalRevision { + return { id: 'post-1', type: 'post', revisionTimestamp: 1000, ...FIELDS, ...overrides }; +} + +function draft(overrides: Partial = {}): LocalRevisionDraft { + return { id: 'post-1', ...FIELDS, ...overrides }; +} + +describe('writeLocalRevision', () => { + it('stores the revision as JSON under its post id and timestamp', () => { + const storage = new MemoryStorage(); + const onError = vi.fn(); + + const key = writeLocalRevision(storage, revision(), onError); + + expect(key).toBe('post-revision-post-1-1000'); + expect(JSON.parse(storage.getItem('post-revision-post-1-1000') ?? '')).toEqual(revision()); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps the newest five revisions of a post and leaves other posts alone', () => { + const storage = new MemoryStorage(); + writeLocalRevision(storage, revision({ id: 'post-2', revisionTimestamp: 1 }), vi.fn()); + for (let timestamp = 1; timestamp <= 6; timestamp += 1) { + writeLocalRevision(storage, revision({ revisionTimestamp: timestamp }), vi.fn()); + } + + expect(storage.keys().sort()).toEqual([ + 'post-revision-post-1-2', + 'post-revision-post-1-3', + 'post-revision-post-1-4', + 'post-revision-post-1-5', + 'post-revision-post-1-6', + 'post-revision-post-2-1', + ]); + }); + + it('never trims revisions of a post that has not been created', () => { + const storage = new MemoryStorage(); + for (let timestamp = 1; timestamp <= 7; timestamp += 1) { + writeLocalRevision(storage, revision({ id: 'draft', revisionTimestamp: timestamp }), vi.fn()); + } + + expect(storage.length).toBe(7); + }); + + it('gives up the oldest revisions, of any post, until a write fits', () => { + const entry = (id: string, timestamp: number) => + revision({ id, revisionTimestamp: timestamp, lexical: 'x'.repeat(400) }); + const size = (value: LocalRevision) => + `post-revision-${value.id}-${value.revisionTimestamp}`.length + JSON.stringify(value).length; + const storage = new MemoryStorage(size(entry('post-2', 1)) * 3 + 10); + const onError = vi.fn(); + writeLocalRevision(storage, entry('post-2', 1), onError); + writeLocalRevision(storage, entry('post-3', 2), onError); + writeLocalRevision(storage, entry('post-1', 3), onError); + + const key = writeLocalRevision(storage, entry('post-1', 4), onError); + + expect(key).toBe('post-revision-post-1-4'); + expect(storage.keys().sort()).toEqual([ + 'post-revision-post-1-3', + 'post-revision-post-1-4', + 'post-revision-post-3-2', + ]); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + tags: { localRevisions: 'quotaExceeded' }, + }); + }); + + it('evicts by the time in the key, and a key without one first', () => { + const value = revision({ lexical: 'x'.repeat(200) }); + const size = `post-revision-post-1-1000`.length + JSON.stringify(value).length; + const storage = new MemoryStorage(size * 2 + 10); + writeLocalRevision( + storage, + revision({ id: 'post-2', revisionTimestamp: 1, lexical: 'x'.repeat(200) }), + vi.fn(), + ); + storage.setItem('post-revision-post-9-x', 'not json'.padEnd(size - 25, '!')); + + writeLocalRevision(storage, value, vi.fn()); + + expect(storage.getItem('post-revision-post-9-x')).toBeNull(); + expect(storage.getItem('post-revision-post-2-1')).not.toBeNull(); + expect(storage.getItem('post-revision-post-1-1000')).not.toBeNull(); + }); + + it('keeps a revision a skewed clock stamped older than the ones already kept', () => { + const storage = new MemoryStorage(); + for (let timestamp = 10; timestamp <= 14; timestamp += 1) { + writeLocalRevision(storage, revision({ revisionTimestamp: timestamp }), vi.fn()); + } + + writeLocalRevision(storage, revision({ revisionTimestamp: 1 }), vi.fn()); + + expect(storage.keys().sort()).toEqual([ + 'post-revision-post-1-1', + 'post-revision-post-1-11', + 'post-revision-post-1-12', + 'post-revision-post-1-13', + 'post-revision-post-1-14', + ]); + }); + + it('reports a revision that cannot fit even in an empty store', () => { + const storage = new MemoryStorage(10); + const onError = vi.fn(); + + expect(writeLocalRevision(storage, revision(), onError)).toBeUndefined(); + + expect(storage.length).toBe(0); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + tags: { localRevisions: 'quotaExceededNoSpace' }, + }); + }); + + it('reports any other storage failure instead of throwing', () => { + const storage = new MemoryStorage(); + const failure = new Error('storage is broken'); + vi.spyOn(storage, 'setItem').mockImplementation(() => { + throw failure; + }); + const onError = vi.fn(); + + expect(writeLocalRevision(storage, revision(), onError)).toBeUndefined(); + + expect(onError).toHaveBeenCalledWith(failure, { tags: { localRevisions: 'saveError' } }); + }); +}); + +describe('readLocalRevisions', () => { + it('lists every revision newest first, with the key it is stored under', () => { + const storage = new MemoryStorage(); + writeLocalRevision(storage, revision({ revisionTimestamp: 1 }), vi.fn()); + writeLocalRevision(storage, revision({ id: 'post-2', revisionTimestamp: 3 }), vi.fn()); + writeLocalRevision(storage, revision({ revisionTimestamp: 2 }), vi.fn()); + + expect(readLocalRevisions(storage).map(({ key }) => key)).toEqual([ + 'post-revision-post-2-3', + 'post-revision-post-1-2', + 'post-revision-post-1-1', + ]); + expect(readLocalRevisions(storage)[0]).toMatchObject({ id: 'post-2', title: 'Hello' }); + }); + + it('keeps every field of an entry that carries more than the editor writes', () => { + const storage = new MemoryStorage(); + storage.setItem( + 'post-revision-post-1-5', + JSON.stringify({ + ...revision({ revisionTimestamp: 5 }), + authors: [{ id: 'user-1', name: 'Jo', email: 'jo@example.com' }], + meta_title: 'Meta', + }), + ); + + expect(readLocalRevisions(storage)).toEqual([ + expect.objectContaining({ + key: 'post-revision-post-1-5', + authors: [{ id: 'user-1', name: 'Jo', email: 'jo@example.com' }], + meta_title: 'Meta', + }), + ]); + }); + + it('skips entries it cannot read and keys that are not revisions', () => { + const storage = new MemoryStorage(); + storage.setItem('post-revision-post-1-1', 'not json'); + storage.setItem('post-revision-post-1-2', JSON.stringify(['a list'])); + storage.setItem('post-revision-post-1-3', JSON.stringify({ title: 'No timestamp' })); + storage.setItem('ghost-last-published-post', JSON.stringify(revision())); + writeLocalRevision(storage, revision({ revisionTimestamp: 4 }), vi.fn()); + + expect(readLocalRevisions(storage).map(({ key }) => key)).toEqual(['post-revision-post-1-4']); + }); +}); + +describe('createLocalRevisionWriter', () => { + let storage: MemoryStorage; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-29T10:00:00.000Z')); + storage = new MemoryStorage(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function writer(type: 'post' | 'page' = 'post') { + return createLocalRevisionWriter({ type, storage: () => storage, onError: vi.fn() }); + } + + function stored(): StoredLocalRevision[] { + return readLocalRevisions(storage); + } + + it('starts nothing until something is recorded', () => { + writer(); + + expect(vi.getTimerCount()).toBe(0); + expect(storage.length).toBe(0); + }); + + it('writes the first draft at once, stamped with its type and time', () => { + writer('page').record(draft({ title: 'First' })); + + expect(stored()).toEqual([ + expect.objectContaining({ + id: 'post-1', + type: 'page', + title: 'First', + revisionTimestamp: Date.parse('2026-09-29T10:00:00.000Z'), + }), + ]); + }); + + it('stores a post that has not been created under the draft id', () => { + writer().record(draft({ id: null })); + + expect(stored()[0].key).toBe(`post-revision-draft-${Date.now()}`); + }); + + it('collapses the drafts of the following minute into one write of the newest', () => { + const revisions = writer(); + revisions.record(draft({ title: 'First' })); + + vi.advanceTimersByTime(10_000); + revisions.record(draft({ title: 'Second' })); + vi.advanceTimersByTime(10_000); + revisions.record(draft({ title: 'Third' })); + + expect(stored().map(({ title }) => title)).toEqual(['First']); + + vi.advanceTimersByTime(40_000); + + expect(stored().map(({ title }) => title)).toEqual(['Third', 'First']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('writes at once again after a quiet minute', () => { + const revisions = writer(); + revisions.record(draft({ title: 'First' })); + + vi.advanceTimersByTime(60_000); + revisions.record(draft({ title: 'Second' })); + + expect(stored().map(({ title }) => title)).toEqual(['Second', 'First']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('flushes the given draft now and drops the one waiting for the minute', () => { + const revisions = writer(); + revisions.record(draft({ title: 'First' })); + vi.advanceTimersByTime(1_000); + revisions.record(draft({ title: 'Waiting' })); + + revisions.flush(draft({ title: 'Latest' })); + vi.advanceTimersByTime(60_000); + + expect(stored().map(({ title }) => title)).toEqual(['Latest', 'First']); + }); + + it('discards the draft waiting for the minute', () => { + const revisions = writer(); + revisions.record(draft({ title: 'First' })); + vi.advanceTimersByTime(1_000); + revisions.record(draft({ title: 'Waiting' })); + + revisions.discard(); + vi.advanceTimersByTime(60_000); + + expect(stored().map(({ title }) => title)).toEqual(['First']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('skips a draft identical to the last copy written', () => { + const revisions = writer(); + revisions.record(draft({ title: 'Same' })); + + vi.advanceTimersByTime(1_000); + revisions.record(draft({ title: 'Same' })); + vi.advanceTimersByTime(60_000); + revisions.flush(draft({ title: 'Same' })); + + expect(stored().map(({ title }) => title)).toEqual(['Same']); + }); + + it('keeps at most five copies of a post it has not created, leaving other sessions alone', () => { + storage.setItem( + 'post-revision-draft-1', + JSON.stringify(revision({ id: 'draft', revisionTimestamp: 1 })), + ); + const revisions = writer(); + + for (let index = 1; index <= 7; index += 1) { + revisions.record(draft({ id: null, title: `Take ${index}` })); + vi.advanceTimersByTime(60_000); + } + + expect(stored().map(({ title }) => title)).toEqual([ + 'Take 7', + 'Take 6', + 'Take 5', + 'Take 4', + 'Take 3', + 'Hello', + ]); + }); + + it('removes its copies of a post that has not been created once it is', () => { + storage.setItem( + 'post-revision-draft-1', + JSON.stringify(revision({ id: 'draft', revisionTimestamp: 1 })), + ); + const revisions = writer(); + revisions.record(draft({ id: null, title: 'Before the create' })); + vi.advanceTimersByTime(60_000); + revisions.record(draft({ id: null, title: 'Still before the create' })); + + revisions.created(); + vi.advanceTimersByTime(60_000); + revisions.record(draft({ id: 'post-1', title: 'After the create' })); + + expect( + stored().map(({ key, title }) => [key.split('-').slice(2, -1).join('-'), title]), + ).toEqual([ + ['post-1', 'After the create'], + ['draft', 'Hello'], + ]); + }); + + it('writes at once when the clock has moved backwards', () => { + const revisions = writer(); + revisions.record(draft({ title: 'First' })); + + vi.setSystemTime(new Date('2026-09-29T09:00:00.000Z')); + revisions.record(draft({ title: 'Second' })); + + expect(stored().map(({ title }) => title)).toEqual(['First', 'Second']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('writes an identical draft again after the write before it failed', () => { + const failure = new Error('storage is broken'); + const setItem = storage.setItem.bind(storage); + vi.spyOn(storage, 'setItem') + .mockImplementationOnce(() => { + throw failure; + }) + .mockImplementation(setItem); + const revisions = writer(); + + revisions.record(draft({ title: 'Same' })); + vi.advanceTimersByTime(60_000); + revisions.record(draft({ title: 'Same' })); + + expect(stored().map(({ title }) => title)).toEqual(['Same']); + }); + + it('reports storage that cannot be reached once instead of throwing', () => { + const onError = vi.fn(); + const failure = new DOMException('Access is denied.', 'SecurityError'); + const revisions = createLocalRevisionWriter({ + type: 'post', + storage: () => { + throw failure; + }, + onError, + }); + + revisions.record(draft({ title: 'First' })); + vi.advanceTimersByTime(60_000); + revisions.record(draft({ title: 'Second' })); + revisions.flush(draft({ title: 'Third' })); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(failure, { tags: { localRevisions: 'saveError' } }); + }); +}); diff --git a/apps/admin/src/editor/local-revisions.ts b/apps/admin/src/editor/local-revisions.ts new file mode 100644 index 00000000000..407d62b1b64 --- /dev/null +++ b/apps/admin/src/editor/local-revisions.ts @@ -0,0 +1,291 @@ +import type { EditorErrorContext } from '@/editor/report-error'; + +/** Every entry is stored under `post-revision--`. */ +export const LOCAL_REVISION_PREFIX = 'post-revision'; + +const UNSAVED_POST_ID = 'draft'; +const MIN_WRITE_INTERVAL_MS = 60_000; +const KEPT_PER_POST = 5; + +export interface LocalRevisionAuthor { + id?: string; +} + +export interface LocalRevisionTag { + id?: string; + name?: string; + slug?: string | null; +} + +/** A copy of a post as the editor held it. Entries may carry more fields than these. */ +export interface LocalRevision { + id: string; + type: string; + revisionTimestamp: number; + title?: string; + slug?: string; + status?: string; + lexical?: string | null; + custom_excerpt?: string | null; + excerpt?: string | null; + feature_image?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + authors?: LocalRevisionAuthor[]; + tags?: LocalRevisionTag[]; +} + +export interface StoredLocalRevision extends LocalRevision { + key: string; +} + +/** What the editor hands over: `id` is null until the post has been created. */ +export type LocalRevisionDraft = Omit & { + id: string | null; +}; + +type ErrorReporter = (error: unknown, context?: EditorErrorContext) => void; + +function revisionKey(id: string, timestamp: number): string { + return `${LOCAL_REVISION_PREFIX}-${id}-${timestamp}`; +} + +function revisionKeys(storage: Storage, prefix = `${LOCAL_REVISION_PREFIX}-`): string[] { + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(prefix)) { + keys.push(key); + } + } + return keys; +} + +function parseRevision(value: string | null): LocalRevision | null { + if (value === null) { + return null; + } + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + const revision = parsed as LocalRevision; + return typeof revision.revisionTimestamp === 'number' ? revision : null; + } catch { + return null; + } +} + +/** Every readable entry, newest first. Unreadable entries are skipped, never thrown. */ +export function readLocalRevisions(storage: Storage): StoredLocalRevision[] { + const revisions: StoredLocalRevision[] = []; + for (const key of revisionKeys(storage)) { + const revision = parseRevision(storage.getItem(key)); + if (revision) { + revisions.push({ ...revision, key }); + } + } + return revisions.sort((a, b) => b.revisionTimestamp - a.revisionTimestamp); +} + +function isQuotaExceeded(error: unknown): boolean { + return ( + error instanceof DOMException && + (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') + ); +} + +// Every key ends in the time it was written, so ordering never needs to parse an entry. +function keyTimestamp(key: string): number { + const timestamp = Number(key.slice(key.lastIndexOf('-') + 1)); + return Number.isFinite(timestamp) ? timestamp : -Infinity; +} + +function byAge(keys: string[]): string[] { + return keys.sort((a, b) => keyTimestamp(a) - keyTimestamp(b)); +} + +// The key just written is kept even when a skewed clock stamped it older than the rest. +function keepNewest(storage: Storage, id: string, written: string): void { + if (id === UNSAVED_POST_ID) { + return; + } + const others = byAge(revisionKeys(storage, `${LOCAL_REVISION_PREFIX}-${id}-`)).filter( + (key) => key !== written, + ); + for (const key of others.slice(0, Math.max(0, others.length - (KEPT_PER_POST - 1)))) { + storage.removeItem(key); + } +} + +/** + * Stores one revision and keeps the newest five per post; unsaved drafts are + * never trimmed. A full store gives up its oldest entries until the write fits. + */ +export function writeLocalRevision( + storage: Storage, + revision: LocalRevision, + onError: ErrorReporter, +): string | undefined { + const key = revisionKey(revision.id, revision.revisionTimestamp); + const value = JSON.stringify(revision); + let evictable: string[] | null = null; + + for (;;) { + try { + storage.setItem(key, value); + keepNewest(storage, revision.id, key); + return key; + } catch (error) { + if (!isQuotaExceeded(error)) { + onError(error, { tags: { localRevisions: 'saveError' } }); + return undefined; + } + storage.removeItem(key); + if (evictable === null) { + evictable = byAge(revisionKeys(storage)); + if (evictable.length > 0) { + onError(new Error('LocalStorage quota exceeded. Removing old revisions.'), { + tags: { localRevisions: 'quotaExceeded' }, + }); + } + } + const oldest = evictable.shift(); + if (oldest === undefined) { + onError(new Error('LocalStorage quota exceeded. Unable to save revision.'), { + tags: { localRevisions: 'quotaExceededNoSpace' }, + }); + return undefined; + } + storage.removeItem(oldest); + } + } +} + +export interface LocalRevisionWriter { + /** Writes at once when the last write is a minute old; otherwise the newest draft waits for the minute. */ + record: (draft: LocalRevisionDraft) => void; + /** Writes the draft now and drops whatever was waiting. */ + flush: (draft: LocalRevisionDraft) => void; + /** Drops whatever was waiting without writing it. */ + discard: () => void; + /** The post now exists on the server, so this writer's copies under the unsaved-post id go. */ + created: () => void; +} + +export interface LocalRevisionWriterOptions { + type: 'post' | 'page'; + /** Read on every write: reading `localStorage` can throw where storage is blocked. */ + storage: () => Storage; + onError: ErrorReporter; + now?: () => number; + minWriteIntervalMs?: number; +} + +/** + * Creating a writer starts nothing. A draft identical to the last copy written + * is skipped, and a post that has not been created keeps at most five copies. + */ +export function createLocalRevisionWriter({ + type, + storage, + onError, + now = Date.now, + minWriteIntervalMs = MIN_WRITE_INTERVAL_MS, +}: LocalRevisionWriterOptions): LocalRevisionWriter { + let lastWriteAt: number | null = null; + let lastWritten: string | null = null; + let waiting: LocalRevisionDraft | null = null; + let timer: ReturnType | null = null; + let unsavedKeys: string[] = []; + let storageBlocked = false; + + function reachStorage(): Storage | null { + try { + return storage(); + } catch (error) { + // Blocked storage stays blocked for the page's life; one report says so. + if (!storageBlocked) { + storageBlocked = true; + onError(error, { tags: { localRevisions: 'saveError' } }); + } + return null; + } + } + + function write(draft: LocalRevisionDraft): void { + if (storageBlocked) { + return; + } + const content = JSON.stringify(draft); + if (content === lastWritten) { + return; + } + const store = reachStorage(); + if (!store) { + return; + } + const timestamp = now(); + lastWriteAt = timestamp; + const key = writeLocalRevision( + store, + { ...draft, id: draft.id ?? UNSAVED_POST_ID, type, revisionTimestamp: timestamp }, + onError, + ); + if (key) { + lastWritten = content; + } + if (key && draft.id === null) { + unsavedKeys = [...unsavedKeys.filter((kept) => kept !== key), key]; + for (const stale of unsavedKeys.splice(0, Math.max(0, unsavedKeys.length - KEPT_PER_POST))) { + store.removeItem(stale); + } + } + } + + function cancelWaiting(): void { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + waiting = null; + } + + return { + record: (draft) => { + if (timer !== null) { + waiting = draft; + return; + } + const elapsed = lastWriteAt === null ? Infinity : now() - lastWriteAt; + // A clock that moved backwards counts as a quiet minute. + if (elapsed < 0 || elapsed >= minWriteIntervalMs) { + write(draft); + return; + } + waiting = draft; + timer = setTimeout(() => { + timer = null; + const next = waiting; + waiting = null; + if (next) { + write(next); + } + }, minWriteIntervalMs - elapsed); + }, + flush: (draft) => { + cancelWaiting(); + write(draft); + }, + discard: cancelWaiting, + created: () => { + const keys = unsavedKeys; + unsavedKeys = []; + const store = keys.length > 0 ? reachStorage() : null; + for (const key of keys) { + store?.removeItem(key); + } + }, + }; +} diff --git a/apps/admin/src/editor/post-editor.acceptance.test.tsx b/apps/admin/src/editor/post-editor.acceptance.test.tsx index 0e6de177783..2ac230ad2d6 100644 --- a/apps/admin/src/editor/post-editor.acceptance.test.tsx +++ b/apps/admin/src/editor/post-editor.acceptance.test.tsx @@ -317,6 +317,29 @@ describe('Post editor', () => { await expect(editorScreen.body()).toHaveCount(0); }); + it('shows the load error when the post cannot be read, and opens it on retry', async () => { + fakeEditorChrome(); + fakeAdminEndpoint( + 'GET', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ type: 'InternalServerError', message: 'Boom' }] }, + { status: 500 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.loadError()).toHaveTextContent('Couldn’t load this post.'); + await expect(editorScreen.body()).toHaveCount(0); + + // A later handler for the same route wins: the retried read finds the post. + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [post({ id: POST_ID, lexical: buildLexicalParagraph('Hello from React') })], + }); + await editorScreen.retryLoad().click(); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await expect(editorScreen.loadError()).toHaveCount(0); + }); + it('shows a 404 for a post that does not exist', async () => { fakeEditorChrome(); fakeAdminEndpoint( diff --git a/apps/admin/src/editor/publish/README.md b/apps/admin/src/editor/publish/README.md index e835ae61058..127fa518f66 100644 --- a/apps/admin/src/editor/publish/README.md +++ b/apps/admin/src/editor/publish/README.md @@ -152,6 +152,10 @@ It is self-contained: the caller supplies the post projection, the site and user The stateful journey is keyed by post id. If a mounted caller replaces the post, the gates, options machine, limits readiness, failures and completion state all start again for the new post. +## Opening the flow + +The flow opens at its email-failure step for a published or sent post whose email failed, and at the options step for anything else. `initialEmailError()` is that test, exported so a caller can tell whether opening the flow leads to a retry. A caller must not open the flow before `usePublishInputs()` reports the inputs ready: the machine is built from them once. + ## Steps The flow is a four-way branch, taken in this order: @@ -194,6 +198,8 @@ A reload that throws — a transport failure, or the 401 the redirect opt-out be The email's id is only knowable from a reload, so the poller's reload records it for the retry. For the same reason the flow polls rather than short-circuiting on a known email: the acknowledged save result carries no email, and the pre-save one would resolve the confirmation to "not needed" immediately. Closing the flow cancels the poll and marks every pending pre-save, save, confirmation and retry continuation as abandoned, so none can complete the post journey after the caller closes it. +The poller reads the post around the query cache, so the cached post reads never see what it found. Once a confirmation settles with any outcome but `cancelled`, after a publish or a retry, the flow invalidates the post reads so whatever is drawn from them catches up with the send. A reload that throws leaves them alone, since a refetch would most likely fail the same way. + ## Requests Every request the flow makes passes the editor's shared request options, which opt out of the transport's session-expiry redirect: the two it issues directly (the poller's reload and the published-post count), the settings, config, newsletter, tier, label and recipient-count reads behind its hooks, the member and email counts the limit ports read through the limiter, and the email retry, which carries the same flag on its mutation payload. An expired session is left to surface where the user is — as an uncounted audience, a note on the complete step, or an error on the email-error step. diff --git a/apps/admin/src/editor/publish/use-publish-flow.test.ts b/apps/admin/src/editor/publish/use-publish-flow.test.ts index aab72ce9405..faeb57f03a2 100644 --- a/apps/admin/src/editor/publish/use-publish-flow.test.ts +++ b/apps/admin/src/editor/publish/use-publish-flow.test.ts @@ -1,6 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createElement, type ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { usePublishFlow, type PublishFlowOptions } from './use-publish-flow'; +import type { EmailConfirmationOutcome } from './email-confirmation'; import type { NewsletterInput } from './publish-options'; const transport = vi.hoisted(() => ({ fetchApi: vi.fn(), retryEmail: vi.fn() })); @@ -9,6 +12,25 @@ vi.mock('@tryghost/admin-x-framework/api/emails', () => ({ useRetryEmail: () => ({ mutateAsync: transport.retryEmail }), })); +// A confirmation each spec settles itself; tearing the flow down settles it as cancelled. +const confirmation = vi.hoisted(() => ({ + settle: undefined as ((outcome: EmailConfirmationOutcome) => void) | undefined, +})); +vi.mock('./email-confirmation', async (importOriginal) => ({ + ...(await importOriginal()), + createEmailConfirmation: () => ({ + confirm: () => + new Promise((resolve) => { + confirmation.settle = resolve; + }), + retryAndConfirm: () => + new Promise((resolve) => { + confirmation.settle = resolve; + }), + cancel: () => confirmation.settle?.({ kind: 'cancelled' }), + }), +})); + const NOW = new Date('2026-09-02T10:00:00.000Z'); const SCHEDULED_AT = '2026-09-03T10:00:00.000Z'; const WEEKLY: NewsletterInput = { slug: 'weekly', name: 'Weekly', status: 'active', sortOrder: 0 }; @@ -41,15 +63,20 @@ function options(): PublishFlowOptions { }; } +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: new QueryClient() }, children); +} + afterEach(() => { localStorage.clear(); vi.clearAllMocks(); + confirmation.settle = undefined; }); describe('publish option actions', () => { it('renders changed options and confirms the same command without a caller refresh', async () => { const inputs = options(); - const { result } = renderHook(() => usePublishFlow(inputs)); + const { result } = renderHook(() => usePublishFlow(inputs), { wrapper }); await waitFor(() => expect(result.current.limitsChecked).toBe(true)); act(() => result.current.setPublishType('send')); @@ -87,6 +114,7 @@ describe('publish option actions', () => { const inputs = options(); const { result, rerender } = renderHook((props) => usePublishFlow(props), { initialProps: inputs, + wrapper, }); await waitFor(() => expect(result.current.limitsChecked).toBe(true)); const setNewsletter = result.current.setNewsletter; @@ -100,3 +128,95 @@ describe('publish option actions', () => { expect(inputs.dispatch).not.toHaveBeenCalled(); }); }); + +describe('post reads after an emailed publish', () => { + /** Publishes and emails, leaving the flow waiting on its email confirmation. */ + async function publishAndEmail() { + const client = new QueryClient(); + const invalidateQueries = vi.spyOn(client, 'invalidateQueries'); + const inputs = options(); + const { result } = renderHook(() => usePublishFlow(inputs), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children), + }); + await waitFor(() => expect(result.current.limitsChecked).toBe(true)); + expect(result.current.state.willEmailImmediately).toBe(true); + + act(() => result.current.toConfirm()); + let publishing: Promise = Promise.resolve(); + act(() => { + publishing = result.current.confirmPublish(); + }); + await waitFor(() => expect(confirmation.settle).toBeDefined()); + + return { result, invalidateQueries, publishing }; + } + + it('refreshes them once the send is confirmed', async () => { + const { invalidateQueries, publishing } = await publishAndEmail(); + + await act(async () => { + confirmation.settle?.({ kind: 'submitted' }); + await publishing; + }); + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['PostsResponseType'] }); + }); + + it.each([null, ''])('keeps a failed send recoverable with error %j', async (error) => { + const { result, invalidateQueries, publishing } = await publishAndEmail(); + + await act(async () => { + confirmation.settle?.({ kind: 'failed', error, partial: false }); + await publishing; + }); + + expect(result.current.step).toBe('email-error'); + expect(result.current.emailErrorMessage).toBe('Unknown error'); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['PostsResponseType'] }); + }); + + it('leaves them alone when the flow is closed before the send is confirmed', async () => { + const { result, invalidateQueries, publishing } = await publishAndEmail(); + + await act(async () => { + result.current.cancel(); + await publishing; + }); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); +}); + +describe('failed newsletter retry', () => { + it.each([null, ''])('keeps a failed retry recoverable with error %j', async (error) => { + const inputs = options(); + inputs.post = { + ...inputs.post, + status: 'published', + email: { + id: 'email-1', + status: 'failed', + error: 'The email service was unavailable.', + email_count: 20, + opened_count: 0, + }, + }; + const { result } = renderHook(() => usePublishFlow(inputs), { wrapper }); + await waitFor(() => expect(result.current.limitsChecked).toBe(true)); + + let retrying: Promise = Promise.resolve(); + act(() => { + retrying = result.current.retryEmail(); + }); + + await act(async () => { + confirmation.settle?.({ kind: 'failed', error, partial: false }); + await retrying; + }); + + expect(result.current.step).toBe('email-error'); + expect(result.current.emailErrorMessage).toBe('Unknown error'); + expect(result.current.retryStatus).toBe('idle'); + }); +}); diff --git a/apps/admin/src/editor/publish/use-publish-flow.ts b/apps/admin/src/editor/publish/use-publish-flow.ts index a4d196bbbfe..a36c636a0a2 100644 --- a/apps/admin/src/editor/publish/use-publish-flow.ts +++ b/apps/admin/src/editor/publish/use-publish-flow.ts @@ -1,7 +1,10 @@ import { apiUrl } from '@tryghost/admin-x-framework/helpers'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useFetchApi } from '@tryghost/admin-x-framework/hooks'; import { useRetryEmail } from '@tryghost/admin-x-framework/api/emails'; +import { pagesDataType } from '@tryghost/admin-x-framework/api/pages'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; import { confirmationResponseSchema, publishedPostCountResponseSchema, @@ -86,13 +89,14 @@ const UNKNOWN_RETRY_ERROR = 'Unknown Error occurred when attempting to resend'; export const EMAIL_UNCONFIRMED = 'We couldn’t confirm the newsletter was sent. Check the post’s email status from the posts list.'; -function initialEmailError(post: PublishFlowPost): string | null { +/** The error the flow opens on: set only for a published or sent post whose email failed. */ +export function initialEmailError(post: PublishFlowPost): string | null { const didEmailFail = post.displayName === 'post' && (post.status === 'published' || post.status === 'sent') && post.email?.status === 'failed'; - return didEmailFail ? (post.email?.error ?? UNKNOWN_EMAIL_ERROR) : null; + return didEmailFail ? post.email?.error || UNKNOWN_EMAIL_ERROR : null; } export function usePublishFlow({ @@ -106,6 +110,7 @@ export function usePublishFlow({ onCompleted, }: PublishFlowOptions): PublishFlow { const fetchApi = useFetchApi(); + const queryClient = useQueryClient(); const { mutateAsync: retryEmailRequest } = useRetryEmail(); const [, refresh] = useReducer((tick: number) => tick + 1, 0); @@ -182,6 +187,18 @@ export function usePublishFlow({ [fetchApi, retryEmailRequest], ); + // The poll reads around the query cache, so a settled send refreshes the post reads. + const refreshPostReads = useCallback( + (outcome: EmailConfirmationOutcome) => { + if (outcome.kind !== 'cancelled') { + void queryClient.invalidateQueries({ + queryKey: [post.displayName === 'page' ? pagesDataType : postsDataType], + }); + } + }, + [post.displayName, queryClient], + ); + const [step, setStep] = useState(() => initialEmailError(post) ? 'email-error' : 'options', ); @@ -361,7 +378,7 @@ export function usePublishFlow({ } if (outcome.kind === 'failed') { - setEmailErrorMessage(outcome.error ?? UNKNOWN_EMAIL_ERROR); + setEmailErrorMessage(outcome.error || UNKNOWN_EMAIL_ERROR); setStep('email-error'); setConfirmStatus('idle'); return; @@ -464,6 +481,7 @@ export function usePublishFlow({ return; } + refreshPostReads(outcome); applyEmailOutcome(outcome, isScheduled); return; } @@ -477,6 +495,7 @@ export function usePublishFlow({ machine, onBeforePublish, post.id, + refreshPostReads, state, ]); @@ -499,6 +518,7 @@ export function usePublishFlow({ try { const outcome = await confirmation.retryAndConfirm(post.id, emailId); + refreshPostReads(outcome); if (!activeRef.current) { return; @@ -507,7 +527,7 @@ export function usePublishFlow({ if (outcome.kind === 'failed' || outcome.kind === 'cancelled') { retryRunningRef.current = false; if (outcome.kind === 'failed') { - setEmailErrorMessage(outcome.error ?? UNKNOWN_EMAIL_ERROR); + setEmailErrorMessage(outcome.error || UNKNOWN_EMAIL_ERROR); } setRetryStatus('idle'); return; @@ -525,7 +545,7 @@ export function usePublishFlow({ setRetryStatus('failure'); } } - }, [complete, confirmation, post.id]); + }, [complete, confirmation, post.id, refreshPostReads]); return { ...optionActions, diff --git a/apps/admin/src/editor/restore/restore-screen.acceptance.test.tsx b/apps/admin/src/editor/restore/restore-screen.acceptance.test.tsx new file mode 100644 index 00000000000..b8ae37d3432 --- /dev/null +++ b/apps/admin/src/editor/restore/restore-screen.acceptance.test.tsx @@ -0,0 +1,291 @@ +import moment from 'moment-timezone'; +import { beforeEach, describe, expect, it, onTestFinished } from 'vitest'; +import { page } from 'vitest/browser'; +import { + currentRoute, + currentUserResponse, + fakeAdminEndpoint, + renderAdminApp, + staffRole, + type CapturedEndpointRequest, +} from '@test-utils/acceptance'; +import { LOCAL_REVISION_PREFIX } from '@/editor/local-revisions'; +import { sidebarScreen } from '@/layout/sidebar.screen'; +import { restoreScreen } from '@/editor/restore/restore.screen'; + +const OLDER = Date.parse('2026-09-28T09:15:00.000Z'); +const NEWER = Date.parse('2026-09-29T11:45:00.000Z'); +const LEXICAL = JSON.stringify({ + root: { + type: 'root', + version: 1, + direction: null, + format: '', + indent: 0, + children: [ + { + type: 'paragraph', + version: 1, + direction: null, + format: '', + indent: 0, + children: [{ type: 'extended-text', version: 1, text: 'Words worth keeping' }], + }, + ], + }, +}); + +function clearLocalRevisions(): void { + for (const key of Object.keys(localStorage)) { + if (key.startsWith(`${LOCAL_REVISION_PREFIX}-`)) { + localStorage.removeItem(key); + } + } +} + +function storeCopy(id: string, timestamp: number, fields: Record): void { + localStorage.setItem( + `${LOCAL_REVISION_PREFIX}-${id}-${timestamp}`, + JSON.stringify({ id, revisionTimestamp: timestamp, ...fields }), + ); +} + +function created(request: CapturedEndpointRequest | undefined, resource: 'posts' | 'pages') { + const body = request?.body as Record[]> | undefined; + return body?.[resource][0] ?? {}; +} + +/** + * The restore screen lists the copies the editor keeps in this browser and turns + * any of them back into a draft. + */ +describe('Restore posts', () => { + beforeEach(() => { + clearLocalRevisions(); + onTestFinished(clearLocalRevisions); + }); + + it('lists every local copy newest first, with its title, excerpt and time', async () => { + storeCopy('post-1', OLDER, { + type: 'post', + title: 'An older draft', + custom_excerpt: 'What it was about', + }); + // Copies can carry the post's full serialization, with its excerpt under `excerpt`. + storeCopy('post-2', NEWER, { + type: 'post', + title: 'A newer draft', + excerpt: 'Written moments ago', + authors: [{ id: '1', name: 'Jo', email: 'jo@example.com' }], + }); + storeCopy('post-4', OLDER - 1000, { type: 'post', title: 'No excerpt', lexical: LEXICAL }); + localStorage.setItem(`${LOCAL_REVISION_PREFIX}-post-3-${NEWER}`, 'not a copy'); + + await renderAdminApp('/restore'); + + await expect.element(restoreScreen.heading()).toBeVisible(); + await expect(restoreScreen.revisionRows()).toHaveCount(3); + await expect.element(restoreScreen.revisionRows().nth(0)).toHaveTextContent('A newer draft'); + await expect.element(restoreScreen.revisionRows().nth(1)).toHaveTextContent('An older draft'); + await expect + .element(restoreScreen.revisionRow('A newer draft')) + .toHaveTextContent('Written moments ago'); + await expect + .element(restoreScreen.revisionRow('An older draft')) + .toHaveTextContent('What it was about'); + await expect + .element(restoreScreen.revisionRow('An older draft')) + .toHaveTextContent(moment(OLDER).format('MMM D, YYYY HH:mm')); + await expect + .element(restoreScreen.revisionRow('No excerpt')) + .toHaveTextContent('Words worth keeping'); + }); + + it('says so when this browser holds no copies', async () => { + await renderAdminApp('/restore'); + + await expect.element(restoreScreen.emptyState()).toBeVisible(); + }); + + it.each(['light', 'dark'] as const)( + 'highlights only the hovered revision and keeps the introduction readable in %s mode', + async (theme) => { + storeCopy('post-1', OLDER, { title: 'An older draft', custom_excerpt: 'Earlier words' }); + storeCopy('post-2', NEWER, { title: 'A newer draft', custom_excerpt: 'Recent words' }); + await renderAdminApp('/restore'); + await sidebarScreen.selectAppearance(theme); + await restoreScreen.heading().hover(); + const row = restoreScreen.revisionRow('A newer draft'); + await expect.element(row).toBeVisible(); + const introduction = page.getByText('Posts are regularly saved locally', { exact: false }); + const headers = page.getByRole('columnheader').elements(); + const background = (element: Element) => getComputedStyle(element).backgroundColor; + const headerBackgrounds = headers.map(background); + const otherCells = restoreScreen.revisionRow('An older draft').getByRole('cell').elements(); + const otherBackgrounds = otherCells.map(background); + const cells = row.getByRole('cell').elements(); + const originalBackground = background(cells[0]); + + await row.hover(); + + await expect.poll(() => background(cells[0])).not.toBe(originalBackground); + expect(cells.map(background)).toEqual(cells.map(() => background(cells[0]))); + expect(headers.map(background)).toEqual(headerBackgrounds); + expect(otherCells.map(background)).toEqual(otherBackgrounds); + await expect.element(introduction).toBeVisible(); + await expect.element(row.getByText('Recent words')).toBeVisible(); + }, + ); + + it('keeps long revision titles and restore actions inside a mobile viewport', async () => { + await page.viewport(390, 844); + onTestFinished(() => page.viewport(1280, 800)); + const title = 'A'.repeat(255); + storeCopy('post-1', NEWER, { title, custom_excerpt: 'Words worth keeping' }); + fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => ({ + posts: [{ ...(body as { posts: object[] }).posts[0], id: 'restored-mobile' }], + })); + await renderAdminApp('/restore'); + const row = restoreScreen.revisionRow(title); + await expect.element(row).toBeVisible(); + await expect + .element( + row + .getByRole('cell') + .first() + .getByText(moment(NEWER).format('MMM D, YYYY HH:mm'), { exact: true }), + ) + .toBeVisible(); + expect(row.restoreButton().element().getBoundingClientRect().bottom).toBeLessThan( + window.innerHeight, + ); + expect(row.element().getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth); + expect(row.restoreButton().element().getBoundingClientRect().right).toBeLessThanOrEqual( + window.innerWidth, + ); + await row.restoreButton().click(); + await expect.element(row.openLink()).toBeVisible(); + expect(row.openLink().element().getBoundingClientRect().right).toBeLessThanOrEqual( + window.innerWidth, + ); + }); + + it('restores a copy as a new draft and links to it', async () => { + storeCopy('post-1', NEWER, { + type: 'post', + status: 'draft', + title: 'Lost words', + slug: 'lost-words', + lexical: LEXICAL, + authors: [{ id: '1' }], + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }, { name: 'Typed only' }], + }); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => ({ + posts: [{ ...(body as { posts: object[] }).posts[0], id: 'restored-1' }], + })); + await renderAdminApp('/restore'); + + await restoreScreen.revisionRow('Lost words').restoreButton().click(); + + await expect.element(page.getByText('Post restored')).toBeVisible(); + expect(created(createApi.lastRequest, 'posts')).toEqual({ + title: '(Restored) Lost words', + slug: 'lost-words', + lexical: LEXICAL, + status: 'draft', + authors: [{ id: '1' }], + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }, { name: 'Typed only' }], + }); + + await expect.element(restoreScreen.revisionRow('Lost words').openLink()).toHaveFocus(); + await restoreScreen.revisionRow('Lost words').openLink().click(); + await expect.poll(currentRoute).toBe('/editor/post/restored-1'); + }); + + it('credits only a Contributor who restores a copy of a post someone else wrote', async () => { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: 'Contributor' })]; + storeCopy('post-1', NEWER, { + type: 'post', + title: 'Shared draft', + lexical: LEXICAL, + authors: [{ id: 'editor-1' }, { id: me.users[0].id }], + }); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => ({ + posts: [{ ...(body as { posts: object[] }).posts[0], id: 'restored-2' }], + })); + await renderAdminApp('/restore', { boot: { browseMe: { response: me } } }); + + await restoreScreen.revisionRow('Shared draft').restoreButton().click(); + + await expect.element(restoreScreen.revisionRow('Shared draft').openLink()).toBeVisible(); + expect(created(createApi.lastRequest, 'posts').authors).toEqual([{ id: me.users[0].id }]); + }); + + it('starts one restore however quickly the button is pressed twice', async () => { + storeCopy('post-1', NEWER, { type: 'post', title: 'Lost words', lexical: LEXICAL }); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => ({ + posts: [{ ...(body as { posts: object[] }).posts[0], id: 'restored-3' }], + })); + await renderAdminApp('/restore'); + await expect.element(restoreScreen.revisionRow('Lost words').restoreButton()).toBeVisible(); + const button = restoreScreen.revisionRow('Lost words').restoreButton().element() as HTMLElement; + + button.click(); + button.click(); + + await expect.element(restoreScreen.revisionRow('Lost words').openLink()).toBeVisible(); + expect(createApi.requests).toHaveLength(1); + }); + + it('restores a page as a page', async () => { + storeCopy('page-1', NEWER, { type: 'page', title: 'About us', lexical: LEXICAL }); + const createApi = fakeAdminEndpoint('POST', /^\/pages\/\?/, ({ body }) => ({ + pages: [{ ...(body as { pages: object[] }).pages[0], id: 'restored-page' }], + })); + await renderAdminApp('/restore'); + + await restoreScreen.revisionRow('About us').restoreButton().click(); + + await expect.element(page.getByText('Page restored')).toBeVisible(); + expect(created(createApi.lastRequest, 'pages')).toMatchObject({ + title: '(Restored) About us', + slug: 'untitled', + status: 'draft', + }); + await expect + .element(restoreScreen.revisionRow('About us').openLink()) + .toHaveAccessibleName('Open restored page'); + await expect + .element(restoreScreen.revisionRow('About us').openLink()) + .toHaveAttribute('href', expect.stringContaining('/editor/page/restored-page')); + }); + + it('keeps the copy restorable when the server refuses the draft', async () => { + storeCopy('post-1', NEWER, { type: 'post', title: 'Lost words', lexical: LEXICAL }); + fakeAdminEndpoint( + 'POST', + /^\/posts\/\?/, + { + errors: [ + { + message: 'Validation error, cannot save post.', + context: 'Value in [posts.title] exceeds maximum length of 255 characters.', + type: 'ValidationError', + }, + ], + }, + { status: 422 }, + ); + await renderAdminApp('/restore'); + + await restoreScreen.revisionRow('Lost words').restoreButton().click(); + + await expect + .element(page.getByText('Value in [posts.title] exceeds maximum length of 255 characters.')) + .toBeVisible(); + await expect.element(restoreScreen.revisionRow('Lost words').restoreButton()).toBeEnabled(); + await expect.element(restoreScreen.revisionRow('Lost words').restoreButton()).toHaveFocus(); + expect(localStorage.getItem(`${LOCAL_REVISION_PREFIX}-post-1-${NEWER}`)).not.toBeNull(); + }); +}); diff --git a/apps/admin/src/editor/restore/restore-screen.tsx b/apps/admin/src/editor/restore/restore-screen.tsx new file mode 100644 index 00000000000..3e9d5787c15 --- /dev/null +++ b/apps/admin/src/editor/restore/restore-screen.tsx @@ -0,0 +1,233 @@ +import moment from 'moment-timezone'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { useAddPage } from '@tryghost/admin-x-framework/api/pages'; +import { useAddPost } from '@tryghost/admin-x-framework/api/posts'; +import { isAuthorOrContributor } from '@tryghost/admin-x-framework/api/users'; +import { useHandleError } from '@tryghost/admin-x-framework/hooks'; +import { + Button, + EmptyIndicator, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tryghost/shade/components'; +import { ListPage } from '@tryghost/shade/page-templates'; +import { PageHeader } from '@tryghost/shade/patterns'; +import { Box, Container, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { readLocalRevisions, type StoredLocalRevision } from '@/editor/local-revisions'; +import { restoredPost, type RestoredPost } from '@/editor/restore/restored-post'; +import { lexicalToText } from '@/editor/session/content-text'; +import { AdminLink } from '@/shared/admin-link'; + +const PREVIEW_LENGTH = 100; + +interface Restored { + id: string; + type: RestoredPost['type']; +} + +// Storage can be unreadable where the browser blocks it; that reads as having no copies. +function readCopies(): StoredLocalRevision[] { + try { + return readLocalRevisions(window.localStorage); + } catch { + return []; + } +} + +// A copy with no excerpt shows the start of its body, so copies of one post can be told apart. +function previewOf(revision: StoredLocalRevision): string { + const excerpt = [revision.custom_excerpt, revision.excerpt].find( + (value): value is string => typeof value === 'string' && value.trim() !== '', + ); + let preview = excerpt ?? ''; + if (!preview && typeof revision.lexical === 'string') { + try { + preview = lexicalToText(revision.lexical); + } catch { + preview = ''; + } + } + preview = preview.replace(/\s+/g, ' ').trim(); + return preview.length > PREVIEW_LENGTH ? `${preview.slice(0, PREVIEW_LENGTH)}…` : preview; +} + +export default function RestoreScreen() { + const [revisions] = useState(readCopies); + const previews = useMemo( + () => new Map(revisions.map((revision) => [revision.key, previewOf(revision)])), + [revisions], + ); + const [restoring, setRestoring] = useState(null); + const [restored, setRestored] = useState>({}); + const [justRestored, setJustRestored] = useState(null); + const openLinks = useRef(new Map()); + // Two clicks dispatched in one task both see the same render's state. + const inFlight = useRef(false); + const titleIdPrefix = useId(); + const { data: currentUser } = useCurrentUser(); + const { mutateAsync: addPost } = useAddPost(); + const { mutateAsync: addPage } = useAddPage(); + const handleError = useHandleError(); + + // The button the writer pressed is replaced by the link, so focus moves to it. + useEffect(() => { + if (justRestored) { + openLinks.current.get(justRestored)?.focus(); + } + }, [justRestored]); + + const restore = async (revision: StoredLocalRevision) => { + if (inFlight.current) { + return; + } + inFlight.current = true; + setRestoring(revision.key); + try { + const soleAuthorId = + currentUser && isAuthorOrContributor(currentUser) ? currentUser.id : undefined; + const { type, ...post } = restoredPost(revision, { soleAuthorId }); + const created = + type === 'page' + ? (await addPage({ page: post })).pages[0] + : (await addPost({ post })).posts[0]; + setRestored((current) => ({ ...current, [revision.key]: { id: created.id, type } })); + setJustRestored(revision.key); + toast.success(`${type === 'page' ? 'Page' : 'Post'} restored`); + } catch (error) { + handleError(error); + } finally { + inFlight.current = false; + setRestoring(null); + } + }; + + return ( + + + + + + + Restore posts + + Posts are regularly saved locally on this device. If you’ve lost a post, you can + restore it from here as long as too much time hasn’t passed. + + + + + + {revisions.length === 0 ? ( + + + + ) : ( + + + + Title + Saved + + Actions + + + + + {revisions.map((revision, index) => { + const done = restored[revision.key]; + const preview = previews.get(revision.key); + const titleId = `${titleIdPrefix}-${index}`; + const title = + typeof revision.title === 'string' && revision.title + ? revision.title + : '(no title)'; + const savedAt = moment(revision.revisionTimestamp).format('MMM D, YYYY HH:mm'); + return ( + + + + {title} + + {preview ? ( + + {preview} + + ) : null} + + {savedAt} + + + + + {savedAt} + + + + {done ? ( + + ) : ( + + )} + + + ); + })} + +
+ )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/editor/restore/restore.screen.ts b/apps/admin/src/editor/restore/restore.screen.ts new file mode 100644 index 00000000000..ec144b492e3 --- /dev/null +++ b/apps/admin/src/editor/restore/restore.screen.ts @@ -0,0 +1,38 @@ +import { page, type Locator } from 'vitest/browser'; +import { + localRevisionsTable, + noLocalRevisionsText, + openRestoredPageLink, + openRestoredPostLink, + restoreHeading, + restoreRevisionButton, +} from '@tryghost/test-data/selectors/restore'; + +/** A revision row locator augmented with factories for the row's parts. */ +export type RevisionRowScope = Locator & { + restoreButton(): Locator; + openLink(): Locator; +}; + +function rowScope(row: Locator): RevisionRowScope { + return Object.assign(row, { + restoreButton: () => row.getByRole('button', { name: restoreRevisionButton, exact: true }), + openLink: () => + row + .getByRole('link', { name: openRestoredPostLink }) + .or(row.getByRole('link', { name: openRestoredPageLink })), + }); +} + +export const restoreScreen = { + heading: () => page.getByRole('heading', { name: restoreHeading }), + emptyState: () => page.getByText(noLocalRevisionsText), + // The second row group is the body; the first holds the column headers. + revisionRows: () => + page + .getByRole('table', { name: localRevisionsTable }) + .getByRole('rowgroup') + .nth(1) + .getByRole('row'), + revisionRow: (title: string) => rowScope(restoreScreen.revisionRows().filter({ hasText: title })), +}; diff --git a/apps/admin/src/editor/restore/restored-post.test.ts b/apps/admin/src/editor/restore/restored-post.test.ts new file mode 100644 index 00000000000..79c5a5c7be7 --- /dev/null +++ b/apps/admin/src/editor/restore/restored-post.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import type { LocalRevision, LocalRevisionAuthor } from '@/editor/local-revisions'; +import { restoredPost } from '@/editor/restore/restored-post'; + +function copy(overrides: Partial = {}): LocalRevision { + return { + id: 'post-1', + type: 'post', + revisionTimestamp: 1000, + title: 'Lost words', + slug: 'lost-words', + status: 'draft', + lexical: '{"root":{"children":[]}}', + ...overrides, + }; +} + +describe('restoredPost', () => { + it('makes a new draft marked as restored from the copy', () => { + expect( + restoredPost( + copy({ + authors: [{ id: 'user-1' }], + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }], + }), + ), + ).toEqual({ + type: 'post', + title: '(Restored) Lost words', + slug: 'lost-words', + lexical: '{"root":{"children":[]}}', + status: 'draft', + authors: [{ id: 'user-1' }], + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }], + }); + }); + + it('keeps a page a page', () => { + expect(restoredPost(copy({ type: 'page' })).type).toBe('page'); + }); + + it('restores a copy written with no type as a post', () => { + const untyped = { ...copy(), type: undefined } as unknown as LocalRevision; + + expect(restoredPost(untyped).type).toBe('post'); + }); + + it('falls back to an untitled slug and a bare title', () => { + const restored = restoredPost(copy({ title: '', slug: '' })); + + expect(restored.title).toBe('(Restored)'); + expect(restored.slug).toBe('untitled'); + }); + + it('sends only the id of each author, dropping any without one', () => { + const restored = restoredPost( + copy({ + authors: [{ id: 'user-1', name: 'Jo', email: 'jo@example.com' } as LocalRevisionAuthor, {}], + }), + ); + + expect(restored.authors).toEqual([{ id: 'user-1' }]); + }); + + it('names a tag that was typed but never saved, so the create makes it', () => { + const restored = restoredPost(copy({ tags: [{ name: 'Brand new' }, {}] })); + + expect(restored.tags).toEqual([{ name: 'Brand new' }]); + }); + + it('keeps the body empty when the copy has none', () => { + expect(restoredPost(copy({ lexical: undefined })).lexical).toBeNull(); + }); + + it('leaves authors out when the copy credits nobody, so the server credits the restorer', () => { + expect(restoredPost(copy({ authors: [] }))).not.toHaveProperty('authors'); + expect(restoredPost(copy({ authors: undefined }))).not.toHaveProperty('authors'); + }); + + it('credits only the restorer when they may not create posts for anyone else', () => { + const restored = restoredPost(copy({ authors: [{ id: 'editor-1' }, { id: 'user-2' }] }), { + soleAuthorId: 'user-2', + }); + + expect(restored.authors).toEqual([{ id: 'user-2' }]); + }); + + it('brings back the excerpt and feature image the copy kept', () => { + const restored = restoredPost( + copy({ + custom_excerpt: 'In short', + feature_image: 'https://example.com/image.jpg', + feature_image_alt: 'A hillside', + feature_image_caption: 'Taken at dawn', + }), + ); + + expect(restored).toMatchObject({ + custom_excerpt: 'In short', + feature_image: 'https://example.com/image.jpg', + feature_image_alt: 'A hillside', + feature_image_caption: 'Taken at dawn', + }); + expect(restoredPost(copy({ feature_image: null }))).not.toHaveProperty('feature_image'); + }); + + it('shortens a title the server would refuse once it is marked as restored', () => { + const restored = restoredPost(copy({ title: 'é'.repeat(250) })); + + expect(Array.from(restored.title)).toHaveLength(255); + expect(restored.title.startsWith('(Restored) éé')).toBe(true); + }); + + it('shortens an excerpt the server would refuse', () => { + const restored = restoredPost(copy({ custom_excerpt: 'é'.repeat(301) })); + + expect(Array.from(restored.custom_excerpt ?? '')).toHaveLength(300); + }); + + it('keeps a tag the copy names only by its slug', () => { + expect(restoredPost(copy({ tags: [{ slug: 'news' }] })).tags).toEqual([{ slug: 'news' }]); + }); + + it('ignores authors and tags a damaged copy holds in the wrong shape', () => { + const damaged = { ...copy(), authors: 'user-1', tags: [null, 'News', { name: 7 }] }; + + const restored = restoredPost(damaged as unknown as LocalRevision); + + expect(restored).not.toHaveProperty('authors'); + expect(restored.tags).toEqual([]); + }); +}); diff --git a/apps/admin/src/editor/restore/restored-post.ts b/apps/admin/src/editor/restore/restored-post.ts new file mode 100644 index 00000000000..168545df079 --- /dev/null +++ b/apps/admin/src/editor/restore/restored-post.ts @@ -0,0 +1,95 @@ +import type { PostAuthorInput, PostTagInput } from '@tryghost/admin-x-framework/api/posts'; +import type { LocalRevision, LocalRevisionTag } from '@/editor/local-revisions'; + +// Core refuses a longer title or custom excerpt, counted in characters. +const MAX_TITLE_LENGTH = 255; +const MAX_EXCERPT_LENGTH = 300; + +/** The draft a restore creates from a local copy. */ +export interface RestoredPost { + type: 'post' | 'page'; + title: string; + slug: string; + lexical: string | null; + status: 'draft'; + /** Left out when there is nobody to credit, so the server credits the current user. */ + authors?: PostAuthorInput[]; + tags: PostTagInput[]; + custom_excerpt?: string; + feature_image?: string; + feature_image_alt?: string; + feature_image_caption?: string; +} + +export interface RestoredPostOptions { + /** Authors and Contributors may only create posts they are the first author of. */ + soleAuthorId?: string; +} + +function tagInput({ id, name, slug }: LocalRevisionTag): PostTagInput[] { + const known = { + ...(typeof name === 'string' && name ? { name } : {}), + ...(typeof slug === 'string' && slug ? { slug } : {}), + }; + if (typeof id === 'string' && id) { + return [{ id, ...known }]; + } + if (known.name) { + return [{ ...known, name: known.name }]; + } + return known.slug ? [{ slug: known.slug }] : []; +} + +function listOf(value: unknown): T[] { + if (!Array.isArray(value)) { + return []; + } + return (value as unknown[]).filter( + (item): item is T => item !== null && typeof item === 'object', + ); +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +function clipped(value: string, length: number): string { + return Array.from(value).slice(0, length).join(''); +} + +function restoredTitle(title: unknown): string { + return clipped( + `(Restored) ${typeof title === 'string' ? title : ''}`.trimEnd(), + MAX_TITLE_LENGTH, + ); +} + +/** A copy becomes a new draft, marked as restored; the post it was taken from is left alone. */ +export function restoredPost( + revision: LocalRevision, + { soleAuthorId }: RestoredPostOptions = {}, +): RestoredPost { + const authors = soleAuthorId + ? [{ id: soleAuthorId }] + : listOf<{ id?: unknown }>(revision.authors).flatMap(({ id }) => + typeof id === 'string' && id ? [{ id }] : [], + ); + const excerpt = text(revision.custom_excerpt); + const optional = { + custom_excerpt: excerpt === undefined ? undefined : clipped(excerpt, MAX_EXCERPT_LENGTH), + feature_image: text(revision.feature_image), + feature_image_alt: text(revision.feature_image_alt), + feature_image_caption: text(revision.feature_image_caption), + }; + + return { + type: revision.type === 'page' ? 'page' : 'post', + title: restoredTitle(revision.title), + slug: text(revision.slug) ?? 'untitled', + lexical: text(revision.lexical) ?? null, + status: 'draft', + ...(authors.length > 0 ? { authors } : {}), + tags: listOf(revision.tags).flatMap(tagInput), + ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined)), + }; +} diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index b81dbc77c88..1e9d71c3469 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -212,6 +212,43 @@ A restore is a document boundary for the slug: the restored title is not a title the writer typed, so the slug is kept rather than moved to it, and whether it goes on following the title is re-read from the slug itself. +## Keeping a local copy + +While a draft holds unsaved work, the session keeps a copy of it in the +browser's local storage, so work that never reached the server can be brought +back from the restore screen. A copy carries the title, slug, body, excerpt, +feature image with its alt text and caption, authors and tags, and is stored +under `post-revision--`; `draft` stands in for the id until +the post has been created. + +Only unsaved changes to what a copy carries count. The body is judged by the +tracker's verdict: a body that differs from the saved copy only by Koenig's +load-time normalization matches the hidden instance's baseline, and a change +that arrives before that baseline has been reported waits for it, so opening a +post leaves no copy behind. If the hidden instance fails, the body is compared +with the saved copy alone. The other carried fields are judged by their own +compare; a change to a field a copy does not carry, such as the meta title, does +not write one, and neither does a save that failed. Published, scheduled and +sent posts are never copied. + +The first change writes a copy at once. After that at most one copy a minute is +written, carrying the newest draft, and a copy identical to the last one written +is skipped. A copy still waiting for the minute is dropped once a save leaves +nothing unsaved or the post leaves draft. A copy is written straight away when +the page is hidden or closed, when the session is disposed holding unsaved work, +before a revision from the post's history replaces the body, and when a save +stops on a conflict, a deleted post, an expired session or a crash; a conflict +that failing saves keep re-entering is copied once. A save that keeps failing is +otherwise left to the minute's pace, and nothing is copied while a revision's +restore is being saved. + +Each post keeps its newest five copies. A post that has not been created keeps +at most five from one session; once it is created those copies are removed, and +any work the create did not carry is written again under the new id. When +storage is full, the oldest copies of any post are removed until the new one +fits. Storage never interrupts editing: a copy that cannot be written is +reported, not thrown. + ## Reloading the document A reload replaces the whole document with the server's copy when the writer @@ -222,13 +259,20 @@ before any of those replacements happen. Its retained collision record authorize recovery even after a retry fails for another reason; an active save or frozen authentication attempt must settle before the document can be replaced. The replacement completes before recovery is announced to subscribers, so an edit -made from that notification belongs to the new document and is preserved. The read is its own request, never a -refetch of the query the screen rendered from: a failing refetch puts that query -into an error state and replaces the editor, taking the unsaved content and the -way to copy it out with it. A reload that fails leaves the halt, the content and -the banner exactly as they were. A reload that succeeds seeds the screen's query -with the accepted document, so a quick close and reopen cannot resurrect the -version it first read. +made from that notification belongs to the new document and is preserved. The +read is its own request, never a refetch of the query the screen rendered from, +so nothing is replaced until the session has accepted the copy. A reload that +fails leaves the halt, the content and the banner exactly as they were. A reload +that succeeds seeds the screen's query with the accepted document, so a quick +close and reopen cannot resurrect the version it first read. + +The screen's query also refetches on its own, after every save that lands and +on reconnect once it is stale. Only a read that never produced the post +replaces the screen, with the load error or a missing post, so reopening a post +whose stale copy is still cached shows that copy even when its refetch fails. +Once the post is on screen, a refetch that fails leaves the editor, the session +and the unsaved content where they are, and the next save reports a deleted +post, an expired session or a collision itself. What a halted queue looks like is the session's caller's decision, not the engine's: `reauth-pending` and `conflict` are states, not UI. The writer gets a @@ -259,6 +303,22 @@ references are kept stable across engine events, so body edits need no new React snapshot while the rendered values stay the same. That makes the view suitable for `useSyncExternalStore` and lets it stand in for those values as a dependency. +## Leaving the editor + +While the post holds unsaved work, every way out of the editor is put to the +save engine: a link, the browser's Back and Forward buttons, and any other +change to the URL's hash. The engine finishes or saves what is outstanding and +answers either that leaving loses nothing, and the navigation goes ahead, or +that the writer has to confirm it. Until then the URL stays on the editor. A +Back or Forward is undone as it happens and replayed once the writer may leave, +so they land on the entry it reached. Undoing it puts the editor back directly +above that entry: a held Back drops the forward history, and a Forward or a hash +change from outside that the writer cancels leaves its destination directly +below the editor, where the next Back goes. A URL that differs only by a +trailing slash is the same screen, not an exit. A clean editor leaves at once, a +tab close or reload gets the browser's own prompt, and the URL replace after a +create is not an exit. + ## What the session reports Failures never reach the writer as thrown errors; the session reports them. Every @@ -270,7 +330,10 @@ is abandoned, not when it is retried. A leave the writer has to confirm is reported with the reason codes the tracker holds the post dirty for. A draft disposed with a title but a slug still derived from the default title is reported as an error. A throwing subscriber or slug listener is reported as an -error, and so is a slug edit the generator rejected. +error, and so is a slug edit the generator rejected. A local copy that storage +refused is reported with a `localRevisions` tag naming why: `quotaExceeded` when +older copies had to make room, `quotaExceededNoSpace` when nothing could, and +`saveError` for any other failure. Sentry receives these through the editor's own reporter, with the response status and URL when the transport answered. Validation failures, host limits and diff --git a/apps/admin/src/editor/session/editor-session.local-revisions.test.ts b/apps/admin/src/editor/session/editor-session.local-revisions.test.ts new file mode 100644 index 00000000000..76eed2d6db1 --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.local-revisions.test.ts @@ -0,0 +1,475 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildLexicalParagraph } from '@tryghost/test-data'; +import type { LocalRevisionWriter } from '@/editor/local-revisions'; +import { + body, + record, + sessionHarness, + updateCollision, +} from '@/editor/session/__test-utils__/session-harness'; + +function writerSpy() { + return { + record: vi.fn(), + flush: vi.fn(), + discard: vi.fn(), + created: vi.fn(), + }; +} + +const LOADED_BODY = buildLexicalParagraph('Hello'); + +describe('Editor session local revisions', () => { + it('records a copy of a draft once the writer changes its body', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ + record: record({ tags: [{ id: 'tag-1', name: 'News', slug: 'news' }] }), + baseline: LOADED_BODY, + localRevisions, + }); + + session.patchLexical(body('Hello there')); + + expect(localRevisions.record).toHaveBeenCalledTimes(1); + expect(localRevisions.record).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'abc123', + status: 'draft', + title: 'Hello', + slug: 'hello', + lexical: JSON.stringify(body('Hello there')), + tags: [{ id: 'tag-1', name: 'News', slug: 'news' }], + }), + ); + }); + + it('pairs the latest title with the latest body', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + + session.patchLexical(body('Hello there')); + session.patchTitle('A new title'); + + expect(localRevisions.record).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: 'A new title', + lexical: JSON.stringify(body('Hello there')), + }), + ); + }); + + it('records nothing for a body the hidden instance normalized the same way', () => { + const localRevisions = writerSpy(); + const normalized = body('Hello, normalized'); + const { session } = sessionHarness({ + record: record(), + baseline: JSON.stringify(normalized), + localRevisions, + }); + + session.patchLexical(normalized); + + expect(localRevisions.record).not.toHaveBeenCalled(); + }); + + it('records nothing while the hidden instance has not reported a baseline', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), localRevisions }); + + session.patchLexical(body('Hello, normalized')); + + expect(localRevisions.record).not.toHaveBeenCalled(); + }); + + it('records nothing for a post that is no longer a draft', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ + record: record({ status: 'published', published_at: '2026-01-01T00:00:00.000Z' }), + baseline: LOADED_BODY, + localRevisions, + }); + + session.patchLexical(body('Hello there')); + session.flushLocalRevision(); + session.dispose(); + + expect(localRevisions.record).not.toHaveBeenCalled(); + expect(localRevisions.flush).not.toHaveBeenCalled(); + }); + + it('records a post that has not been created yet without an id', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: undefined, localRevisions }); + + session.patchTitle('Brand new'); + + expect(localRevisions.record).toHaveBeenLastCalledWith( + expect.objectContaining({ id: null, status: 'draft', title: 'Brand new' }), + ); + }); + + it('flushes the current draft when asked and it holds unsaved work', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + + session.flushLocalRevision(); + expect(localRevisions.flush).not.toHaveBeenCalled(); + + session.patchLexical(body('Hello there')); + session.flushLocalRevision(); + + expect(localRevisions.flush).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Hello there')) }), + ); + }); + + it('leaves a copy behind when it is disposed holding unsaved work', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + session.patchLexical(body('Hello there')); + + session.dispose(); + + expect(localRevisions.flush).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Hello there')) }), + ); + expect(localRevisions.discard).toHaveBeenCalled(); + expect(localRevisions.flush.mock.invocationCallOrder[0]).toBeLessThan( + localRevisions.discard.mock.invocationCallOrder[0], + ); + }); + + it('drops the waiting copy without writing when it is disposed with nothing unsaved', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + + session.dispose(); + + expect(localRevisions.flush).not.toHaveBeenCalled(); + expect(localRevisions.discard).toHaveBeenCalled(); + }); + + it('leaves failing saves to the minute pace instead of flushing on each one', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { failUpdateWith: new Error('Server exploded') }, + ); + session.patchLexical(body('Hello there')); + + await session.dispatchExplicit(); + await session.dispatchExplicit(); + + expect(session.getState().kind).toBe('error'); + expect(localRevisions.record).toHaveBeenCalledTimes(1); + expect(localRevisions.flush).not.toHaveBeenCalled(); + }); + + it('flushes when a save is refused because someone else changed the post', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { failUpdateWith: updateCollision() }, + ); + session.patchLexical(body('Hello there')); + + await session.dispatchExplicit(); + + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Hello there')) }), + ); + }); + + it('records nothing once the only thing unsaved is the failed save itself', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { failUpdateWith: new Error('Server exploded') }, + ); + session.patchLexical(body('Hello there')); + await session.dispatchExplicit(); + + session.patchLexical(body('Hello')); + + expect(localRevisions.record).toHaveBeenCalledTimes(1); + }); + + it('drops the copy waiting for the minute once a save leaves nothing unsaved', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + session.patchLexical(body('Hello there')); + expect(localRevisions.discard).not.toHaveBeenCalled(); + + await session.dispatchExplicit(); + + expect(localRevisions.discard).toHaveBeenCalled(); + }); + + it('keeps the copy waiting for the minute when typing continued during the save', async () => { + const localRevisions = writerSpy(); + let typeDuringSave = (): void => {}; + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { duringSave: () => typeDuringSave() }, + ); + typeDuringSave = () => session.patchLexical(body('Hello there, still typing')); + session.patchLexical(body('Hello there')); + + await session.dispatchExplicit(); + + expect(localRevisions.discard).not.toHaveBeenCalled(); + }); + + it('tells the writer once a new post has been created', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: undefined, localRevisions }); + session.patchTitle('Brand new'); + expect(localRevisions.created).not.toHaveBeenCalled(); + + await session.dispatchExplicit(); + + expect(localRevisions.created).toHaveBeenCalledTimes(1); + }); + + it('keeps the unsaved draft before a revision replaces it', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + session.patchLexical(body('Typed before the restore')); + + await session.restoreRevision({ + lexical: buildLexicalParagraph('From an older revision'), + title: 'Hello', + custom_excerpt: null, + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + }); + + expect(localRevisions.flush).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Typed before the restore')) }), + ); + // The restore's own content reaches the server; it is never copied as unsaved work. + const written = [...localRevisions.flush.mock.calls, ...localRevisions.record.mock.calls].map( + ([draft]) => draft.lexical, + ); + expect(written.some((lexical) => lexical?.includes('From an older revision'))).toBe(false); + }); + + it('records a new post that starts with its body', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: undefined, baseline: null, localRevisions }); + + session.patchLexical(body('First words')); + + expect(localRevisions.record).toHaveBeenLastCalledWith( + expect.objectContaining({ id: null, lexical: JSON.stringify(body('First words')) }), + ); + }); + + it('writes unsaved work again under the id a new post gets when its create lands', async () => { + const localRevisions = writerSpy(); + let typeDuringCreate = (): void => {}; + const { session } = sessionHarness( + { record: undefined, baseline: null, localRevisions, createdId: 'new-id' }, + { duringSave: () => typeDuringCreate() }, + ); + typeDuringCreate = () => session.patchLexical(body('Typed while it was created')); + session.patchTitle('Brand new'); + + await session.dispatchExplicit(); + + expect(localRevisions.created).toHaveBeenCalledTimes(1); + expect(localRevisions.flush).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new-id', + lexical: JSON.stringify(body('Typed while it was created')), + }), + ); + expect(localRevisions.created.mock.invocationCallOrder[0]).toBeLessThan( + localRevisions.flush.mock.invocationCallOrder[0], + ); + }); + + it('drops the waiting copy once the post leaves draft', async () => { + const localRevisions = writerSpy(); + let typeDuringPublish = (): void => {}; + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { + duringSave: () => typeDuringPublish(), + applied: (payload) => ({ + title: payload.title, + slug: payload.slug, + lexical: payload.lexical, + status: payload.status, + }), + }, + ); + session.patchLexical(body('Hello there')); + typeDuringPublish = () => session.patchLexical(body('Hello there, and more')); + + await session.dispatchPublish(); + + expect(session.getSaveSnapshot().status).toBe('published'); + expect(localRevisions.discard).toHaveBeenCalled(); + expect(localRevisions.flush).not.toHaveBeenCalled(); + }); + + it('keeps no copy of a revision whose restore someone else refused', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + { failUpdateWith: updateCollision() }, + ); + session.patchLexical(body('Typed before the restore')); + + const restored = await session.restoreRevision({ + lexical: buildLexicalParagraph('From an older revision'), + title: 'Hello', + custom_excerpt: null, + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + }); + + expect(restored).toBe(false); + const written = [...localRevisions.flush.mock.calls, ...localRevisions.record.mock.calls].map( + ([draft]) => draft.lexical, + ); + expect(written.some((lexical) => lexical?.includes('From an older revision'))).toBe(false); + expect(localRevisions.flush).toHaveBeenCalledTimes(1); + }); + + it('copies a held conflict once, however often failing saves re-enter it', async () => { + const localRevisions = writerSpy(); + const hooks: Parameters[1] = { failUpdateWith: updateCollision() }; + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + hooks, + ); + session.patchLexical(body('Hello there')); + await session.dispatchExplicit(); + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).toHaveBeenCalledTimes(1); + + session.recordRefetched(record({ updated_at: '2026-01-01T00:00:09.000Z' })); + hooks.failUpdateWith = new Error('Server exploded'); + session.patchLexical(body('Hello there, and more')); + await session.dispatchExplicit(); + + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).toHaveBeenCalledTimes(1); + }); + + it('records work typed before the hidden instance reported its baseline', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), localRevisions }); + session.patchLexical(body('Typed straight away')); + expect(localRevisions.record).not.toHaveBeenCalled(); + + session.setBaseline(LOADED_BODY); + + expect(localRevisions.record).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Typed straight away')) }), + ); + }); + + it('keeps no copy for a change to a field the copy does not carry', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + + session.patchFields({ meta_title: 'Only the meta title' }); + + expect(localRevisions.record).not.toHaveBeenCalled(); + }); + + it('records a manual slug edit', async () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), baseline: LOADED_BODY, localRevisions }); + + await session.editSlug('my-own-slug'); + + expect(localRevisions.record).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'my-own-slug' }), + ); + }); + + it('records a body once the hidden instance fails, compared with the saved copy alone', () => { + const localRevisions = writerSpy(); + const { session } = sessionHarness({ record: record(), localRevisions }); + session.patchLexical(body('Typed straight away')); + expect(localRevisions.record).not.toHaveBeenCalled(); + + session.baselineFailed(); + + expect(localRevisions.record).toHaveBeenCalledWith( + expect.objectContaining({ lexical: JSON.stringify(body('Typed straight away')) }), + ); + }); + + it('copies a conflict whose first flush held nothing to copy once there is work', async () => { + const localRevisions = writerSpy(); + const hooks: Parameters[1] = { failUpdateWith: updateCollision() }; + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + hooks, + ); + session.patchFields({ meta_title: 'Only the meta title' }); + await session.dispatchExplicit(); + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).not.toHaveBeenCalled(); + + session.recordRefetched(record({ updated_at: '2026-01-01T00:00:09.000Z' })); + hooks.failUpdateWith = new Error('Server exploded'); + session.patchLexical(body('Hello there')); + await session.dispatchExplicit(); + + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).toHaveBeenCalledTimes(1); + }); + + it('copies a new conflict after the writer recovered from the last one', async () => { + const localRevisions = writerSpy(); + const hooks: Parameters[1] = { failUpdateWith: updateCollision() }; + const { session } = sessionHarness( + { record: record(), baseline: LOADED_BODY, localRevisions }, + hooks, + ); + session.patchLexical(body('Hello there')); + await session.dispatchExplicit(); + expect(localRevisions.flush).toHaveBeenCalledTimes(1); + + hooks.failUpdateWith = undefined; + session.recordRefetched(record({ updated_at: '2026-01-01T00:00:09.000Z' })); + await session.dispatchExplicit(); + expect(session.getState().kind).not.toBe('conflict'); + + hooks.failUpdateWith = updateCollision(); + session.patchLexical(body('Hello there, again')); + await session.dispatchExplicit(); + + expect(session.getState().kind).toBe('conflict'); + expect(localRevisions.flush).toHaveBeenCalledTimes(2); + }); + + it('keeps editing when the writer throws', () => { + const onError = vi.fn(); + const failure = new Error('No storage'); + const localRevisions = writerSpy(); + localRevisions.record.mockImplementation(() => { + throw failure; + }); + const { session } = sessionHarness({ + record: record(), + baseline: LOADED_BODY, + localRevisions, + onError, + }); + + session.patchLexical(body('Hello there')); + + expect(onError).toHaveBeenCalledWith(failure); + expect(session.getLiveLexical()).toBe(JSON.stringify(body('Hello there'))); + }); +}); diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index ee6b143b75e..594b28657ee 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -28,6 +28,7 @@ import type { } from '@/editor/engine/change-tracker'; import type { LexicalInput } from '@/editor/engine/lexical-compare'; import { pick } from '@/editor/engine/pick'; +import type { LocalRevisionDraft, LocalRevisionWriter } from '@/editor/local-revisions'; import type { PostWriteOptions } from '@tryghost/admin-x-framework/api/post-contract'; import type { EditorErrorContext } from '@/editor/report-error'; import { toSaveError } from './error-mapping'; @@ -129,6 +130,8 @@ export interface EditorSessionOptions { onSaveFailed?: (failure: EditorSaveFailure) => void; /** Called when a leave request answers `confirm`. */ onLeaveConfirmed?: (leave: EditorLeaveConfirmation) => void; + /** Keeps local copies of a draft that holds unsaved work. */ + localRevisions?: LocalRevisionWriter; } /** The state React renders, published together after a session change. */ @@ -195,9 +198,38 @@ export interface EditorSession { reauthSucceeded: () => void; reauthAbandoned: () => void; leaveRequested: () => Promise; + /** Writes the draft's local copy now if it holds unsaved work, for a page that is going away. */ + flushLocalRevision: () => void; dispose: () => void; } +/** Body reasons that stand for the writer's edits; a pending baseline is load-time normalization. */ +const BODY_WORK_REASONS: ReadonlySet = new Set([ + 'SCRATCH_DIVERGED_FROM_SECONDARY', + 'BASELINE_FAILED', + 'LEXICAL_PARSE_FAILED', +]); + +/** What a local copy carries besides the body. */ +const COPIED_FIELD_KEYS = [ + 'title', + 'slug', + 'tags', + 'custom_excerpt', + 'feature_image', + 'feature_image_alt', + 'feature_image_caption', + 'authors', +] as const; + +/** States a save stays stuck in until the writer acts. `error` is re-entered by every retry. */ +const STUCK_ENGINE_STATES: ReadonlySet = new Set([ + 'reauth-pending', + 'conflict', + 'halted', + 'crashed', +]); + /** Stages one dirty settings field into both the submitted projection and the payload. */ function stageSettingsField( key: Key, @@ -247,6 +279,7 @@ export function createEditorSession({ onError, onSaveFailed, onLeaveConfirmed, + localRevisions, }: EditorSessionOptions): EditorSession { let identity: PersistedIdentity = record ? { id: record.id, updatedAt: record.updated_at ?? '' } @@ -269,6 +302,10 @@ export function createEditorSession({ const writerEdits = new Map(); // The version the in-flight request was built at, or null when none is. let inFlightSince: number | null = null; + // A restore's own save holds the revision, not the writer's work. + let restoringRevision = false; + // A failing save re-enters a held conflict; one copy per conflict is enough. + let flushedConflictError: unknown = null; function livePublishedAt(): string | null { return stagedPublishedAt ?? publishedAt; @@ -366,6 +403,7 @@ export function createEditorSession({ } tracker.setLive(identity.id, patch); notifyChanged(); + recordLocalRevision(); } // A save writes a title and slug the writer never typed: the request's own @@ -434,6 +472,64 @@ export function createEditorSession({ }); } + // The body counts by the baseline-aware verdict, so Koenig's load-time normalization never does. + function holdsUnsavedWork(): boolean { + return ( + COPIED_FIELD_KEYS.some((key) => tracker.isFieldDirty(key)) || + tracker.verdict().reasons.some((reason) => BODY_WORK_REASONS.has(reason.code)) + ); + } + + function localRevisionDraft(): LocalRevisionDraft { + return { + id: identity.id, + status, + title: live.title, + slug: machine.getState().slug, + lexical: live.lexical, + custom_excerpt: live.custom_excerpt, + feature_image: live.feature_image, + feature_image_alt: live.feature_image_alt, + feature_image_caption: live.feature_image_caption, + authors: live.authors.map(({ id }) => ({ id })), + tags: live.tags.map(({ id, name, slug: tagSlug }) => ({ id, name, slug: tagSlug })), + }; + } + + function withLocalRevisions(act: (writer: LocalRevisionWriter) => void): void { + if (!localRevisions) { + return; + } + try { + act(localRevisions); + } catch (error) { + onError(error); + } + } + + // True when the draft held work and was handed to the writer. + function keepLocalRevision(write: (writer: LocalRevisionWriter) => void): boolean { + if (disposed || restoringRevision || status !== 'draft') { + return false; + } + let kept = false; + withLocalRevisions((writer) => { + if (holdsUnsavedWork()) { + write(writer); + kept = true; + } + }); + return kept; + } + + function recordLocalRevision(): void { + keepLocalRevision((writer) => writer.record(localRevisionDraft())); + } + + function flushLocalRevision(): boolean { + return keepLocalRevision((writer) => writer.flush(localRevisionDraft())); + } + // A title commit and a load move the machine's slug without a field patch, so // the URL input hears about them through the session's own subscribers. const stopSlugNotifications = machine.subscribe(notifyChanged); @@ -632,8 +728,18 @@ export function createEditorSession({ live = { ...live, updated_at: result.updatedAt }; if (created) { + withLocalRevisions((writer) => writer.created()); onIdAcquired(result.id); } + // A waiting copy is stale once nothing is unsaved or the post left draft; a new + // post's unsaved work is written again under the id it now has. + withLocalRevisions((writer) => { + if (status !== 'draft' || !holdsUnsavedWork()) { + writer.discard(); + } else if (created) { + writer.flush(localRevisionDraft()); + } + }); notifyChanged(); } @@ -648,6 +754,15 @@ export function createEditorSession({ if (next.kind === 'error' || next.kind === 'conflict') { tracker.markSaveError(); } + if (STUCK_ENGINE_STATES.has(next.kind)) { + const heldConflict = next.kind === 'conflict' ? next.error : null; + if (heldConflict === null) { + flushedConflictError = null; + flushLocalRevision(); + } else if (heldConflict !== flushedConflictError && flushLocalRevision()) { + flushedConflictError = heldConflict; + } + } // A save error also moves dirtiness without going through a field patch. notifyChanged(); }, @@ -755,6 +870,8 @@ export function createEditorSession({ ...restored, title: restored.title.trim() ? restored.title : DEFAULT_TITLE, }; + // The revision replaces whatever copy was waiting, so the unsaved draft is kept first. + flushLocalRevision(); const previous: RestoredRevision = { lexical: live.lexical, title: live.title, @@ -764,30 +881,37 @@ export function createEditorSession({ feature_image_caption: live.feature_image_caption, }; - patchLive(revision); - // Ember's slug task bails once the post carries the revision's title, so a - // restore leaves the URL alone. - slug.titleReplaced(revision.title); - - const completion = await engine.dispatch('explicit'); - if (completion.kind !== 'saved') { - // The editor surface never adopted the revision, so nothing may keep it. - patchLive(previous); - slug.titleReplaced(previous.title); - return false; + restoringRevision = true; + try { + patchLive(revision); + // Ember's slug task bails once the post carries the revision's title, so a + // restore leaves the URL alone. + slug.titleReplaced(revision.title); + + const completion = await engine.dispatch('explicit'); + if (completion.kind !== 'saved') { + // The editor surface never adopted the revision, so nothing may keep it. + patchLive(previous); + slug.titleReplaced(previous.title); + return false; + } + tracker.revisionRestored(identity.id, revision); + notifyChanged(); + return true; + } finally { + restoringRevision = false; } - tracker.revisionRestored(identity.id, revision); - notifyChanged(); - return true; }, setBaseline: (lexical) => { tracker.setBaseline(identity.id, lexical); notifyChanged(); + recordLocalRevision(); }, baselineFailed: () => { tracker.baselineFailed(identity.id); notifyChanged(); + recordLocalRevision(); }, // Only a draft's title drives the slug; a published URL must not move. @@ -885,10 +1009,15 @@ export function createEditorSession({ return decision; }, + flushLocalRevision, + dispose: () => { if (disposed) { return; } + // Whatever still holds unsaved work leaves a copy behind before the engine drops it. + flushLocalRevision(); + withLocalRevisions((writer) => writer.discard()); // A draft leaving with a title but a slug still derived from the default title. if ( status === 'draft' && diff --git a/apps/admin/src/editor/session/leave-guard.test.ts b/apps/admin/src/editor/session/leave-guard.test.ts index ca1b7b9dc00..7589f599ce4 100644 --- a/apps/admin/src/editor/session/leave-guard.test.ts +++ b/apps/admin/src/editor/session/leave-guard.test.ts @@ -60,6 +60,17 @@ describe('isCreatedIdUrlSwap', () => { ).toBe(true); }); + it('recognizes the swap from a new-post URL with a trailing slash', () => { + expect( + isCreatedIdUrlSwap( + { pathname: '/editor/post/' }, + swapTo('/editor/post/abc'), + 'post', + SESSION, + ), + ).toBe(true); + }); + it('rejects a swap that does not carry this session', () => { expect( isCreatedIdUrlSwap( diff --git a/apps/admin/src/editor/session/leave-guard.ts b/apps/admin/src/editor/session/leave-guard.ts index e5901b848e9..650ea84f491 100644 --- a/apps/admin/src/editor/session/leave-guard.ts +++ b/apps/admin/src/editor/session/leave-guard.ts @@ -1,3 +1,4 @@ +import { withoutTrailingSlash } from '@/hooks/use-history-pop-navigation-guard'; import type { SaveEngineState } from '@/editor/engine/save-engine'; import type { PostType } from '@/editor/card-config'; @@ -26,8 +27,8 @@ export function hasUnsavedWork(state: SaveEngineState, isDirty: boolean): boolea /** * Whether a navigation is the session's own URL replace after a create rather - * than a writer leaving: `/editor/post` to `/editor/post/:id`, carrying the - * session key so the same session survives the swap. + * than a writer leaving: `/editor/post` (with or without its trailing slash) to + * `/editor/post/:id`, carrying the session key so the same session survives the swap. */ export function isCreatedIdUrlSwap( current: GuardedLocation, @@ -36,7 +37,10 @@ export function isCreatedIdUrlSwap( sessionKey: string, ): boolean { const newPath = `/editor/${postType}`; - if (current.pathname !== newPath || !next.pathname.startsWith(`${newPath}/`)) { + if ( + withoutTrailingSlash(current.pathname) !== newPath || + !next.pathname.startsWith(`${newPath}/`) + ) { return false; } const id = next.pathname.slice(newPath.length + 1); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index 5fed71c2655..9dcf5768806 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -34,6 +34,7 @@ import { import type { RestoredRevision } from '@/editor/engine/change-tracker'; import type { LexicalInput } from '@/editor/engine/lexical-compare'; import type { PostType } from '@/editor/card-config'; +import { createLocalRevisionWriter } from '@/editor/local-revisions'; import { reportEditorError, reportLeaveConfirmation, @@ -213,6 +214,11 @@ export function useEditorSession({ onError: reportEditorError, onSaveFailed: (failure) => reportSaveFailure(failure, postType), onLeaveConfirmed: (leave) => reportLeaveConfirmation(leave, postType), + localRevisions: createLocalRevisionWriter({ + type: postType, + storage: () => window.localStorage, + onError: reportEditorError, + }), transport: { create: async (payload: EditorCreatePayload) => { const current = transport.current; @@ -267,6 +273,22 @@ export function useEditorSession({ }; }, [session]); + // A closing or backgrounded tab never unmounts the editor, and a discarded one never fires `pagehide`. + useEffect(() => { + const flush = () => session.flushLocalRevision(); + const flushWhenHidden = () => { + if (document.visibilityState === 'hidden') { + flush(); + } + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', flushWhenHidden); + return () => { + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', flushWhenHidden); + }; + }, [session]); + const view = useSyncExternalStore(session.subscribe, session.getView); const { state, pendingSave, title: engineTitle, slug, settings, publishTime } = view; @@ -325,7 +347,7 @@ export function useEditorSession({ } }, [saved, session]); - // Its own request: a failed refetch of the screen's query replaces the editor. + // Its own request: a query refetch would land before the session could refuse the copy. const reload = useCallback(async (): Promise => { if (!persistedId) { return 'failed'; diff --git a/apps/admin/src/editor/session/use-leave-guard.test.ts b/apps/admin/src/editor/session/use-leave-guard.test.ts index c22461ba92d..bcc80efae9d 100644 --- a/apps/admin/src/editor/session/use-leave-guard.test.ts +++ b/apps/admin/src/editor/session/use-leave-guard.test.ts @@ -1,13 +1,33 @@ -import { createElement, useState } from 'react'; +import { createElement, useEffect, useState } from 'react'; import { flushSync } from 'react-dom'; import { act, render, waitFor } from '@testing-library/react'; -import { createMemoryRouter, RouterProvider } from 'react-router'; -import { describe, expect, it, vi } from 'vitest'; +import { createHashRouter, createMemoryRouter, RouterProvider } from 'react-router'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { installHistoryPopGate } from '@/hooks/use-history-pop-navigation-guard'; import { deferred } from '@/utils/deferred'; import { useEditorLeaveGuard, type EditorLeaveGuard } from './use-leave-guard'; -import type { EditorSessionHandle } from './use-editor-session'; +import { useEditorSessionKey, type EditorSessionHandle } from './use-editor-session'; describe('useEditorLeaveGuard', () => { + const reached: string[] = []; + beforeAll(() => { + // Ahead of the gate, so it also sees where the pops the gate holds went. + window.addEventListener( + 'popstate', + () => { + reached.push(window.location.hash); + }, + { capture: true }, + ); + installHistoryPopGate(); + }); + + const traverse = async (move: () => void, target: string) => { + const before = reached.length; + move(); + await waitFor(() => expect(reached.slice(before)).toContain(target)); + }; + it.each(['proceed', 'confirm'] as const)( 'settles a blocked exit (%s) when a create renders before the router', async (outcome) => { @@ -108,4 +128,99 @@ describe('useEditorLeaveGuard', () => { expect(router.state.location.pathname).toBe('/posts'); router.dispose(); }); + + it.each(['confirm', 'proceed'] as const)( + 'keeps asking once an exit accepted by %s lands on the same mounted editor', + async (firstDecision) => { + const leaveRequested = vi.fn().mockResolvedValue('confirm'); + leaveRequested.mockResolvedValueOnce(firstDecision); + const session = { + state: { kind: 'idle' }, + createdId: null, + isDirty: () => true, + leaveRequested, + } as unknown as EditorSessionHandle; + let guard!: EditorLeaveGuard; + let mounts = 0; + function Editor() { + guard = useEditorLeaveGuard(session, 'post'); + useEffect(() => { + mounts += 1; + }, []); + return createElement('main'); + } + // Keyed like the editor screen, so entries with the same session key share one editor. + function EditorRoute() { + return createElement(Editor, { key: useEditorSessionKey() }); + } + window.history.replaceState(null, '', '#/editor/post/first'); + window.history.pushState(null, '', '#/posts'); + window.history.pushState(null, '', '#/editor/post/second'); + const router = createHashRouter([ + { path: '/editor/*', element: createElement(EditorRoute) }, + { path: '/posts', element: 'Posts' }, + ]); + // Hash anchors leave their entries without a router index. + window.history.replaceState(null, ''); + render(createElement(RouterProvider, { router })); + try { + await traverse(() => window.history.go(-2), '#/editor/post/first'); + if (firstDecision === 'confirm') { + await waitFor(() => expect(guard.dialogProps.open).toBe(true)); + act(() => { + guard.dialogProps.onConfirm(); + guard.dialogProps.onOpenChange(false); + }); + } + + await waitFor(() => expect(router.state.location.pathname).toBe('/editor/post/first')); + await waitFor(() => expect(guard.dialogProps.open).toBe(false)); + expect(mounts).toBe(1); + + await traverse(() => window.history.forward(), '#/editor/post/second'); + await waitFor(() => expect(guard.dialogProps.open).toBe(true)); + act(() => guard.dialogProps.onOpenChange(false)); + await waitFor(() => expect(guard.dialogProps.open).toBe(false)); + + await traverse(() => window.history.back(), '#/editor/post/second'); + + await waitFor(() => expect(guard.dialogProps.open).toBe(true)); + expect(leaveRequested).toHaveBeenCalledTimes(3); + } finally { + router.dispose(); + } + }, + ); + + it('replaces the URL of a post created at its trailing-slash URL without asking to leave', async () => { + const leaveRequested = vi.fn().mockResolvedValue('confirm'); + let acknowledgeCreate!: () => void; + function Editor() { + const [createdId, setCreatedId] = useState(null); + acknowledgeCreate = () => setCreatedId('new789'); + useEditorLeaveGuard( + { + state: { kind: 'idle' }, + createdId, + isDirty: () => true, + leaveRequested, + } as unknown as EditorSessionHandle, + 'post', + ); + return createElement('main'); + } + const router = createMemoryRouter( + [{ path: '/editor/post/:id?', element: createElement(Editor) }], + { initialEntries: ['/editor/post/'] }, + ); + render(createElement(RouterProvider, { router })); + try { + act(() => acknowledgeCreate()); + + await waitFor(() => expect(router.state.location.pathname).toBe('/editor/post/new789')); + expect(leaveRequested).not.toHaveBeenCalled(); + } finally { + router.dispose(); + } + }); }); diff --git a/apps/admin/src/editor/session/use-leave-guard.ts b/apps/admin/src/editor/session/use-leave-guard.ts index cb8effe4cf7..ec53f46c383 100644 --- a/apps/admin/src/editor/session/use-leave-guard.ts +++ b/apps/admin/src/editor/session/use-leave-guard.ts @@ -15,16 +15,13 @@ export interface EditorLeaveGuard { } /** - * Stops a navigation from losing what the writer typed. In-router navigations - * and native `` anchors into Ember-owned routes are put to the - * save engine, which finishes or saves whatever is outstanding and answers - * `proceed` (leaving loses nothing) or `confirm` (ask first); a tab close or - * reload gets the browser's own prompt. The session's own URL replace after a - * create is not an exit and passes silently. - * - * Browser Back is not covered: the shared guard lets a POP through unless the - * entry it returns to was created by the router, and admin reaches the editor - * through a native hash anchor. + * Stops a navigation from losing what the writer typed. In-router navigations, + * native `` anchors and history pops (Back, Forward, a hash change + * made outside the router) are put to the save engine, which finishes or saves + * whatever is outstanding and answers `proceed` (leaving loses nothing) or + * `confirm` (ask first); a held pop keeps the editor's URL until then. A tab + * close or reload gets the browser's own prompt. The session's own URL replace + * after a create is not an exit and passes silently. */ export function useEditorLeaveGuard( session: EditorSessionHandle, @@ -42,6 +39,7 @@ export function useEditorLeaveGuard( confirmUnloadWhen: hasWork, interceptNavigation: ({ currentLocation, nextLocation }) => isCreatedIdUrlSwap(currentLocation, nextLocation, postType, sessionKey), + guardHistoryPops: true, }); const guardRef = useRef(guard); @@ -59,6 +57,16 @@ export function useEditorLeaveGuard( }; }, []); + // An accepted exit that lands without unmounting the editor is over. Runs before the ID + // swap below, which waits for it. + useEffect(() => { + if (!isLeavingRef.current) { + return; + } + isLeavingRef.current = false; + setIsConfirmingLeave(false); + }, [location]); + const isUrlSwapBlocked = guard.interceptedNavigation.isBlocked; // Wait for a real exit to settle before replacing a new post's URL. React diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index 80f7e4d8775..146dd36651a 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -109,6 +109,40 @@ describe('syncEmberPostListQueryParams', () => { }); }); +describe('syncEmberFullScreen', () => { + baseTest('applies the value once Ember loads after React', async () => { + vi.useFakeTimers(); + const { syncEmberFullScreen } = await import('./ember-bridge'); + const stop = syncEmberFullScreen(true); + const mock = createMockStateBridge(); + const setReactFullScreen = vi.fn(); + mock.stateBridge.setReactFullScreen = setReactFullScreen; + window.EmberBridge = { state: mock.stateBridge }; + + await vi.advanceTimersByTimeAsync(100); + + expect(setReactFullScreen).toHaveBeenCalledExactlyOnceWith(true); + stop(); + }); + + baseTest('applies only the latest value when the route changes before Ember loads', async () => { + vi.useFakeTimers(); + const { syncEmberFullScreen } = await import('./ember-bridge'); + const stop = syncEmberFullScreen(true); + stop(); + const stopCurrent = syncEmberFullScreen(false); + const mock = createMockStateBridge(); + const setReactFullScreen = vi.fn(); + mock.stateBridge.setReactFullScreen = setReactFullScreen; + window.EmberBridge = { state: mock.stateBridge }; + + await vi.advanceTimersByTimeAsync(100); + + expect(setReactFullScreen).toHaveBeenCalledExactlyOnceWith(false); + stopCurrent(); + }); +}); + beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index d08dec7f729..a95d398e550 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -31,6 +31,7 @@ export interface StateBridge { applyAdminThemePreference?: (mode: AdminThemeMode) => Promise | void; navigateToBillingSubRoute?: (subRoute: string) => void; setPostListQueryParams?: (resource: 'posts' | 'pages', params: Record) => void; + setReactFullScreen?: (isFullScreen: boolean) => void; on( event: K, callback: (event: StateBridgeEventMap[K]) => void, @@ -368,6 +369,13 @@ export function syncEmberPostListQueryParams( }); } +/** Tells Ember whether React's current route hides the admin sidebar. */ +export function syncEmberFullScreen(isFullScreen: boolean): () => void { + return waitForStateBridge((stateBridge) => { + stateBridge.setReactFullScreen?.(isFullScreen); + }); +} + /** * React -> Ember handlers for the FrameworkProvider. Feature flag overrides * wait for Ember to load; mutation handlers no-op when the bridge is absent. diff --git a/apps/admin/src/ember-bridge/index.ts b/apps/admin/src/ember-bridge/index.ts index 8e8cbab82ed..d9ff52f5dd8 100644 --- a/apps/admin/src/ember-bridge/index.ts +++ b/apps/admin/src/ember-bridge/index.ts @@ -18,6 +18,7 @@ export { applyEmberAdminThemePreference, navigateEmberBillingSubRoute, syncEmberPostListQueryParams, + syncEmberFullScreen, emberMutationHandlers, } from './ember-bridge'; export type { diff --git a/apps/admin/src/hooks/use-history-pop-navigation-guard.ts b/apps/admin/src/hooks/use-history-pop-navigation-guard.ts new file mode 100644 index 00000000000..978a540e7a1 --- /dev/null +++ b/apps/admin/src/hooks/use-history-pop-navigation-guard.ts @@ -0,0 +1,152 @@ +import React from 'react'; +import { useLocation } from '@tryghost/admin-x-framework'; + +type PopHolder = () => boolean; + +const holders = new Set(); +const heldUrls: string[] = []; +let isGateInstalled = false; + +/** + * Must be installed before the router or anything else listens for pops, and listens in the + * capture phase, so it runs first whichever order an engine gives window listeners. + */ +export function installHistoryPopGate(): void { + if (isGateInstalled) { + return; + } + isGateInstalled = true; + window.addEventListener( + 'popstate', + (event) => { + const url = window.location.href; + for (const hold of holders) { + if (hold()) { + event.stopImmediatePropagation(); + heldUrls.push(url); + return; + } + } + heldUrls.length = 0; + }, + { capture: true }, + ); + // A held pop still queues its hash change, which describes a URL that is no longer current. + window.addEventListener( + 'hashchange', + (event) => { + const held = heldUrls.indexOf(event.newURL); + if (held === -1 || event.newURL === window.location.href) { + return; + } + heldUrls.splice(held, 1); + event.stopImmediatePropagation(); + }, + { capture: true }, + ); +} + +/** The pathname the hash router reads from a `#/…` URL. */ +export function hashPathname(hash: string): string { + const [path] = hash.replace(/^#/, '').split(/[?#]/); + return path.startsWith('/') ? path : `/${path}`; +} + +function hasRouterIndex(state: unknown): state is { idx: number } { + return ( + typeof state === 'object' && + state !== null && + Number.isInteger((state as { idx?: unknown }).idx) + ); +} + +/** The screen's history state, re-indexed for the router to sit directly above a pop's target. */ +export function restoredState(state: unknown, reachedState: unknown): unknown { + return hasRouterIndex(state) && hasRouterIndex(reachedState) + ? { ...state, idx: reachedState.idx + 1 } + : state; +} + +/** A pathname without its trailing slash, which the router ignores when matching routes. */ +export function withoutTrailingSlash(pathname: string): string { + return pathname.replace(/\/+$/, ''); +} + +interface GuardedEntry { + pathname: string; + href: string; + state: unknown; +} + +/** + * Holds history pops that leave the current pathname while `when` is true. The router + * can only undo a POP between entries it created, so the pop is undone here instead. + */ +export function useHistoryPopNavigationGuard(when: boolean, claim: () => boolean) { + const location = useLocation(); + const [isBlocked, setIsBlocked] = React.useState(false); + const blockedRef = React.useRef(false); + const releasedRef = React.useRef(false); + const entryRef = React.useRef(null); + const whenRef = React.useRef(when); + whenRef.current = when; + const claimRef = React.useRef(claim); + claimRef.current = claim; + + React.useLayoutEffect(() => { + entryRef.current = { + pathname: location.pathname, + href: window.location.href, + state: window.history.state, + }; + releasedRef.current = false; + }, [location]); + + React.useEffect(() => { + const hold: PopHolder = () => { + const entry = entryRef.current; + if (!whenRef.current || releasedRef.current || !entry) { + return false; + } + const reachedPathname = withoutTrailingSlash(hashPathname(window.location.hash)); + if (reachedPathname === withoutTrailingSlash(entry.pathname)) { + return false; + } + // The entry the pop reached stays directly below, where `proceed` returns to. + const state = restoredState(entry.state, window.history.state); + window.history.pushState(state, '', entry.href); + if (!blockedRef.current && claimRef.current()) { + blockedRef.current = true; + setIsBlocked(true); + } + return true; + }; + holders.add(hold); + return () => { + holders.delete(hold); + }; + }, []); + + return { + isBlocked, + /** Stops holding pops until the location changes, so an accepted exit is not held. */ + release: () => { + releasedRef.current = true; + }, + /** Confirm leaving: returns to the entry the held pop reached. */ + proceed: () => { + if (!blockedRef.current) { + return; + } + blockedRef.current = false; + releasedRef.current = true; + setIsBlocked(false); + window.history.back(); + }, + /** Cancel leaving: the screen's URL is already back in place. */ + reset: () => { + blockedRef.current = false; + setIsBlocked(false); + }, + }; +} diff --git a/apps/admin/src/hooks/use-unsaved-changes-guard.test.tsx b/apps/admin/src/hooks/use-unsaved-changes-guard.test.tsx index 451842094ce..c21fa330912 100644 --- a/apps/admin/src/hooks/use-unsaved-changes-guard.test.tsx +++ b/apps/admin/src/hooks/use-unsaved-changes-guard.test.tsx @@ -1,7 +1,12 @@ import React from 'react'; -import { RouterProvider, createMemoryRouter } from 'react-router'; +import { RouterProvider, createHashRouter, createMemoryRouter } from 'react-router'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + hashPathname, + installHistoryPopGate, + restoredState, +} from './use-history-pop-navigation-guard'; import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'; import type { UnsavedChangesGuard, @@ -253,3 +258,330 @@ describe('useUnsavedChangesGuard', () => { expect(window.location.hash).toBe('#/ember-route'); }); }); + +describe('useUnsavedChangesGuard with guardHistoryPops', () => { + const reached: string[] = []; + beforeAll(() => { + // Ahead of the gate, so it also sees where the pops the gate holds went. + window.addEventListener( + 'popstate', + () => { + reached.push(window.location.hash); + }, + { capture: true }, + ); + installHistoryPopGate(); + }); + + // The memory router ignores window history, so the window mirrors its entry + // after one the router did not create. + beforeEach(() => { + window.history.replaceState(null, '', '#/elsewhere'); + window.history.pushState(null, '', '#/guarded'); + }); + + const traverse = async (move: () => void, target: string) => { + const before = reached.length; + move(); + await waitFor(() => expect(reached.slice(before)).toContain(target)); + }; + const popBack = (target = '#/elsewhere') => traverse(() => window.history.back(), target); + const popForward = (target: string) => traverse(() => window.history.forward(), target); + + it('holds a pop out of the screen at its URL, then completes it on confirm', async () => { + renderGuarded({ when: true, guardHistoryPops: true }); + + await popBack(); + + expect(window.location.hash).toBe('#/guarded'); + expect(dialogOpen()).toBe('true'); + + act(() => { + latestGuard.dialogProps.onConfirm(); + latestGuard.dialogProps.onOpenChange(false); + }); + + await waitFor(() => expect(window.location.hash).toBe('#/elsewhere')); + }); + + it('stays on cancel and holds the next pop again', async () => { + renderGuarded({ when: true, guardHistoryPops: true }); + await popBack(); + + act(() => { + latestGuard.dialogProps.onOpenChange(false); + }); + expect(dialogOpen()).toBe('false'); + expect(window.location.hash).toBe('#/guarded'); + + await popBack(); + + expect(window.location.hash).toBe('#/guarded'); + expect(dialogOpen()).toBe('true'); + }); + + it('hides a held pop from the listeners that come after the gate', async () => { + const seen: string[] = []; + const onPop = () => seen.push(`popstate ${window.location.hash}`); + const onHashChange = (event: HashChangeEvent) => + seen.push(`hashchange ${new URL(event.newURL).hash}`); + window.addEventListener('popstate', onPop); + window.addEventListener('hashchange', onHashChange); + try { + renderGuarded({ when: true, guardHistoryPops: true }); + await popBack(); + expect(seen).toEqual([]); + + act(() => { + latestGuard.dialogProps.onConfirm(); + }); + + await waitFor(() => expect(seen).toEqual(['popstate #/elsewhere', 'hashchange #/elsewhere'])); + } finally { + window.removeEventListener('popstate', onPop); + window.removeEventListener('hashchange', onHashChange); + } + }); + + it('hides a held pop from capture listeners added after the gate', async () => { + const seen: string[] = []; + const onPop = () => seen.push(window.location.hash); + window.addEventListener('popstate', onPop, { capture: true }); + try { + renderGuarded({ when: true, guardHistoryPops: true }); + + await popBack(); + + expect(seen).toEqual([]); + } finally { + window.removeEventListener('popstate', onPop, { capture: true }); + } + }); + + it('forgets the URL of a held pop once a pop goes through', async () => { + const heard: string[] = []; + const onHashChange = (event: HashChangeEvent) => heard.push(new URL(event.newURL).hash); + window.addEventListener('hashchange', onHashChange); + try { + renderGuarded({ when: true, guardHistoryPops: true }); + // A held pop whose hash change never follows, as when the URL is back on it first. + window.history.replaceState(null, '', '#/elsewhere'); + act(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(window.location.hash).toBe('#/guarded'); + act(() => { + latestGuard.dialogProps.onOpenChange(false); + setOptions({ when: false, guardHistoryPops: true }); + }); + + window.location.hash = '/elsewhere'; + window.location.hash = '/third'; + + await waitFor(() => expect(heard).toEqual(['#/elsewhere', '#/third'])); + } finally { + window.removeEventListener('hashchange', onHashChange); + } + }); + + it("puts the screen's own entry back above the entry the pop reached", async () => { + window.history.replaceState({ key: 'before', idx: 3 }, '', '#/elsewhere'); + window.history.pushState({ usr: { from: 'list' }, key: 'screen', idx: 4 }, '', '#/guarded'); + renderGuarded({ when: true, guardHistoryPops: true }); + + await popBack(); + + expect(window.location.hash).toBe('#/guarded'); + expect(window.history.state).toEqual({ usr: { from: 'list' }, key: 'screen', idx: 4 }); + }); + + it('keeps the destination of an exit that is already awaiting a decision', async () => { + const router = renderGuarded({ when: true, guardHistoryPops: true }); + await act(async () => { + await router.navigate('/elsewhere'); + }); + expect(dialogOpen()).toBe('true'); + + await popBack(); + expect(window.location.hash).toBe('#/guarded'); + + act(() => { + latestGuard.dialogProps.onConfirm(); + latestGuard.dialogProps.onOpenChange(false); + }); + + await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere')); + expect(window.location.hash).toBe('#/guarded'); + }); + + it('lets through the hash change of an anchor exit the writer confirmed', async () => { + renderGuarded({ when: true, guardHistoryPops: true }); + fireEvent.click(screen.getByText('Ember link')); + const before = reached.length; + + await act(async () => { + latestGuard.dialogProps.onConfirm(); + latestGuard.dialogProps.onOpenChange(false); + await waitFor(() => expect(reached.slice(before)).toContain('#/ember-route')); + }); + + expect(window.location.hash).toBe('#/ember-route'); + expect(dialogOpen()).toBe('false'); + }); + + it('lets pops through while there is nothing to lose', async () => { + renderGuarded({ when: false, guardHistoryPops: true }); + + await popBack(); + + expect(window.location.hash).toBe('#/elsewhere'); + expect(dialogOpen()).toBe('false'); + }); + + it('lets a pop that keeps the pathname through', async () => { + window.history.replaceState(null, '', '#/guarded?tab=first'); + window.history.pushState(null, '', '#/guarded'); + renderGuarded({ when: true, guardHistoryPops: true }); + + await popBack('#/guarded?tab=first'); + + expect(window.location.hash).toBe('#/guarded?tab=first'); + expect(dialogOpen()).toBe('false'); + }); + + it('lets a pop that only drops the trailing slash through', async () => { + window.history.replaceState(null, '', '#/guarded'); + window.history.pushState(null, '', '#/guarded/'); + renderGuarded({ when: true, guardHistoryPops: true }, { initialEntries: ['/guarded/'] }); + + await popBack('#/guarded'); + + expect(window.location.hash).toBe('#/guarded'); + expect(dialogOpen()).toBe('false'); + }); + + it('leaves pops alone without the option', async () => { + renderGuarded({ when: true }); + + await popBack(); + + expect(window.location.hash).toBe('#/elsewhere'); + expect(dialogOpen()).toBe('false'); + }); + + it('does not block a pop to the same screen without its trailing slash', async () => { + let screenGuard!: { guard: UnsavedChangesGuard; setWhen: (when: boolean) => void }; + function Screen() { + const [when, setWhen] = React.useState(false); + screenGuard = { guard: useUnsavedChangesGuard({ when, guardHistoryPops: true }), setWhen }; + return null; + } + window.history.replaceState(null, '', '#/guarded'); + const router = createHashRouter([{ path: '/guarded', element: }]); + render(); + const go = vi.spyOn(window.history, 'go').mockImplementation(() => {}); + try { + await traverse(() => { + window.location.hash = '/guarded/'; + }, '#/guarded/'); + await waitFor(() => expect(router.state.location.pathname).toBe('/guarded/')); + act(() => screenGuard.setWhen(true)); + + await popBack('#/guarded'); + + await waitFor(() => expect(router.state.location.pathname).toBe('/guarded')); + expect(go).not.toHaveBeenCalled(); + expect(screenGuard.guard.isBlocked).toBe(false); + } finally { + go.mockRestore(); + router.dispose(); + } + }); + + it('leaves the router able to undo a later pop onto the entry a cancelled Forward restored', async () => { + const screens: Record< + string, + { guard: UnsavedChangesGuard; setWhen: (when: boolean) => void } + > = {}; + function Screen({ name, guardHistoryPops }: { name: string; guardHistoryPops?: boolean }) { + const [when, setWhen] = React.useState(false); + screens[name] = { guard: useUnsavedChangesGuard({ when, guardHistoryPops }), setWhen }; + return null; + } + window.history.replaceState(null, '', '#/start'); + const router = createHashRouter([ + { path: '/start', element: null }, + { path: '/editor', element: }, + { path: '/tag', element: }, + ]); + render(); + try { + await act(async () => { + await router.navigate('/editor'); + }); + const editorState: unknown = window.history.state; + await act(async () => { + await router.navigate('/tag'); + }); + await popBack('#/editor'); + await waitFor(() => expect(router.state.location.pathname).toBe('/editor')); + act(() => screens.editor.setWhen(true)); + + await popForward('#/tag'); + expect(window.location.hash).toBe('#/editor'); + expect(window.history.state).toEqual({ ...(editorState as object), idx: 3 }); + act(() => { + screens.editor.guard.dialogProps.onOpenChange(false); + screens.editor.setWhen(false); + }); + + await popBack('#/tag'); + await waitFor(() => expect(router.state.location.pathname).toBe('/tag')); + act(() => screens.tag.setWhen(true)); + + await popForward('#/editor'); + + await waitFor(() => expect(window.location.hash).toBe('#/tag')); + expect(screens.tag.guard.dialogProps.open).toBe(true); + act(() => { + screens.tag.guard.dialogProps.onConfirm(); + screens.tag.guard.dialogProps.onOpenChange(false); + }); + await waitFor(() => expect(router.state.location.pathname).toBe('/editor')); + expect(window.location.hash).toBe('#/editor'); + } finally { + router.dispose(); + } + }); +}); + +describe('restoredState', () => { + const screenState = { usr: { from: 'list' }, key: 'screen', idx: 4 }; + + it.each([ + ['a Back reached', { idx: 3 }, screenState], + ['a Forward reached', { idx: 5 }, { ...screenState, idx: 6 }], + ])('sits directly above the entry %s', (_move, reachedState, restored) => { + expect(restoredState(screenState, reachedState)).toEqual(restored); + }); + + it('keeps its own index when the entry reached has none', () => { + expect(restoredState(screenState, null)).toEqual(screenState); + }); + + it('keeps a state that has no router index', () => { + expect(restoredState(null, { idx: 3 })).toBeNull(); + }); +}); + +describe('hashPathname', () => { + it.each([ + ['#/editor/post/abc', '/editor/post/abc'], + ['#/posts?type=draft', '/posts'], + ['#/posts/#section', '/posts/'], + ['#posts', '/posts'], + ['', '/'], + ])('reads %s as %s', (hash, pathname) => { + expect(hashPathname(hash)).toBe(pathname); + }); +}); diff --git a/apps/admin/src/hooks/use-unsaved-changes-guard.ts b/apps/admin/src/hooks/use-unsaved-changes-guard.ts index 651e9a25716..d924649f229 100644 --- a/apps/admin/src/hooks/use-unsaved-changes-guard.ts +++ b/apps/admin/src/hooks/use-unsaved-changes-guard.ts @@ -7,12 +7,19 @@ import { useLocation, } from '@tryghost/admin-x-framework'; import { useHashLinkNavigationGuard } from '@/hooks/use-hash-link-navigation-guard'; +import { + useHistoryPopNavigationGuard, + withoutTrailingSlash, +} from '@/hooks/use-history-pop-navigation-guard'; import type { BlockerFunction } from '@tryghost/admin-x-framework'; type BlockerFunctionArgs = Parameters[0]; export interface UseUnsavedChangesGuardOptions { - /** Guards in-router navigations and raw `` anchors while true. */ + /** + * Guards in-router navigations and raw `` anchors while true, and history pops + * too with `guardHistoryPops`. + */ when: boolean; /** `beforeunload` guard condition; defaults to `when`. */ confirmUnloadWhen?: boolean; @@ -25,6 +32,8 @@ export interface UseUnsavedChangesGuardOptions { * closed and the caller settles it via `interceptedNavigation`. */ interceptNavigation?: (args: BlockerFunctionArgs) => boolean; + /** Also holds Back, Forward and other history pops out of the screen, keeping its URL. */ + guardHistoryPops?: boolean; } export interface UnsavedChangesGuard { @@ -60,6 +69,8 @@ export interface UnsavedChangesGuard { * `useBlocker` (in-router navigations), and `useHashLinkNavigationGuard` * (native hash anchors that reach the router as untracked POPs it cannot * block) — plus the proceed/reset choreography behind the discard dialog. + * With `guardHistoryPops` it also holds Back, Forward and other history pops + * through `useHistoryPopNavigationGuard`. * * Settings uses a different exit-point model (`useGlobalDirtyState` + * `useDirtyConfirmation`); this hook is for screens that own their routes. @@ -69,6 +80,7 @@ export function useUnsavedChangesGuard({ confirmUnloadWhen, isSaving = false, interceptNavigation, + guardHistoryPops = false, }: UseUnsavedChangesGuardOptions): UnsavedChangesGuard { const location = useLocation(); // Lets the caller's own programmatic navigations (post-save redirects, @@ -104,7 +116,10 @@ export function useUnsavedChangesGuard({ return true; } blockedByInterceptRef.current = false; - const shouldBlock = when && args.currentLocation.pathname !== args.nextLocation.pathname; + const shouldBlock = + when && + withoutTrailingSlash(args.currentLocation.pathname) !== + withoutTrailingSlash(args.nextLocation.pathname); if (shouldBlock) { blockedNavigationRef.current = true; } @@ -113,16 +128,40 @@ export function useUnsavedChangesGuard({ const anchorGuard = useHashLinkNavigationGuard(when, () => { blockedNavigationRef.current = true; }); + const popGuard = useHistoryPopNavigationGuard(when && guardHistoryPops, () => { + if (blockedNavigationRef.current) { + return false; + } + blockedNavigationRef.current = true; + return true; + }); const isBlockedByIntercept = blocker.state === 'blocked' && blockedByInterceptRef.current; const isBlocked = - (blocker.state === 'blocked' && !blockedByInterceptRef.current) || anchorGuard.isBlocked; + (blocker.state === 'blocked' && !blockedByInterceptRef.current) || + anchorGuard.isBlocked || + popGuard.isBlocked; + + const proceedBlocked = () => { + // An anchor proceeds with a hash change, which arrives as a history pop. + popGuard.release(); + if (anchorGuard.isBlocked) { + anchorGuard.proceed(); + } else if (popGuard.isBlocked) { + // The held pop is replayed as a POP the router blocker must let through. + bypassRef.current = true; + popGuard.proceed(); + } else { + blocker.proceed?.(); + } + }; // One-shot state is scoped to the current route target. React.useEffect(() => { bypassRef.current = false; blockedNavigationRef.current = false; resumeAfterSaveRef.current = false; + leaveConfirmedRef.current = false; }, [location.pathname]); React.useEffect(() => { @@ -131,12 +170,8 @@ export function useUnsavedChangesGuard({ } resumeAfterSaveRef.current = false; blockedNavigationRef.current = false; - if (anchorGuard.isBlocked) { - anchorGuard.proceed(); - } else { - blocker.proceed?.(); - } - }, [isSaving, isBlocked, anchorGuard, blocker]); + proceedBlocked(); + }, [isSaving, isBlocked, proceedBlocked]); const bypassNextNavigation = React.useCallback(() => { bypassRef.current = true; @@ -161,11 +196,7 @@ export function useUnsavedChangesGuard({ leaveConfirmedRef.current = true; blockedNavigationRef.current = false; resumeAfterSaveRef.current = false; - if (anchorGuard.isBlocked) { - anchorGuard.proceed(); - } else { - blocker.proceed?.(); - } + proceedBlocked(); }, onOpenChange: (open: boolean) => { if (open) { @@ -180,6 +211,7 @@ export function useUnsavedChangesGuard({ resumeAfterSaveRef.current = false; blocker.reset?.(); anchorGuard.reset(); + popGuard.reset(); } }, }, diff --git a/apps/admin/src/layout/admin7-design.acceptance.test.tsx b/apps/admin/src/layout/admin7-design.acceptance.test.tsx index 038a2ff60b0..839eca2679a 100644 --- a/apps/admin/src/layout/admin7-design.acceptance.test.tsx +++ b/apps/admin/src/layout/admin7-design.acceptance.test.tsx @@ -27,7 +27,7 @@ it.each<{ labs: { admin7Pill: true }, enabled: true, }, - { name: 'Ember route excluded', route: '/restore', labs: {}, enabled: false }, + { name: 'Ember route excluded', route: '/pro/plans', labs: {}, enabled: false }, { name: 'Ember editor excluded', route: '/editor/post/new', diff --git a/apps/admin/src/layout/sidebar-visibility.test.tsx b/apps/admin/src/layout/sidebar-visibility.test.tsx index f62f0649ad0..d5bbc7126aa 100644 --- a/apps/admin/src/layout/sidebar-visibility.test.tsx +++ b/apps/admin/src/layout/sidebar-visibility.test.tsx @@ -7,12 +7,14 @@ type RouteMatch = { const useMatchesMock = vi.fn<() => RouteMatch[]>(); const useEmberSidebarVisibilityMock = vi.fn<() => boolean>(); +const syncEmberFullScreenMock = vi.fn<(isFullScreen: boolean) => () => void>(); vi.mock('@tryghost/admin-x-framework', () => ({ useMatches: () => useMatchesMock(), })); vi.mock('@/ember-bridge', () => ({ + syncEmberFullScreen: (isFullScreen: boolean) => syncEmberFullScreenMock(isFullScreen), useSidebarVisibility: () => useEmberSidebarVisibilityMock(), })); @@ -55,3 +57,23 @@ describe('useAdminSidebarVisibility', () => { expect(result.current).toBe(true); }); }); + +describe('useSyncEmberFullScreen', () => { + it('publishes whether a matched route hides the sidebar', async () => { + const { useSyncEmberFullScreen } = await import('./sidebar-visibility'); + const stopSync = vi.fn(); + syncEmberFullScreenMock.mockReturnValue(stopSync); + + useMatchesMock.mockReturnValue([{}, { handle: { hideAdminSidebar: true } }]); + const { rerender } = renderHook(() => useSyncEmberFullScreen()); + + expect(syncEmberFullScreenMock).toHaveBeenCalledExactlyOnceWith(true); + + useMatchesMock.mockReturnValue([{}, { handle: { allowInForceUpgrade: true } }]); + rerender(); + + expect(stopSync).toHaveBeenCalledOnce(); + expect(syncEmberFullScreenMock).toHaveBeenCalledTimes(2); + expect(syncEmberFullScreenMock).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/apps/admin/src/layout/sidebar-visibility.ts b/apps/admin/src/layout/sidebar-visibility.ts index d69a7541b9e..c70943651c9 100644 --- a/apps/admin/src/layout/sidebar-visibility.ts +++ b/apps/admin/src/layout/sidebar-visibility.ts @@ -1,5 +1,9 @@ +import { useEffect } from 'react'; import { type AdminRouteHandle, useMatches } from '@tryghost/admin-x-framework'; -import { useSidebarVisibility as useEmberSidebarVisibility } from '@/ember-bridge'; +import { + syncEmberFullScreen, + useSidebarVisibility as useEmberSidebarVisibility, +} from '@/ember-bridge'; function hidesAdminSidebar(handle: unknown): handle is AdminRouteHandle { return ( @@ -10,11 +14,22 @@ function hidesAdminSidebar(handle: unknown): handle is AdminRouteHandle { ); } -export function useAdminSidebarVisibility(): boolean { - const emberSidebarVisible = useEmberSidebarVisibility(); +export function useRouteHidesAdminSidebar(): boolean { const matches = useMatches(); - const routeHidesSidebar = matches.some((match) => hidesAdminSidebar(match.handle)); + return matches.some((match) => hidesAdminSidebar(match.handle)); +} + +export function useAdminSidebarVisibility(): boolean { + const emberSidebarVisible = useEmberSidebarVisibility(); + const routeHidesSidebar = useRouteHidesAdminSidebar(); return emberSidebarVisible && !routeHidesSidebar; } + +/** Publishes whether the current route hides the admin sidebar. */ +export function useSyncEmberFullScreen(): void { + const routeHidesSidebar = useRouteHidesAdminSidebar(); + + useEffect(() => syncEmberFullScreen(routeHidesSidebar), [routeHidesSidebar]); +} diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx index 5ad49428776..83b8b0fd7ee 100644 --- a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -714,8 +714,15 @@ describe('Import members custom fields', () => { // Dismissed without answering which part the column holds. await expect.element(importMembersScreen.option('Shipping address (City)')).toBeVisible(); + // Visible options can precede Radix registering the reopened picker as the active + // layer. Wait for an actionable input before Escape, or it can reach the import dialog. + await importMembersScreen.searchFieldsInput().click(); + await expect.element(importMembersScreen.searchFieldsInput()).toHaveFocus(); await userEvent.keyboard('{Escape}'); + await expect.element(importMembersScreen.searchFieldsInput()).not.toBeInTheDocument(); + await expect.element(fieldSelect('city')).toHaveFocus(); + await expect.element(importMembersScreen.leaveConfirmationText()).not.toBeInTheDocument(); await importMembersScreen.importButton(1).click(); await expect .element(importMembersScreen.messageText(/Choose a field for "city"/)) diff --git a/apps/admin/src/posts/list/components/post-list-row.tsx b/apps/admin/src/posts/list/components/post-list-row.tsx index 4291f746da5..5e09f6cd3b3 100644 --- a/apps/admin/src/posts/list/components/post-list-row.tsx +++ b/apps/admin/src/posts/list/components/post-list-row.tsx @@ -1,4 +1,4 @@ -import { Button } from '@tryghost/shade/components'; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@tryghost/shade/components'; import { Inline, Stack, Text } from '@tryghost/shade/primitives'; import { cn, LucideIcon } from '@tryghost/shade/utils'; import FeatureImagePlaceholder from '@/shared/feature-image-placeholder'; @@ -117,7 +117,11 @@ function FeatureImage({ post }: { post: PostListItem }) { // `p-0` because the placeholder's own padding is sized for a larger box; // here the icon just centres in the thumbnail. - return ; + return ( + + ); } const PostListRowComponent = forwardRef( @@ -233,7 +237,7 @@ const PostListRowComponent = forwardRef )} - + {post.title} @@ -275,7 +279,15 @@ const PostListRowComponent = forwardRef ) : ( - + {displayedStatusLabel} {/* Mounted only while hovered, as Ember does. A CSS opacity fade would keep it in the DOM, so a screen @@ -300,34 +312,41 @@ const PostListRowComponent = forwardRef {/* Always visible so the action stays discoverable and remains available on touch devices. */} - + + + + + {action.label} + ); diff --git a/apps/admin/src/posts/list/components/posts-empty-state.tsx b/apps/admin/src/posts/list/components/posts-empty-state.tsx index e45cfe9f599..0016e54bb2d 100644 --- a/apps/admin/src/posts/list/components/posts-empty-state.tsx +++ b/apps/admin/src/posts/list/components/posts-empty-state.tsx @@ -24,7 +24,11 @@ export function PostsEmptyState({ resource, hasFilters, onClearFilters }: PostsE if (hasFilters) { return ( Show all {copy.plural}} + actions={ + + } data-testid="posts-empty-filtered" title={`No ${copy.plural} match the current filter`} > diff --git a/apps/admin/src/posts/list/post-context-menu-items.test.ts b/apps/admin/src/posts/list/post-context-menu-items.test.ts index 6f552afb187..f45832a85d6 100644 --- a/apps/admin/src/posts/list/post-context-menu-items.test.ts +++ b/apps/admin/src/posts/list/post-context-menu-items.test.ts @@ -221,13 +221,12 @@ describe('getPostContextMenuItems', () => { expect(withoutGift.find((item) => item.key === 'unpublish')?.separated).toBe(false); }); - // Ember hardcodes the noun here, as it does in the matching toast. Ported - // rather than corrected, so the two implementations read alike. - it('still says "post" on a page, as Ember does', () => { - const items = getPostContextMenuItems( - inputs([post({ status: 'published' })], { resource: 'pages' }), - ); + it.each([ + ['posts', 'Copy link to post'], + ['pages', 'Copy link to page'], + ] as const)('uses the copy-link label for %s', (resource, label) => { + const items = getPostContextMenuItems(inputs([post({ status: 'published' })], { resource })); - expect(items.find((item) => item.key === 'copy-link')?.label).toBe('Copy link to post'); + expect(items.find((item) => item.key === 'copy-link')?.label).toBe(label); }); }); diff --git a/apps/admin/src/posts/list/post-context-menu-items.ts b/apps/admin/src/posts/list/post-context-menu-items.ts index 24365f2a421..a344259de7d 100644 --- a/apps/admin/src/posts/list/post-context-menu-items.ts +++ b/apps/admin/src/posts/list/post-context-menu-items.ts @@ -43,9 +43,6 @@ export interface PostContextMenuInputs { * necessarily reasons about the ones in memory. */ posts: PostListItem[]; - /** Unused by the item list today — every label Ember emits here is - * hardcoded to "post" — but kept so callers pass a complete description of - * the selection, and for the Phase 8 modals. */ resource: PostResource; /** Owner or Administrator. Only they may delete. */ isAdmin: boolean; @@ -75,7 +72,7 @@ function shouldFeature(posts: PostListItem[]): boolean { } export function getPostContextMenuItems(inputs: PostContextMenuInputs): PostContextMenuItem[] { - const { posts, isAdmin, membersEnabled, canCopyGiftLink } = inputs; + const { posts, resource, isAdmin, membersEnabled, canCopyGiftLink } = inputs; if (posts.length === 0) { return []; @@ -94,9 +91,7 @@ export function getPostContextMenuItems(inputs: PostContextMenuInputs): PostCont // anything published offers the public link, and never the preview link. if (hasStatus(posts, 'published')) { if (isSingle(posts)) { - // "post" on both resources, as Ember hardcodes it — matching the - // "Post link copied" toast, which is hardcoded the same way. - add('copy-link', 'Copy link to post'); + add('copy-link', resource === 'pages' ? 'Copy link to page' : 'Copy link to post'); } if (canCopyGiftLink) { diff --git a/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx index 603cba0779e..242264cff10 100644 --- a/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx +++ b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx @@ -586,6 +586,10 @@ describe('Posts list trailing action button', () => { const action = postsListScreen.rowAction().first(); await expect.element(action).toHaveAccessibleName('Post analytics'); await expect.element(action).toHaveAttribute('href', `#/posts/analytics/${emailedPost.id}`); + await expect.element(action).not.toHaveAttribute('title'); + await action.hover(); + expect(action.element()).toHaveAttribute('data-state', 'closed'); + await expect.element(postsListScreen.actionTooltip('Post analytics')).toBeVisible(); }); // Same post, lesser role: Ember gates the analytics screen on isAdmin. @@ -622,6 +626,10 @@ describe('Posts list trailing action button', () => { const action = postsListScreen.rowAction().first(); await expect.element(action).toHaveAccessibleName('Edit'); await expect.element(action).toHaveAttribute('href', `#/editor/page/${page.id}`); + await expect.element(action).not.toHaveAttribute('title'); + await action.hover(); + expect(action.element()).toHaveAttribute('data-state', 'closed'); + await expect.element(postsListScreen.actionTooltip('Edit')).toBeVisible(); }); }); diff --git a/apps/admin/src/posts/list/posts-list.screen.ts b/apps/admin/src/posts/list/posts-list.screen.ts index b39c3e67935..90b34a12455 100644 --- a/apps/admin/src/posts/list/posts-list.screen.ts +++ b/apps/admin/src/posts/list/posts-list.screen.ts @@ -111,6 +111,7 @@ export const postsListScreen = { giftLinkModal: () => page.getByRole('dialog', { name: /gift/i }), /** The trailing button at a row's end — Analytics, View, or Editor. */ rowAction: () => page.getByTestId(postListItemAction), + actionTooltip: (label: string) => page.getByRole('tooltip', { name: label, exact: true }), featuredMarkers: () => page.getByTestId(postFeaturedMarker), emptyCold: () => page.getByTestId(postsEmptyCold), emptyFiltered: () => page.getByTestId(postsEmptyFiltered), diff --git a/apps/admin/src/routes.test.tsx b/apps/admin/src/routes.test.tsx new file mode 100644 index 00000000000..a166940e68e --- /dev/null +++ b/apps/admin/src/routes.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { matchRoutes } from '@tryghost/admin-x-framework'; +import { useRouteHidesAdminSidebar } from '@/layout/sidebar-visibility'; +import { routes } from './routes'; + +const useMatchesMock = vi.fn<() => Array<{ handle: unknown }>>(); + +vi.mock('@tryghost/admin-x-framework', async (importOriginal) => ({ + ...(await importOriginal()), + useMatches: () => useMatchesMock(), +})); + +function routeHidesAdminSidebar(path: string): boolean { + const matches = matchRoutes(routes, path) ?? []; + useMatchesMock.mockReturnValue( + matches.map((match) => ({ handle: match.route.handle as unknown })), + ); + + return renderHook(() => useRouteHidesAdminSidebar()).result.current; +} + +// Ember's search and settings shortcuts stay off exactly where these routes hide the sidebar +describe('routes', () => { + it.each([ + '/editor/post/abc123', + '/settings', + '/settings/newsletters', + '/automations/abc123', + '/migrate/substack', + ])('hides the admin sidebar on %s', (path) => { + expect(routeHidesAdminSidebar(path)).toBe(true); + }); + + it('shows the admin sidebar on /posts', () => { + expect(routeHidesAdminSidebar('/posts')).toBe(false); + }); +}); diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 8163287fd64..c24a1db520b 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -20,6 +20,7 @@ import { EmberFallback, ForceUpgradeGuard } from './ember-bridge'; import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; import { EditorGate } from './editor-gate'; +import { lazyRestoreScreen } from './editor/api'; import { PagesListGate, PostsListGate } from './posts-list-gate'; import { MemberActivityGate } from './member-activity-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; @@ -50,7 +51,7 @@ import { type AuthRouteHandle, authRoutes, useAuthScreensOwner } from './auth/ap // Routes handled by the Ember admin app. React delegates these to Ember via // EmberFallback. When migrating a route to React, remove its entry from here. -const EMBER_ROUTES: string[] = ['/pro/*', '/restore']; +const EMBER_ROUTES: string[] = ['/pro/*']; const emberFallbackHandle = { allowInForceUpgrade: true } satisfies AdminRouteHandle; @@ -199,6 +200,7 @@ const appRoutes: RouteObject[] = [ handle: { ...emberFallbackHandle, hideAdminSidebar: true } satisfies AdminRouteHandle, }, { path: '/site', lazy: lazyComponent(lazyViewSiteScreen) }, + { path: '/restore', lazy: lazyComponent(lazyRestoreScreen) }, { path: '/migrate/*', lazy: lazyComponent(lazyMigrateScreen), diff --git a/apps/admin/test-utils/acceptance/setup.ts b/apps/admin/test-utils/acceptance/setup.ts index 183f34d87ee..912c90194ba 100644 --- a/apps/admin/test-utils/acceptance/setup.ts +++ b/apps/admin/test-utils/acceptance/setup.ts @@ -38,6 +38,12 @@ afterEach(async () => { resetFakeApi(); resetDeclaredResources(); sessionStorage.clear(); + // The editor keeps local copies of drafts here, and the restore screen lists them all. + for (const key of Object.keys(localStorage)) { + if (key.startsWith('post-revision-')) { + localStorage.removeItem(key); + } + } window.location.hash = ''; try { await resetFakeFrameOrigins(); diff --git a/apps/admin/test-utils/acceptance/worker.test.ts b/apps/admin/test-utils/acceptance/worker.test.ts new file mode 100644 index 00000000000..2f0a6804d07 --- /dev/null +++ b/apps/admin/test-utils/acceptance/worker.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Keep requests outside MSW until the test releases them. This reproduces +// service-worker delivery lag without depending on browser scheduling. +vi.mock('msw/browser', () => ({ + setupWorker: () => ({ + events: { on: vi.fn() }, + start: vi.fn().mockResolvedValue(undefined), + }), +})); + +function deferredResponse() { + let resolve!: (response: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('fake API request teardown', () => { + let settleRequests: typeof import('./worker').settleRequests; + let fetchMock: ReturnType>; + + beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const worker = await import('./worker'); + settleRequests = worker.settleRequests; + await worker.startFakeApi({ resolver: () => undefined, routes: [] }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('waits for a fetch that has not reached the service worker', async () => { + const response = new Response('{}'); + const pending = deferredResponse(); + fetchMock.mockReturnValue(pending.promise); + + const request = window.fetch('/ghost/api/admin/stats/posts-member-counts/', { + method: 'POST', + }); + const settled = vi.fn(); + const draining = settleRequests().then(settled); + + // Longer than the quiet window, with no MSW lifecycle events yet. + await vi.advanceTimersByTimeAsync(100); + expect(settled).not.toHaveBeenCalled(); + + pending.resolve(response); + await expect(request).resolves.toBe(response); + await vi.advanceTimersByTimeAsync(60); + await draining; + expect(settled).toHaveBeenCalledOnce(); + }); + + it('removes a rejected fetch from the ledger', async () => { + const pending = deferredResponse(); + fetchMock.mockReturnValue(pending.promise); + const error = new TypeError('Network request failed'); + const request = window.fetch(new URL('https://ghost.org/changelog.json')); + const rejected = expect(request).rejects.toBe(error); + + pending.reject(error); + await rejected; + const draining = settleRequests(); + await vi.advanceTimersByTimeAsync(60); + await draining; + }); + + it('reports the method and path of a fetch still waiting for MSW', async () => { + const pending = deferredResponse(); + fetchMock.mockReturnValue(pending.promise); + const input = new Request('http://localhost/ghost/api/admin/stats/posts-member-counts/', { + method: 'POST', + }); + const request = window.fetch(input); + const draining = expect(settleRequests({ timeoutMs: 100 })).rejects.toThrow( + 'POST /stats/posts-member-counts/', + ); + + await vi.advanceTimersByTimeAsync(100); + await draining; + pending.resolve(new Response('{}')); + await request; + }); + + it('does not wait for unrelated asset requests', async () => { + const pending = deferredResponse(); + fetchMock.mockReturnValue(pending.promise); + const request = window.fetch('/assets/image.png'); + const settled = vi.fn(); + const draining = settleRequests().then(settled); + + await vi.advanceTimersByTimeAsync(60); + expect(settled).toHaveBeenCalledOnce(); + await draining; + pending.resolve(new Response('image')); + await request; + }); +}); diff --git a/apps/admin/test-utils/acceptance/worker.ts b/apps/admin/test-utils/acceptance/worker.ts index c40d53ac85b..3b7ce089ff6 100644 --- a/apps/admin/test-utils/acceptance/worker.ts +++ b/apps/admin/test-utils/acceptance/worker.ts @@ -113,9 +113,30 @@ export function verifyNoUnhandledRequests(): void { } } -// In-flight ledger (requestId → "METHOD path") for requests the worker owns; -// drained in afterEach so stragglers can't cross test boundaries. -const inFlightRequests = new Map(); +// In-flight ledger for requests the worker owns; drained in afterEach so +// stragglers can't cross test boundaries. Symbols track fetch calls before +// the service worker delivers request:start; MSW request IDs also cover XHR. +const inFlightRequests = new Map(); + +function trackFetchRequests(): void { + const fetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input, window.location.href).href; + if (!isTrackedUrl(url)) { + return fetch(input, init); + } + + const id = Symbol(); + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + const path = ADMIN_API_PATTERN.test(url) ? toAdminApiPath(url) : url; + inFlightRequests.set(id, `${method.toUpperCase()} ${path}`); + try { + return await fetch(input, init); + } finally { + inFlightRequests.delete(id); + } + }; +} function trackInFlightRequests(worker: SetupWorker): void { worker.events.on('request:start', ({ request, requestId }) => { @@ -498,6 +519,11 @@ export async function startFakeApi({ } } + // request:start arrives asynchronously through the service worker. Track + // fetch synchronously too, or teardown can see an empty ledger and remove + // a declared fake while its request is still travelling to MSW. + trackFetchRequests(); + return worker; } diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index c9cda6c6c87..7b07403719b 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -63,7 +63,7 @@ export default defineConfig(({ command }) => ({ environment: 'jsdom', globals: true, setupFiles: ['./test-utils/setup.ts'], - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'test-utils/**/*.test.ts'], // Acceptance and component tests run in a real browser via // vitest.acceptance.config.ts exclude: [ diff --git a/apps/ember-admin/app/controllers/restore-posts.js b/apps/ember-admin/app/controllers/restore-posts.js deleted file mode 100644 index 48e539d4cb9..00000000000 --- a/apps/ember-admin/app/controllers/restore-posts.js +++ /dev/null @@ -1,22 +0,0 @@ -import Controller from '@ember/controller'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class RestorePostsController extends Controller { - @service localRevisions; - @service notifications; - - @task - *restorePostTask(revision) { - try { - yield this.localRevisions.restore(revision.key); - this.notifications.showNotification('Post restored successfully', {type: 'success'}); - return true; - } catch (error) { - this.notifications.showNotification('Failed to restore post', {type: 'error'}); - // eslint-disable-next-line no-console - console.error('Failed to restore post:', error); - return false; - } - } -} \ No newline at end of file diff --git a/apps/ember-admin/app/router.js b/apps/ember-admin/app/router.js index 2bdc2f51081..aa9c7abd339 100644 --- a/apps/ember-admin/app/router.js +++ b/apps/ember-admin/app/router.js @@ -22,7 +22,6 @@ Router.map(function () { }); this.route('posts'); - this.route('restore-posts', {path: '/restore'}); this.route('pages'); diff --git a/apps/ember-admin/app/routes/application.js b/apps/ember-admin/app/routes/application.js index fd7f7db0bf3..1125385aad0 100644 --- a/apps/ember-admin/app/routes/application.js +++ b/apps/ember-admin/app/routes/application.js @@ -251,7 +251,7 @@ export default Route.extend(ShortcutsRoute, { // Don't open the search modal if the sidebar is hidden // e.g. in the editor or settings screens - if (this.ui.isFullScreen) { + if (this.ui.isFullScreen || this.stateBridge.isReactFullScreen) { return; } @@ -261,7 +261,7 @@ export default Route.extend(ShortcutsRoute, { openSettings() { // Don't open the settings screen if the sidebar is hidden // e.g. in the editor or settings screens - if (this.ui.isFullScreen) { + if (this.ui.isFullScreen || this.stateBridge.isReactFullScreen) { return; } diff --git a/apps/ember-admin/app/routes/restore-posts.js b/apps/ember-admin/app/routes/restore-posts.js deleted file mode 100644 index 649163ed5ed..00000000000 --- a/apps/ember-admin/app/routes/restore-posts.js +++ /dev/null @@ -1,10 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {inject as service} from '@ember/service'; - -export default class RevisionsRoute extends AuthenticatedRoute { - @service localRevisions; - - model() { - return this.localRevisions.findAll(); - } -} \ No newline at end of file diff --git a/apps/ember-admin/app/services/local-revisions.js b/apps/ember-admin/app/services/local-revisions.js index b946ab5ade7..cf01be85269 100644 --- a/apps/ember-admin/app/services/local-revisions.js +++ b/apps/ember-admin/app/services/local-revisions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/ember'; -import Service, {inject as service} from '@ember/service'; +import Service from '@ember/service'; import config from 'ghost-admin/config/environment'; import {task, timeout} from 'ember-concurrency'; @@ -17,8 +17,6 @@ export default class LocalRevisionsService extends Service { this.storage = window.localStorage; } - @service store; - // base key prefix to avoid collisions in localStorage _prefix = 'post-revision'; latestRevisionTime = null; @@ -180,80 +178,6 @@ export default class LocalRevisionsService extends Service { return allKeys; } - /** - * Logs all revisions to the console - * - * Currently this is the only UI for local revisions - */ - list() { - const revisions = this.findAll(); - const data = {}; - for (const [key, revision] of Object.entries(revisions)) { - if (!data[revision.title]) { - data[revision.title] = []; - } - data[revision.title].push({ - key, - timestamp: revision.revisionTimestamp, - time: new Date(revision.revisionTimestamp).toLocaleString(), - title: revision.title, - type: revision.type, - id: revision.id - }); - } - /* eslint-disable no-console */ - console.groupCollapsed('Local revisions'); - for (const [title, row] of Object.entries(data)) { - console.groupCollapsed(`${title}`); - for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) { - console.groupCollapsed(`${item.time}`); - console.log('Revision ID: ', item.key); - console.groupEnd(); - } - console.groupEnd(); - } - console.groupEnd(); - /* eslint-enable no-console */ - } - - /** - * Creates a new post from the specified revision - * - * @param {string} key - * @returns {Promise} - the new post model - */ - async restore(key) { - try { - const revision = this.find(key); - const authors = []; - if (revision.authors) { - for (const author of revision.authors) { - const authorModel = await this.store.queryRecord('user', {id: author.id}); - authors.push(authorModel); - } - } - const post = this.store.createRecord('post', { - title: `(Restored) ${revision.title}`, - lexical: revision.lexical, - authors, - type: revision.type, - slug: revision.slug || 'untitled', - status: 'draft', - tags: revision.tags || [], - post_revisions: [] - }); - await post.save(); - const location = window.location; - const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`; - // eslint-disable-next-line no-console - console.log('Post restored: ', url); - return post; - } catch (err) { - // eslint-disable-next-line no-console - console.warn(err); - } - } - /** * Filters revisions to keep only the most recent 5 for a given post ID * @param {string} postId - ID of the post to filter revisions for diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 201b39887e2..d0e8776395e 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -46,11 +46,19 @@ export default class StateBridgeService extends Service.extend(Evented) { @tracked postListQueryParams = {posts: {}, pages: {}}; + // True while the React route hides the admin sidebar + @tracked isReactFullScreen = false; + @action setPostListQueryParams(resource, params) { this.postListQueryParams = {...this.postListQueryParams, [resource]: params}; } + @action + setReactFullScreen(isFullScreen) { + this.isReactFullScreen = isFullScreen; + } + /** * Gives React the same synchronous Labs route-ownership decision Ember * uses. Both routers must share one authority or they can each defer to diff --git a/apps/ember-admin/app/templates/restore-posts.hbs b/apps/ember-admin/app/templates/restore-posts.hbs deleted file mode 100644 index f04cd2e6209..00000000000 --- a/apps/ember-admin/app/templates/restore-posts.hbs +++ /dev/null @@ -1,50 +0,0 @@ -
- -
-

- Restore Posts -

-
-
-
-

Posts are regularly saved locally on your device. If you've lost a post, you can restore it from here as long as too much time hasn't passed.

-
    - {{#if this.model.length}} -
  1. -
    Title
    -
    Created
    -
    -
  2. - -
  3. -
    -

    {{if revision.title revision.title "(no title)"}}

    -

    {{truncate revision.excerpt 100}}

    -
    -
    - {{moment-format revision.revisionTimestamp "MMM D, YYYY HH:mm"}} -
    -
    - -
    -
  4. -
    - {{else}} -
  5. -
    - {{svg-jar "revision-placeholder" class="gh-revisions-placeholder"}} -

    No local revisions found.

    -
    -
  6. - {{/if}} -
-
-
\ No newline at end of file diff --git a/apps/ember-admin/tests/acceptance/restore-post-test.js b/apps/ember-admin/tests/acceptance/restore-post-test.js deleted file mode 100644 index 6753b590631..00000000000 --- a/apps/ember-admin/tests/acceptance/restore-post-test.js +++ /dev/null @@ -1,49 +0,0 @@ -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {beforeEach, describe, it} from 'mocha'; -import {click} from '@ember/test-helpers'; -import {expect} from 'chai'; -import {find, visit} from '@ember/test-helpers'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; - -describe('Acceptance: Restore', function () { - const hooks = setupApplicationTest(); - setupMirage(hooks); - - beforeEach(async function () { - // Create a user and authenticate the session - const role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role], slug: 'owner'}); - await authenticateSession(); - }); - - it('restores a post from a revision', async function () { - // Create a post revision in localStorage - const revisionData = { - id: 'test-id', - title: 'Test Post', - lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Test content","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}', - revisionTimestamp: Date.now() - }; - const revisionKey = `post-revision-${revisionData.id}-${revisionData.revisionTimestamp}`; - localStorage.setItem(revisionKey, JSON.stringify(revisionData)); - localStorage.setItem('ghost-revisions', JSON.stringify([revisionKey])); - - // Visit the restore route - await visit(`/restore/`); - - // Verify that the post title is displayed - const postTitle = find('[data-test-id="restore-post-title"]').textContent.trim(); - expect(postTitle).to.equal('Test Post'); - - // Verify that the restore button is present - const restoreButton = find('[data-test-id="restore-post-button"]'); - expect(restoreButton).to.exist; - - // Click the restore button - await click(restoreButton); - - // Verify that the post is restored (notification will show) - expect(find('.gh-notification-title').textContent.trim()).to.equal('Post restored successfully'); - }); -}); \ No newline at end of file diff --git a/apps/ember-admin/tests/integration/services/local-revisions-test.js b/apps/ember-admin/tests/integration/services/local-revisions-test.js deleted file mode 100644 index dc8f1b0174c..00000000000 --- a/apps/ember-admin/tests/integration/services/local-revisions-test.js +++ /dev/null @@ -1,59 +0,0 @@ -import Pretender from 'pretender'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -function stubCreatePostEndpoint(server) { - server.post(`${ghostPaths().apiRoot}/posts/`, function () { - return [ - 201, - {'Content-Type': 'application/json'}, - JSON.stringify({posts: [{ - id: 'test id', - lexical: 'test lexical string', - title: 'test title', - post_revisions: [] - }]}) - ]; - }); - - server.get(`${ghostPaths().apiRoot}/users/`, function () { - return [ - 200, - {'Content-Type': 'application/json'}, - JSON.stringify({users: [{ - id: '1', - name: 'test name', - roles: ['owner'] - }]}) - ]; - }); -} - -describe('Integration: Service: local-revisions', function () { - setupTest(); - - let server; - - beforeEach(function () { - server = new Pretender(); - this.service = this.owner.lookup('service:local-revisions'); - this.service.clear(); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('restores a post from a revision', async function () { - stubCreatePostEndpoint(server); - // create a post to restore - const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'}); - - // restore the post - const post = await this.service.restore(key); - - expect(post.get('lexical')).to.equal('test lexical string'); - }); -}); diff --git a/apps/ember-admin/tests/unit/routes/application-test.js b/apps/ember-admin/tests/unit/routes/application-test.js new file mode 100644 index 00000000000..c5eb9b2df41 --- /dev/null +++ b/apps/ember-admin/tests/unit/routes/application-test.js @@ -0,0 +1,45 @@ +import sinon from 'sinon'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit: Route: application', function () { + setupTest(); + + let route, modals, router, stateBridge; + + beforeEach(function () { + sinon.stub(this.owner.lookup('service:feature'), 'globalSearchReact').get(() => false); + modals = this.owner.lookup('service:modals'); + sinon.stub(modals, 'open'); + router = this.owner.lookup('service:router'); + sinon.stub(router, 'transitionTo'); + stateBridge = this.owner.lookup('service:state-bridge'); + route = this.owner.lookup('route:application'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('ignores the search and settings shortcuts while a React route hides the sidebar', function () { + stateBridge.setReactFullScreen(true); + + route.send('openSearchModal'); + route.send('openSettings'); + + expect(modals.open.called, 'search modal opened').to.be.false; + expect(router.transitionTo.called, 'settings opened').to.be.false; + }); + + it('handles the search and settings shortcuts once React shows the sidebar again', function () { + stateBridge.setReactFullScreen(true); + stateBridge.setReactFullScreen(false); + + route.send('openSearchModal'); + route.send('openSettings'); + + expect(modals.open.calledOnce, 'search modal opened').to.be.true; + expect(router.transitionTo.calledOnceWithExactly('/settings'), 'settings opened').to.be.true; + }); +}); diff --git a/apps/ember-admin/tests/unit/services/local-revisions-test.js b/apps/ember-admin/tests/unit/services/local-revisions-test.js index 6bc70f17794..79f6932817e 100644 --- a/apps/ember-admin/tests/unit/services/local-revisions-test.js +++ b/apps/ember-admin/tests/unit/services/local-revisions-test.js @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/ember'; -import Service from '@ember/service'; import sentryTestKit from 'sentry-testkit/browser'; import sinon from 'sinon'; import {describe, it} from 'mocha'; @@ -381,35 +380,4 @@ describe('Unit: Service: local-revisions', function () { expect(this.service.find(keyToRemove)).to.be.null; }); }); - - describe('restore', function () { - it('creates a new post based on the revision data', async function () { - // stub out the store service - const saveStub = sinon.stub().resolves({id: 'test-id'}); - const setStub = sinon.stub(); - const getStub = sinon.stub().returns('post'); - const queryRecordStub = sinon.stub().resolves({id: '1'}); - this.owner.register('service:store', Service.extend({ - createRecord: () => { - return { - id: 'new-id', - save: saveStub, - set: setStub, - get: getStub - }; - }, - queryRecord: queryRecordStub - })); - // create a post to restore - const key = this.service.performSave('post', {id: 'test-id', status: 'draft', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'}); - // restore the post - const post = await this.service.restore(key); - - // Ensure the post is saved - expect(saveStub.calledOnce).to.be.true; - - // Restore should return the post object - expect(post.id).to.equal('new-id'); - }); - }); }); diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index cb34c44c384..d70af24feec 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -182,6 +182,8 @@ export const editorUpdateButton = 'Update'; export const editorSaveButton = 'Save'; export const editorUnpublishButton = 'Unpublish'; export const editorUnscheduleButton = 'Unschedule'; +export const editorRetryNewsletterButton = 'Retry now'; +export const editorNewsletterDetailsButton = 'View details'; export const publishTypePublishAndEmailOption = 'Publish and email'; export const publishTypePublishOnlyOption = 'Publish only'; export const publishTypeEmailOnlyOption = 'Email only'; diff --git a/packages/testing/test-data/src/selectors/restore.ts b/packages/testing/test-data/src/selectors/restore.ts new file mode 100644 index 00000000000..a1d15096527 --- /dev/null +++ b/packages/testing/test-data/src/selectors/restore.ts @@ -0,0 +1,14 @@ +/** + * Restore screen selector strings, consumed by the admin screen helpers and the + * e2e page objects. Source of truth: apps/admin/src/editor/restore. + */ + +// accessible names +export const restoreHeading = 'Restore posts'; +export const localRevisionsTable = 'Local revisions'; +export const restoreRevisionButton = 'Restore'; +export const openRestoredPostLink = 'Open restored post'; +export const openRestoredPageLink = 'Open restored page'; + +// text +export const noLocalRevisionsText = 'No local revisions found.';