From cd0a266e4c040bdafbf174ae0e994c5b4bb4bd88 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 14:26:55 +0200 Subject: [PATCH 1/5] Changed the editor to derive pending saves from live content (#30946) no ref Field commits (title, feature image, settings) and body autosave used different eligibility checks. All content saves now go through the save engine and one whole-document validator. Published, scheduled and sent edits still wait for Update or Cmd-S. When local validation blocks a save, for example an emptied author list, title, body and image edits stay pending together and the editor shows why. Correcting the problem saves the combined document. --- .../editor-feature-image.acceptance.test.tsx | 31 ++ .../src/editor/editor-header-actions.tsx | 5 +- apps/admin/src/editor/editor-screen.tsx | 1 + ...ditor-settings-authors.acceptance.test.tsx | 115 ++++++- apps/admin/src/editor/editor-status.test.tsx | 20 +- apps/admin/src/editor/editor.screen.ts | 2 + apps/admin/src/editor/engine/README.md | 75 ++++- .../engine/save-engine.failures.test.ts | 136 +++++++- .../engine/save-engine.lifecycle.test.ts | 43 ++- .../editor/engine/save-engine.pending.test.ts | 291 ++++++++++++++++++ .../editor/engine/save-engine.queue.test.ts | 22 +- .../editor/engine/save-engine.reauth.test.ts | 48 +++ .../editor/engine/save-engine.slug.test.ts | 2 +- apps/admin/src/editor/engine/save-engine.ts | 166 ++++++++-- apps/admin/src/editor/session/README.md | 92 +++--- .../session/__test-utils__/save-engine-spy.ts | 4 +- .../session/editor-session.pending.test.ts | 197 ++++++++++++ .../editor-session.record-sync.test.ts | 57 ++++ .../editor-session.settings-fields.test.ts | 23 +- .../session/editor-session.slug.test.ts | 2 +- .../src/editor/session/editor-session.ts | 74 ++--- .../session/feature-image-binding.test.ts | 126 +++++++- .../editor/session/feature-image-binding.ts | 13 +- .../src/editor/session/leave-guard.test.ts | 1 + apps/admin/src/editor/session/leave-guard.ts | 5 +- .../editor/session/publish-time-save.test.ts | 4 +- .../editor/session/session-banners.test.tsx | 33 +- .../src/editor/session/session-banners.tsx | 18 +- .../session/use-editor-session.test.tsx | 56 +++- .../src/editor/session/use-editor-session.ts | 17 +- apps/admin/src/editor/settings/README.md | 7 +- 31 files changed, 1495 insertions(+), 191 deletions(-) create mode 100644 apps/admin/src/editor/engine/save-engine.pending.test.ts create mode 100644 apps/admin/src/editor/session/editor-session.pending.test.ts diff --git a/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx index 213d7b2d2e61..7510fe21b323 100644 --- a/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx @@ -177,6 +177,37 @@ describe('Post editor feature image', () => { }); }); + it('holds a new image on a published post until Update, then sends it once', async () => { + const saveApi = fakeSavablePost({ + status: 'published', + published_at: '2026-01-01T00:00:00.000Z', + }); + const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', { + images: [{ url: UPLOADED, ref: null }], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.updateButton()).toBeDisabled(); + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1); + await expect.element(editorScreen.removeFeatureImage()).toBeVisible(); + await expect.element(editorScreen.updateButton()).toBeEnabled(); + expect(saveApi.requests).toHaveLength(0); + + await editorScreen.updateButton().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + id: POST_ID, + status: 'published', + feature_image: UPLOADED, + }); + }); + it('saves an image picked from Unsplash with the credit it carries', async () => { const saveApi = fakeSavablePost(); fakeUnsplashPhotos(); diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index f2352c790347..ec4846800f5c 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -100,7 +100,10 @@ export function EditorHeaderActions({ await requireSaved(session.saveExplicit()); }, [session]); - const isSaving = session.state.kind === 'saving' || session.state.kind === 'pending-coalesced'; + const isSaving = + session.state.kind === 'preparing' || + session.state.kind === 'saving' || + session.state.kind === 'pending-coalesced'; const isContributor = !!currentUser && isContributorUser(currentUser); // A post the server has never seen can be neither published nor previewed. diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 74e742240e64..009449a96a45 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -186,6 +186,7 @@ function EditorContent({ { await editorScreen.removeAuthor('Owner User').click(); - // Staged rather than saved: the field gate holds an empty list back. + // The preparation validator holds the whole document while authors are empty. await expect.element(editorScreen.settingsAuthorsError()).toBeVisible(); await expect .element(editorScreen.settingsAuthorsInput()) @@ -209,6 +210,118 @@ describe('Post settings authors', () => { await expect(editorScreen.settingsAuthorsError()).toHaveCount(0); }); + it('keeps the validation notice visible while unrelated edits await a save', async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAuthors(); + await editorScreen.removeAuthor('Owner User').click(); + await expect.element(editorScreen.pendingSaveNotice()).toBeVisible(); + + // Keep focus in the title: no blur or successful preparation can clear the hold. + await editorScreen.titleInput().fill('Still waiting for an author'); + await expect.element(editorScreen.pendingSaveNotice()).toBeVisible(); + await editorScreen.body().fill('Body awaiting the same author correction'); + // This test disables the debounce, so a second blocked attempt cannot restore + // a notice that disappeared on the keystroke. + await expect.element(editorScreen.pendingSaveNotice()).toBeVisible(); + expect(saveApi.requests).toHaveLength(0); + + await openAuthorList(); + await editorScreen.settingsAuthorOption('Nadia Ahmed').click(); + await expect(saveApi).toHaveSavedFields({ + title: 'Still waiting for an author', + authors: [{ id: NADIA.id }], + }); + expect(submittedPost(saveApi).lexical).toContain('Body awaiting the same author correction'); + await expect(editorScreen.pendingSaveNotice()).toHaveCount(0); + }); + + it('does not revive the authors warning after undoing their removal', async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAuthors(); + await editorScreen.removeAuthor('Owner User').click(); + await expect.element(editorScreen.pendingSaveNotice()).toBeVisible(); + + await openAuthorList(); + await editorScreen.settingsAuthorOption('Owner User').click(); + await expect(editorScreen.pendingSaveNotice()).toHaveCount(0); + await expect.poll(unsavedChangesGuarded).toBe(false); + expect(saveApi.requests).toHaveLength(0); + + // Keep the title focused: a later save must not hide a stale warning. + await editorScreen.titleInput().fill('Unrelated title edit'); + await expect(editorScreen.pendingSaveNotice()).toHaveCount(0); + expect(saveApi.requests).toHaveLength(0); + }); + + it('holds a new feature image back while the author list is emptied', async () => { + const saveApi = fakeSavablePost({ feature_image: null }); + const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', { + images: [{ url: UPLOADED_IMAGE, ref: null }], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, { labs: { editorReact: true } }); + await openAuthors(); + + await editorScreen.removeAuthor('Owner User').click(); + await expect.element(editorScreen.settingsAuthorsError()).toBeVisible(); + + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); + await expect.element(editorScreen.removeFeatureImage()).toBeVisible(); + await expect.element(editorScreen.settingsAuthorsError()).toBeVisible(); + // Exercise a real body autosave too, with the normal production debounce. + await editorScreen.body().fill('Body edited while authors are invalid'); + await expect.element(editorScreen.pendingSaveNotice()).toBeVisible(); + await expect(editorScreen.saveErrorBanner()).toHaveCount(0); + expect(saveApi.requests).toHaveLength(0); + + // Crediting someone again lets the staged image through with the authors. + await openAuthorList(); + await editorScreen.settingsAuthorOption('Nadia Ahmed').click(); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi).lexical).toContain('Body edited while authors are invalid'); + await expect(editorScreen.pendingSaveNotice()).toHaveCount(0); + expect(submittedPost(saveApi)).toMatchObject({ + authors: [{ id: NADIA.id }], + feature_image: UPLOADED_IMAGE, + }); + }); + + it('holds a renamed title back while the author list is emptied', async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAuthors(); + await expect.element(editorScreen.settingsSlug()).toHaveValue('hello-from-react'); + + await editorScreen.removeAuthor('Owner User').click(); + await expect.element(editorScreen.settingsAuthorsError()).toBeVisible(); + + await editorScreen.titleInput().fill('Brand New Name'); + await editorScreen.body().click(); + + await expect.element(editorScreen.settingsSlug()).toHaveValue('brand-new-name'); + await expect.element(editorScreen.settingsAuthorsError()).toBeVisible(); + await expect(editorScreen.saveErrorBanner()).toHaveCount(0); + expect(saveApi.requests).toHaveLength(0); + + // Crediting someone again lets the staged title and slug through with the authors. + await openAuthorList(); + await editorScreen.settingsAuthorOption('Nadia Ahmed').click(); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + title: 'Brand New Name', + slug: 'brand-new-name', + authors: [{ id: NADIA.id }], + }); + }); + it('stages a published post’s authors until Update', async () => { const saveApi = fakeSavablePost({ status: 'published', published_at: PUBLISHED_AT }); await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); diff --git a/apps/admin/src/editor/editor-status.test.tsx b/apps/admin/src/editor/editor-status.test.tsx index 812a2f2e426c..571c6d3d64ed 100644 --- a/apps/admin/src/editor/editor-status.test.tsx +++ b/apps/admin/src/editor/editor-status.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { RecipientCount } from './editor-status'; +import { EditorStatus, RecipientCount } from './editor-status'; + +vi.mock('./use-editor-settings', () => ({ useSiteTimezone: () => 'Etc/UTC' })); const mocks = vi.hoisted(() => ({ useMembersCount: vi.fn(() => ({ count: null })), @@ -18,6 +20,22 @@ vi.mock('@tryghost/admin-x-framework/api/members', async (importOriginal) => { // it initiates have to opt out of the session-expiry redirect too. const OPTED_OUT = { requestOptions: { sessionExpiryRedirect: false } }; +describe('EditorStatus', () => { + it('starts the saving indicator only after preparation succeeds', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.queryByText('Saving…')).not.toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('Saving…')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Saving…')).toBeInTheDocument(); + }); +}); + describe('RecipientCount', () => { it('keeps descriptive copy when the member count is unavailable', () => { const filter = 'newsletters.slug:weekly+email_disabled:0+(status:free,status:-free)'; diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 26dbc9627ad5..9461f8837fba 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -140,6 +140,8 @@ export const editorScreen = { .getByTestId(editorConflictReloadConfirm) .getByRole('button', { name: conflictCancelReloadButton }), status: () => page.getByTestId(editorStatus), + pendingSaveNotice: () => + page.getByRole('status').filter({ hasText: 'Changes are waiting to save.' }), headerActions: () => page.getByTestId(editorHeaderActions), previewButton: () => diff --git a/apps/admin/src/editor/engine/README.md b/apps/admin/src/editor/engine/README.md index dfd09f9c2ead..008acba732b2 100644 --- a/apps/admin/src/editor/engine/README.md +++ b/apps/admin/src/editor/engine/README.md @@ -8,14 +8,14 @@ ### Intents -| Intent | Trigger | Debounce | `save_revision` | Changes status? | -| --------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------- | -| `autosave` | body change; a new post's first edit fires immediately | 3s restartable (none for new posts) | no | never; drafts only, pinned to `draft` | -| `timed` | armed by an autosave dispatch, fires after 60s of continuous editing | 60s cycle | no | never; drafts only | -| `field` | a title blur, a feature-image change, or a settings field commit | none | no | never; drafts only. On a published/scheduled/sent post it is dropped with reason `not-draft` | -| `explicit` | Cmd-S / Save / Update | none | yes | never; preserves the current status (a past-scheduled post saves as `scheduled`, the server owns that transition) | -| `leave` | navigating away from a dirty draft with unrevisioned changes or an armed autosave | none | yes | never; preserves the current status | -| `publish` / `schedule` / `revert` | the publish flow | none | no | the only status-changing commands; each carries an explicit target | +| Intent | Trigger | Debounce | `save_revision` | Changes status? | +| --------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `autosave` | body change; an unblocked new post fires immediately | 3s restartable (first create immediate) | no | never; drafts only, pinned to `draft` | +| `timed` | armed by an autosave dispatch, fires after 60s of continuous editing | 60s cycle | no | never; drafts only | +| `field` | a title, feature-image or settings commit | none | no | never; drafts only. On a published/scheduled/sent post the attempt is dropped with reason `not-draft`; content remains pending | +| `explicit` | Cmd-S / Save / Update | none | yes | never; preserves the current status (a past-scheduled post saves as `scheduled`, the server owns that transition) | +| `leave` | navigating away from a dirty draft with unrevisioned changes or an armed autosave | none | yes | never; preserves the current status | +| `publish` / `schedule` / `revert` | the publish flow | none | no | the only status-changing commands; each carries an explicit target | The autosave debounce is 3 seconds unless the caller passes `autosaveDebounceMs`, which the engine calls at each restart of the debounce and uses in place of the default. @@ -32,6 +32,37 @@ The autosave debounce is 3 seconds unless the caller passes `autosaveDebounceMs` Email extras (`newsletter`, `emailSegment`, `emailOnly`) ride on exactly that command's request. A failed status command is disarmed: nothing retains its target, the publish flow dispatches a fresh command. Publish times are serialized with zeroed milliseconds, because the API stores seconds and a non-zero millisecond value can fail validation when a scheduled post is updated. +### Pending content + +`getPendingSave()` derives pending content directly from the current session +snapshot and the engine's active work. It returns `null` when the document is +clean, otherwise `{blockedBy}`. The session reads it whenever its document or +engine activity changes. + +`blockedBy` holds the error preventing progress. The engine's activity state +reports preparation, saving, and queued commands separately. Typing can restart +a debounce while validation blocks the document, and an edit can await a commit +while an older version is saving. These facts do not require an unresolved save +promise. Every eligible dispatch builds from the current whole document; +acknowledgements preserve newer edits. + +Local validation in `prepare` completes a background attempt as `blocked`. +Explicit and status commands complete as `failed`, retaining content but no +automatic replay of their status/email target. One hold records the validation +error and attempted version: unrelated edits retain the error, while the version +controls suppression of unchanged background retries. A passing preparation +clears a local validation hold, even when the command's target changes without a +content edit. A save attempt that finds the document clean also releases local +validation, including when the version is unchanged or slug work was pending. +Restoring saved values therefore does not leave a warning for a later edit. +Subsequent body edits on a blocked new post use the normal debounce instead of +preparing a create on every keystroke. + +Server validation and host-limit holds keep their existing suppression rules; +a passing local preparation does not establish that the server will accept the +request. A collision is tracked separately because its rejected baseline can +remain unsafe while another error is being resolved. + ### Queue semantics One save in flight, one pending slot. A command arriving while idle runs immediately (after its debounce); one arriving during a save lands in the pending slot and coalesces: priority `publish`/`schedule`/`revert` > `explicit` > `leave` > `field` > `timed` > `autosave`, the winner's kind executes, every waiter keeps its own command, `requiresRevision` ORs across the slot, and the payload is rebuilt from the current post at execution, so coalescing never loses newer content. A later status command supersedes only the earlier status command; its riders stay with the winner. A new autosave restarts the debounce; an explicit cancels it and carries its waiters. @@ -41,6 +72,7 @@ Every dispatch settles with a typed `SaveCompletion`: | Completion | Meaning | | -------------------------------- | ----------------------------------------------------------------------------------------- | | `saved` (`result`, `executedAs`) | the save that carried this command's content landed; `executedAs` names the kind that ran | +| `blocked` (`error`) | Local validation held a background attempt; content remains pending | | `failed` (`error`, `executedAs`) | typed error; content stays dirty | | `dropped` (`reason`) | `not-draft`, `clean`, `suppressed`, `conflict`, `halted`, `disposed` | | `superseded` (`by`) | a later status command replaced this one before it ran | @@ -50,8 +82,8 @@ Every dispatch settles with a typed `SaveCompletion`: `capture → prepare → execute → reconcile → drain`, all inside the single-flight unit: -1. **Prepare** awaits pending manual slug work (`slug.settled()`), re-reads the snapshot, substitutes `(Untitled)` for a blank or whitespace title, asks `slug.fromTitle()` for every draft save and whenever the post has no slug (the port answers `generated` or `unchanged`; after an `unchanged` answer the slug current at that moment is sent), re-reads the snapshot again after the answer and re-runs the drop rules on it, resolves the target, then hands the `SaveRequest` to the caller's `prepare()` to build and validate the candidate. IO starts only after prepare settles; a prepare that answers `{ok: false, error}` fails the save with that error's kind and sends no request, a rejected prepare fails it as `unknown`, and a save disposed during slug work is never prepared. -2. **Execute** is IO only and returns a typed `SaveOutcome`; its `AbortSignal` is aborted on `dispose()`, and a response arriving after dispose is never reconciled. +1. **Prepare** occupies the single-flight slot and reports `preparing`, then awaits pending manual slug work (`slug.settled()`), re-reads the snapshot, substitutes `(Untitled)` for a blank or whitespace title, asks `slug.fromTitle()` for every draft save and whenever the post has no slug (the port answers `generated` or `unchanged`; after an `unchanged` answer the slug current at that moment is sent), re-reads the snapshot again after the answer and re-runs the drop rules on it, resolves the target, then hands the `SaveRequest` to the caller's `prepare()` to build and validate the candidate. IO starts only after prepare settles; a prepare that answers `{ok: false, error}` sends no request; local validation holds background work and fails explicit work, a rejected prepare fails it as `unknown`, and a save disposed during slug work is never prepared. +2. **Execute** starts `saving` only after preparation succeeds, and is IO only and returns a typed `SaveOutcome`; its `AbortSignal` is aborted on `dispose()`, and a response arriving after dispose is never reconciled. 3. **Reconcile** is awaited before the pending slot drains and must not throw: adopt the acknowledged id, status and `updated_at` first, keep edits made after `prepared.snapshot.version`, resync server-normalized values only where the local value did not change in flight. 4. **Drain** starts the pending slot only when nothing is in flight. @@ -64,20 +96,33 @@ Reconcile-before-drain is a hard ordering contract because the server enforces o | `session-invalid` | `reauth-pending`; queue frozen, later commands coalesce into the pending slot, content untouched | `reauthSucceeded()` / `reauthAbandoned()` | | `not-found` with an id | `halted` (deleted elsewhere); every queued command dropped `halted`, content kept for copy-out | none | | `not-found` without an id | `crashed` (corrupt new-post state) | none | -| `conflict` (`UPDATE_COLLISION`) | `conflict`; timers and the pending slot dropped `conflict`, background saves refused while the snapshot still carries the rejected `updated_at`, content intact and dirty | an explicit save, or `contentReloaded(updatedAt)` with a candidate that no longer matches the rejected one | -| `validation` | `error`; background saves suppressed until the snapshot version moves | next edit, or an explicit save | +| `conflict` (`UPDATE_COLLISION`) | `conflict`; timers and the pending slot dropped `conflict`, background saves refused while the snapshot still carries the rejected `updated_at`, content intact and dirty | an explicit save, or `contentReloaded(updatedAt, adopt?)` with a candidate different from the rejected one | +| server `validation` | `error`; background saves suppressed until the snapshot version moves | next edit, or an explicit save | | `host-limit` | `error`; suppression as for validation, but only for a status-preserving save (a publish limit never halts autosave) | next edit, or an explicit save | | `transport` / `unknown` | `error`, no suppression | next save | -`error` and `conflict` persist until a save actually starts; timers arming or a dropped save do not clear them. `contentReloaded(updatedAt)` lets the caller validate a server document before replacing local content: it lifts the collision halt when the candidate is a valid timestamp that has moved past the rejected `updated_at`, and returns false otherwise. With no argument it checks the current snapshot. Other states: `idle`, `debouncing`, `saving`, `pending-coalesced`, `disposed`. +`error` and `conflict` persist until an attempt starts; timers arming or a +dropped save do not clear them. An unsuccessful retry still returns its actual +failure to the caller, but restores the retained conflict state so reload +recovery remains available. A transport failure cannot prove that a rejected +collision token is safe. + +`contentReloaded(updatedAt)` validates a replacement against the retained +collision record rather than the latest activity label. It accepts a valid +timestamp different from the rejected token, only while the engine is recoverable +and no attempt is active or frozen for authentication. An optional synchronous +adoption callback, which must not throw, replaces the document before recovery +is announced to subscribers. With no argument it checks the current snapshot. A +successful save or accepted reload releases the collision. Other states: +`idle`, `debouncing`, `preparing`, `saving`, `pending-coalesced`, `disposed`. ### Re-auth -`reauthSucceeded()` inspects every waiter in both the frozen and the pending slot and judges each by its resolved effect against the current post: a command whose target would change the status resolves `needs-retry` and never auto-fires; everything else is coalesced into the pending slot with its own command and drained, without re-debouncing, so a frozen explicit rider re-runs while the publish it coalesced into does not. Content a disarmed status command would have carried resumes through the autosave path; if the snapshot cannot be read at that point the debounce is re-armed so the retry surfaces a failure instead of abandoning content. `reauthAbandoned()` settles every waiter with the session error and moves to `error`; the caller decides on a sign-in redirect, the queue never dangles. +`reauthSucceeded()` retains internal commands even when they have no promise waiters, so a resumed autosave survives another authentication failure. It inspects every waiter in both the frozen and the pending slot and judges each by its resolved effect against the current post: a command whose target would change the status resolves `needs-retry` and never auto-fires; everything else is coalesced into the pending slot with its own command and drained, without re-debouncing, so a frozen explicit rider re-runs while the publish it coalesced into does not. Content a disarmed status command would have carried resumes through the autosave path; if the snapshot cannot be read at that point the debounce is re-armed so the retry surfaces a failure instead of abandoning content. `reauthAbandoned()` settles every waiter with the session error and moves to `error` (or restores a retained `conflict`); the caller decides on a sign-in redirect, the queue never dangles. ### Leave -`leaveRequested()` returns `proceed` or `confirm` and loops until nothing is in flight, pending, or armed with dirty content, re-reading the post after every wait: `saving` is never safe to leave. Save-on-leave (with a revision) fires at most once per attempt, only for a dirty draft with unrevisioned changes or an armed autosave, never while the snapshot carries a rejected `updated_at`, never while frozen, halted, or crashed. A frozen command always asks for confirmation, even when its input snapshot is clean. A post still dirty afterwards also asks for confirmation, as does an unreadable snapshot. Concurrent calls share one decision; a decision that outlives the engine resolves `proceed`. +`leaveRequested()` returns `proceed` or `confirm` and loops until nothing is in flight, pending, or armed with dirty content, re-reading the post after every wait: `preparing` and `saving` are never safe to leave. Save-on-leave (with a revision) fires at most once per attempt, only for a dirty draft with unrevisioned changes or an armed autosave, never while the snapshot carries a rejected `updated_at`, never while frozen, halted, or crashed. A frozen command always asks for confirmation, even when its input snapshot is clean. A post still dirty afterwards also asks for confirmation, as does an unreadable snapshot. Concurrent calls share one decision; a decision that outlives the engine resolves `proceed`. ### Subscriptions diff --git a/apps/admin/src/editor/engine/save-engine.failures.test.ts b/apps/admin/src/editor/engine/save-engine.failures.test.ts index 82b51a4e3e3a..447a045ecae9 100644 --- a/apps/admin/src/editor/engine/save-engine.failures.test.ts +++ b/apps/admin/src/editor/engine/save-engine.failures.test.ts @@ -6,8 +6,10 @@ import { flush, FUTURE, hostLimit, + notFound, PAST, setup, + sessionInvalid, transport, unknown, validation, @@ -49,7 +51,7 @@ describe('createSaveEngine', () => { expect(h.engine.getState()).toEqual({ kind: 'error', intent: 'explicit', error: transport }); void h.engine.dispatch('explicit'); - expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + expect(h.engine.getState()).toEqual({ kind: 'preparing', intent: 'explicit' }); }); it('treats a rejected execute as an unknown error rather than swallowing it', async () => { @@ -186,6 +188,138 @@ describe('createSaveEngine', () => { expect(h.engine.getState()).toEqual({ kind: 'idle' }); }); + it.each([true, false])( + 'retains collision recovery after a retry fails with dirty=%s', + async (isDirty) => { + const h = setup({ isDirty }); + const first = h.engine.dispatch('publish'); + await h.fail(conflict); + await first; + const retry = h.engine.dispatch('explicit'); + await h.fail(transport); + await expect(retry).resolves.toMatchObject({ kind: 'failed', error: transport }); + expect(h.engine.getState()).toEqual({ + kind: 'conflict', + intent: 'publish', + error: conflict, + }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'conflict', + }); + expect(h.engine.contentReloaded(FUTURE)).toBe(true); + h.patch({ updatedAt: FUTURE }); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }, + ); + + it('refuses reload during preparation, execution and reauth, then retains recovery on abandonment', async () => { + const h = setup({ isDirty: false }); + const first = h.engine.dispatch('publish'); + await h.fail(conflict); + await first; + const release = h.holdSlugWork(); + const retry = h.engine.dispatch('explicit'); + expect(h.engine.contentReloaded(FUTURE)).toBe(false); + await release(); + expect(h.engine.contentReloaded(FUTURE)).toBe(false); + await h.fail(sessionInvalid); + expect(h.engine.contentReloaded(FUTURE)).toBe(false); + h.engine.reauthAbandoned(); + await expect(retry).resolves.toMatchObject({ kind: 'failed', error: sessionInvalid }); + expect(h.engine.getPendingSave()).toBeNull(); + expect(h.engine.getState()).toEqual({ kind: 'conflict', intent: 'publish', error: conflict }); + expect(h.engine.contentReloaded(FUTURE)).toBe(true); + }); + + it('retires a validation hold belonging to the document replaced after conflict', async () => { + const h = setup(); + const first = h.engine.dispatch('explicit'); + await h.fail(conflict); + await first; + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await expect(h.engine.dispatch('explicit')).resolves.toMatchObject({ + kind: 'failed', + error: validation, + }); + expect(h.engine.getState().kind).toBe('conflict'); + expect(h.engine.contentReloaded(FUTURE)).toBe(true); + h.patch({ updatedAt: FUTURE }); + expect(h.engine.getPendingSave()?.blockedBy).toBeNull(); + const save = h.engine.dispatch('field'); + await h.succeed(); + await expect(save).resolves.toMatchObject({ kind: 'saved' }); + }); + + it('does not reload a post deleted during a collision retry', async () => { + const h = setup(); + const first = h.engine.dispatch('explicit'); + await h.fail(conflict); + await first; + const retry = h.engine.dispatch('explicit'); + await h.fail(notFound); + await retry; + const adopt = vi.fn(); + expect(h.engine.contentReloaded(FUTURE, adopt)).toBe(false); + expect(adopt).not.toHaveBeenCalled(); + expect(h.engine.getState()).toEqual({ kind: 'halted' }); + }); + + it('adopts the replacement before recovery subscribers edit or dispatch', async () => { + const h = setup(); + const first = h.engine.dispatch('explicit'); + await h.fail(conflict); + await first; + let save: ReturnType | undefined; + const stop = h.engine.subscribe((state) => { + if (state.kind === 'idle') { + stop(); + expect(h.snapshot.updatedAt).toBe(FUTURE); + h.edit(); + save = h.engine.dispatch('explicit'); + } + }); + expect( + h.engine.contentReloaded(FUTURE, () => { + h.patch({ updatedAt: FUTURE, isDirty: false }); + const nestedAdopt = vi.fn(); + expect(h.engine.contentReloaded(FUTURE, nestedAdopt)).toBe(false); + expect(nestedAdopt).not.toHaveBeenCalled(); + }), + ).toBe(true); + await h.succeed(); + await expect(save).resolves.toMatchObject({ kind: 'saved' }); + expect(h.requests[1].snapshot).toMatchObject({ version: 2, updatedAt: FUTURE }); + }); + + it('preserves disposal and dispatches triggered inside document adoption', async () => { + for (const dispose of [true, false]) { + const h = setup(); + const first = h.engine.dispatch('explicit'); + await h.fail(conflict); + await first; + let save: ReturnType | undefined; + expect( + h.engine.contentReloaded(FUTURE, () => { + h.patch({ updatedAt: FUTURE }); + if (dispose) { + h.engine.dispose(); + } else { + save = h.engine.dispatch('explicit'); + } + }), + ).toBe(!dispose); + if (dispose) { + expect(h.engine.getState()).toEqual({ kind: 'disposed' }); + } else { + await h.succeed(); + await expect(save).resolves.toMatchObject({ kind: 'saved' }); + expect(h.requests[1].snapshot.updatedAt).toBe(FUTURE); + expect(h.maxConcurrent()).toBe(1); + } + } + }); + it('drops queued background work on a conflict and keeps the content dirty', async () => { const h = setup(); void h.engine.dispatch('explicit'); diff --git a/apps/admin/src/editor/engine/save-engine.lifecycle.test.ts b/apps/admin/src/editor/engine/save-engine.lifecycle.test.ts index d88763b722ef..51b96e94f9b8 100644 --- a/apps/admin/src/editor/engine/save-engine.lifecycle.test.ts +++ b/apps/admin/src/editor/engine/save-engine.lifecycle.test.ts @@ -92,7 +92,7 @@ describe('createSaveEngine', () => { void h.engine.dispatch('explicit'); await flush(); - expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + expect(h.engine.getState()).toEqual({ kind: 'preparing', intent: 'explicit' }); expect(h.execute).not.toHaveBeenCalled(); prepared.resolve({ ok: true, prepared: h.prepare.mock.calls[0][0] }); @@ -100,6 +100,47 @@ describe('createSaveEngine', () => { expect(h.execute).toHaveBeenCalledTimes(1); }); + it.each(['preparing', 'saving'])( + 'does not execute when disposed by a %s subscriber', + async (phase) => { + const h = setup(); + h.engine.subscribe((state) => { + if (state.kind === phase) { + h.engine.dispose(); + } + }); + await expect(h.engine.dispatch('explicit')).resolves.toEqual({ + kind: 'dropped', + reason: 'disposed', + }); + await flush(); + expect(h.execute).not.toHaveBeenCalled(); + expect(h.reconcile).not.toHaveBeenCalled(); + }, + ); + + it('serializes commands queued by a preparing subscriber and carries its revision requirement', async () => { + const h = setup(); + let queued: ReturnType | undefined; + const stop = h.engine.subscribe((state) => { + if (state.kind === 'preparing') { + stop(); + h.edit(); + queued = h.engine.dispatch('explicit'); + } + }); + const first = h.engine.dispatch('field'); + await flush(); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.requests[0].snapshot.version).toBe(2); + await h.succeed(); + await first; + expect(h.requests[1].saveRevision).toBe(true); + await h.succeed(); + await expect(queued).resolves.toMatchObject({ kind: 'saved' }); + expect(h.maxConcurrent()).toBe(1); + }); + it('aborts the in-flight signal on dispose and never reconciles the late response', async () => { const h = setup(); const explicit = h.engine.dispatch('explicit'); diff --git a/apps/admin/src/editor/engine/save-engine.pending.test.ts b/apps/admin/src/editor/engine/save-engine.pending.test.ts new file mode 100644 index 000000000000..d00fe0b98273 --- /dev/null +++ b/apps/admin/src/editor/engine/save-engine.pending.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AUTOSAVE_DEBOUNCE_MS, TIMED_SAVE_INTERVAL_MS, type PostStatus } from './save-engine'; +import { + flush, + setup, + validation, + sessionInvalid, + FUTURE, + transport, +} from './__test-utils__/engine-harness'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('pending content', () => { + it('reads edits without notifications or starting a save, and releases an undone edit', () => { + const h = setup(); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: null }); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + expect(h.execute).not.toHaveBeenCalled(); + + h.patch({ isDirty: false }); + expect(h.engine.getPendingSave()).toBeNull(); + }); + + it.each(['published', 'scheduled', 'sent'])( + 'retains %s edits until Update without blocking navigation', + async (status) => { + const h = setup({ status }); + await h.engine.dispatch('field'); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: null }); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + expect(h.execute).not.toHaveBeenCalled(); + + const save = h.engine.dispatch('explicit'); + await h.succeed(); + await expect(save).resolves.toMatchObject({ kind: 'saved' }); + expect(h.engine.getPendingSave()).toBeNull(); + }, + ); + + it('holds an armed autosave and a field commit on the same invalid document', async () => { + const h = setup(); + const autosave = h.engine.dispatch('autosave'); + h.edit(); + h.prepare.mockResolvedValue({ ok: false, error: validation }); + const field = h.engine.dispatch('field'); + await flush(); + + await expect(autosave).resolves.toEqual({ kind: 'blocked', error: validation }); + await expect(field).resolves.toEqual({ kind: 'blocked', error: validation }); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: validation }); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.execute).not.toHaveBeenCalled(); + + // Correcting and committing persists the newest complete document once. + h.edit(); + h.prepare.mockImplementation((request) => Promise.resolve({ ok: true, prepared: request })); + const corrected = h.engine.dispatch('field'); + await h.succeed(); + await expect(corrected).resolves.toMatchObject({ kind: 'saved' }); + expect(h.requests).toHaveLength(1); + expect(h.requests[0].snapshot.version).toBe(3); + expect(h.engine.getPendingSave()).toBeNull(); + }); + + it.each(['before-dispatch', 'same-version', 'slug-settled', 'slug-generated'])( + 'releases local validation when a correction is clean at %s', + async (when) => { + const h = setup(); + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await h.engine.dispatch('field'); + if (when !== 'same-version') { + h.edit(); + } + if (when === 'before-dispatch' || when === 'same-version') { + h.patch({ isDirty: false }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'clean', + }); + } else if (when === 'slug-settled') { + const release = h.holdSlugWork(); + const correction = h.engine.dispatch('field'); + h.patch({ isDirty: false }); + await release(); + await expect(correction).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + } else { + h.holdSlugRequests(); + const correction = h.engine.dispatch('field'); + await flush(); + h.patch({ isDirty: false }); + await h.resolveSlug('hello', 'unchanged'); + await expect(correction).resolves.toEqual({ kind: 'dropped', reason: 'clean' }); + } + expect(h.engine.getPendingSave()).toBeNull(); + h.edit(); + expect(h.engine.getPendingSave()?.blockedBy).toBeNull(); + expect(h.prepare).toHaveBeenCalledTimes(1); + expect(h.execute).not.toHaveBeenCalled(); + }, + ); + + it('starts a corrected new post immediately after a clean validation exit', async () => { + const h = setup({ id: null, updatedAt: null }); + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await h.engine.dispatch('field'); + h.patch({ isDirty: false }); + await h.engine.dispatch('field'); + h.edit(); + const save = h.engine.dispatch('autosave'); + await flush(); + expect(h.requests).toHaveLength(1); + await h.succeed(); + await expect(save).resolves.toMatchObject({ kind: 'saved' }); + }); + + it('keeps server suppression when a same-version snapshot becomes clean', async () => { + const h = setup(); + const save = h.engine.dispatch('field'); + await h.fail(validation); + await save; + h.patch({ isDirty: false }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + h.patch({ isDirty: true }); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + }); + + it('holds invalid body autosaves without entering a save error', async () => { + const h = setup(); + h.prepare.mockResolvedValue({ ok: false, error: validation }); + const save = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + await expect(save).resolves.toEqual({ kind: 'blocked', error: validation }); + expect(h.engine.getPendingSave()?.blockedBy).toBe(validation); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + expect(h.execute).not.toHaveBeenCalled(); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + }); + + it('keeps validation visible through unrelated edits until preparation passes', async () => { + const h = setup(); + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await h.engine.dispatch('field'); + h.edit(); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: validation }); + + const save = h.engine.dispatch('autosave'); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: validation }); + const seen: Array> = []; + h.engine.subscribe(() => seen.push(h.engine.getPendingSave())); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(seen).toEqual([{ blockedBy: validation }, { blockedBy: null }]); + await h.succeed(); + await save; + }); + + it('debounces repeated edits to an invalid new post', async () => { + const h = setup({ id: null, updatedAt: null }); + h.prepare.mockResolvedValue({ ok: false, error: validation }); + await expect(h.engine.dispatch('autosave')).resolves.toMatchObject({ kind: 'blocked' }); + for (let index = 0; index < 5; index += 1) { + h.edit(); + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(100); + expect(h.engine.getPendingSave()?.blockedBy).toBe(validation); + } + expect(h.prepare).toHaveBeenCalledTimes(1); + expect(h.states.some((state) => state.kind === 'saving')).toBe(false); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.prepare).toHaveBeenCalledTimes(2); + expect(h.execute).not.toHaveBeenCalled(); + + h.edit(); + h.prepare.mockImplementation((request) => Promise.resolve({ ok: true, prepared: request })); + const corrected = h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + await h.succeed(); + await expect(corrected).resolves.toMatchObject({ kind: 'saved' }); + expect(h.requests[0].snapshot.version).toBe(7); + }); + + it('retains server suppression through a passing preparation and a failed retry', async () => { + const h = setup(); + const initial = h.engine.dispatch('explicit'); + await h.fail(validation); + await initial; + const retry = h.engine.dispatch('explicit'); + await flush(); + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + await h.fail(transport); + await retry; + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'dropped', + reason: 'suppressed', + }); + h.edit(); + const corrected = h.engine.dispatch('field'); + await h.succeed(); + await expect(corrected).resolves.toMatchObject({ kind: 'saved' }); + }); + + it('keeps edits made after submission pending even without another command', async () => { + const h = setup(); + const first = h.engine.dispatch('field'); + await flush(); + h.edit(); + expect(h.engine.getState().kind).toBe('saving'); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: null }); + await h.succeed(); + await first; + expect(h.engine.getPendingSave()).toEqual({ blockedBy: null }); + expect(h.execute).toHaveBeenCalledTimes(1); + + const next = h.engine.dispatch('field'); + await h.succeed(); + await next; + expect(h.requests[1].snapshot.version).toBe(2); + expect(h.engine.getPendingSave()).toBeNull(); + }); + + it('holds an invalid queued save after the active save finishes', async () => { + const h = setup(); + const first = h.engine.dispatch('field'); + await flush(); + h.edit(); + h.prepare.mockResolvedValue({ ok: false, error: validation }); + const second = h.engine.dispatch('field'); + await h.succeed(); + await first; + await expect(second).resolves.toEqual({ kind: 'blocked', error: validation }); + expect(h.engine.getPendingSave()).toMatchObject({ blockedBy: validation }); + expect(h.execute).toHaveBeenCalledTimes(1); + await expect(h.engine.leaveRequested()).resolves.toBe('confirm'); + }); + + it('fails an explicit publish promptly without replaying its email command after correction', async () => { + const h = setup(); + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await expect( + h.engine.dispatch('publish', { newsletter: 'news', emailOnly: true }), + ).resolves.toMatchObject({ kind: 'failed', error: validation }); + expect(h.engine.getPendingSave()?.blockedBy).toBe(validation); + h.edit(); + const corrected = h.engine.dispatch('field'); + await h.succeed(); + await corrected; + expect(h.requests).toHaveLength(1); + expect(h.requests[0].target).toEqual({ status: 'draft', publishedAt: null }); + expect(h.requests[0].command.kind).toBe('field'); + }); + + it('replaces a previous validation hold when a same-version command can save', async () => { + const h = setup({ publishedAt: FUTURE }); + h.prepare.mockResolvedValueOnce({ ok: false, error: validation }); + await h.engine.dispatch('field'); + expect(h.engine.getPendingSave()?.blockedBy).toBe(validation); + + // Scheduling permits the future date without requiring another content edit. + const retry = h.engine.dispatch('schedule', { publishedAt: FUTURE }); + await flush(); + expect(h.requests).toHaveLength(1); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: null }); + + await h.fail(transport); + await expect(retry).resolves.toMatchObject({ kind: 'failed', error: transport }); + expect(h.engine.getPendingSave()).toEqual({ blockedBy: transport }); + }); + + it('retains content while authentication is pending and releases it on disposal', async () => { + const h = setup(); + const save = h.engine.dispatch('field'); + await h.fail(sessionInvalid); + expect(h.engine.getPendingSave()?.blockedBy).toBe(sessionInvalid); + h.edit(); + expect(h.engine.getPendingSave()).toMatchObject({ blockedBy: sessionInvalid }); + h.engine.dispose(); + await expect(save).resolves.toEqual({ kind: 'dropped', reason: 'disposed' }); + expect(h.engine.getPendingSave()).toBeNull(); + }); +}); diff --git a/apps/admin/src/editor/engine/save-engine.queue.test.ts b/apps/admin/src/editor/engine/save-engine.queue.test.ts index 6198dcc79285..c75f3dee8faf 100644 --- a/apps/admin/src/editor/engine/save-engine.queue.test.ts +++ b/apps/admin/src/editor/engine/save-engine.queue.test.ts @@ -156,7 +156,7 @@ describe('createSaveEngine', () => { it('saves a new post immediately on its first edit', async () => { const h = setup({ id: null, updatedAt: null }); void h.engine.dispatch('autosave'); - expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'autosave' }); + expect(h.engine.getState()).toEqual({ kind: 'preparing', intent: 'autosave' }); await flush(); expect(h.execute).toHaveBeenCalledTimes(1); @@ -445,7 +445,7 @@ describe('createSaveEngine', () => { const release = h.holdSlugWork(); const field = h.engine.dispatch('field'); await flush(); - expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'field' }); + expect(h.engine.getState()).toEqual({ kind: 'preparing', intent: 'field' }); h.patch({ isDirty: false }); await release(); @@ -461,12 +461,16 @@ describe('createSaveEngine', () => { void h.engine.dispatch('explicit'); await h.succeed(); - expect(seen).toEqual([{ kind: 'saving', intent: 'explicit' }, { kind: 'idle' }]); + expect(seen).toEqual([ + { kind: 'preparing', intent: 'explicit' }, + { kind: 'saving', intent: 'explicit' }, + { kind: 'idle' }, + ]); expect(h.states).toEqual(seen); unsubscribe(); void h.engine.dispatch('explicit'); - expect(seen).toHaveLength(2); + expect(seen).toHaveLength(3); }); it('dispose cancels timers and settles every outstanding dispatch', async () => { @@ -598,7 +602,7 @@ describe('createSaveEngine', () => { const seen: string[] = []; const unsubscribe = h.engine.subscribe((state) => { seen.push(state.kind); - if (state.kind === 'saving') { + if (state.kind === 'preparing') { unsubscribe(); void h.engine.dispatch('field'); } @@ -607,9 +611,9 @@ describe('createSaveEngine', () => { void h.engine.dispatch('explicit'); - expect(seen).toEqual(['saving', 'other:pending-coalesced']); + expect(seen).toEqual(['preparing', 'other:preparing']); expect(h.engine.getState()).toEqual({ - kind: 'pending-coalesced', + kind: 'preparing', intent: 'explicit', pending: 'field', }); @@ -627,7 +631,7 @@ describe('createSaveEngine', () => { void h.engine.dispatch('explicit'); expect(h.listenerErrors).toEqual([failure]); - expect(seen).toEqual([{ kind: 'saving', intent: 'explicit' }]); + expect(seen).toEqual([{ kind: 'preparing', intent: 'explicit' }]); }); it('still saves when the onStateChange port throws, and reports it', async () => { @@ -652,7 +656,7 @@ describe('createSaveEngine', () => { await expect(engine.dispatch('explicit')).resolves.toMatchObject({ kind: 'saved' }); expect(engine.getState()).toEqual({ kind: 'idle' }); - expect(reported).toEqual([failure, failure]); + expect(reported).toEqual([failure, failure, failure]); }); it('re-runs a frozen explicit after a superseded publish while the winning revert needs retry', async () => { diff --git a/apps/admin/src/editor/engine/save-engine.reauth.test.ts b/apps/admin/src/editor/engine/save-engine.reauth.test.ts index a8371e760f3f..bdcee4bee1e0 100644 --- a/apps/admin/src/editor/engine/save-engine.reauth.test.ts +++ b/apps/admin/src/editor/engine/save-engine.reauth.test.ts @@ -59,6 +59,54 @@ describe('createSaveEngine', () => { await expect(field).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); }); + it.each([false, true])( + 'retries an internal autosave through repeated authentication failures (new=%s)', + async (isNew) => { + const h = setup(isNew ? { id: null, updatedAt: null } : {}); + const publish = h.engine.dispatch('publish', { newsletter: 'news', emailOnly: true }); + await h.fail(sessionInvalid); + h.engine.reauthSucceeded(); + await expect(publish).resolves.toEqual({ kind: 'needs-retry' }); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(h.requests).toHaveLength(2); + await h.fail(sessionInvalid); + h.edit(); + h.engine.reauthSucceeded(); + await flush(); + expect(h.requests).toHaveLength(3); + expect(h.requests[2]).toMatchObject({ + command: { kind: 'autosave' }, + snapshot: { version: 2 }, + target: { status: 'draft' }, + }); + expect(h.requests[2].target.newsletter).toBeUndefined(); + await h.fail(sessionInvalid); + const explicit = h.engine.dispatch('explicit'); + h.engine.reauthSucceeded(); + await h.succeed(); + await expect(explicit).resolves.toMatchObject({ kind: 'saved', executedAs: 'explicit' }); + expect(h.requests).toHaveLength(4); + expect(h.snapshot.isDirty).toBe(false); + expect(h.maxConcurrent()).toBe(1); + }, + ); + + it('does not replay an internal draft autosave after the post becomes published', async () => { + const h = setup(); + const publish = h.engine.dispatch('publish'); + await h.fail(sessionInvalid); + h.engine.reauthSucceeded(); + await publish; + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + await h.fail(sessionInvalid); + h.patch({ status: 'published' }); + h.engine.reauthSucceeded(); + await vi.advanceTimersByTimeAsync(TIMED_SAVE_INTERVAL_MS); + expect(h.requests).toHaveLength(2); + expect(h.snapshot.status).toBe('published'); + expect(h.engine.getState()).toEqual({ kind: 'idle' }); + }); + it('ignores reauthSucceeded when nothing is waiting on re-authentication', async () => { const h = setup(); h.engine.reauthSucceeded(); diff --git a/apps/admin/src/editor/engine/save-engine.slug.test.ts b/apps/admin/src/editor/engine/save-engine.slug.test.ts index 9ba3fc3c4c91..df4998376173 100644 --- a/apps/admin/src/editor/engine/save-engine.slug.test.ts +++ b/apps/admin/src/editor/engine/save-engine.slug.test.ts @@ -18,7 +18,7 @@ describe('createSaveEngine', () => { const explicit = h.engine.dispatch('explicit'); await flush(); - expect(h.engine.getState()).toEqual({ kind: 'saving', intent: 'explicit' }); + expect(h.engine.getState()).toEqual({ kind: 'preparing', intent: 'explicit' }); expect(h.execute).not.toHaveBeenCalled(); await release(); diff --git a/apps/admin/src/editor/engine/save-engine.ts b/apps/admin/src/editor/engine/save-engine.ts index 52a04b7d5667..9f2cd669fd15 100644 --- a/apps/admin/src/editor/engine/save-engine.ts +++ b/apps/admin/src/editor/engine/save-engine.ts @@ -130,15 +130,22 @@ export type SaveOutcome = | { ok: true; result: R } | { ok: false; error: SaveError }; -/** A prepare failure is typed like an execute failure, so its kind drives the same handling. */ +/** Local validation holds background work; other failures use the normal error handling. */ export type PrepareOutcome

= { ok: true; prepared: P } | { ok: false; error: SaveError }; +/** Unsaved content is independent of the commands currently allowed to execute. */ +export interface PendingSave { + blockedBy: SaveError | null; +} + export type DropReason = 'not-draft' | 'clean' | 'suppressed' | 'conflict' | 'halted' | 'disposed'; export type SaveCompletion = | { kind: 'saved'; result: SaveResult; executedAs: SaveIntent } | { kind: 'failed'; error: SaveError; executedAs: SaveIntent } | { kind: 'dropped'; reason: DropReason } + /** Content stays pending; this background attempt has no runnable command. */ + | { kind: 'blocked'; error: SaveError } /** A later status command replaced this one before it ran; riders stayed with the winner. */ | { kind: 'superseded'; by: SaveIntent } /** The command would change the status and re-auth interrupted it; the publish flow must re-confirm. */ @@ -147,6 +154,7 @@ export type SaveCompletion = export type SaveEngineState = | { kind: 'idle' } | { kind: 'debouncing' } + | { kind: 'preparing'; intent: SaveIntent; pending?: SaveIntent } | { kind: 'saving'; intent: SaveIntent } | { kind: 'pending-coalesced'; intent: SaveIntent; pending: SaveIntent } | { kind: 'reauth-pending'; intent: SaveIntent } @@ -192,7 +200,7 @@ export interface SaveEnginePorts< setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (handle: unknown) => void; onStateChange?: (state: SaveEngineState) => void; - /** A throwing `onStateChange` or subscriber is reported here instead of interrupting the save. */ + /** A throwing state callback or subscriber is reported here instead of interrupting the save. */ onListenerError?: (error: unknown) => void; } @@ -201,11 +209,12 @@ export interface SaveEngine { dispatch(kind: 'publish', options?: PublishOptions): Promise; dispatch(kind: Exclude): Promise; getState(): SaveEngineState; + getPendingSave(): PendingSave | null; subscribe(listener: (state: SaveEngineState) => void): () => void; reauthSucceeded(): void; reauthAbandoned(): void; - /** Leaves `conflict` once the caller has a document past the rejected `updated_at`. */ - contentReloaded(updatedAt?: string): boolean; + /** Validates recovery, calls the synchronous non-throwing adoption, then notifies subscribers. */ + contentReloaded(updatedAt?: string, adopt?: () => void): boolean; leaveRequested(): Promise; /** Also aborts the in-flight signal; a response arriving afterwards is never reconciled. */ dispose(): void; @@ -374,19 +383,37 @@ export function createSaveEngine< const transitionWatchers = new Set<() => void>(); let state: SaveEngineState = { kind: 'idle' }; - let inFlight: Slot | null = null; + let inFlight: { slot: Slot; submittedVersion: number | null } | null = null; let inFlightAbort: AbortController | null = null; let pending: Slot | null = null; // Set while re-authentication is pending: the failed slot freezes the queue until reauth resolves. let frozen: Frozen | null = null; let debounce: Timer | null = null; let timedCycle: Timer | null = null; - let suppressedVersion: number | null = null; - // updated_at the server rejected; automatic saves stay halted while the snapshot still carries it. - let staleUpdatedAt: string | null = null; + // The version controls automatic retries; local validation lasts until preparation passes or a clean attempt. + let hold: { version: number; source: 'local-validation' | 'server'; error: SaveError } | null = + null; + // A failed retry cannot prove a rejected collision token safe. + let conflict: { updatedAt: string | null; error: SaveError; intent: SaveIntent } | null = null; let leaveInProgress: Promise | null = null; let disposed = false; + function getPendingSave(): PendingSave | null { + if (disposed) { + return null; + } + const snapshot = ports.getSnapshot(); + if (!snapshot.isDirty) { + return null; + } + const blockedBy = + frozen?.error ?? + (isStale(snapshot) ? conflict?.error : null) ?? + (hold?.source === 'local-validation' ? hold.error : null) ?? + (state.kind === 'error' ? state.error : null); + return { blockedBy }; + } + function setState(next: SaveEngineState): void { if (sameState(state, next)) { return; @@ -418,15 +445,25 @@ export function createSaveEngine< return state; } if (inFlight) { + if (inFlight.submittedVersion === null) { + return { + kind: 'preparing', + intent: inFlight.slot.command.kind, + ...(pending ? { pending: pending.command.kind } : {}), + }; + } return pending ? { kind: 'pending-coalesced', - intent: inFlight.command.kind, + intent: inFlight.slot.command.kind, pending: pending.command.kind, } - : { kind: 'saving', intent: inFlight.command.kind }; + : { kind: 'saving', intent: inFlight.slot.command.kind }; } - if (keepHalt && (state.kind === 'error' || state.kind === 'conflict')) { + if (conflict) { + return { kind: 'conflict', intent: conflict.intent, error: conflict.error }; + } + if (keepHalt && state.kind === 'error') { return state; } if (debounce || timedCycle) { @@ -436,11 +473,11 @@ export function createSaveEngine< } function isSuppressed(snapshot: S): boolean { - return suppressedVersion !== null && snapshot.version === suppressedVersion; + return hold !== null && snapshot.version === hold.version; } function isStale(snapshot: S): boolean { - return staleUpdatedAt !== null && snapshot.updatedAt === staleUpdatedAt; + return conflict !== null && snapshot.updatedAt === conflict.updatedAt; } // Field saves on published/scheduled/sent posts are dropped: the sidebar stages those edits until Update. @@ -539,9 +576,15 @@ export function createSaveEngine< } } + function failureState(intent: SaveIntent, error: SaveError): SaveEngineState { + return conflict + ? { kind: 'conflict', intent: conflict.intent, error: conflict.error } + : { kind: 'error', intent, error }; + } + function failSlot(slot: Slot, error: SaveError): void { settle(slot.waiters, failed(error, slot.command.kind)); - setState({ kind: 'error', intent: slot.command.kind, error }); + setState(failureState(slot.command.kind, error)); drain(); } @@ -584,7 +627,14 @@ export function createSaveEngine< return true; } + function releaseValidationOnClean(snapshot: S): void { + if (!snapshot.isDirty && hold?.source === 'local-validation') { + hold = null; + } + } + function dropReason(slot: Slot, snapshot: S): DropReason | null { + releaseValidationOnClean(snapshot); if (!isBackgroundIntent(slot.command.kind)) { return null; } @@ -615,7 +665,7 @@ export function createSaveEngine< return; } - inFlight = slot; + inFlight = { slot, submittedVersion: null }; const abort = new AbortController(); inFlightAbort = abort; setState(deriveState()); @@ -649,8 +699,38 @@ export function createSaveEngine< return; } if (!preparation.ok) { + if (preparation.error.kind === 'validation') { + // Local validation holds content without retaining a publish/email target. + // An explicit caller still receives a failure immediately. + hold = { + version: snapshot.version, + source: 'local-validation', + error: preparation.error, + }; + inFlight = null; + inFlightAbort = null; + settle( + slot.waiters, + isBackgroundIntent(slot.command.kind) + ? { kind: 'blocked', error: preparation.error } + : failed(preparation.error, slot.command.kind), + ); + if (!isBackgroundIntent(slot.command.kind)) { + setState(failureState(slot.command.kind, preparation.error)); + } + drain(); + return; + } outcome = { ok: false, error: preparation.error }; } else { + if (hold?.source === 'local-validation') { + hold = null; + } + inFlight.submittedVersion = snapshot.version; + setState(deriveState()); + if (disposed) { + return; + } outcome = await ports.execute(preparation.prepared, abort.signal); if (disposed) { return; @@ -670,8 +750,8 @@ export function createSaveEngine< inFlightAbort = null; if (outcome.ok) { - suppressedVersion = null; - staleUpdatedAt = null; + hold = null; + conflict = null; settle(slot.waiters, { kind: 'saved', result: outcome.result, @@ -707,7 +787,7 @@ export function createSaveEngine< // No automatic retry against the stale baseline: queued work is dropped, an explicit retry is the way out. if (error.kind === 'conflict') { - staleUpdatedAt = snapshot.updatedAt; + conflict = { updatedAt: snapshot.updatedAt, error, intent }; const dropWaiters: Waiter[] = []; clearTimers(dropWaiters); if (pending) { @@ -720,12 +800,12 @@ export function createSaveEngine< return; } - if (error.kind === 'validation') { - suppressedVersion = snapshot.version; - } - // A limit hit by a status change says nothing about draft persistence; only a draft save's limit halts it. - if (error.kind === 'host-limit' && !changesStatus(slot.command, snapshot)) { - suppressedVersion = snapshot.version; + // A publish limit says nothing about whether draft content can be saved. + if ( + error.kind === 'validation' || + (error.kind === 'host-limit' && !changesStatus(slot.command, snapshot)) + ) { + hold = { version: snapshot.version, source: 'server', error }; } failSlot(slot, error); } @@ -776,8 +856,12 @@ export function createSaveEngine< return; } + // A clean refetch can keep the edit version unchanged; release local + // validation before the version-based suppression check. + releaseValidationOnClean(snapshot); const reason = backgroundDropReason(snapshot); if (reason) { + setState(deriveState()); resolve(dropped(reason)); return; } @@ -786,7 +870,7 @@ export function createSaveEngine< return; } armTimedCycle(); - if (snapshot.id === null) { + if (snapshot.id === null && hold?.source !== 'local-validation') { enqueue(waiter.command, [waiter]); return; } @@ -817,6 +901,14 @@ export function createSaveEngine< const snapshot = readSnapshot(); let reconfirm = false; for (const slot of slots) { + // Internal autosaves have work to retry even without a caller awaiting it. + if (slot.waiters.length === 0) { + if (needsReconfirmation(slot.command, snapshot)) { + reconfirm = true; + } else { + coalesce(slot.command, []); + } + } for (const waiter of slot.waiters) { if (needsReconfirmation(waiter.command, snapshot)) { waiter.resolve({ kind: 'needs-retry' }); @@ -844,7 +936,7 @@ export function createSaveEngine< return; } armTimedCycle(); - if (snapshot.id === null) { + if (snapshot.id === null && hold?.source !== 'local-validation') { enqueue(AUTOSAVE, []); return; } @@ -867,22 +959,31 @@ export function createSaveEngine< for (const waiter of waiters) { waiter.resolve(failed(error, waiter.command.kind)); } - setState({ kind: 'error', intent: slot.command.kind, error }); + setState(failureState(slot.command.kind, error)); } // A server document that no longer carries the rejected updated_at ends the // halt the collision caused. A candidate lets the caller check before replacing it. - function contentReloaded(updatedAt?: string): boolean { + function contentReloaded(updatedAt?: string, adopt?: () => void): boolean { const candidate = updatedAt ?? readSnapshot()?.updatedAt; if ( disposed || - state.kind !== 'conflict' || + isTerminal() || + !conflict || + inFlight || + frozen || !isCollisionToken(candidate) || - (staleUpdatedAt !== null && candidate === staleUpdatedAt) + candidate === conflict.updatedAt ) { return false; } - staleUpdatedAt = null; + // Consume recovery before adoption can notify its own subscribers and reenter. + conflict = null; + hold = null; + adopt?.(); + if (disposed) { + return false; + } setState(deriveState({ keepHalt: false })); return true; } @@ -971,7 +1072,7 @@ export function createSaveEngine< inFlightAbort = null; const waiters: Waiter[] = []; clearTimers(waiters); - for (const slot of [inFlight, pending, frozen?.slot ?? null]) { + for (const slot of [inFlight?.slot ?? null, pending, frozen?.slot ?? null]) { if (slot) { waiters.push(...slot.waiters); } @@ -987,6 +1088,7 @@ export function createSaveEngine< return { dispatch, getState: () => state, + getPendingSave, subscribe, reauthSucceeded, reauthAbandoned, diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index 46c9486d7bc2..d30b250ddb75 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -5,7 +5,8 @@ and the slug machine — into one editing session, and is the editor's only writer. Every title, excerpt, body, feature-image and settings change goes through it, and it owns everything those three modules deliberately do not: -what a save sends, when it runs, and what an acknowledgement may change. +what a save sends and what an acknowledgement may change. The save engine owns +pending work and scheduling. ## One session per post @@ -55,37 +56,45 @@ a field patch would be dropped before the request is built. ## Staging and committing -A settings field is staged with one call and committed with another. Staging -writes the value into the live projection and nothing else; committing puts it -through the one save policy gate. What that gate does depends on the post's -status. - -| Status | On a field commit | Persisted by | -| -------------------------------- | ------------------------------------------------------------------- | -------------------------------------- | -| `draft` | dispatches the save engine's `field` intent, as a title commit does | the field save itself | -| `published`, `scheduled`, `sent` | nothing — the value is staged in the live document | the next explicit save (Update, Cmd-S) | - -The gate also holds a draft's field save back while a value it would send is not -yet valid: an incomplete tier pairing, a meta or social-card title or -description past its column width, an author list the writer emptied, or a -publish time that has not passed. The value stays staged, the section says why, -and the next save the writer asks for is refused with the same message. A -draft's body autosave is not held back the same way: it runs, and the same rules -fail it before any request is sent, so the engine reports that error until the -value is valid again. The save banner carries the message whether or not the -sidebar is open, so closing the panel does not hide it. - -Staging is not a weaker form of saving. A staged value lives in the same live -document as the body, so it counts everywhere unsaved work counts: the post -reads dirty, the Update button enables, and the leave guard asks before the -writer navigates away. The save engine independently refuses background saves -for anything that is not a draft, so the gate states the policy rather than -being its only enforcement. - -Failures leave staged values alone. A rejected explicit save keeps them in the -live document and surfaces the error in the editor's banners; a collision goes -to the conflict banner, and only the writer choosing the server's copy discards -what they staged. +Every edit updates the live projection. The save engine derives pending content +from that projection when the session reads its view. Staging alone does not +start a request. A field commit — including title blur, image changes, and +settings — dispatches `field` unconditionally; +body edits dispatch `autosave`. The engine owns when these requests may run. + +| Status | Background save request | Persisted by | +| -------------------------------- | --------------------------------------------------------------- | ------------------------ | +| `draft` | Runs immediately for a field commit, or after the body debounce | The eligible save | +| `published`, `scheduled`, `sent` | Retains pending content until Update | Explicit Update or Cmd-S | + +Pending content is separate from the runnable queue. It includes edits awaiting +a field commit, the autosave debounce, Update, validation, or recovery, and can +coexist with an older request in flight. A blocked document never leaves a +command in the runnable queue, so navigating away does not wait indefinitely. +The live document remains the source of truth: Update enables, the post stays +dirty, and leaving requires a save or confirmation. + +All saves use the same preparation validator. An incomplete tier pairing, an +over-long meta/social field, an emptied author list, or a newly staged future +publish time holds a background save with a validation blocker. Body autosave, +title and image commits follow the same rule, including an already armed timer +or queued request. The editor explains why changes are waiting even when the +settings panel is closed. A saved future publish time is not itself invalid. + +An explicit save returns a validation failure promptly and shows the save error. +Its content stays pending; its publish/schedule/email target is not retained for +automatic retry. Correcting the document and committing requests a new save that +combines its current values. Unchanged invalid versions suppress background +retries; an explicit retry still revalidates. Unrelated edits retain the warning +until a preparation succeeds or a save attempt finds the document clean. Body edits on a blocked new post debounce too, +and preparation occupies the save slot without displaying “Saving…”. + +Failures never discard pending content. Server validation, network errors and +authentication expiry retain their recovery policies. A collision remains +recoverable after a retry fails for a different reason. +Only accepting a server reload discards outstanding local work. Successful +acknowledgement clears pending content only when the reconciled live document +is clean; edits made after submission remain pending. ## What a save sends @@ -179,7 +188,7 @@ Only a draft's title commit drives generation, so a published URL does not move under the writer. A slug is regenerated whenever the post has none, for any status, including after the default title has been substituted for a blank one. The session does not persist a proposal itself: an applied proposal is patched -into the live document and then goes through the same gate as any other field, +into the live document and then dispatches the same engine intent as any other field, so a draft saves it and every other status stages it until Update. ## Restoring a revision @@ -202,7 +211,11 @@ A reload replaces the whole document with the server's copy when the writer chooses it. The tracker is loaded afresh, so the hidden instance's old baseline goes with it; the identity adopts the fresh collision token, the editor surface re-seeds both Koenig instances, and the save engine validates the candidate -before any of those replacements happen. The read is its own request, never a +before any of those replacements happen. Its retained collision record authorizes +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 @@ -216,13 +229,14 @@ way back in and the content stays untouched. ## The view React subscribes to -The session publishes one cached view — the engine state, dirtiness, the title, -the slug, the settings fields and the publish time — and republishes it only -when one of those values changes. The nested settings and publish-time +The session publishes one cached view — the engine state, pending-save +blocking information, +dirtiness, title, slug, settings and publish time — and republishes it only +when one of those values changes. Pending content is read on demand after +tracker changes, including save errors that make a clean document dirty. The nested settings and publish-time 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 all six values as a -dependency. +for `useSyncExternalStore` and lets it stand in for those values as a dependency. ## The autosave debounce diff --git a/apps/admin/src/editor/session/__test-utils__/save-engine-spy.ts b/apps/admin/src/editor/session/__test-utils__/save-engine-spy.ts index 667727b9d30a..5e6c9b937fad 100644 --- a/apps/admin/src/editor/session/__test-utils__/save-engine-spy.ts +++ b/apps/admin/src/editor/session/__test-utils__/save-engine-spy.ts @@ -11,8 +11,8 @@ export const dispatchedIntents: string[] = []; /** The ports each session handed the engine, so a spec can drive one on its own. */ export const capturedPorts: EditorPorts[] = []; -// A pass-through wrapper. The engine refuses a background save on anything but -// a draft anyway, so `commitField`'s gate is only observable at the dispatch. +// A pass-through wrapper for command-routing and preparation-port tests. +// Pending-work behavior is asserted against the real engine. export function spiedSaveEngine(actual: SaveEngineModule): SaveEngineModule { const createSaveEngine = ((ports: EditorPorts) => { capturedPorts.push(ports); diff --git a/apps/admin/src/editor/session/editor-session.pending.test.ts b/apps/admin/src/editor/session/editor-session.pending.test.ts new file mode 100644 index 000000000000..101a26eb94b5 --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.pending.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AUTOSAVE_DEBOUNCE_MS } from '@/editor/engine/save-engine'; +import { body, record, serializedFields, sessionHarness } from './__test-utils__/session-harness'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('session pending saves', () => { + it('keeps validation visible through unrelated edits and stable while body typing continues', async () => { + const loaded = record({ authors: [{ id: 'author-1' }] }); + const { session, state } = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { applied: serializedFields }, + ); + session.patchFields({ authors: [] }); + session.commitField(); + await vi.advanceTimersByTimeAsync(0); + const blocker = session.getView().pendingSave?.blockedBy; + expect(blocker?.kind).toBe('validation'); + + session.patchTitle('Unrelated title edit'); + expect(session.getView().pendingSave).toEqual({ blockedBy: blocker }); + session.patchLexical(body('First body edit')); + session.dispatchAutosave(); + const debouncingView = session.getView(); + expect(debouncingView.pendingSave).toEqual({ blockedBy: blocker }); + const listener = vi.fn(); + session.subscribe(listener); + session.patchLexical(body('Second body edit')); + session.dispatchAutosave(); + expect(session.getView()).toBe(debouncingView); + expect(listener).not.toHaveBeenCalled(); + expect(state.updates).toHaveLength(0); + + session.patchFields({ authors: [{ id: 'author-2' }] }); + expect(session.getView().pendingSave?.blockedBy).toBe(blocker); + session.commitField(); + await vi.advanceTimersByTimeAsync(0); + expect(state.updates).toHaveLength(1); + expect(session.getView().pendingSave).toBeNull(); + session.dispose(); + }); + + it('never enters saving for invalid new-post edits', async () => { + const { session, state } = sessionHarness({ currentUserId: 'author-1' }); + const kinds: string[] = []; + session.subscribe(() => kinds.push(session.getState().kind)); + session.patchFields({ authors: [] }); + session.patchLexical(body('First words')); + session.dispatchAutosave(); + await vi.advanceTimersByTimeAsync(0); + expect(session.getView().pendingSave?.blockedBy?.kind).toBe('validation'); + + for (let count = 0; count < 5; count += 1) { + session.patchLexical(body(`More words ${count}`)); + session.dispatchAutosave(); + } + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + expect(kinds).not.toContain('saving'); + expect(kinds).not.toContain('pending-coalesced'); + expect(state.creates).toHaveLength(0); + session.dispose(); + }); + + it('retires the authors warning when restoring the original authors makes the document clean', async () => { + const loaded = record({ authors: [{ id: 'author-1' }] }); + const { session, state } = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { applied: serializedFields }, + ); + session.patchFields({ authors: [] }); + session.commitField(); + await vi.advanceTimersByTimeAsync(0); + expect(session.getView().pendingSave?.blockedBy?.kind).toBe('validation'); + session.patchFields({ authors: [{ id: 'author-1' }] }); + session.commitField(); + await vi.advanceTimersByTimeAsync(0); + expect(session.isDirty()).toBe(false); + expect(session.getView().pendingSave).toBeNull(); + session.patchTitle('An unrelated title edit'); + expect(session.getView().pendingSave).toEqual({ blockedBy: null }); + expect(state.updates).toHaveLength(0); + session.dispose(); + }); + + it('keeps the saving view stable when later body edits only change the edit version', async () => { + const loaded = record(); + let before: unknown; + let after: unknown; + const listener = vi.fn(); + const { session } = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { + applied: serializedFields, + duringSave: () => { + before = session.getView(); + const stop = session.subscribe(listener); + session.patchLexical(body('Typed during the save')); + after = session.getView(); + stop(); + }, + }, + ); + session.patchLexical(body('Submitted body')); + await session.dispatchExplicit(); + expect(after).toBe(before); + expect(listener).not.toHaveBeenCalled(); + expect(session.getLiveLexical()).toBe(JSON.stringify(body('Typed during the save'))); + expect(session.isDirty()).toBe(true); + expect(session.getView().pendingSave).toEqual({ blockedBy: null }); + session.dispose(); + }); + + it('derives pending work after a failed clean save marks the document dirty', async () => { + const loaded = record(); + const { session } = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { failSave: () => true }, + ); + expect(session.getView().pendingSave).toBeNull(); + await session.dispatchExplicit(); + expect(session.getView()).toMatchObject({ + isDirty: true, + pendingSave: { blockedBy: { kind: 'unknown' } }, + }); + session.dispose(); + }); + + it('combines body, title, image and corrected authors after an armed autosave is blocked', async () => { + const loaded = record({ authors: [{ id: 'author-1' }] }); + const { session, state } = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { applied: serializedFields }, + ); + session.patchLexical(body('New body')); + session.dispatchAutosave(); + session.patchFields({ authors: [] }); + session.patchTitle('New title'); + session.patchFeatureImage({ feature_image: 'https://example.com/image.png' }); + session.commitField(); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + expect(state.updates).toHaveLength(0); + expect(session.getState()).toEqual({ kind: 'idle' }); + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }); + expect(session.hasUnsavedContent()).toBe(true); + + session.patchFields({ authors: [{ id: 'author-2' }] }); + session.commitField(); + await vi.advanceTimersByTimeAsync(0); + + expect(state.updates).toHaveLength(1); + expect(state.updates[0].payload).toMatchObject({ + title: 'New title', + lexical: JSON.stringify(body('New body')), + feature_image: 'https://example.com/image.png', + authors: [{ id: 'author-2' }], + }); + expect(session.getView().pendingSave).toBeNull(); + expect(session.isDirty()).toBe(false); + session.dispose(); + }); + + it('retains a later feature-image edit after an older request is acknowledged', async () => { + let first = true; + const loaded = record({ feature_image: null }); + const built = sessionHarness( + { record: loaded, baseline: loaded.lexical }, + { + applied: serializedFields, + duringSave: () => { + if (first) { + first = false; + built.session.patchFeatureImage({ feature_image: 'https://example.com/later.png' }); + } + }, + }, + ); + built.session.patchTitle('Renamed'); + await built.session.dispatchExplicit(); + expect(built.state.updates).toHaveLength(1); + expect(built.state.updates[0].payload.feature_image).toBeNull(); + expect(built.session.getView().pendingSave).toMatchObject({ blockedBy: null }); + expect(built.session.getFields().feature_image).toBe('https://example.com/later.png'); + + built.session.commitField(); + await vi.advanceTimersByTimeAsync(0); + expect(built.state.updates).toHaveLength(2); + expect(built.state.updates[1].payload).toMatchObject({ + feature_image: 'https://example.com/later.png', + updated_at: '2026-01-01T00:00:01.000Z', + }); + expect(built.session.isDirty()).toBe(false); + expect(built.session.getView().pendingSave).toBeNull(); + built.session.dispose(); + }); +}); diff --git a/apps/admin/src/editor/session/editor-session.record-sync.test.ts b/apps/admin/src/editor/session/editor-session.record-sync.test.ts index 44077924055d..4f45c08e90cf 100644 --- a/apps/admin/src/editor/session/editor-session.record-sync.test.ts +++ b/apps/admin/src/editor/session/editor-session.record-sync.test.ts @@ -90,6 +90,63 @@ describe('createEditorSession', () => { expect(session.getLiveLexical()).toBe(reloaded.lexical); }); + it('keeps a reload available after a collision retry fails with a transport error', async () => { + const hooks = { failUpdateWith: updateCollision() as Error }; + const { session } = sessionHarness({ record: record(), baseline: record().lexical }, hooks); + session.patchTitle('My unsaved title'); + await session.dispatchExplicit(); + hooks.failUpdateWith = new TypeError('Failed to fetch'); + await session.dispatchExplicit(); + + expect(session.getView().pendingSave?.blockedBy?.kind).toBe('conflict'); + expect( + session.recordReloaded( + record({ title: 'Server title', updated_at: '2026-01-02T00:00:00.000Z' }), + ), + ).toBe(true); + expect(session.getView()).toMatchObject({ + title: 'Server title', + isDirty: false, + pendingSave: null, + }); + session.dispose(); + }); + + it('publishes the recovered document before subscribers can edit it', async () => { + const { session } = sessionHarness( + { record: record(), baseline: record().lexical }, + { failUpdateWith: updateCollision() }, + ); + session.patchTitle('Unsaved old title'); + await session.dispatchExplicit(); + const reloaded = record({ + title: 'Recovered title', + slug: 'recovered-title', + status: 'published', + updated_at: '2026-01-02T00:00:00.000Z', + }); + let recovered = false; + session.subscribe(() => { + if (recovered || session.getState().kind !== 'idle') { + return; + } + recovered = true; + expect(session.getSaveSnapshot()).toMatchObject({ + title: reloaded.title, + status: reloaded.status, + updatedAt: reloaded.updated_at, + }); + expect(session.getView().pendingSave).toBeNull(); + session.patchTitle('Edited after recovery'); + }); + + expect(session.recordReloaded(reloaded)).toBe(true); + expect(recovered).toBe(true); + expect(session.getFields().title).toBe('Edited after recovery'); + expect(session.getView().pendingSave).toMatchObject({ blockedBy: null }); + session.dispose(); + }); + it('notifies leave-guard subscribers when a reload clears unsaved work', async () => { const { session } = sessionHarness({ record: record() }, { failUpdateWith: updateCollision() }); session.patchTitle('My unsaved title'); diff --git a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts index 51397b64b4bc..45163ca61fcb 100644 --- a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts +++ b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts @@ -478,22 +478,6 @@ describe('createEditorSession', () => { expect(built.session.isDirty()).toBe(false); }); - it.each([ - { status: 'draft' as const, dispatches: true }, - { status: 'published' as const, dispatches: false }, - { status: 'scheduled' as const, dispatches: false }, - { status: 'sent' as const, dispatches: false }, - ])('$status: commitField reaches the engine=$dispatches', ({ status, dispatches }) => { - const { session } = sessionHarness({ - record: record({ status, published_at: status === 'draft' ? null : PUBLISHED_AT }), - }); - - session.patchFields({ featured: true }); - session.commitField(); - - expect(dispatchedIntents).toEqual(dispatches ? ['field'] : []); - }); - it.each([ { status: 'draft' as const, persists: true }, { status: 'published' as const, persists: false }, @@ -522,7 +506,7 @@ describe('createEditorSession', () => { session.commitField(); await settle(); - expect(dispatchedIntents).toEqual([]); + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }); expect(state.updates).toHaveLength(0); expect(await session.dispatchExplicit()).toMatchObject({ @@ -723,8 +707,9 @@ describe('createEditorSession', () => { session.patchFields({ authors: [] }); session.commitField(); - // The gate holds the field save back, as an incomplete tier pairing is. - expect(dispatchedIntents).toEqual([]); + // The engine retains invalid content without sending a request. + await settle(); + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }); expect(await session.dispatchExplicit()).toMatchObject({ kind: 'failed', error: { kind: 'validation', message: 'At least one author is required.' }, diff --git a/apps/admin/src/editor/session/editor-session.slug.test.ts b/apps/admin/src/editor/session/editor-session.slug.test.ts index 100d0ba0d247..6a178bb38c39 100644 --- a/apps/admin/src/editor/session/editor-session.slug.test.ts +++ b/apps/admin/src/editor/session/editor-session.slug.test.ts @@ -172,7 +172,7 @@ describe('createEditorSession', () => { await session.editSlug('A New Slug'); await settle(); - expect(dispatchedIntents).toEqual([]); + expect(session.getView().pendingSave).toMatchObject({ blockedBy: null }); expect(state.updates).toHaveLength(0); expect(session.getSlug()).toBe('a-new-slug'); expect(session.isDirty()).toBe(true); diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index 9be113b7f7a8..7c4602e85a52 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -7,6 +7,7 @@ import { zeroMilliseconds, type LeaveDecision, type PersistedIdentity, + type PendingSave, type PostStatus, type PrepareOutcome, type PublishOptions, @@ -111,6 +112,7 @@ export interface EditorSessionOptions { /** The state React renders, published together after a session change. */ export interface EditorSessionView { readonly state: SaveEngineState; + readonly pendingSave: PendingSave | null; readonly isDirty: boolean; /** The title the engine holds, which is DEFAULT_TITLE while the input is blank. */ readonly title: string; @@ -147,7 +149,7 @@ export interface EditorSession { editPublishedAt: (publishedAt: string) => void; /** The publish time the writer is looking at, staged edit included. */ getPublishedAt: () => string | null; - /** The one save policy gate for settings fields; see the README. */ + /** Requests a field save; the engine owns eligibility and pending work. */ commitField: () => void; /** The slug the machine holds, which a title commit moves without a field patch. */ getSlug: () => string; @@ -159,7 +161,6 @@ export interface EditorSession { setBaseline: (lexical: LexicalInput) => void; baselineFailed: () => void; commitTitle: (title: string) => void; - dispatchField: () => void; dispatchAutosave: () => void; dispatchExplicit: () => Promise; dispatchPublish: (options?: PublishOptions) => Promise; @@ -277,6 +278,11 @@ export function createEditorSession({ return; } const state = engine.getState(); + const pending = engine.getPendingSave(); + // getPendingSave() allocates per call; typing more body text must not + // republish an otherwise unchanged React snapshot. + const pendingSave = + view?.pendingSave?.blockedBy === pending?.blockedBy ? (view?.pendingSave ?? null) : pending; const isDirty = getSnapshot().isDirty; const currentSlug = machine.getState().slug; const currentPublishedAt = livePublishedAt(); @@ -296,6 +302,7 @@ export function createEditorSession({ if ( view && view.state === state && + view.pendingSave === pendingSave && view.isDirty === isDirty && view.title === live.title && view.slug === currentSlug && @@ -304,7 +311,15 @@ export function createEditorSession({ ) { return; } - view = { state, isDirty, title: live.title, slug: currentSlug, settings, publishTime }; + view = { + state, + pendingSave, + isDirty, + title: live.title, + slug: currentSlug, + settings, + publishTime, + }; for (const listener of changeListeners) { try { listener(); @@ -379,11 +394,6 @@ export function createEditorSession({ tracker.setLive(identity.id, patch); } - /** The writer removed every author the post had; Ember's validator refuses it too. */ - function authorsEmptied(): boolean { - return live.authors.length === 0 && tracker.isFieldDirty('authors'); - } - function getSnapshot(): EditorSaveSnapshot { const verdict = tracker.verdict(); return buildSaveSnapshot({ @@ -609,18 +619,8 @@ export function createEditorSession({ // Seed the external-store snapshot before the session is handed to React. notifyChanged(); - // The one place the sidebar's save policy lives. A draft persists a settings - // field the way the body does; every other status stages it until Update. + // Every field commit enters the engine; it owns eligibility and pending work. function commitField(): void { - // Invalid settings stay staged rather than dispatching a field save. - if ( - status !== 'draft' || - settingsFieldError(validatedFieldsOf(live)) || - authorsEmptied() || - publishedAtInFuture(status, livePublishedAt()) - ) { - return; - } void engine.dispatch('field'); } @@ -764,7 +764,6 @@ export function createEditorSession({ slug.commitTitle(title); } }, - dispatchField: () => void engine.dispatch('field'), dispatchAutosave: () => void engine.dispatch('autosave'), dispatchExplicit: () => engine.dispatch('explicit'), dispatchPublish: (options) => engine.dispatch('publish', options), @@ -809,23 +808,28 @@ export function createEditorSession({ ) { return false; } - if (engine.getState().kind !== 'conflict' || !engine.contentReloaded(updatedAt)) { + if ( + !engine.contentReloaded(updatedAt, () => { + identity = { id: next.id, updatedAt }; + status = next.status ?? 'draft'; + publishedAt = next.published_at ?? null; + latestRevision = latestRevisionOf(next); + live = projectionOf(next); + stagedPublishedAt = null; + publishedAtEditedAt = 0; + pendingSlugEdits.clear(); + writerEdits.clear(); + inFlightSince = null; + version += 1; + tracker.load(identity.id, live); + slug.reset(); + // The machine may notify subscribers, so the document boundary must be + // complete first and no later mutation may overwrite a subscriber edit. + machine.loaded({ slug: live.slug, title: live.title }); + }) + ) { return false; } - identity = { id: next.id, updatedAt }; - status = next.status ?? 'draft'; - publishedAt = next.published_at ?? null; - latestRevision = latestRevisionOf(next); - live = projectionOf(next); - stagedPublishedAt = null; - publishedAtEditedAt = 0; - pendingSlugEdits.clear(); - writerEdits.clear(); - inFlightSince = null; - version += 1; - tracker.load(identity.id, live); - machine.loaded({ slug: live.slug, title: live.title }); - slug.reset(); notifyChanged(); return true; }, diff --git a/apps/admin/src/editor/session/feature-image-binding.test.ts b/apps/admin/src/editor/session/feature-image-binding.test.ts index a2e38795ca9a..6180c638df23 100644 --- a/apps/admin/src/editor/session/feature-image-binding.test.ts +++ b/apps/admin/src/editor/session/feature-image-binding.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; +import { dispatchedIntents } from '@/editor/session/__test-utils__/save-engine-spy'; +import { + record as sessionRecord, + sessionHarness, +} from '@/editor/session/__test-utils__/session-harness'; import type { EditorRecord } from './projection'; import { cleanCaptionHtml, @@ -8,8 +13,19 @@ import { withCaptionParagraph, } from './feature-image-binding'; +type SaveEngineModule = typeof import('@/editor/engine/save-engine'); + +vi.mock('@/editor/engine/save-engine', async (importOriginal) => { + const spy = await import('@/editor/session/__test-utils__/save-engine-spy'); + return spy.spiedSaveEngine(await importOriginal()); +}); + +beforeEach(() => { + dispatchedIntents.length = 0; +}); + function port() { - return { patchFeatureImage: vi.fn(), dispatchField: vi.fn() }; + return { patchFeatureImage: vi.fn(), commitSettings: vi.fn() }; } function record(overrides: Partial = {}): EditorRecord { @@ -98,7 +114,7 @@ describe('useFeatureImageBinding', () => { expect(session.patchFeatureImage).toHaveBeenCalledWith({ feature_image: 'https://example.com/a.png', }); - expect(session.dispatchField).toHaveBeenCalledTimes(1); + expect(session.commitSettings).toHaveBeenCalledTimes(1); expect(result.current.featureImage).toBe('https://example.com/a.png'); }); @@ -122,7 +138,7 @@ describe('useFeatureImageBinding', () => { feature_image_alt: null, feature_image_caption: null, }); - expect(session.dispatchField).toHaveBeenCalledTimes(1); + expect(session.commitSettings).toHaveBeenCalledTimes(1); expect(result.current.featureImageCaption).toBeNull(); }); @@ -135,7 +151,7 @@ describe('useFeatureImageBinding', () => { expect(session.patchFeatureImage).toHaveBeenCalledWith({ feature_image_alt: 'A field of grass', }); - expect(session.dispatchField).toHaveBeenCalledTimes(1); + expect(session.commitSettings).toHaveBeenCalledTimes(1); }); it('holds the caption until it loses focus', () => { @@ -145,11 +161,11 @@ describe('useFeatureImageBinding', () => { act(() => result.current.onFeatureImageCaptionChange('

A caption

')); expect(session.patchFeatureImage).toHaveBeenCalledWith({ feature_image_caption: 'A caption' }); - expect(session.dispatchField).not.toHaveBeenCalled(); + expect(session.commitSettings).not.toHaveBeenCalled(); act(() => result.current.onFeatureImageCaptionBlur()); - expect(session.dispatchField).toHaveBeenCalledTimes(1); + expect(session.commitSettings).toHaveBeenCalledTimes(1); }); it('ignores a caption the editor only re-serialized', () => { @@ -195,3 +211,99 @@ describe('useFeatureImageBinding', () => { }); }); }); + +describe('useFeatureImageBinding through the session', () => { + const IMAGE = 'https://example.com/a.png'; + // Whole seconds, as a saved publish time always is. + const FUTURE = new Date( + Math.floor(Date.now() / 1000) * 1000 + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + + // A field save awaits the slug port and the transport before it lands. + const settle = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + function bound(loaded: EditorRecord) { + const harness = sessionHarness({ record: loaded }); + const { session } = harness; + const { result } = renderHook(() => + useFeatureImageBinding( + { patchFeatureImage: session.patchFeatureImage, commitSettings: session.commitField }, + loaded, + ), + ); + return { ...harness, result }; + } + + it('saves a new image on a draft as a field save', async () => { + const { result, state } = bound(sessionRecord()); + + act(() => result.current.onFeatureImageChange(IMAGE)); + await settle(); + + expect(dispatchedIntents).toEqual(['field']); + expect(state.updates[0].payload).toMatchObject({ feature_image: IMAGE }); + }); + + it('keeps a new image on a draft staged while an emptied author list is staged', async () => { + const { result, session, state } = bound(sessionRecord({ authors: [{ id: 'author-1' }] })); + + session.patchFields({ authors: [] }); + act(() => result.current.onFeatureImageChange(IMAGE)); + await settle(); + + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }); + expect(state.updates).toHaveLength(0); + expect(session.getState().kind).not.toBe('error'); + expect(session.getFields().feature_image).toBe(IMAGE); + expect(session.isDirty()).toBe(true); + }); + + it('saves a new image on a draft whose saved publish time is in the future', async () => { + const { result, state } = bound(sessionRecord({ published_at: FUTURE })); + + act(() => result.current.onFeatureImageChange(IMAGE)); + await settle(); + + expect(dispatchedIntents).toEqual(['field']); + expect(state.updates).toHaveLength(1); + expect(state.updates[0].payload).toMatchObject({ feature_image: IMAGE }); + }); + + it('keeps a new image on a draft staged while a future publish time is staged', async () => { + const { result, session, state } = bound(sessionRecord()); + + session.editPublishedAt(FUTURE); + act(() => result.current.onFeatureImageChange(IMAGE)); + await settle(); + + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }); + expect(state.updates).toHaveLength(0); + expect(session.getState().kind).not.toBe('error'); + expect(session.isDirty()).toBe(true); + }); + + it('stages a new image on a published post until an explicit save', async () => { + const { result, session, state } = bound( + sessionRecord({ status: 'published', published_at: '2025-12-01T00:00:00.000Z' }), + ); + + act(() => result.current.onFeatureImageChange(IMAGE)); + act(() => result.current.onFeatureImageAltChange('A field of grass')); + await settle(); + + expect(session.getView().pendingSave).toMatchObject({ blockedBy: null }); + expect(state.updates).toHaveLength(0); + expect(session.isDirty()).toBe(true); + + await session.dispatchExplicit(); + + expect(state.updates).toHaveLength(1); + expect(state.updates[0].payload).toMatchObject({ + feature_image: IMAGE, + feature_image_alt: 'A field of grass', + }); + }); +}); diff --git a/apps/admin/src/editor/session/feature-image-binding.ts b/apps/admin/src/editor/session/feature-image-binding.ts index 347f6c40669b..8d17ef582cec 100644 --- a/apps/admin/src/editor/session/feature-image-binding.ts +++ b/apps/admin/src/editor/session/feature-image-binding.ts @@ -11,7 +11,7 @@ export interface FeatureImagePatch { /** The session calls the feature image needs; the rest of the handle is irrelevant to it. */ export interface FeatureImagePort { patchFeatureImage: (patch: FeatureImagePatch) => void; - dispatchField: () => void; + commitSettings: () => void; } export interface FeatureImageBinding { @@ -77,7 +77,8 @@ export function normalizeCaptionHtml(html: string | null | undefined): string { /** * Feature image, alt text and caption as the editor holds them. Setting, - * clearing and alt edits save immediately; the caption saves on blur. + * clearing and alt edits commit immediately; the caption commits on blur. Each + * commit enters the save engine, which retains work until it can be saved. */ export function useFeatureImageBinding( port: FeatureImagePort, @@ -105,7 +106,7 @@ export function useFeatureImageBinding( const onFeatureImageChange = useCallback((url: string) => { setFeatureImage(url); session.current.patchFeatureImage({ feature_image: url }); - session.current.dispatchField(); + session.current.commitSettings(); }, []); const onFeatureImageClear = useCallback(() => { @@ -117,13 +118,13 @@ export function useFeatureImageBinding( feature_image_alt: null, feature_image_caption: null, }); - session.current.dispatchField(); + session.current.commitSettings(); }, []); const onFeatureImageAltChange = useCallback((alt: string) => { setFeatureImageAlt(alt); session.current.patchFeatureImage({ feature_image_alt: alt }); - session.current.dispatchField(); + session.current.commitSettings(); }, []); const onFeatureImageCaptionChange = useCallback((html: string) => { @@ -135,7 +136,7 @@ export function useFeatureImageBinding( session.current.patchFeatureImage({ feature_image_caption: cleaned }); }, []); - const onFeatureImageCaptionBlur = useCallback(() => session.current.dispatchField(), []); + const onFeatureImageCaptionBlur = useCallback(() => session.current.commitSettings(), []); return { featureImage, diff --git a/apps/admin/src/editor/session/leave-guard.test.ts b/apps/admin/src/editor/session/leave-guard.test.ts index 3d77a41474eb..ca1b7b9dc00c 100644 --- a/apps/admin/src/editor/session/leave-guard.test.ts +++ b/apps/admin/src/editor/session/leave-guard.test.ts @@ -23,6 +23,7 @@ describe('hasUnsavedWork', () => { }); it('guards a clean post while a write is still outstanding', () => { + expect(hasUnsavedWork({ kind: 'preparing', intent: 'publish' }, false)).toBe(true); expect(hasUnsavedWork({ kind: 'saving', intent: 'autosave' }, false)).toBe(true); expect( hasUnsavedWork({ kind: 'pending-coalesced', intent: 'autosave', pending: 'explicit' }, false), diff --git a/apps/admin/src/editor/session/leave-guard.ts b/apps/admin/src/editor/session/leave-guard.ts index e4bd8c419513..e5901b848e95 100644 --- a/apps/admin/src/editor/session/leave-guard.ts +++ b/apps/admin/src/editor/session/leave-guard.ts @@ -17,7 +17,10 @@ export function hasUnsavedWork(state: SaveEngineState, isDirty: boolean): boolea return true; } return ( - state.kind === 'saving' || state.kind === 'pending-coalesced' || state.kind === 'reauth-pending' + state.kind === 'preparing' || + state.kind === 'saving' || + state.kind === 'pending-coalesced' || + state.kind === 'reauth-pending' ); } diff --git a/apps/admin/src/editor/session/publish-time-save.test.ts b/apps/admin/src/editor/session/publish-time-save.test.ts index a329029f0531..c3fbba720c20 100644 --- a/apps/admin/src/editor/session/publish-time-save.test.ts +++ b/apps/admin/src/editor/session/publish-time-save.test.ts @@ -286,7 +286,9 @@ describe('staging the publish time', () => { await Promise.resolve(); expect(update).not.toHaveBeenCalled(); - // Staged, not attempted: no save the writer did not ask for may fail. + await vi.waitFor(() => + expect(session.getView().pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }), + ); expect(session.getState().kind).toBe('idle'); }); diff --git a/apps/admin/src/editor/session/session-banners.test.tsx b/apps/admin/src/editor/session/session-banners.test.tsx index c5b716043f27..f87ec9a3b19d 100644 --- a/apps/admin/src/editor/session/session-banners.test.tsx +++ b/apps/admin/src/editor/session/session-banners.test.tsx @@ -1,13 +1,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { editorConflictReloadConfirm } from '@tryghost/test-data/selectors/editor'; import { toast } from 'sonner'; -import type { SaveEngineState, SaveError } from '@/editor/engine/save-engine'; +import type { PendingSave, SaveEngineState, SaveError } from '@/editor/engine/save-engine'; import { SessionBanners } from './session-banners'; import type { ReloadOutcome } from './use-editor-session'; const noop = () => undefined; interface BannerOverrides { + pendingSave?: PendingSave; hasUnsavedContent?: () => boolean; contentText?: () => string; onReload?: () => Promise; @@ -18,6 +19,7 @@ function renderBanners(state: SaveEngineState, overrides: BannerOverrides = {}) '')} hasUnsavedContent={overrides.hasUnsavedContent ?? (() => false)} + pendingSave={overrides.pendingSave} state={state} onDismissReauth={noop} onReload={overrides.onReload ?? (() => Promise.resolve('reloaded'))} @@ -42,6 +44,19 @@ function errored(error: Partial): SaveEngineState { } describe('SessionBanners', () => { + it('keeps collision recovery available after a retry reports another error', () => { + renderBanners( + { kind: 'error', intent: 'explicit', error: { kind: 'transport', message: 'Offline' } }, + { + pendingSave: { + blockedBy: { kind: 'conflict', message: 'Another writer changed this post.' }, + }, + }, + ); + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -63,6 +78,22 @@ describe('SessionBanners', () => { expect(container).toBeEmptyDOMElement(); }); + it('explains held validation without presenting a failed network save', () => { + renderBanners( + { kind: 'idle' }, + { + pendingSave: { + blockedBy: { kind: 'validation', message: 'At least one author is required.' }, + }, + }, + ); + expect(screen.getByRole('status')).toHaveTextContent( + 'Changes are waiting to save. At least one author is required.', + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + }); + it('offers a retry in place when the session expired', () => { renderBanners({ kind: 'reauth-pending', intent: 'explicit' }); diff --git a/apps/admin/src/editor/session/session-banners.tsx b/apps/admin/src/editor/session/session-banners.tsx index 5e0674264322..bd33e82c4936 100644 --- a/apps/admin/src/editor/session/session-banners.tsx +++ b/apps/admin/src/editor/session/session-banners.tsx @@ -19,7 +19,7 @@ import { editorReauthBanner, editorSaveErrorBanner, } from '@tryghost/test-data/selectors/editor'; -import type { SaveError, SaveEngineState } from '@/editor/engine/save-engine'; +import type { PendingSave, SaveError, SaveEngineState } from '@/editor/engine/save-engine'; import { EDITOR_CONFIRM_DIALOG_LAYER } from '@/editor/layering'; import type { ReloadOutcome } from './use-editor-session'; @@ -31,6 +31,7 @@ const GONE = export interface SessionBannersProps { state: SaveEngineState; + pendingSave?: PendingSave | null; hasUnsavedContent: () => boolean; contentText: () => string; onRetryReauth: () => void; @@ -155,6 +156,7 @@ function ConflictBanner({ export function SessionBanners({ state, + pendingSave, hasUnsavedContent, contentText, onRetryReauth, @@ -184,7 +186,11 @@ export function SessionBanners({ ); } - if (state.kind === 'conflict' || state.kind === 'halted') { + if ( + state.kind === 'conflict' || + state.kind === 'halted' || + pendingSave?.blockedBy?.kind === 'conflict' + ) { return ( + Changes are waiting to save. {pendingSave.blockedBy.message} + + ); + } + return null; } diff --git a/apps/admin/src/editor/session/use-editor-session.test.tsx b/apps/admin/src/editor/session/use-editor-session.test.tsx index 8e7965193093..4c797cb07fce 100644 --- a/apps/admin/src/editor/session/use-editor-session.test.tsx +++ b/apps/admin/src/editor/session/use-editor-session.test.tsx @@ -1,10 +1,23 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReactNode } from 'react'; +import { dispatchedIntents } from './__test-utils__/save-engine-spy'; import { record } from './__test-utils__/session-harness'; +import type { EditorRecord } from './projection'; import { useEditorSession } from './use-editor-session'; +type SaveEngineModule = typeof import('@/editor/engine/save-engine'); + +vi.mock('@/editor/engine/save-engine', async (importOriginal) => { + const spy = await import('@/editor/session/__test-utils__/save-engine-spy'); + return spy.spiedSaveEngine(await importOriginal()); +}); + +beforeEach(() => { + dispatchedIntents.length = 0; +}); + vi.mock('@tryghost/admin-x-framework', () => ({ useLocation: () => ({ key: 'editor', state: null }), })); @@ -45,12 +58,12 @@ function Wrapper({ children }: { children: ReactNode }) { return {children}; } -function setup() { +function setup(loaded: EditorRecord = record()) { return renderHook( () => useEditorSession({ postType: 'post', - record: record(), + record: loaded, siteUrl: 'https://example.com', }), { wrapper: Wrapper }, @@ -115,3 +128,38 @@ describe('useEditorSession handle identity', () => { expect(result.current.isDirty).not.toBe(isDirty); }); }); + +describe('useEditorSession title blur', () => { + it('commits a draft title as a field save', () => { + const { result } = setup(); + + act(() => result.current.bind.onTitleChange('A new title')); + act(() => result.current.bind.onTitleBlur()); + + expect(dispatchedIntents).toEqual(['field']); + }); + + it('stages a published title until an explicit save', () => { + const { result } = setup( + record({ status: 'published', published_at: '2025-12-01T00:00:00.000Z' }), + ); + + act(() => result.current.bind.onTitleChange('A new title')); + act(() => result.current.bind.onTitleBlur()); + + expect(result.current.pendingSave).toMatchObject({ blockedBy: null }); + expect(result.current.isDirty()).toBe(true); + }); + + it('keeps a draft title staged while an emptied author list is staged', async () => { + const { result } = setup(record({ authors: [{ id: 'author-1' }] })); + + act(() => result.current.editSettings({ authors: [] })); + act(() => result.current.bind.onTitleChange('A new title')); + act(() => result.current.bind.onTitleBlur()); + + await waitFor(() => + expect(result.current.pendingSave).toMatchObject({ blockedBy: { kind: 'validation' } }), + ); + }); +}); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index b0a263a08308..4f5aca05af83 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -86,6 +86,7 @@ export interface EditorSessionBinding { export interface EditorSessionHandle { bind: EditorSessionBinding; state: SaveEngineState; + pendingSave: EditorSessionView['pendingSave']; /** The server ID the post holds, once a create has acknowledged one. */ persistedId: string | null; /** The server ID acquired by this session's first create, if it began new. */ @@ -106,13 +107,13 @@ export interface EditorSessionHandle { patchFeatureImage: EditorSession['patchFeatureImage']; /** The live settings fields, re-read on every sidebar edit. */ settings: EditorSettingsFields; - /** Stages a settings field, then applies the sidebar's save policy. */ + /** Stages a settings field, then asks the engine to save it. */ editSettings: (patch: EditorSettingsPatch) => void; /** Stages a settings field the writer is still typing into, committing nothing. */ stageSettings: (patch: EditorSettingsPatch) => void; /** - * Applies the sidebar's save policy to what is staged, on the blur that ends - * an edit. The excerpt is a settings field wherever it is rendered. + * Requests a field save from the engine on the blur that ends + * an edit. The excerpt and the feature image go through it wherever they render. */ commitSettings: () => void; /** The title the engine holds, which is the default title while the input is blank. */ @@ -129,7 +130,6 @@ export interface EditorSessionHandle { getSaveSnapshot: EditorSession['getSaveSnapshot']; /** The body the writer is looking at, which a save has not necessarily seen yet. */ getLiveLexical: EditorSession['getLiveLexical']; - dispatchField: () => void; dispatchExplicit: () => void; /** An explicit save whose completion the caller acts on, such as before a publish or preview. */ saveExplicit: () => Promise; @@ -262,9 +262,9 @@ export function useEditorSession({ }, [session]); const view = useSyncExternalStore(session.subscribe, session.getView); - const { state, title: engineTitle, slug, settings, publishTime } = view; + const { state, pendingSave, title: engineTitle, slug, settings, publishTime } = view; - // The view keeps its identity until one of the six values it publishes + // The view keeps its identity until one of the values it publishes // changes, so it stands in for all of them as a dependency. const isDirtyNow = useCallback(() => view.isDirty, [view]); @@ -416,7 +416,7 @@ export function useEditorSession({ const onTitleBlur = useCallback(() => { session.commitTitle(title); - session.dispatchField(); + session.commitField(); }, [session, title]); const onLexicalChange = useCallback( @@ -478,6 +478,7 @@ export function useEditorSession({ () => ({ bind, state, + pendingSave, persistedId, createdId: isNew ? persistedId : null, isDirty: isDirtyNow, @@ -499,7 +500,6 @@ export function useEditorSession({ editPublishedAt, getSaveSnapshot: session.getSaveSnapshot, getLiveLexical: session.getLiveLexical, - dispatchField: session.dispatchField, dispatchExplicit, saveExplicit: session.dispatchExplicit, dispatchPublish, @@ -530,6 +530,7 @@ export function useEditorSession({ slug, stageSettings, state, + pendingSave, ], ); } diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index eddb779b2f22..d03651edf52e 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -12,15 +12,16 @@ members the sections read — rather than the whole editing handle. A settings field is staged as the writer changes it and committed on the gesture that ends the edit — a blur for a text field, the choice itself for a toggle or a picker. Committing is not saving: whether the value is persisted now or held -until the writer asks for a save is the session's one save policy gate, and +until the writer asks for a save is decided by the save engine, and [the session README](../session/README.md#staging-and-committing) describes it -and the rules that hold a commit back. +and its pending-work contract. Title, feature image, settings and body saves all +reach the engine and use the same preparation validator. Two of the panel's sections write something that is not a settings field, so they have their own routes onto the session: the URL section edits the slug through the slug machine, and the Publish date section stages the publish time, which is the save engine's command target. Both are then subject to the same -gate as everything else. +engine policy as everything else. The meta and social-card text fields are held to the widths their columns give them, 300 characters for a title and 500 for a description. Past one of those From b8d31cdc19f93f5684c0d9cec92b7812758b5c42 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 25 Sep 2026 15:00:38 +0200 Subject: [PATCH 2/5] Moved the post debug screen from Ember to React (#30688) no ref Moved the post debug screen to React and Shade at the existing URL and removed the Ember screen and its styles. Dates and custom refetch ranges use Temporal. There's been a design update. --------- Co-authored-by: Steve Larson <9larsons@gmail.com> --- .../src/api/content-types.ts | 5 + apps/admin-x-framework/src/api/emails.ts | 84 +++ apps/admin/src/posts/api.ts | 2 + apps/admin/src/posts/debug/analytics-sync.tsx | 290 +++++++++++ apps/admin/src/posts/debug/batches.tsx | 126 +++++ apps/admin/src/posts/debug/failures.tsx | 186 +++++++ apps/admin/src/posts/debug/format.test.ts | 82 +++ apps/admin/src/posts/debug/format.ts | 109 ++++ .../src/posts/debug/group-failures.test.ts | 39 ++ apps/admin/src/posts/debug/group-failures.ts | 50 ++ apps/admin/src/posts/debug/jobs.ts | 21 + apps/admin/src/posts/debug/overview.tsx | 369 +++++++++++++ .../debug/post-debug.acceptance.test.tsx | 401 ++++++++++++++ apps/admin/src/posts/debug/post-debug.tsx | 184 +++++++ apps/admin/src/routes.tsx | 11 +- .../app/components/posts/debug.hbs | 491 ------------------ .../ember-admin/app/components/posts/debug.js | 389 -------------- .../posts/debug/email-error-message.hbs | 5 - .../posts/debug/email-error-message.js | 15 - .../app/controllers/posts/debug.js | 7 - apps/ember-admin/app/router.js | 1 - apps/ember-admin/app/routes/posts/debug.js | 50 -- apps/ember-admin/app/styles/layouts/posts.css | 360 ------------- .../ember-admin/app/templates/posts/debug.hbs | 1 - 24 files changed, 1957 insertions(+), 1321 deletions(-) create mode 100644 apps/admin/src/posts/debug/analytics-sync.tsx create mode 100644 apps/admin/src/posts/debug/batches.tsx create mode 100644 apps/admin/src/posts/debug/failures.tsx create mode 100644 apps/admin/src/posts/debug/format.test.ts create mode 100644 apps/admin/src/posts/debug/format.ts create mode 100644 apps/admin/src/posts/debug/group-failures.test.ts create mode 100644 apps/admin/src/posts/debug/group-failures.ts create mode 100644 apps/admin/src/posts/debug/jobs.ts create mode 100644 apps/admin/src/posts/debug/overview.tsx create mode 100644 apps/admin/src/posts/debug/post-debug.acceptance.test.tsx create mode 100644 apps/admin/src/posts/debug/post-debug.tsx delete mode 100644 apps/ember-admin/app/components/posts/debug.hbs delete mode 100644 apps/ember-admin/app/components/posts/debug.js delete mode 100644 apps/ember-admin/app/components/posts/debug/email-error-message.hbs delete mode 100644 apps/ember-admin/app/components/posts/debug/email-error-message.js delete mode 100644 apps/ember-admin/app/controllers/posts/debug.js delete mode 100644 apps/ember-admin/app/routes/posts/debug.js delete mode 100644 apps/ember-admin/app/templates/posts/debug.hbs diff --git a/apps/admin-x-framework/src/api/content-types.ts b/apps/admin-x-framework/src/api/content-types.ts index 51613b0ae41f..b50e1874fccc 100644 --- a/apps/admin-x-framework/src/api/content-types.ts +++ b/apps/admin-x-framework/src/api/content-types.ts @@ -11,6 +11,11 @@ export type Email = { email_count: number; status?: 'pending' | 'submitting' | 'submitted' | 'failed'; error?: string | null; + submitted_at?: string | null; + recipient_filter?: string | null; + delivered_count?: number; + failed_count?: number; + feedback_enabled?: boolean; track_opens?: boolean; track_clicks?: boolean; }; diff --git a/apps/admin-x-framework/src/api/emails.ts b/apps/admin-x-framework/src/api/emails.ts index deb7e48c7c42..0d5dbe26d989 100644 --- a/apps/admin-x-framework/src/api/emails.ts +++ b/apps/admin-x-framework/src/api/emails.ts @@ -93,3 +93,87 @@ export const useRetryEmail = createMutation ({ sessionExpiryRedirect }), invalidateQueries: { dataType: postsDataType }, }); + +export interface EmailDebugBatch extends EmailBatch { + created_at?: string | null; + member_segment?: string | null; + mailgun_message_id?: string | null; + error_message?: string | null; + error_status_code?: number | null; + count?: { recipients: number }; +} + +export interface EmailRecipientFailure { + id: string; + severity: 'temporary' | 'permanent'; + code: number; + enhanced_code?: string | null; + message: string; + email_recipient?: { member_name?: string | null; member_email?: string | null } | null; + member?: { id: string; name?: string | null; avatar_image?: string | null } | null; +} + +export interface EmailAnalyticsJob { + running?: boolean; + lastStarted?: string | null; + lastBegin?: string | null; + lastEventTimestamp?: string | null; + fetchedThrough?: string | null; + lagSeconds?: number | null; + canceled?: boolean; + schedule?: { begin: string; end: string } | null; +} + +export interface EmailAnalyticsStatus { + latest?: EmailAnalyticsJob; + latestOpened?: EmailAnalyticsJob; + missing?: EmailAnalyticsJob; + scheduled?: EmailAnalyticsJob; +} + +const emailAnalyticsDataType = 'EmailAnalyticsStatus'; + +export const useEmail = createQueryWithId({ + dataType: 'EmailsResponseType', + path: (id) => `/emails/${id}/`, +}); + +// The sending-status query parses a minimal projection. Keep diagnostic details +// in a separate query so its schema does not discard the full batch response. +export const useEmailDebugBatches = createQueryWithId<{ batches: EmailDebugBatch[] }>({ + dataType: 'EmailDebugBatches', + path: (id) => `/emails/${id}/batches/`, + defaultSearchParams: { + include: 'count.recipients', + limit: 'all', + order: 'status asc, created_at desc', + }, +}); + +export const useEmailRecipientFailures = createQueryWithId<{ failures: EmailRecipientFailure[] }>({ + dataType: 'EmailRecipientFailures', + path: (id) => `/emails/${id}/recipient-failures/`, + defaultSearchParams: { include: 'member,email_recipient', limit: 'all' }, +}); + +export const useEmailAnalyticsStatus = createQueryWithId({ + dataType: emailAnalyticsDataType, + path: (id) => `/emails/${id}/analytics/`, +}); + +export const useScheduleEmailAnalytics = createMutation< + unknown, + { id: string; begin?: string; end?: string } +>({ + method: 'PUT', + path: ({ id }) => `/emails/${id}/analytics/`, + searchParams: ({ begin, end }) => ({ ...(begin ? { begin } : {}), ...(end ? { end } : {}) }), + body: () => ({}), + invalidateQueries: { dataType: emailAnalyticsDataType }, +}); + +export const useCancelEmailAnalytics = createMutation({ + method: 'DELETE', + path: () => '/emails/analytics/', + invalidateQueries: { dataType: emailAnalyticsDataType }, +}); diff --git a/apps/admin/src/posts/api.ts b/apps/admin/src/posts/api.ts index 5ba64f42af7d..879c9cdd2db2 100644 --- a/apps/admin/src/posts/api.ts +++ b/apps/admin/src/posts/api.ts @@ -16,3 +16,5 @@ export const lazyPagesListRoute = () => import('./list/pages-route'); // Lazy entry, not a component re-export: the shell's host loads the modal on // demand, so a static re-export would pull the chunk into the shell bundle. export const lazyGiftLinkModal = () => import('./analytics/modals/gift-link-modal'); + +export const lazyPostDebugScreen = () => import('./debug/post-debug'); diff --git a/apps/admin/src/posts/debug/analytics-sync.tsx b/apps/admin/src/posts/debug/analytics-sync.tsx new file mode 100644 index 000000000000..1fd688883819 --- /dev/null +++ b/apps/admin/src/posts/debug/analytics-sync.tsx @@ -0,0 +1,290 @@ +import { useState } from 'react'; +import { getErrorMessage } from '@tryghost/admin-x-framework/errors'; +import type { Email } from '@tryghost/admin-x-framework/api/content-types'; +import { + type EmailAnalyticsJob, + useCancelEmailAnalytics, + useEmailAnalyticsStatus, + useScheduleEmailAnalytics, +} from '@tryghost/admin-x-framework/api/emails'; +import { + Button, + Indicator, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { + defaultRefetchRange, + formatDebugDate, + formatDuration, + formatSyncTime, + refetchRangeToUtc, +} from './format'; +import { JOBS, hasJobActivity } from './jobs'; + +function Time({ value, title }: { value?: string | null; title?: string }) { + const formatted = formatSyncTime(value); + if (!formatted) { + return N/A; + } + return ( + + {formatted.date} + {formatted.time} + + ); +} + +function JobRow({ + label, + detail, + job, + state, +}: { + label: string; + detail?: string; + job?: EmailAnalyticsJob; + state?: string; +}) { + const started = formatSyncTime(job?.lastStarted); + return ( + + + + {label} + {detail && ( + + {detail} + + )} + + + + + + + {state ?? (job?.running ? 'Running' : 'Idle')} + + {started && ( + + Started {started.date}, {started.time} + + )} + + + + {formatDuration(job?.lagSeconds)} + + + + + + + ); +} + +export default function AnalyticsSync({ email, emailId }: { email: Email; emailId: string }) { + const analytics = useEmailAnalyticsStatus(emailId, { + refetchInterval: 5000, + retry: false, + defaultErrorHandler: false, + }); + const schedule = useScheduleEmailAnalytics(); + const cancel = useCancelEmailAnalytics(); + const [customRange, setCustomRange] = useState<{ begin: string; end: string } | null>(null); + const [error, setError] = useState(null); + const pending = schedule.isPending || cancel.isPending; + const status = analytics.data; + const scheduled = status?.scheduled; + + function openCustomRange() { + setCustomRange(defaultRefetchRange(email.created_at)); + setError(null); + } + + async function scheduleRefetch(custom = false) { + setError(null); + let range = {}; + if (custom && customRange) { + try { + range = refetchRangeToUtc(customRange); + } catch { + setError('Choose a begin date before the end date.'); + return; + } + } + try { + await schedule.mutateAsync({ id: emailId, ...range }); + setCustomRange(null); + } catch (cause) { + setError(getErrorMessage(cause, 'Could not schedule analytics refetch.')); + } + } + + async function cancelRefetch() { + setError(null); + try { + await cancel.mutateAsync(); + } catch (cause) { + setError(getErrorMessage(cause, 'Could not cancel analytics refetch.')); + } + } + + let actions = null; + if (analytics.isLoading) { + // Wait for the status so a scheduled refetch doesn't flash the refetch buttons. + } else if (scheduled?.schedule) { + actions = !scheduled.canceled && ( + + ); + } else if (!customRange) { + actions = ( + + + + + ); + } + + const hasActivity = hasJobActivity(status); + + return ( + + + + All times UTC. Hover a time for full detail. + + {actions} + + {customRange && !scheduled?.schedule && ( +
{ + event.preventDefault(); + void scheduleRefetch(true); + }} + > + + + + setCustomRange({ ...customRange, begin: event.target.value })} + /> + + + + setCustomRange({ ...customRange, end: event.target.value })} + /> + + + + + + +
+ )} + {error && ( +

+ {error} +

+ )} + {analytics.isLoading &&

Loading analytics status…

} + {analytics.isError && ( +

Could not load analytics status. Retrying automatically.

+ )} + {status && !hasActivity &&

No fetches recorded yet.

} + {status && hasActivity && ( + + + + {['Job', 'State', 'Lag', 'Fetched through', 'Last event'].map((label) => ( + + {label} + + ))} + + + + {JOBS.map(([key, label]) => ( + + ))} + {scheduled?.schedule && ( + + )} + +
+ )} +
+ ); +} diff --git a/apps/admin/src/posts/debug/batches.tsx b/apps/admin/src/posts/debug/batches.tsx new file mode 100644 index 000000000000..d8af31b2539c --- /dev/null +++ b/apps/admin/src/posts/debug/batches.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import type { EmailDebugBatch } from '@tryghost/admin-x-framework/api/emails'; +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { formatDebugDate, statusLabel } from './format'; + +const STATUS_BADGE = { + submitted: 'success', + failed: 'destructive', + pending: 'secondary', + submitting: 'secondary', +} as const; + +function BatchError({ message }: { message: string }) { + const [expanded, setExpanded] = useState(false); + return ( + +

+ {message} +

+ +
+ ); +} + +function summary(batches: EmailDebugBatch[], failed: number) { + const total = `${formatNumber(batches.length)} ${batches.length === 1 ? 'batch' : 'batches'}`; + if (failed > 0) { + return `${formatNumber(failed)} of ${total} failed`; + } + if (batches.every((batch) => batch.status === 'submitted')) { + return `${total}, all submitted`; + } + return total; +} + +export default function Batches({ + batches, + isLoading, + isError, +}: { + batches: EmailDebugBatch[]; + isLoading: boolean; + isError: boolean; +}) { + if (isLoading) { + return null; + } + if (isError) { + return

Could not load email batches.

; + } + if (!batches.length) { + return

No batch data.

; + } + const failed = batches.filter((batch) => batch.status === 'failed').length; + + return ( + + + {summary(batches, failed)}. + + + + + {['Status', 'Created', 'Segment', 'Recipients', 'Details'].map((label) => ( + {label} + ))} + + + + {batches.map((batch) => ( + + + {statusLabel(batch.status)} + + + {formatDebugDate(batch.created_at)} + + + {batch.member_segment || 'All'} + + + {formatNumber(batch.count?.recipients ?? 0)} + + + + {batch.mailgun_message_id && ( + + Provider id: {batch.mailgun_message_id} + + )} + {!!batch.error_status_code && ( + Failure status code: {batch.error_status_code} + )} + {batch.error_message && } + {!batch.mailgun_message_id && + !batch.error_status_code && + !batch.error_message && + 'N/A'} + + + + ))} + +
+
+ ); +} diff --git a/apps/admin/src/posts/debug/failures.tsx b/apps/admin/src/posts/debug/failures.tsx new file mode 100644 index 000000000000..7eb121383d61 --- /dev/null +++ b/apps/admin/src/posts/debug/failures.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from 'react'; +import type { EmailRecipientFailure } from '@tryghost/admin-x-framework/api/emails'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Button, + Tabs, + TabsList, + TabsTrigger, + TabsTriggerCount, +} from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { type FailureGroup, groupFailures } from './group-failures'; + +const RECIPIENTS_PER_PAGE = 20; + +type Filter = 'all' | EmailRecipientFailure['severity']; + +function initials(value: string) { + return (value || 'U') + .split(' ') + .filter(Boolean) + .map((part) => part[0]) + .slice(0, 2) + .join('') + .toUpperCase(); +} + +function Recipient({ + failure, + groupMessage, +}: { + failure: EmailRecipientFailure; + groupMessage: string; +}) { + const name = failure.email_recipient?.member_name || ''; + const email = failure.email_recipient?.member_email || ''; + const identity = ( + + + + {initials(name || email)} + + + {name || email} + {name && {email}} + + + ); + return ( + + {failure.member?.id ? {identity} : identity} + {failure.message !== groupMessage && ( + + {failure.message} + + )} + + ); +} + +function FailureGroupItem({ group, showSeverity }: { group: FailureGroup; showSeverity: boolean }) { + const [visible, setVisible] = useState(RECIPIENTS_PER_PAGE); + const count = group.failures.length; + const remaining = count - visible; + const code = group.enhancedCode ? `${group.code} · ${group.enhancedCode}` : String(group.code); + + return ( + + + + + {code} + + + {group.message} + + + {formatNumber(count)} {count === 1 ? 'recipient' : 'recipients'} + {showSeverity && ` · ${group.severity}`} + + + + +
+ {group.failures.slice(0, visible).map((failure) => ( + + ))} +
+ {remaining > 0 && ( + + )} +
+
+ ); +} + +/** Recipient failures grouped by provider response. */ +export default function Failures({ + failures, + isLoading, + isError, +}: { + failures: EmailRecipientFailure[]; + isLoading: boolean; + isError: boolean; +}) { + const [filter, setFilter] = useState('all'); + const permanent = failures.filter((failure) => failure.severity === 'permanent').length; + const temporary = failures.length - permanent; + const mixed = permanent > 0 && temporary > 0; + const groups = useMemo( + () => + groupFailures( + mixed && filter !== 'all' + ? failures.filter((failure) => failure.severity === filter) + : failures, + ), + [failures, filter, mixed], + ); + + if (isLoading) { + return null; + } + if (isError) { + return

Could not load recipient failures.

; + } + if (!failures.length) { + return

No recipient failures.

; + } + + return ( + + + + {formatNumber(failures.length)} {failures.length === 1 ? 'recipient' : 'recipients'}, + grouped by provider response. + + {mixed && ( + setFilter(value as Filter)}> + + + All + {formatNumber(failures.length)} + + + Permanent + {formatNumber(permanent)} + + + Temporary + {formatNumber(temporary)} + + + + )} + + + {groups.map((group) => ( + + ))} + + + ); +} diff --git a/apps/admin/src/posts/debug/format.test.ts b/apps/admin/src/posts/debug/format.test.ts new file mode 100644 index 000000000000..492bd6c6540f --- /dev/null +++ b/apps/admin/src/posts/debug/format.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { Temporal } from 'temporal-polyfill'; +import { + defaultRefetchRange, + formatDebugDate, + formatDuration, + formatPublishedDate, + formatSyncTime, + refetchRangeToUtc, + secondsBetween, +} from './format'; + +describe('debug dates', () => { + it('keeps diagnostics in UTC, with optional millisecond precision', () => { + expect(formatDebugDate('2026-09-10T11:30:01.023+02:00', true)).toBe( + '10 Sep, 2026, 09:30:01.023 UTC', + ); + expect(formatDebugDate('2026-09-10T11:30:01.023+02:00')).toBe('10 Sep, 2026, 09:30:01 UTC'); + expect(formatDebugDate(null)).toBe('N/A'); + expect(formatDebugDate('2026-09-10 11:30:01')).toBe('N/A'); + expect(formatSyncTime('2026-09-10T11:30:01.023+02:00')).toEqual({ + date: '10 Sep', + time: '09:30:01', + offsetNanoseconds: 0, + }); + expect( + formatSyncTime('2026-09-10T23:30:01Z', { year: true, timezone: 'Australia/Sydney' }), + ).toEqual({ date: '11 Sep 2026', time: '09:30:01', offsetNanoseconds: 36_000_000_000_000 }); + expect(formatSyncTime(undefined)).toBeNull(); + }); + + it('measures the seconds between two timestamps', () => { + expect(secondsBetween('2026-09-10T10:00:00Z', '2026-09-10T10:01:30Z')).toBe(90); + expect(secondsBetween('2026-09-10T10:00:00Z', null)).toBeNull(); + }); + + it('formats publication metadata in the site timezone across a date boundary', () => { + expect(formatPublishedDate('2026-09-10T23:30:00Z', 'Europe/London')).toBe( + '11 Sep 2026 at 00:30', + ); + }); + + it('limits the default range to a week after creation or an hour ago', () => { + const now = Temporal.Instant.from('2026-09-10T12:00:00Z'); + expect(defaultRefetchRange('2026-09-01T10:00:00Z', now)).toEqual({ + begin: '2026-09-01T10:00', + end: '2026-09-08T10:00', + }); + expect(defaultRefetchRange('2026-09-09T10:00:00Z', now)).toEqual({ + begin: '2026-09-09T10:00', + end: '2026-09-10T11:00', + }); + }); + + it('ends the default range now for an email created within the last hour', () => { + const now = Temporal.Instant.from('2026-09-10T12:00:00Z'); + expect(defaultRefetchRange('2026-09-10T11:30:00Z', now)).toEqual({ + begin: '2026-09-10T11:30', + end: '2026-09-10T12:00', + }); + }); + + it('interprets date inputs as UTC even on a daylight saving transition', () => { + expect(refetchRangeToUtc({ begin: '2026-03-29T01:30', end: '2026-03-29T02:30' })).toEqual({ + begin: '2026-03-29T01:30:00.000Z', + end: '2026-03-29T02:30:00.000Z', + }); + expect(() => refetchRangeToUtc({ begin: '2026-09-10T12:00', end: '2026-09-10T11:00' })).toThrow( + 'Choose a begin date', + ); + expect(() => refetchRangeToUtc({ begin: '', end: '' })).toThrow(); + }); +}); + +it('formats durations without wrapping days and tolerates absent older-backend fields', () => { + expect(formatDuration(90061)).toBe('1d 1h 1m 1s'); + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(60)).toBe('1m'); + for (const value of [undefined, null, -1, NaN, Infinity]) { + expect(formatDuration(value)).toBe('N/A'); + } +}); diff --git a/apps/admin/src/posts/debug/format.ts b/apps/admin/src/posts/debug/format.ts new file mode 100644 index 000000000000..1e2515bd9367 --- /dev/null +++ b/apps/admin/src/posts/debug/format.ts @@ -0,0 +1,109 @@ +import { Temporal } from 'temporal-polyfill'; + +const pad = (value: number) => String(value).padStart(2, '0'); + +function parseInstant(value?: string | null) { + if (!value) { + return null; + } + try { + return Temporal.Instant.from(value); + } catch { + return null; + } +} + +function formatUtc(instant: Temporal.Instant, milliseconds: boolean) { + const date = instant.toZonedDateTimeISO('UTC'); + const month = date.toLocaleString('en-US', { month: 'short' }); + const fraction = milliseconds ? `.${String(date.millisecond).padStart(3, '0')}` : ''; + return `${pad(date.day)} ${month}, ${date.year}, ${pad(date.hour)}:${pad(date.minute)}:${pad(date.second)}${fraction}`; +} + +export function formatDebugDate(value?: string | null, milliseconds = false) { + const instant = parseInstant(value); + return instant ? `${formatUtc(instant, milliseconds)} UTC` : 'N/A'; +} + +/** Compact UTC date and time for tables; callers expose the full timestamp on hover. */ +export function formatSyncTime( + value?: string | null, + { year = false, timezone = 'UTC' }: { year?: boolean; timezone?: string } = {}, +) { + const instant = parseInstant(value); + if (!instant) { + return null; + } + const date = instant.toZonedDateTimeISO(timezone); + const month = date.toLocaleString('en-US', { month: 'short' }); + return { + date: year ? `${pad(date.day)} ${month} ${date.year}` : `${pad(date.day)} ${month}`, + time: `${pad(date.hour)}:${pad(date.minute)}:${pad(date.second)}`, + offsetNanoseconds: date.offsetNanoseconds, + }; +} + +export function secondsBetween(from?: string | null, to?: string | null) { + const begin = parseInstant(from); + const end = parseInstant(to); + if (!begin || !end) { + return null; + } + return begin.until(end).total({ unit: 'seconds' }); +} + +export function formatPublishedDate(value: string, timezone: string) { + const instant = parseInstant(value); + if (!instant) { + return null; + } + const date = instant.toZonedDateTimeISO(timezone); + const month = date.toLocaleString('en-US', { month: 'short' }); + return `${date.day} ${month} ${date.year} at ${pad(date.hour)}:${pad(date.minute)}`; +} + +export function defaultRefetchRange(createdAt?: string | null, now = Temporal.Now.instant()) { + const begin = parseInstant(createdAt) ?? now; + const weekAfterCreation = begin.add({ hours: 7 * 24 }); + const hourAgo = now.subtract({ hours: 1 }); + const cappedEnd = + Temporal.Instant.compare(weekAfterCreation, hourAgo) < 0 ? weekAfterCreation : hourAgo; + // An email sent within the last hour would otherwise end before it begins. + const end = Temporal.Instant.compare(cappedEnd, begin) > 0 ? cappedEnd : now; + const inputValue = (instant: Temporal.Instant) => + instant.toZonedDateTimeISO('UTC').toPlainDateTime().toString({ smallestUnit: 'minute' }); + return { begin: inputValue(begin), end: inputValue(end) }; +} + +export function refetchRangeToUtc(range: { begin: string; end: string }) { + const begin = Temporal.PlainDateTime.from(range.begin).toZonedDateTime('UTC').toInstant(); + const end = Temporal.PlainDateTime.from(range.end).toZonedDateTime('UTC').toInstant(); + if (Temporal.Instant.compare(begin, end) >= 0) { + throw new Error('Choose a begin date before the end date.'); + } + return { + begin: begin.toString({ fractionalSecondDigits: 3 }), + end: end.toString({ fractionalSecondDigits: 3 }), + }; +} + +export function formatDuration(seconds?: number | null) { + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) { + return 'N/A'; + } + return ( + [ + [Math.floor(seconds / 86400), 'd'], + [Math.floor(seconds / 3600) % 24, 'h'], + [Math.floor(seconds / 60) % 60, 'm'], + [Math.floor(seconds) % 60, 's'], + ] + .filter(([value]) => Number(value) > 0) + .map(([value, unit]) => `${value}${unit}`) + .join(' ') || '0s' + ); +} + +export function statusLabel(status?: string) { + return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'N/A'; +} diff --git a/apps/admin/src/posts/debug/group-failures.test.ts b/apps/admin/src/posts/debug/group-failures.test.ts new file mode 100644 index 000000000000..a11ca1a054ba --- /dev/null +++ b/apps/admin/src/posts/debug/group-failures.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { EmailRecipientFailure } from '@tryghost/admin-x-framework/api/emails'; +import { groupFailures } from './group-failures'; + +let id = 0; +const failure = (overrides: Partial): EmailRecipientFailure => ({ + id: `failure-${(id += 1)}`, + severity: 'permanent', + code: 550, + enhanced_code: '5.1.1', + message: 'Mailbox does not exist', + ...overrides, +}); + +describe('groupFailures', () => { + it('groups by severity and codes, largest first, permanent before temporary on ties', () => { + const groups = groupFailures([ + failure({ severity: 'temporary', code: 421, enhanced_code: null, message: 'Busy' }), + failure({}), + failure({ code: 554, enhanced_code: '5.7.1', message: 'Policy' }), + failure({}), + ]); + expect(groups.map((group) => [group.code, group.enhancedCode, group.failures.length])).toEqual([ + [550, '5.1.1', 2], + [554, '5.7.1', 1], + [421, null, 1], + ]); + }); + + it('keeps the same code at different severities apart', () => { + const groups = groupFailures([failure({}), failure({ severity: 'temporary' })]); + expect(groups.map((group) => group.severity)).toEqual(['permanent', 'temporary']); + }); + + it('headlines the most common message in a group', () => { + const [group] = groupFailures([failure({ message: 'Mailbox full' }), failure({}), failure({})]); + expect(group.message).toBe('Mailbox does not exist'); + }); +}); diff --git a/apps/admin/src/posts/debug/group-failures.ts b/apps/admin/src/posts/debug/group-failures.ts new file mode 100644 index 000000000000..fe43fe3485ad --- /dev/null +++ b/apps/admin/src/posts/debug/group-failures.ts @@ -0,0 +1,50 @@ +import type { EmailRecipientFailure } from '@tryghost/admin-x-framework/api/emails'; + +export interface FailureGroup { + key: string; + code: number; + enhancedCode: string | null; + severity: EmailRecipientFailure['severity']; + /** The most common provider message in the group. */ + message: string; + failures: EmailRecipientFailure[]; +} + +function mostCommonMessage(failures: EmailRecipientFailure[]) { + const counts = new Map(); + for (const { message } of failures) { + counts.set(message, (counts.get(message) ?? 0) + 1); + } + let best = failures[0].message; + for (const [message, count] of counts) { + if (count > (counts.get(best) ?? 0)) { + best = message; + } + } + return best; +} + +/** Groups recipient failures that share a provider response, largest group first. */ +export function groupFailures(failures: EmailRecipientFailure[]): FailureGroup[] { + const groups = new Map(); + for (const failure of failures) { + const key = `${failure.severity}:${failure.code}:${failure.enhanced_code || ''}`; + groups.set(key, [...(groups.get(key) ?? []), failure]); + } + const severityRank = (group: FailureGroup) => (group.severity === 'permanent' ? 0 : 1); + return [...groups.entries()] + .map(([key, members]) => ({ + key, + code: members[0].code, + enhancedCode: members[0].enhanced_code || null, + severity: members[0].severity, + message: mostCommonMessage(members), + failures: members, + })) + .sort( + (a, b) => + b.failures.length - a.failures.length || + severityRank(a) - severityRank(b) || + a.code - b.code, + ); +} diff --git a/apps/admin/src/posts/debug/jobs.ts b/apps/admin/src/posts/debug/jobs.ts new file mode 100644 index 000000000000..ec61e74a3fb1 --- /dev/null +++ b/apps/admin/src/posts/debug/jobs.ts @@ -0,0 +1,21 @@ +import type { EmailAnalyticsJob } from '@tryghost/admin-x-framework/api/emails'; + +/** Analytics jobs reported by the email analytics status endpoint, in display order. */ +export const JOBS = [ + ['latestOpened', 'Opens'], + ['latest', 'Delivery and failures'], + ['missing', 'Missing events'], +] as const; + +type JobStatus = Partial>; + +/** True once any job has started, fetched or seen an event, or a refetch is scheduled. */ +export function hasJobActivity(status?: JobStatus) { + return ( + !!status?.scheduled?.schedule || + JOBS.some(([key]) => { + const job = status?.[key]; + return !!(job?.running || job?.lastStarted || job?.fetchedThrough || job?.lastEventTimestamp); + }) + ); +} diff --git a/apps/admin/src/posts/debug/overview.tsx b/apps/admin/src/posts/debug/overview.tsx new file mode 100644 index 000000000000..c31edaddd111 --- /dev/null +++ b/apps/admin/src/posts/debug/overview.tsx @@ -0,0 +1,369 @@ +import type { ReactNode } from 'react'; +import type { Email } from '@tryghost/admin-x-framework/api/content-types'; +import { + type EmailDebugBatch, + type EmailRecipientFailure, + useEmailAnalyticsStatus, +} from '@tryghost/admin-x-framework/api/emails'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Indicator, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tryghost/shade/components'; +import { KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue } from '@tryghost/shade/patterns'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, cn, formatNumber, formatPercentage } from '@tryghost/shade/utils'; +import { JOBS, hasJobActivity } from './jobs'; +import { formatDuration, formatSyncTime, secondsBetween, statusLabel } from './format'; +import { groupFailures } from './group-failures'; + +export type DebugTab = 'overview' | 'failures' | 'batches' | 'analytics'; + +type Tone = 'ok' | 'off' | 'info' | 'error'; + +interface Check { + key: string; + tone: Tone; + title: ReactNode; + detail?: ReactNode; + action?: ReactNode; +} + +const ICONS: Record = { + ok: , + off: , + info: , + error: , +}; + +// KpiCardHeader draws a right border on every cell but the last; on the two-column +// layout the second cell also ends a row, and the top pair needs a divider below. +const STACKED_GRID = 'max-sm:[&>*:nth-child(-n+2)]:border-b max-sm:[&>*:nth-child(2n)]:border-r-0'; + +function plural(count: number, one: string, many = `${one}s`) { + return `${formatNumber(count)} ${count === 1 ? one : many}`; +} + +function Metric({ + label, + color, + value, + share, +}: { + label: string; + color: string; + value: number; + share?: string | null; +}) { + return ( + + + {label} + + + + ); +} + +function CheckRow({ check }: { check: Check }) { + return ( +
+ {ICONS[check.tone]} + + {check.title} + {check.detail && ( + + {check.detail} + + )} + + {check.action &&
{check.action}
} +
+ ); +} + +function Detail({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function DateTime({ value, timezone }: { value?: string | null; timezone: string }) { + const utc = formatSyncTime(value, { year: true }); + const local = formatSyncTime(value, { year: true, timezone }); + if (!utc) { + return <>N/A; + } + return ( + + {utc.date} + + {utc.time} UTC + + {local && local.offsetNanoseconds !== 0 && ( + + {local.date === utc.date ? local.time : `${local.date}, ${local.time}`} {timezone} + + )} + + ); +} + +function batchesCheck( + email: Email, + batches: EmailDebugBatch[], + postId: string, + view: ReactNode, +): Check { + const failed = batches.filter((batch) => batch.status === 'failed'); + const unsent = batches.filter( + (batch) => batch.status === 'pending' || batch.status === 'submitting', + ); + const total = plural(batches.length, 'batch', 'batches'); + + if (email.status === 'failed' || failed.length) { + return { + key: 'batches', + tone: 'error', + title: failed.length + ? `${formatNumber(failed.length)} of ${total} failed` + : 'Email failed to send', + detail: email.error || failed[0]?.error_message, + action: ( + + {view} + {email.status === 'failed' && ( + + )} + + ), + }; + } + if (email.status === 'pending' || email.status === 'submitting' || unsent.length) { + return { + key: 'batches', + tone: 'info', + title: unsent.length + ? `${formatNumber(unsent.length)} of ${total} not sent yet` + : 'Sending in progress', + action: view, + }; + } + const delay = secondsBetween(email.created_at, email.submitted_at); + return { + key: 'batches', + tone: 'ok', + title: batches.length ? `Sent in ${total}` : `Status: ${statusLabel(email.status)}`, + detail: + delay === null + ? undefined + : delay < 1 + ? 'Submitted immediately' + : `Submitted ${formatDuration(delay)} after creation`, + action: view, + }; +} + +function failuresCheck(failures: EmailRecipientFailure[], sent: number, view: ReactNode): Check { + if (!failures.length) { + return { key: 'failures', tone: 'ok', title: 'No recipient failures' }; + } + const [top] = groupFailures(failures); + const code = top.enhancedCode ? `${top.code} ${top.enhancedCode}` : top.code; + return { + key: 'failures', + tone: 'info', + title: ( + <> + {plural(failures.length, 'recipient')} failed + {sent > 0 && ( + + {' '} + ({formatPercentage(failures.length / sent)}) + + )} + + ), + detail: `Most common: ${code} ${top.message} · ${plural(top.failures.length, 'recipient')}`, + action: view, + }; +} + +function settingCheck(key: string, label: string, enabled?: boolean): Check { + return { key, tone: enabled ? 'ok' : 'off', title: `${label} ${enabled ? 'on' : 'off'}` }; +} + +function AnalyticsSummary({ emailId, onView }: { emailId: string; onView: () => void }) { + const { data, isError } = useEmailAnalyticsStatus(emailId, { + refetchInterval: 5000, + retry: false, + defaultErrorHandler: false, + }); + + return ( + + + Analytics sync + + + {hasJobActivity(data) ? ( + + + + + Job + Fetched through (UTC) + Behind + + + + {JOBS.map(([key, label]) => { + const job = data?.[key]; + const through = formatSyncTime(job?.fetchedThrough, { year: true }); + return ( + + + + {label} + {job?.running && ( + + )} + + + + {through ? ( + `${through.date}, ${through.time}` + ) : ( + N/A + )} + + + {typeof job?.lagSeconds === 'number' ? ( + formatDuration(job.lagSeconds) + ) : ( + N/A + )} + + + ); + })} + +
+
+ ) : ( + + + {isError ? 'Could not load analytics status.' : 'No analytics activity yet.'} + + + )} +
+ ); +} + +export default function Overview({ + email, + emailId, + postId, + timezone, + batches, + failures, + onOpenTab, +}: { + email: Email; + emailId: string; + postId: string; + timezone: string; + batches: EmailDebugBatch[] | null; + failures: EmailRecipientFailure[] | null; + onOpenTab: (tab: DebugTab) => void; +}) { + const sent = email.email_count ?? 0; + const failed = email.failed_count ?? 0; + const share = (count: number) => (sent > 0 ? formatPercentage(count / sent) : null); + const view = (tab: DebugTab, label: string) => ( + + ); + + const checks: Check[] = [ + batchesCheck(email, batches ?? [], postId, view('batches', 'View batches')), + ...(failures ? [failuresCheck(failures, sent, view('failures', 'View failures'))] : []), + settingCheck('opens', 'Open tracking', email.track_opens), + settingCheck('clicks', 'Click tracking', email.track_clicks), + settingCheck('feedback', 'Member feedback', email.feedback_enabled), + ]; + + const recipients = ( + + {email.recipient_filter || 'all'} + + ); + + return ( + + + + + + + + + + {statusLabel(email.status)} + {recipients} + + + + + + + + +
+ {checks.map((check) => ( + + ))} +
+ onOpenTab('analytics')} /> +
+ ); +} diff --git a/apps/admin/src/posts/debug/post-debug.acceptance.test.tsx b/apps/admin/src/posts/debug/post-debug.acceptance.test.tsx new file mode 100644 index 000000000000..a0300983d79a --- /dev/null +++ b/apps/admin/src/posts/debug/post-debug.acceptance.test.tsx @@ -0,0 +1,401 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { + currentRoute, + currentUserResponse, + fakeAdminEndpoint, + fakePosts, + post, + renderAdminApp, + staffRole, +} from '@test-utils/acceptance'; + +const POST_ID = '609a7f43e0c07e0022c5d78b'; +const EMAIL_ID = '609a7f43e0c07e0022c5d78c'; +const route = `/posts/analytics/${POST_ID}/debug`; +const email = { + id: EMAIL_ID, + status: 'failed' as const, + error: 'Provider rejected the email', + email_count: 1234, + opened_count: 42, + delivered_count: 1200, + failed_count: 34, + created_at: '2026-09-01T10:00:00.000Z', + submitted_at: '2026-09-01T10:01:00.000Z', + recipient_filter: 'status:paid', + track_opens: true, + track_clicks: false, + feedback_enabled: true, +}; +const job = { + running: true, + lastStarted: '2026-09-10T10:00:00.000Z', + lastBegin: '2026-09-10T09:00:00.000Z', + lastEventTimestamp: '2026-09-10T09:30:00.123Z', + fetchedThrough: '2026-09-10T09:59:00.456Z', + lagSeconds: 90061, +}; + +function seed() { + const posts = fakePosts([ + post({ id: POST_ID, title: 'Newsletter diagnostics', status: 'published', email }), + ]); + const emails = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/`, { emails: [email] }); + const batches = fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/batches/`), { + batches: [ + { + id: 'batch-1', + status: 'failed', + created_at: email.created_at, + member_segment: 'status:paid', + count: { recipients: 1234 }, + mailgun_message_id: 'provider-id', + error_status_code: 500, + error_message: 'Provider unavailable', + }, + { id: 'batch-2', status: 'submitted', count: { recipients: 12 } }, + ], + }); + const failures = fakeAdminEndpoint( + 'GET', + new RegExp(`^/emails/${EMAIL_ID}/recipient-failures/`), + { + failures: [ + { + id: 'failure-1', + severity: 'permanent', + code: 550, + enhanced_code: '5.1.1', + message: 'Mailbox does not exist', + email_recipient: { member_name: 'Ada', member_email: 'ada@example.com' }, + member: { id: 'member-1', name: 'Ada' }, + }, + { + id: 'failure-2', + severity: 'temporary', + code: 451, + message: 'Try again later', + email_recipient: { member_name: 'Deleted member', member_email: 'deleted@example.com' }, + member: null, + }, + ], + }, + ); + const analytics = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/analytics/`, { + latest: job, + latestOpened: {}, + missing: {}, + scheduled: {}, + }); + return { posts, emails, batches, failures, analytics }; +} + +const openTab = (name: RegExp) => page.getByRole('tab', { name }).click(); + +describe('Post debug', () => { + it('lands on an overview of delivery, sending and tracking', async () => { + const api = seed(); + await renderAdminApp(route); + await expect + .element(page.getByRole('heading', { name: 'Newsletter diagnostics' })) + .toBeVisible(); + await expect.element(page.getByText('1,234', { exact: true })).toBeVisible(); + await expect.element(page.getByText('97%', { exact: true })).toBeVisible(); + await expect.element(page.getByText('1 of 2 batches failed')).toBeVisible(); + await expect.element(page.getByText('Provider rejected the email')).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Retry' })) + .toHaveAttribute('href', `#/editor/post/${POST_ID}`); + await expect.element(page.getByText('2 recipients failed')).toBeVisible(); + await expect + .element(page.getByText('Most common: 550 5.1.1 Mailbox does not exist · 1 recipient')) + .toBeVisible(); + await expect.element(page.getByText('Open tracking on')).toBeVisible(); + await expect.element(page.getByText('Click tracking off')).toBeVisible(); + await expect.element(page.getByText('Member feedback on')).toBeVisible(); + await expect.element(page.getByText('status:paid', { exact: true })).toBeVisible(); + await expect.element(page.getByRole('tab', { name: /^Batches\s*1 failed$/ })).toBeVisible(); + await expect(api.posts).toHaveSentFilter(`id:${POST_ID}`); + }); + + it('summarises a healthy send and pending batches', async () => { + seed(); + fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/recipient-failures/`), { + failures: [], + }); + const batches = fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/batches/`), { + batches: [{ id: 'batch-1', status: 'submitted', count: { recipients: 1234 } }], + }); + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/`, { + emails: [{ ...email, status: 'submitted', error: null, submitted_at: email.created_at }], + }); + await renderAdminApp(route); + await expect.element(page.getByText('Sent in 1 batch')).toBeVisible(); + await expect.element(page.getByText('Submitted immediately')).toBeVisible(); + await expect.element(page.getByText('No recipient failures')).toBeVisible(); + expect(batches.requests.length).toBeGreaterThan(0); + }); + + it('flags batches that have not been sent yet', async () => { + seed(); + fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/batches/`), { + batches: [ + { id: 'batch-1', status: 'submitted', count: { recipients: 1000 } }, + { id: 'batch-2', status: 'pending', count: { recipients: 234 } }, + ], + }); + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/`, { + emails: [{ ...email, status: 'submitting', error: null }], + }); + await renderAdminApp(route); + await expect.element(page.getByText('1 of 2 batches not sent yet')).toBeVisible(); + }); + + it('groups failures by provider response on the failures tab', async () => { + const api = seed(); + await renderAdminApp(route); + await page.getByRole('button', { name: 'View failures' }).click(); + await expect + .element(page.getByRole('link', { name: /Ada ada@example.com/ })) + .toHaveAttribute('href', '#/members/member-1'); + await expect + .element( + page.getByRole('button', { + name: /550 · 5.1.1\s*Mailbox does not exist\s*1 recipient · permanent/, + }), + ) + .toHaveAttribute('aria-expanded', 'true'); + await expect + .element( + page.getByRole('button', { name: /451\s*Try again later\s*1 recipient · temporary/ }), + ) + .toHaveAttribute('aria-expanded', 'false'); + await openTab(/^Temporary\s*1$/); + await expect.element(page.getByText('deleted@example.com')).toBeVisible(); + await expect + .element(page.getByRole('link', { name: /Deleted member/ })) + .not.toBeInTheDocument(); + expect(new URL(api.failures.lastRequest!.url).searchParams.get('include')).toBe( + 'member,email_recipient', + ); + }); + + it('pages long failure groups and shows messages that differ from the group', async () => { + seed(); + fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/recipient-failures/`), { + failures: Array.from({ length: 25 }, (_, index) => ({ + id: `failure-${index}`, + severity: 'permanent', + code: 550, + enhanced_code: '5.1.1', + message: index === 0 ? 'Mailbox full' : 'Mailbox does not exist', + email_recipient: { member_name: `Reader ${index}`, member_email: `r${index}@example.com` }, + member: null, + })), + }); + await renderAdminApp(route); + await openTab(/^Failures/); + await expect + .element( + page.getByRole('button', { name: /550 · 5.1.1\s*Mailbox does not exist\s*25 recipients/ }), + ) + .toHaveAttribute('aria-expanded', 'true'); + await expect.element(page.getByText('r19@example.com')).toBeVisible(); + await expect.element(page.getByText('r20@example.com')).not.toBeInTheDocument(); + await expect.element(page.getByText('Mailbox full', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Show 5 more' }).click(); + await expect.element(page.getByText('r24@example.com')).toBeVisible(); + }); + + it('lists batch details on the batches tab', async () => { + const api = seed(); + await renderAdminApp(route); + await page.getByRole('button', { name: 'View batches' }).click(); + await expect.element(page.getByText('1 of 2 batches failed.')).toBeVisible(); + await expect.element(page.getByText('Provider id: provider-id')).toBeVisible(); + await page.getByRole('button', { name: 'Show full error' }).click(); + await expect + .element(page.getByRole('button', { name: 'Show less' })) + .toHaveAttribute('aria-expanded', 'true'); + expect(new URL(api.batches.lastRequest!.url).searchParams.get('include')).toBe( + 'count.recipients', + ); + expect(new URL(api.batches.lastRequest!.url).searchParams.get('order')).toBe( + 'status asc, created_at desc', + ); + }); + + it('summarises analytics jobs on the overview', async () => { + seed(); + await renderAdminApp(route); + await expect + .element(page.getByRole('row').filter({ hasText: 'Delivery and failures' })) + .toHaveTextContent('Delivery and failures10 Sep 2026, 09:59:001d 1h 1m 1s'); + await expect + .element(page.getByRole('row').filter({ hasText: 'Opens' })) + .toHaveTextContent('OpensN/AN/A'); + await page.getByRole('button', { name: 'View details' }).click(); + await expect + .element(page.getByRole('button', { name: 'Refetch analytics', exact: true })) + .toBeVisible(); + }); + + it('renders UTC diagnostics and schedules default and custom refetches', async () => { + seed(); + const schedule = fakeAdminEndpoint('PUT', new RegExp(`^/emails/${EMAIL_ID}/analytics/`), {}); + await renderAdminApp(route); + await openTab(/^Analytics sync$/); + await expect.element(page.getByText('1d 1h 1m 1s')).toBeVisible(); + await expect + .element(page.getByTitle('10 Sep, 2026, 09:30:00.123 UTC')) + .toHaveTextContent('10 Sep09:30:00'); + await expect + .element( + page.getByTitle( + 'Fetching from 10 Sep, 2026, 09:00:00.000 UTC\nFetched through 10 Sep, 2026, 09:59:00.456 UTC', + ), + ) + .toHaveTextContent('10 Sep09:59:00'); + await page.getByRole('button', { name: 'Refetch analytics', exact: true }).click(); + await expect.poll(() => schedule.requests.length).toBe(1); + expect(new URL(schedule.lastRequest!.url).search).toBe(''); + await page.getByRole('button', { name: 'Custom date range' }).click(); + await expect.element(page.getByLabelText('Begin (UTC)')).toHaveValue('2026-09-01T10:00'); + await page.getByLabelText('Begin (UTC)').fill('2026-09-02T12:30'); + await page.getByLabelText('End (UTC)').fill('2026-09-03T13:45'); + await page.getByRole('button', { name: 'Schedule refetch' }).click(); + await expect.poll(() => schedule.requests.length).toBe(2); + const params = new URL(schedule.lastRequest!.url).searchParams; + expect(params.get('begin')).toBe('2026-09-02T12:30:00.000Z'); + expect(params.get('end')).toBe('2026-09-03T13:45:00.000Z'); + await expect.element(page.getByLabelText('Begin (UTC)')).not.toBeInTheDocument(); + }); + + it('cancels the global scheduled refetch and refreshes its status', async () => { + seed(); + let canceled = false; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/analytics/`, () => ({ + scheduled: { + running: true, + canceled, + schedule: { begin: email.created_at, end: email.submitted_at }, + }, + })); + const cancel = fakeAdminEndpoint('DELETE', '/emails/analytics/', () => { + canceled = true; + return {}; + }); + await renderAdminApp(route); + await openTab(/^Analytics sync$/); + await page.getByRole('button', { name: 'Cancel scheduled refetch' }).click(); + await expect.poll(() => cancel.requests.length).toBe(1); + await expect + .element(page.getByRole('button', { name: 'Cancel scheduled refetch' })) + .not.toBeInTheDocument(); + }); + + it('keeps custom dates and shows a scheduling failure', async () => { + seed(); + fakeAdminEndpoint( + 'PUT', + new RegExp(`^/emails/${EMAIL_ID}/analytics/`), + { errors: [{ message: 'Refetch unavailable' }] }, + { status: 500 }, + ); + await renderAdminApp(route); + await openTab(/^Analytics sync$/); + await page.getByRole('button', { name: 'Custom date range' }).click(); + await page.getByRole('button', { name: 'Schedule refetch' }).click(); + await expect.element(page.getByText('Refetch unavailable')).toBeVisible(); + await expect.element(page.getByLabelText('Begin (UTC)')).toBeVisible(); + }); + + it('supports older analytics responses without ingestion lag fields', async () => { + seed(); + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/analytics/`, { + latest: { running: false, lastStarted: job.lastStarted }, + missing: {}, + latestOpened: {}, + scheduled: {}, + }); + await renderAdminApp(route); + await openTab(/^Analytics sync$/); + await expect + .element(page.getByRole('row').filter({ hasText: 'Delivery and failures' })) + .toHaveTextContent(/IdleStarted 10 Sep, 10:00:00N\/AN\/AN\/A$/); + await expect + .element(page.getByRole('button', { name: 'Refetch analytics', exact: true })) + .toBeVisible(); + }); + + it('keeps the refetch controls when analytics status cannot be loaded', async () => { + seed(); + fakeAdminEndpoint( + 'GET', + `/emails/${EMAIL_ID}/analytics/`, + { errors: [{ message: 'Analytics unavailable' }] }, + { status: 500 }, + ); + const schedule = fakeAdminEndpoint('PUT', new RegExp(`^/emails/${EMAIL_ID}/analytics/`), {}); + await renderAdminApp(route); + await openTab(/^Analytics sync$/); + await expect + .element(page.getByText('Could not load analytics status.', { exact: false })) + .toBeVisible(); + await page.getByRole('button', { name: 'Refetch analytics', exact: true }).click(); + await expect.poll(() => schedule.requests.length).toBe(1); + await expect.element(page.getByRole('button', { name: 'Custom date range' })).toBeVisible(); + }); + + it('refreshes analytics and email status and stops polling on unmount', async () => { + const api = seed(); + const app = await renderAdminApp(route); + await expect.poll(() => api.emails.requests.length).toBeGreaterThan(0); + const analyticsRequests = api.analytics.requests.length; + const refreshedEmail = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/`, { + emails: [{ ...email, status: 'submitted', error: null }], + }); + await expect + .poll(() => api.analytics.requests.length, { timeout: 7000 }) + .toBeGreaterThan(analyticsRequests); + await expect + .poll(() => page.getByRole('link', { name: 'Retry' }).query(), { timeout: 12000 }) + .toBeNull(); + expect(refreshedEmail.requests.length).toBeGreaterThan(0); + await app.unmount(); + const stopped = api.analytics.requests.length; + await new Promise((resolve) => { + setTimeout(resolve, 5500); + }); + expect(api.analytics.requests.length).toBe(stopped); + }, 25000); + + it('does not request email diagnostics for a post without an email', async () => { + fakePosts([post({ id: POST_ID, title: 'Web only', email: null })]); + await renderAdminApp(route); + await expect.element(page.getByText('No email data for this post.')).toBeVisible(); + }); + + it.each(['Author', 'Contributor'] as const)( + 'redirects a %s who does not own the post', + async (role) => { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: role })]; + fakePosts([post({ id: POST_ID, authors: [{ id: 'someone-else' }], email })]); + await renderAdminApp(route, { boot: { browseMe: { response: me } } }); + await expect.poll(currentRoute).toBe('/posts'); + // Any diagnostic request would be unhandled and fail this test. + }, + ); + + it('redirects a contributor from their published post', async () => { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: 'Contributor' })]; + fakePosts([ + post({ id: POST_ID, status: 'published', authors: [{ id: me.users[0].id }], email }), + ]); + await renderAdminApp(route, { boot: { browseMe: { response: me } } }); + await expect.poll(currentRoute).toBe('/posts'); + }); +}); diff --git a/apps/admin/src/posts/debug/post-debug.tsx b/apps/admin/src/posts/debug/post-debug.tsx new file mode 100644 index 000000000000..300b64ec4c63 --- /dev/null +++ b/apps/admin/src/posts/debug/post-debug.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { Navigate, useParams } from '@tryghost/admin-x-framework'; +import { useBrowsePosts } from '@tryghost/admin-x-framework/api/posts'; +import type { Post } from '@tryghost/admin-x-framework/api/content-types'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { isAuthorOrContributor, isContributorUser } from '@tryghost/admin-x-framework/api/users'; +import { useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; +import { + useEmail, + useEmailDebugBatches, + useEmailRecipientFailures, +} from '@tryghost/admin-x-framework/api/emails'; +import { + LoadingIndicator, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + TabsTriggerCount, +} from '@tryghost/shade/components'; +import { PageHeader } from '@tryghost/shade/patterns'; +import { Stack } from '@tryghost/shade/primitives'; +import { cn, formatNumber } from '@tryghost/shade/utils'; +import { NotFound } from '@/shared/not-found'; +import AnalyticsSync from './analytics-sync'; +import Batches from './batches'; +import Failures from './failures'; +import { formatPublishedDate } from './format'; +import Overview, { type DebugTab } from './overview'; + +const PLAIN_COUNT = 'bg-transparent px-0'; + +function DebugContent({ post }: { post: Post }) { + const [tab, setTab] = useState('overview'); + const emailId = post.email?.id ?? ''; + const emailQuery = useEmail(emailId, { + enabled: !!emailId, + refetchInterval: 10000, + retry: false, + defaultErrorHandler: false, + }); + const batchesQuery = useEmailDebugBatches(emailId, { + enabled: !!emailId, + defaultErrorHandler: false, + }); + const failuresQuery = useEmailRecipientFailures(emailId, { + enabled: !!emailId, + defaultErrorHandler: false, + }); + const { data: settings } = useBrowseSettings(); + const email = emailQuery.data?.emails[0] ?? post.email; + const batches = batchesQuery.data?.batches ?? []; + const failures = failuresQuery.data?.failures ?? []; + const failedBatches = batches.filter((batch) => batch.status === 'failed').length; + const timezone = getSiteTimezone(settings?.settings ?? []); + const publishedAt = post.published_at ? formatPublishedDate(post.published_at, timezone) : null; + const sent = + Boolean(email) && + (post.status === 'published' || post.status === 'sent') && + email?.status !== 'failed'; + const publication = sent + ? post.email_only + ? 'Sent' + : 'Published and sent' + : email?.status === 'failed' + ? 'Published but failed to send' + : 'Published on your site'; + + return ( + + + + + Posts + + Analytics + + Debug + + + {post.title || '(Untitled)'} + + {publishedAt && ( + + {publication} on {publishedAt} + + )} + + + {emailQuery.isError && ( +

Could not refresh email status. Showing the last available data.

+ )} + {!emailId || !email ? ( +

No email data for this post.

+ ) : ( + setTab(value as DebugTab)}> + + Overview + + Failures + {failuresQuery.data && ( + + {formatNumber(failures.length)} + + )} + + + Batches + {batchesQuery.data && + (failedBatches > 0 ? ( + + {formatNumber(failedBatches)} failed + + ) : ( + + {formatNumber(batches.length)} + + ))} + + Analytics sync + + + + + + + + + + + + + + + )} +
+ ); +} + +export default function PostDebug() { + const { postId } = useParams(); + const { data: user, isLoading: userLoading } = useCurrentUser(); + const { data, isLoading, isError } = useBrowsePosts({ + searchParams: { + filter: `id:${postId}`, + include: 'tags,authors,authors.roles,email,tiers,newsletter', + }, + defaultErrorHandler: false, + }); + const post = data?.posts[0]; + if (isLoading || userLoading) { + return ; + } + if (isError || !user) { + return

Could not load this post.

; + } + if (!post) { + return ; + } + if ( + (isAuthorOrContributor(user) && !post.authors?.some((author) => author.id === user.id)) || + (isContributorUser(user) && post.status !== 'draft') + ) { + return ; + } + return ; +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index d9ab93542d84..993f39e5e0cd 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -29,7 +29,11 @@ import { lazyAutomationEditorScreen, lazyAutomationsScreen } from './automations import { lazyCommentsScreen } from './comments/api'; import { membersRouteChildren } from './members/api'; import { OnboardingRedirect, lazyOnboardingScreen } from './onboarding/api'; -import { lazyPostAnalyticsRoot, postAnalyticsRouteChildren } from './posts/api'; +import { + lazyPostAnalyticsRoot, + lazyPostDebugScreen, + postAnalyticsRouteChildren, +} from './posts/api'; import { canAccessSettingsRoute, lazySettingsScreen, settingsRouteChildren } from './settings/api'; import { lazyTagDetailScreen, lazyTagsScreen } from './tags/api'; import { @@ -50,7 +54,6 @@ const EMBER_ROUTES: string[] = [ '/signup/*', '/reset/*', '/pro/*', - '/posts/analytics/:postId/debug', '/restore', '/migrate/*', ]; @@ -122,6 +125,10 @@ const appRoutes: RouteObject[] = [ requiresAccess: canManageMembers, } satisfies AccessRouteHandle & AdminRouteHandle, }, + { + path: '/posts/analytics/:postId/debug', + lazy: lazyComponent(lazyPostDebugScreen), + }, { path: '/posts/analytics/:postId', lazy: lazyPostAnalyticsRoot, diff --git a/apps/ember-admin/app/components/posts/debug.hbs b/apps/ember-admin/app/components/posts/debug.hbs deleted file mode 100644 index ff4ed8c4d74a..000000000000 --- a/apps/ember-admin/app/components/posts/debug.hbs +++ /dev/null @@ -1,491 +0,0 @@ -
- -
-
- - Posts - - {{svg-jar "arrow-right-small"}} - - Analytics - - {{svg-jar "arrow-right-small"}} - Debug -
-

- {{this.post.title}} -

-
-
- {{#if this.post.hasBeenEmailed }} - {{#if this.post.emailOnly}} - Sent - {{else}} - Published and sent - {{/if}} - {{else}} - Published - {{#if @post.didEmailFail}} - but failed to send - {{else}} - on your site - {{/if}} - {{/if}} - - {{#let (moment-site-tz this.post.publishedAtUTC) as |publishedAt|}} - on - {{moment-format publishedAt "D MMM YYYY"}} - at - {{moment-format publishedAt "HH:mm"}} - {{/let}} -
-
- {{#if (eq this.post.email.status 'failed') }} -
- {{svg-jar "warning"}} -
-

{{this.emailError.message}}

-

{{this.emailError.details}}

-
- - Retry - -
- {{/if}} -
-
- - - -

{{svg-jar "analytics-tab-sent-large"}} {{this.tabTotals.permanentFailures}}

-

Permanent {{gh-pluralize this.tabTotals.permanentFailures "failure" without-count=true}}

-
- - - - - {{#each this.permanentFailureData as |failure index|}} - -
- {{#if failure.member.id}} - - -
-

{{failure.recipient.name}}

-

{{failure.recipient.email}}

-
-
- {{else}} -
-
-
- {{failure.recipient.initials}} -
-
-
-

{{failure.recipient.name}}

-

{{failure.recipient.email}}

-
-
- {{/if}} -
-
-
-
- Failure code: {{failure.code}} - {{#if failure.enhancedCode}} - Enhanced code: {{failure.enhancedCode}} - {{/if}} -
- -
-
- - {{/each}} - {{#unless this.permanentFailureData}} - - - - {{/unless}} - -
-
No permanent failures.
-
-
- - -

{{svg-jar "analytics-tab-opened-large"}}{{this.tabTotals.temporaryFailures}}

-

Temporary {{gh-pluralize this.tabTotals.temporaryFailures "failure" without-count=true}}

-
- - - - - {{#each this.temporaryFailureData as |failure index|}} - -
- {{#if failure.member.id}} - - -
-

{{failure.recipient.name}}

-

{{failure.recipient.email}}

-
-
- {{else}} -
-
-
- {{failure.recipient.initials}} -
-
-
-

{{failure.recipient.name}}

-

{{failure.recipient.email}}

-
-
- {{/if}} -
-
-
-
-
- Failure code: {{failure.code}} - {{#if failure.enhancedCode}} - Enhanced code: {{failure.enhancedCode}} - {{/if}} -
- -
-
-
- - {{/each}} - {{#unless this.temporaryFailureData}} - - - - {{/unless}} - -
-
No temporary failures.
-
-
- - -

{{svg-jar "analytics-tab-clicked-large"}}{{this.tabTotals.erroredBatches}}

-

{{gh-pluralize this.tabTotals.erroredBatches "batch" without-count=true}} errored

-
- - - - - - - - - - - - - - {{#each this.emailBatchesData as |batch|}} - -
- {{batch.status}} -
-
- {{batch.createdAt}} -
-
- {{#if batch.segment}} - {{batch.segment}} - {{else}} -
N/A
- {{/if}} -
-
- {{batch.recipientCount}} -
-
-
- {{#if (or batch.mailgunMessageId batch.errorStatusCode batch.errorMessage)}} -
- {{#if batch.mailgunMessageId}} -
- Provider id: - - {{batch.mailgunMessageId}} - -
- {{/if}} - {{#if batch.errorStatusCode}} -
- Failure status code: {{batch.errorStatusCode}} -
- {{/if}} - {{#if batch.errorMessage}} - {{batch.errorMessage}} - {{/if}} -
- {{!-- {{#if batch.errorMessage}} -
{{svg-jar "download" title="Download full failure message"}}
- {{/if}} --}} - {{else}} -
N/A
- {{/if}} -
-
- - {{/each}} - {{#unless this.emailBatchesData}} - - - - {{/unless}} - -
StatusCreatedSegmentRecipientsDetails
-
No batch data.
-
-
- - -
{{svg-jar "info"}}
-

Overview

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{#if (and this.analyticsStatus this.analyticsStatus.scheduled this.analyticsStatus.scheduled.schedule) }} - - - - - - - - - - - - - - - - - {{#unless this.analyticsStatus.scheduled.canceled}} - - - - {{/unless}} - {{else}} - {{#if this.showCustomSchedule}} - - - - - - - - - - - - {{else}} - - - - {{/if}} - {{/if}} - -
Status:{{this.emailSettings.status}}
Recipient filter:{{this.emailSettings.recipientFilter}}
Created at:{{this.emailSettings.createdAt}}
Submitted at:{{this.emailSettings.submittedAt}}

Emails sent:{{ format-number this.emailSettings.emailsSent}}
Delivered:{{ format-number this.emailSettings.emailsDelivered}}
Opened:{{ format-number this.emailSettings.emailsOpened}}
Failed:{{ format-number this.emailSettings.emailsFailed}}

Track opens: - {{#if this.emailSettings.trackOpens}} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Track clicks: - {{#if this.emailSettings.trackClicks}} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Member feedback: - {{#if this.emailSettings.feedbackEnabled}} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -

Analytics Delivery/failures running: - {{#if (and this.analyticsStatus this.analyticsStatus.latest this.analyticsStatus.latest.running) }} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Last started:{{ this.analyticsStatus.latest.lastStarted }}
Fetching from:{{ this.analyticsStatus.latest.lastBegin }}
Last event time:{{ this.analyticsStatus.latest.lastEventTimestamp }}
Fetched through:{{ this.analyticsStatus.latest.fetchedThrough }}
Ingestion lag:{{ this.analyticsStatus.latest.lag }}

Analytics Opens running: - {{#if (and this.analyticsStatus this.analyticsStatus.latestOpened this.analyticsStatus.latestOpened.running) }} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Last started:{{ this.analyticsStatus.latestOpened.lastStarted }}
Fetching from:{{ this.analyticsStatus.latestOpened.lastBegin }}
Last event time:{{ this.analyticsStatus.latestOpened.lastEventTimestamp }}
Fetched through:{{ this.analyticsStatus.latestOpened.fetchedThrough }}
Ingestion lag:{{ this.analyticsStatus.latestOpened.lag }}

Analytics Missing running: - {{#if (and this.analyticsStatus this.analyticsStatus.missing this.analyticsStatus.missing.running) }} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Last started:{{ this.analyticsStatus.missing.lastStarted }}
Fetching from:{{ this.analyticsStatus.missing.lastBegin }}
Last event time:{{ this.analyticsStatus.missing.lastEventTimestamp }}
Fetched through:{{ this.analyticsStatus.missing.fetchedThrough }}
Ingestion lag:{{ this.analyticsStatus.missing.lag }}

Analytics Scheduled running: - {{#if this.analyticsStatus.scheduled.running }} - {{svg-jar "check-2"}} - {{else}} - {{svg-jar "close"}} - {{/if}} -
Schedule:{{ this.analyticsStatus.scheduled.schedule.begin }} - {{ this.analyticsStatus.scheduled.schedule.end }}
Last started:{{ this.analyticsStatus.scheduled.lastStarted }}
Last event time:{{ this.analyticsStatus.scheduled.lastEventTimestamp }}
- -
- - -
- - -
- -
- -
-
diff --git a/apps/ember-admin/app/components/posts/debug.js b/apps/ember-admin/app/components/posts/debug.js deleted file mode 100644 index 7ea9e07f1c3f..000000000000 --- a/apps/ember-admin/app/components/posts/debug.js +++ /dev/null @@ -1,389 +0,0 @@ -import Component from '@glimmer/component'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {didCancel, task, timeout} from 'ember-concurrency'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; -import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; -export default class Debug extends Component { - @service ajax; - @service ghostPaths; - @service settings; - @service membersUtils; - @service utils; - @service feature; - @service store; - - @tracked emailBatches = null; - @tracked recipientFailures = null; - @tracked loading = true; - @tracked analyticsStatus = null; - @tracked latestEmail = null; - @tracked showCustomSchedule = false; - @tracked customBeginDate = null; - @tracked customEndDate = null; - - get post() { - return this.args.post; - } - - get email() { - return this.latestEmail ?? this.post.email; - } - - async updateEmail() { - try { - this.latestEmail = await this.store.findRecord('email', this.post.email.id, {reload: true}); - } catch (e) { - // Skip - } - } - - get emailError() { - // get failed batches count - const failedBatches = this.emailBatchesData?.filter((batch) => { - return batch.statusClass === 'failed'; - }).length || 0; - // get total batch count - const totalBatches = this.emailBatchesData?.length || 0; - - const details = (this.loading || !totalBatches) ? '' : `${failedBatches} of ${ghPluralize(totalBatches, 'batch')} failed to send, check below for more details.`; - return { - message: this.post.email?.error || 'Failed to send email.', - details - }; - } - - get emailSettings() { - return { - statusClass: this.email?.status, - status: this.getStatusLabel(this.email?.status), - recipientFilter: this.email?.recipientFilter, - createdAt: this.email?.createdAtUTC ? moment.utc(this.email.createdAtUTC).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', - submittedAt: this.email?.submittedAtUTC ? moment.utc(this.email.submittedAtUTC).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', - emailsSent: this.email?.emailCount, - emailsDelivered: this.email?.deliveredCount, - emailsOpened: this.email?.openedCount, - emailsFailed: this.email?.failedCount, - trackOpens: this.email?.trackOpens, - trackClicks: this.email?.trackClicks, - feedbackEnabled: this.email?.feedbackEnabled - }; - } - - get tabTotals() { - return { - temporaryFailures: formatNumber(this.temporaryFailureData?.length || 0), - permanentFailures: formatNumber(this.permanentFailureData?.length || 0), - erroredBatches: formatNumber(this.emailBatchesData?.filter((batch) => { - return batch.statusClass === 'failed'; - }).length || 0) - }; - } - - get emailBatchesData() { - return this.emailBatches?.map((batch) => { - return { - id: batch.id, - status: this.getStatusLabel(batch.status), - statusClass: batch.status, - createdAt: batch.created_at ? moment.utc(batch.created_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', - segment: batch.member_segment || '', - mailgunMessageId: batch.mailgun_message_id || null, - errorMessage: batch.error_message || '', - errorStatusCode: batch.error_status_code || '', - recipientCount: batch.count?.recipients || 0 - }; - }); - } - - get temporaryFailureData() { - return this.recipientFailures?.filter((failure) => { - return failure.severity === 'temporary'; - }).map((failure) => { - return { - id: failure.id, - code: failure.code, - failedAt: failure.failed_at ? moment.utc(failure.failed_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', - processedAt: failure.email_recipient.processed_at ? moment.utc(failure.email_recipient.processed_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', - batchId: failure.email_recipient.batch_id, - enhancedCode: failure.enhanced_code, - message: failure.message, - recipient: { - name: failure.email_recipient.member_name || '', - email: failure.email_recipient.member_email || '', - initials: this.getInitials(failure.email_recipient?.member_name || failure.email_recipient?.member_email) - }, - member: { - record: failure.member, - id: failure.member?.id, - name: failure.member?.name || '', - email: failure.member?.email || '', - initials: this.getInitials(failure.member?.name) - } - }; - }); - } - - get permanentFailureData() { - return this.recipientFailures?.filter((failure) => { - return failure.severity === 'permanent'; - }).map((failure) => { - return { - id: failure.id, - code: failure.code, - enhancedCode: failure.enhanced_code, - message: failure.message, - recipient: { - name: failure.email_recipient.member_name || '', - email: failure.email_recipient.member_email || '', - initials: this.getInitials(failure.email_recipient?.member_name || failure.email_recipient?.member_email) - }, - member: { - record: failure.member, - id: failure.member?.id, - name: failure.member?.name || '', - email: failure.member?.email || '', - initials: this.getInitials(failure.member?.name) - } - }; - }); - } - - getInitials(name) { - if (!name) { - return 'U'; - } - const names = name.split(' '); - const intials = names.length > 1 ? [names[0][0], names[names.length - 1][0]] : [names[0][0]]; - return intials.join('').toUpperCase(); - } - - getStatusLabel(status) { - if (status === 'submitted') { - return 'Submitted'; - } else if (status === 'submitting') { - return 'Submitting'; - } else if (status === 'pending') { - return 'Pending'; - } else if (status === 'failed') { - return 'Failed'; - } - return status; - } - - @action - loadData() { - if (this.post.email) { - this.fetchEmailBatches(); - this.fetchRecipientFailures(); - this.pollAnalyticsStatus.perform(); - this.pollEmail.perform(); - } - } - - async fetchEmailBatches() { - try { - if (this._fetchEmailBatches.isRunning) { - return this._fetchEmailBatches.last; - } - return this._fetchEmailBatches.perform(); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @task - *_fetchEmailBatches() { - const data = { - include: 'count.recipients', - limit: 'all', - order: 'status asc, created_at desc' - }; - - const statsUrl = this.ghostPaths.url.api(`emails/${this.post.email.id}/batches`); - const result = yield this.ajax.request(statsUrl, {data}); - this.emailBatches = result.batches; - this.loading = false; - } - - async fetchRecipientFailures() { - try { - if (this._fetchRecipientFailures.isRunning) { - return this._fetchRecipientFailures.last; - } - return this._fetchRecipientFailures.perform(); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @task - *pollAnalyticsStatus() { - while (true) { - yield this.fetchAnalyticsStatus(); - yield timeout(5 * 1000); - } - } - - @task - *pollEmail() { - while (true) { - yield timeout(10 * 1000); - yield this.updateEmail(); - } - } - - async fetchAnalyticsStatus() { - try { - if (this._fetchAnalyticsStatus.isRunning) { - return await this._fetchAnalyticsStatus.last; - } - return await this._fetchAnalyticsStatus.perform(); - } catch (e) { - // Skip - } - } - - @task - *_fetchRecipientFailures() { - const data = { - include: 'member,email_recipient', - limit: 'all' - }; - const statsUrl = this.ghostPaths.url.api(`/emails/${this.post.email.id}/recipient-failures`); - const result = yield this.ajax.request(statsUrl, {data}); - this.recipientFailures = result.failures; - } - - @task - *_fetchAnalyticsStatus() { - const statsUrl = this.ghostPaths.url.api(`/emails/${this.post.email.id}/analytics`); - const result = yield this.ajax.request(statsUrl); - this.analyticsStatus = result; - - // Parse dates - for (const type of ['latest', 'latestOpened', 'missing', 'scheduled']) { - if (!result[type]) { - result[type] = {}; - } - let object = result[type]; - for (const key of ['lastStarted', 'lastBegin', 'lastEventTimestamp', 'fetchedThrough']) { - if (object[key]) { - object[key] = moment.utc(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS [UTC]'); - } else { - object[key] = 'N/A'; - } - } - - object.lag = this.formatIngestionLag(object.lagSeconds); - - if (object.schedule) { - object = object.schedule; - for (const key of ['begin', 'end']) { - if (object[key]) { - object[key] = moment.utc(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS [UTC]'); - } else { - object[key] = 'N/A'; - } - } - } - } - } - - formatIngestionLag(seconds) { - if (!Number.isFinite(seconds) || seconds < 0) { - return 'N/A'; - } - const duration = moment.duration(seconds, 'seconds'); - return [ - [Math.floor(duration.asDays()), 'd'], - [duration.hours(), 'h'], - [duration.minutes(), 'm'], - [duration.seconds(), 's'] - ].filter(([value]) => value > 0) - .map(([value, unit]) => `${value}${unit}`).join(' ') || '0s'; - } - - @action - toggleCustomSchedule() { - this.showCustomSchedule = !this.showCustomSchedule; - if (this.showCustomSchedule) { - this.customBeginDate = moment.utc(this.email?.createdAtUTC).format('YYYY-MM-DDTHH:mm'); - const createdAt = moment.utc(this.email?.createdAtUTC); - const maxEnd = moment.min(moment.utc().subtract(1, 'hour'), createdAt.clone().add(7, 'days')); - this.customEndDate = maxEnd.format('YYYY-MM-DDTHH:mm'); - } else { - this.customBeginDate = null; - this.customEndDate = null; - } - } - - @action - updateCustomBeginDate(event) { - this.customBeginDate = event.target.value; - } - - @action - updateCustomEndDate(event) { - this.customEndDate = event.target.value; - } - - @action - scheduleAnalytics() { - try { - if (this._scheduleAnalytics.isRunning) { - return this._scheduleAnalytics.last; - } - return this._scheduleAnalytics.perform(); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @task - *_scheduleAnalytics() { - const url = new URL(this.ghostPaths.url.api(`/emails/${this.post.email.id}/analytics`), window.location.origin); - if (this.customBeginDate) { - url.searchParams.set('begin', moment.utc(this.customBeginDate).toISOString()); - } - if (this.customEndDate) { - url.searchParams.set('end', moment.utc(this.customEndDate).toISOString()); - } - yield this.ajax.put(url.pathname + url.search, {}); - yield this.fetchAnalyticsStatus(); - this.showCustomSchedule = false; - } - - @action - cancelScheduleAnalytics() { - try { - if (this._cancelScheduleAnalytics.isRunning) { - return this._cancelScheduleAnalytics.last; - } - return this._cancelScheduleAnalytics.perform(); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @task - *_cancelScheduleAnalytics() { - const statsUrl = this.ghostPaths.url.api(`/emails/analytics`); - yield this.ajax.delete(statsUrl, {}); - yield this.fetchAnalyticsStatus(); - } -} diff --git a/apps/ember-admin/app/components/posts/debug/email-error-message.hbs b/apps/ember-admin/app/components/posts/debug/email-error-message.hbs deleted file mode 100644 index fb6f6a4398e0..000000000000 --- a/apps/ember-admin/app/components/posts/debug/email-error-message.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
- - - {{@message}} -
diff --git a/apps/ember-admin/app/components/posts/debug/email-error-message.js b/apps/ember-admin/app/components/posts/debug/email-error-message.js deleted file mode 100644 index 1a2f0e210a2d..000000000000 --- a/apps/ember-admin/app/components/posts/debug/email-error-message.js +++ /dev/null @@ -1,15 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {tracked} from '@glimmer/tracking'; - -export default class EmailErrorMessage extends Component { - @tracked more = false; - - @action - setup(element) { - const errorContainer = element.querySelector('span'); - if (element.offsetWidth <= errorContainer.offsetWidth) { - this.more = true; - } - } -} diff --git a/apps/ember-admin/app/controllers/posts/debug.js b/apps/ember-admin/app/controllers/posts/debug.js deleted file mode 100644 index d41159a432be..000000000000 --- a/apps/ember-admin/app/controllers/posts/debug.js +++ /dev/null @@ -1,7 +0,0 @@ -import Controller from '@ember/controller'; - -export default class DebugController extends Controller { - get post() { - return this.model; - } -} diff --git a/apps/ember-admin/app/router.js b/apps/ember-admin/app/router.js index d37441e3db98..3a91492db3c2 100644 --- a/apps/ember-admin/app/router.js +++ b/apps/ember-admin/app/router.js @@ -24,7 +24,6 @@ Router.map(function () { }); this.route('posts'); - this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'}); this.route('restore-posts', {path: '/restore'}); this.route('pages'); diff --git a/apps/ember-admin/app/routes/posts/debug.js b/apps/ember-admin/app/routes/posts/debug.js deleted file mode 100644 index 305e87e0985e..000000000000 --- a/apps/ember-admin/app/routes/posts/debug.js +++ /dev/null @@ -1,50 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {pluralize} from 'ember-inflector'; - -export default class Debug extends AuthenticatedRoute { - model(params) { - const {post_id: id} = params; - - const query = { - id, - include: [ - 'tags', - 'authors', - 'authors.roles', - 'email', - 'tiers', - 'newsletter' - ].join(',') - }; - - return this.store.query('post', query) - .then((records) => { - return records.get('firstObject'); - }); - } - - // the API will return a post even if the logged in user doesn't have - // permission to edit it (all posts are public) so we need to do our - // own permissions check and redirect if necessary - afterModel(post) { - super.afterModel(...arguments); - - const user = this.session.user; - const returnRoute = pluralize(post.constructor.modelName); - - if (user.isAuthorOrContributor && !post.isAuthoredByUser(user)) { - return this.replaceWith(returnRoute); - } - - // If the post is not a draft and user is contributor, redirect to index - if (user.isContributor && !post.isDraft) { - return this.replaceWith(returnRoute); - } - } - - serialize(model) { - return { - post_id: model.id - }; - } -} diff --git a/apps/ember-admin/app/styles/layouts/posts.css b/apps/ember-admin/app/styles/layouts/posts.css index f07c309d10c3..935bb666aaab 100644 --- a/apps/ember-admin/app/styles/layouts/posts.css +++ b/apps/ember-admin/app/styles/layouts/posts.css @@ -9,366 +9,6 @@ pointer-events: none; } -/* Debug screen -/* ---------------------------------------------------------- */ -.gh-email-debug .gh-post-analytics-header .gh-canvas-header-content { - border-bottom: none; - padding-bottom: 0; -} - -.gh-email-debug-error { - display: flex; - margin-top: 20px; - padding: 12px 16px 12px 40px; -} - -.gh-email-debug-error svg { - height: 20px !important; - margin-top: -2px; -} - -.gh-email-debug-errortext { - flex-grow: 1; - margin-left: 4px; -} - -.gh-email-debug-error h4 { - font-size: 1.5rem; - font-weight: 600; - margin: 0; - padding: 0; -} - -.gh-email-debug-error button { - min-width: 80px; -} - -.gh-email-debug-overviewtab-icon { - width: 20px; - height: 20px; - margin-bottom: 11px; - margin-top: 2px; -} - -.gh-email-debug-overviewtab-icon svg { - overflow: visible; -} - -.gh-email-debug-overviewtab-icon svg path, -.gh-email-debug-overviewtab-icon svg circle { - stroke-width: 2; -} - - -.gh-email-debug .gh-list { - border-bottom: none; -} - -.gh-email-debug-permanent-failures { - table-layout: fixed; -} - -.gh-email-debug .gh-list thead, -.gh-email-debug .gh-list tbody { - width: 100%; -} - -.gh-email-debug .gh-list tr:first-of-type .gh-list-data { - border-top: none; -} - -.gh-email-debug .gh-list tr .gh-list-data:first-of-type { - padding-left: 0; -} - -.gh-email-debug-col-member { - padding-left: 0; - width: 25%; -} - -.gh-email-debug-member { - display: flex; - flex-wrap: nowrap; - align-items: center; - min-width: 0; -} - -.gh-email-debug-member > div { - min-width: 0; -} - -.gh-email-debug-member .gh-members-list-name, -.gh-email-debug-member .gh-members-list-email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.gh-email-debug-failure { - display: flex; -} - -.gh-email-debug-failure svg { - height: 16px; - width: 16px; - margin-right: 4px; -} - -.gh-email-debug-failure-details { - display: flex; - flex-direction: column; - gap: 2px; - width: 100%; -} - -.gh-email-debug-failure-codes { - display: flex; - gap: 20px; -} - -.gh-email-debug-failure-code { - color: var(--midgrey); -} - -.gh-email-debug-failure-code span { - color: var(--darkgrey); - font-weight: 500; -} - -.gh-email-debug .gh-list-data { - height: 98px; -} - -.gh-email-debug-permanent-failures .gh-list-data, -.gh-email-debug-temporary-failures .gh-list-data { - height: 80px; -} - -.gh-email-debug-batch-col-status span { - display: inline-block; - position: relative; - padding-left: 16px; - color: var(--midlightgrey); -} - -.gh-email-debug-batch-col-status span::before { - display: block; - position: absolute; - content: ""; - top: 6px; - left: 0; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--midlightgrey); -} - -.gh-email-debug-batch-col-status .failed { - color: color-mod(var(--red) l(-2%)); -} - -.gh-email-debug-batch-col-status .failed::before { - background: var(--red); -} - -.gh-email-debug-batch-col-status .submitting { - color: var(--blue); -} - -.gh-email-debug-batch-col-status .submitting::before { - background: var(--blue); -} - -.gh-email-debug-batch-col-status .submitted { - color: var(--green); -} - -.gh-email-debug-batch-col-status .submitted::before { - background: var(--green); -} - -.gh-email-debug-batch-col-created, -.gh-email-debug-batch-col-details { - color: var(--midgrey); -} - -.gh-email-debug-batch-col-created span, -.gh-email-debug-batch-col-segment span { - white-space: nowrap; -} - -.gh-email-debug-batch-col-details .detailtext div { - word-break: break-all; -} - -.gh-email-debug-batch-col-details .detailtext .noselect { - user-select: none; -} - -.gh-email-debug-batch-col-details .detailtext div code { - white-space: unset; - word-break: normal; - user-select: text; -} - -.gh-email-debug-batch-col-details .error { - color: color-mod(var(--red) l(-2%)); - font-weight: unset; -} - -.gh-email-debug-batch-col-segment span { - display: inline-block; - border-radius: 2px; - background: color-mod(var(--black) a(5%)); - padding: 1px 6px; - color: var(--middarkgrey); -} - -.gh-email-debug-batch-col-details span { - color: var(--darkgrey); - font-weight: 500; -} - -.gh-email-debug-batch-col-details .download-icon { - width: 20px; - height: 20px; - margin-left: 20px; -} - -.gh-email-debug-batch-col-details .download-icon path { - stroke: var(--midgrey); -} - -.gh-email-debug-batch-col-details .detailtext { - flex-grow: 1; -} - -.gh-email-debug-settings { - font-size: 1.3rem; - margin: 12px 0 20px; -} - -.gh-email-debug-settings .gh-type-number { - font-variant-numeric: tabular-nums; -} - -.gh-email-debug-settings hr { - margin: 8px 0; - border-top-color: var(--whitegrey); -} - -.gh-email-debug-settings tr td { - font-weight: 500; - padding: 6px 0; -} - -.gh-email-debug-settings tr td:first-of-type { - width: 30%; - white-space: nowrap; - color: var(--midgrey); -} - -.gh-email-debug-settings-icon svg { - width: 14px; - height: 14px; -} - -.gh-email-debug-settings-icon .check { - width: 18px; - height: 18px; -} - -.gh-email-debug-settings-icon .check path { - stroke: var(--green); - stroke-width: 2.5; -} - -.gh-email-debug-settings-icon .x path { - stroke: var(--midgrey); -} - -.gh-email-debug-schedule-analytics { - display: flex; - align-items: center; - width: max-content; - margin: .8rem 0 0; - color: var(--green-d1); -} - -.gh-email-debug-schedule-analytics svg { - width: 1rem; - height: 1rem; - margin-right: 6px; -} - -.gh-email-debug-schedule-analytics svg g { - stroke: var(--green-d1); - stroke-width: 3px; -} - -.gh-email-debug-empty-list { - margin: 120px 40px; - text-align: center; - font-size: 1.3rem; - color: var(--midgrey); -} - -.gh-email-debug-readmore-error { - display: inline-flex; - width: 100%; -} - -.gh-email-debug-readmore-error label { - order: 3; - cursor: pointer; -} - -.gh-email-debug-readmore-error .toggle-checkbox { - display: none; -} - -.gh-email-debug-readmore-error span { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - overflow: hidden; - color: color-mod(var(--red) l(-2%)); - word-break: break-all; -} - -.gh-email-debug-readmore-error .toggle-checkbox:checked ~ span { - display: block; - -webkit-box-orient: unset; - -webkit-line-clamp: unset; - overflow: unset; - word-break: break-word; -} - -.gh-email-debug-readmore-error .toggle-checkbox:checked ~ label { - display: none !important; -} - -.gh-email-debug-readmore-error svg { - width: 12px; - height: 12px; - margin: 0; -} - -.gh-email-debug-readmore-error svg circle { - fill: var(--midgrey); -} - -.gh-email-debug-readmore-error label { - display: flex; - align-items: center; - padding: 0 4px; - border-radius: 2px; - height: 14px; - margin-top: 3px; - margin-left: 8px; - background: var(--whitegrey); -} - @media (max-width: 1500px) { .post-header .gh-canvas-header-content { display: grid; diff --git a/apps/ember-admin/app/templates/posts/debug.hbs b/apps/ember-admin/app/templates/posts/debug.hbs deleted file mode 100644 index bcb015eb9f2c..000000000000 --- a/apps/ember-admin/app/templates/posts/debug.hbs +++ /dev/null @@ -1 +0,0 @@ - From 9f526d6d8fcaef9290749bf9dadf4477c60bbcfe Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 15:06:56 +0200 Subject: [PATCH 3/5] Added a smoke journey for the React editor's settings sidebar (#30995) no ref Added smoke tests in e2e covering the editor sidebar, which previously were not covered outside of acceptance tests. --- .../admin/posts/post/post-editor-page.ts | 13 + .../admin/posts/post/post-feature-image.ts | 14 + .../admin/posts/post/post-settings-sidebar.ts | 97 +++ e2e/helpers/pages/public/post-page.ts | 14 + .../admin/posts/editor-settings-react.test.ts | 663 ++++++++++++++++++ .../testing/test-data/src/selectors/editor.ts | 2 + 6 files changed, 803 insertions(+) create mode 100644 e2e/tests/admin/posts/editor-settings-react.test.ts diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts index 1fb2fb7d43ed..d986198fc64f 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -174,6 +174,12 @@ class PublishFlow extends BasePage { await this.closeButton.click(); } + /** The complete step offers no Close button; Escape dismisses the dialog. */ + async dismiss(): Promise { + await this.page.keyboard.press('Escape'); + await this.modal.waitFor({ state: 'hidden' }); + } + async selectPublishType(type: PublishType): Promise { await this.publishTypeButton.click(); @@ -408,6 +414,13 @@ export class PostEditorPage extends AdminPage { await this.page.keyboard.type(text); } + /** Selects the whole body and types over it. */ + async replaceBody(text: string): Promise { + await this.lexicalEditor.click(); + await this.page.keyboard.press('ControlOrMeta+a'); + await this.page.keyboard.type(text); + } + async revertToDraft(): Promise { await this.updateFlowButton.click(); await this.revertToDraftButton.click(); diff --git a/e2e/helpers/pages/admin/posts/post/post-feature-image.ts b/e2e/helpers/pages/admin/posts/post/post-feature-image.ts index cae27cb8f6c4..d1be06fa2172 100644 --- a/e2e/helpers/pages/admin/posts/post/post-feature-image.ts +++ b/e2e/helpers/pages/admin/posts/post/post-feature-image.ts @@ -8,6 +8,7 @@ import { featureImageUnsplashButton, removeFeatureImageButton, toggleFeatureImageAltButton, + unsplashInsertImageButton, unsplashSearchHeading, unsplashSearchModal, } from '@tryghost/test-data/selectors/editor'; @@ -62,6 +63,19 @@ export class FeatureImage { await this.unsplashHeading.waitFor({ state: 'visible' }); } + /** + * Inserts the open search's photo whose alt text is `alt`. The tile's hover + * overlay covers the image and its insert control is not scoped per tile, so + * this expects a gallery holding that photo alone; the search closes on insert. + */ + async insertUnsplashPhoto(alt: string): Promise { + await this.unsplashSearch + .getByRole('img', { name: alt, exact: true }) + .waitFor({ state: 'visible' }); + await this.unsplashSearch.getByText(unsplashInsertImageButton, { exact: true }).click(); + await this.unsplashHeading.waitFor({ state: 'hidden' }); + } + async remove(): Promise { await this.removeButton.click(); } diff --git a/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts index 7f73079bdd8a..40a610df2d39 100644 --- a/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts +++ b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts @@ -108,6 +108,27 @@ function wholeText(text: string): RegExp { /** Long enough for a section to render, short enough that a missing one fails before the test times out. */ const PRESENCE_TIMEOUT = 5000; +// react-day-picker's default English labels: the grid is "March 2026", a day +// "Tuesday, March 10th, 2026", and the nav buttons are named below. +const CALENDAR_MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; +const CALENDAR_PREVIOUS_MONTH = 'Go to the Previous Month'; +const CALENDAR_NEXT_MONTH = 'Go to the Next Month'; +/** Ten years either way; a target further off is a mistake, not a long walk. */ +const MAX_CALENDAR_STEPS = 120; + async function expectPresent(locator: Locator, message: string): Promise { try { await locator.waitFor({ state: 'visible', timeout: PRESENCE_TIMEOUT }); @@ -259,6 +280,57 @@ class PublishDateSection extends InlineSection { return this.dateInput; } + /** + * Chooses `YYYY-MM-DD` in the calendar popover behind the read-only date + * field, paging month by month from wherever the calendar opens. + */ + async setDate(day: string): Promise { + const [year, month, date] = day.split('-').map(Number); + const monthName = CALENDAR_MONTHS[month - 1]; + + await this.dateInput.click(); + const openInput = this.dateInput.and(this.page.locator('[aria-expanded="true"]')); + await openInput.waitFor({ state: 'visible' }); + const calendarId = await openInput.getAttribute('aria-controls'); + const calendar = this.page.locator(`[id="${calendarId}"]`); + + const targetGrid = calendar.getByRole('grid', { name: `${monthName} ${year}`, exact: true }); + for (let step = 0; step < MAX_CALENDAR_STEPS && !(await targetGrid.isVisible()); step++) { + const shown = await calendar.getByRole('grid').getAttribute('aria-label'); + const [shownMonth, shownYear] = (shown ?? '').split(' '); + const shownIndex = Number(shownYear) * 12 + CALENDAR_MONTHS.indexOf(shownMonth); + const targetIndex = year * 12 + (month - 1); + await calendar + .getByRole('button', { + name: targetIndex < shownIndex ? CALENDAR_PREVIOUS_MONTH : CALENDAR_NEXT_MONTH, + exact: true, + }) + .click(); + } + await targetGrid.waitFor({ state: 'visible' }); + + const dayName = new RegExp(`\\b${monthName} ${date}(st|nd|rd|th), ${year}\\b`); + const dayButton = targetGrid.getByRole('button', { name: dayName }); + await dayButton.waitFor({ state: 'visible' }); + if (await dayButton.isDisabled()) { + throw new Error( + `The calendar does not offer ${day}: the Publish date section stops at today in the site timezone.`, + ); + } + + // react-day-picker deselects a re-clicked day and the picker then leaves + // the popover open, so the displayed day is closed over, not clicked. + const selectedDay = targetGrid.getByRole('gridcell', { selected: true }).getByRole('button', { + name: dayName, + }); + if ((await selectedDay.count()) > 0) { + await this.page.keyboard.press('Escape'); + } else { + await dayButton.click(); + } + await calendar.waitFor({ state: 'hidden' }); + } + /** Types `HH:mm` and commits it on blur. */ async setTime(time: string): Promise { await this.timeInput.fill(time); @@ -588,6 +660,31 @@ class CodeInjectionPane extends PaneSection { this.headCode = page.getByRole('textbox', { name: new RegExp(`^${codeInjectionHeadLabel}`) }); this.footCode = page.getByRole('textbox', { name: new RegExp(`^${codeInjectionFootLabel}`) }); } + + /** Replaces the header code; an empty string clears it. */ + async setHead(code: string): Promise { + await replaceCode(this.headCode, code); + } + + /** Replaces the footer code; an empty string clears it. */ + async setFoot(code: string): Promise { + await replaceCode(this.footCode, code); + } +} + +/** + * Clears through CodeMirror's own keymap first: a fill writes to the DOM, + * which races its reconciliation. Blurs to commit, as the text fields do. + */ +async function replaceCode(field: Locator, code: string): Promise { + await field.click(); + const { keyboard } = field.page(); + await keyboard.press('ControlOrMeta+a'); + await keyboard.press('Backspace'); + if (code) { + await field.fill(code); + } + await field.blur(); } class MetaDataPane extends PaneSection { diff --git a/e2e/helpers/pages/public/post-page.ts b/e2e/helpers/pages/public/post-page.ts index c78959ad28db..8e96de8687c2 100644 --- a/e2e/helpers/pages/public/post-page.ts +++ b/e2e/helpers/pages/public/post-page.ts @@ -8,6 +8,12 @@ export class PostPage extends PublicPage { readonly articleTitle: Locator; readonly articleHeader: Locator; readonly articleBody: Locator; + /** The primary tag: the first tag when it is public, and the only one the header shows. */ + readonly articleTag: Locator; + /** One head tag per public tag, in the post's order. */ + readonly articleTagMetas: Locator; + /** The byline: every author's name, in the post's order. */ + readonly articleAuthorName: Locator; readonly accessCtaContent: Locator; readonly accessCtaHeading: Locator; readonly metaDescription: Locator; @@ -23,6 +29,9 @@ export class PostPage extends PublicPage { this.articleTitle = page.locator('.gh-article-title'); this.articleHeader = page.locator('main > article > header'); this.articleBody = page.locator('.gh-content.gh-canvas > p'); + this.articleTag = page.locator('.gh-article-tag'); + this.articleTagMetas = page.locator('meta[property="article:tag"]'); + this.articleAuthorName = page.locator('.gh-article-author-name'); this.accessCtaContent = page.locator('.gh-post-upgrade-cta-content'); this.accessCtaHeading = this.accessCtaContent.locator('h2'); this.metaDescription = page.locator('meta[name="description"]'); @@ -32,6 +41,11 @@ export class PostPage extends PublicPage { this.transistorPlaceholder = page.locator('.kg-transistor-placeholder'); } + /** A social card tag in the head; Ghost keys `twitter:*` by `name` and `og:*` by `property`. */ + socialMetaTag(key: string): Locator { + return this.page.locator(`meta[name="${key}"], meta[property="${key}"]`); + } + async gotoPost(slug: string): Promise { await this.goto(`/${slug}/`); await this.waitForPostToLoad(); diff --git a/e2e/tests/admin/posts/editor-settings-react.test.ts b/e2e/tests/admin/posts/editor-settings-react.test.ts new file mode 100644 index 000000000000..24eae89ed6b3 --- /dev/null +++ b/e2e/tests/admin/posts/editor-settings-react.test.ts @@ -0,0 +1,663 @@ +import { EGRESS_MOCK_RESPONSE_HEADER } from '@/helpers/environment/constants'; +import { + Member, + MemberFactory, + PostFactory, + TagFactory, + buildLexicalParagraph, + createMemberFactory, + createPostFactory, + createTagFactory, +} from '@/data-factory'; +import { PostEditorPage, PostsPage } from '@/admin-pages'; +import { PostPage } from '@/helpers/pages'; +import { SettingsService } from '@/helpers/services/settings/settings-service'; +import { expect, test, withIsolatedPage } from '@/helpers/playwright'; +import { postHistoryLatestText } from '@tryghost/test-data/selectors/editor'; +import { signInAsMember } from '@/helpers/playwright/flows/sign-in'; +import type { Browser, Page } from '@playwright/test'; + +/** + * Smoke round trips through the React post editor's settings sidebar, behind + * the `editorReact` Labs flag. Each case touches many sections in one pass and + * proves them with one Admin API read and one site render; field-level cases + * belong to the acceptance tier. + */ + +const POSTS_API = '/ghost/api/admin/posts/'; +const IMAGES_API = '/ghost/api/admin/images/upload/'; +const USERS_API = '/ghost/api/admin/users/'; +const UNSPLASH_API = 'https://api.unsplash.com'; + +const SITE_TIMEZONE = 'Pacific/Auckland'; +// An early morning under New Zealand daylight time (UTC+13), so the UTC instant +// falls on the previous calendar day +const LOCAL_DAY = '2026-03-10'; +const LOCAL_TIME = '05:30'; +const EXPECTED_INSTANT = '2026-03-09T16:30:00.000Z'; +/** The theme's `DD MMM YYYY`, in the site timezone. */ +const LOCAL_DAY_LABEL = '10 Mar 2026'; + +/** The option as the Access select shows it. */ +const MEMBERS_ONLY = 'Members only'; + +/** A 1x1 PNG, which Ghost accepts as an image upload. */ +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +); + +interface UnsplashPhoto { + id: string; + alt: string; + /** The URL the picker hands the editor, as `urls.regular`. */ + url: string; + photographer: string; + photographerUrl: string; +} + +interface PostTag { + name: string; + slug: string; + visibility: string; +} + +interface Revision { + title: string; + lexical: string | null; + created_at_ts: number; +} + +interface StaffUser { + id: string; + name: string; + email: string; +} + +async function readPost(page: Page, postId: string, query = '') { + const response = await page.request.get(`${POSTS_API}${postId}/${query}`); + expect(response.status()).toBe(200); + const { + posts: [post], + } = await response.json(); + + return post; +} + +/** The post's versions newest first, by the millisecond stamp `created_at_ts`. */ +async function readPostHistory(page: Page, postId: string) { + const post = await readPost(page, postId, '?formats=lexical&include=post_revisions'); + const revisions = (post.post_revisions as Revision[]) + .map((revision) => ({ + title: revision.title, + lexical: revision.lexical, + createdAtTs: revision.created_at_ts, + })) + .sort((a, b) => b.createdAtTs - a.createdAtTs); + + return { title: post.title as string, lexical: post.lexical as string, revisions }; +} + +/** Writes fields the factory does not carry; the PUT needs `updated_at` to pass collision detection. */ +async function updatePost(page: Page, postId: string, fields: Record) { + const current = await readPost(page, postId); + const response = await page.request.put(`${POSTS_API}${postId}/`, { + data: { posts: [{ ...fields, updated_at: current.updated_at }] }, + }); + expect(response.status()).toBe(200); +} + +/** The staff member behind an email, as the authors picker lists them. */ +async function findStaffByEmail(page: Page, email: string): Promise { + const response = await page.request.get(`${USERS_API}?limit=all`); + expect(response.status()).toBe(200); + const { users } = await response.json(); + const user = (users as StaffUser[]).find((candidate) => candidate.email === email); + if (!user) { + throw new Error(`No staff user with email ${email}`); + } + + return { id: user.id, name: user.name, email: user.email }; +} + +function waitForPostSave(page: Page, postId: string) { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`${POSTS_API}${postId}/`) && + response.status() === 200, + ); +} + +/** The save Cmd-S sends, which is the one that asks the server for a version. */ +function waitForRevisionSave(page: Page, postId: string) { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`${POSTS_API}${postId}/`) && + response.url().includes('save_revision=true') && + response.status() === 200, + ); +} + +function waitForPostDelete(page: Page, postId: string) { + return page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && + response.url().includes(`${POSTS_API}${postId}/`) && + response.status() === 204, + ); +} + +/** Uploads through the API and returns the absolute URL Ghost serves it from. */ +async function uploadImage(page: Page, name: string): Promise { + const response = await page.request.post(IMAGES_API, { + multipart: { + file: { name, mimeType: 'image/png', buffer: PNG_1X1 }, + purpose: 'image', + }, + }); + expect(response.status()).toBe(201); + const { + images: [image], + } = await response.json(); + + return image.url as string; +} + +/** The API and the site both carry absolute image URLs, so only the file name is fixed. */ +function storedAs(name: string): RegExp { + return new RegExp(`/${name.replace(/\./g, '\\.')}$`); +} + +/** A photo in the shape Unsplash's API returns, with every value the picker reads stamped. */ +function unsplashPhotoResponse(photo: UnsplashPhoto) { + return { + id: photo.id, + slug: photo.id, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + promoted_at: null, + width: 1200, + height: 800, + color: '#f0f0f0', + blur_hash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + description: null, + alt_description: photo.alt, + breadcrumbs: [], + urls: { + raw: photo.url, + full: photo.url, + regular: photo.url, + small: photo.url, + thumb: photo.url, + }, + links: { + self: `${UNSPLASH_API}/photos/${photo.id}`, + html: `https://unsplash.com/photos/${photo.id}`, + download: `https://unsplash.com/photos/${photo.id}/download`, + download_location: `${UNSPLASH_API}/photos/${photo.id}/download`, + }, + likes: 1, + liked_by_user: false, + current_user_collections: [], + sponsorship: null, + topic_submissions: {}, + user: { + id: `user-${photo.id}`, + updated_at: '2026-01-01T00:00:00Z', + username: photo.id, + name: photo.photographer, + first_name: photo.photographer, + last_name: '', + twitter_username: null, + portfolio_url: null, + bio: null, + location: null, + links: { + self: `${UNSPLASH_API}/users/${photo.id}`, + html: photo.photographerUrl, + photos: `${UNSPLASH_API}/users/${photo.id}/photos`, + likes: `${UNSPLASH_API}/users/${photo.id}/likes`, + portfolio: `${UNSPLASH_API}/users/${photo.id}/portfolio`, + }, + profile_image: { small: photo.url, medium: photo.url, large: photo.url }, + instagram_username: null, + total_collections: 0, + total_likes: 0, + total_photos: 1, + accepted_tos: true, + for_hire: false, + social: { + instagram_username: null, + portfolio_url: null, + twitter_username: null, + paypal_email: null, + }, + }, + }; +} + +/** + * Answers the picker's Unsplash API calls with `photo` alone and aborts every + * other request to an Unsplash host, recording it, so nothing reaches Unsplash. + * The photo's own URL is on the site under test: Ghost probes a remote feature + * image's dimensions at render, which would send the server to Unsplash. + */ +async function stubUnsplash(page: Page, photo: UnsplashPhoto): Promise<{ escaped: string[] }> { + const escaped: string[] = []; + const body = unsplashPhotoResponse(photo); + const responses: Record = { + '/photos': [body], + '/search/photos': { results: [body] }, + [`/photos/${photo.id}/download`]: { url: photo.url }, + }; + + await page.route( + (url) => url.hostname === 'unsplash.com' || url.hostname.endsWith('.unsplash.com'), + async (route) => { + const url = new URL(route.request().url()); + const response = url.origin === UNSPLASH_API ? responses[url.pathname] : undefined; + if (response === undefined) { + escaped.push(url.toString()); + await route.abort(); + return; + } + await route.fulfill({ + body: JSON.stringify(response), + contentType: 'application/json', + headers: { [EGRESS_MOCK_RESPONSE_HEADER]: '1' }, + }); + }, + ); + + return { escaped }; +} + +/** Starts a new draft from the list and returns it once its first save has given it an id. */ +async function startDraft(page: Page, { title, body }: { title: string; body: string }) { + const postsPage = new PostsPage(page, { implementation: 'react' }); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page, { implementation: 'react' }); + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(POSTS_API) && + response.status() === 201, + ), + editor.createDraft({ title, body }), + ]); + + return { editor, postId: await editor.getPostId() }; +} + +/** Publishes the open draft and dismisses the flow so the editor is reachable again. */ +async function publish(page: Page, editor: PostEditorPage, postId: string): Promise { + await editor.publishFlow.open(); + await expect(editor.publishFlow.optionsStep).toBeVisible(); + await Promise.all([waitForPostSave(page, postId), editor.publishFlow.confirm()]); + await expect(editor.publishFlow.completeStep).toBeVisible(); + await editor.publishFlow.dismiss(); +} + +/** The tag names the page head carries, in the order Ghost writes them. */ +function headTagNames(sitePost: PostPage): Promise<(string | null)[]> { + return sitePost.articleTagMetas.evaluateAll((metas) => + metas.map((meta) => meta.getAttribute('content')), + ); +} + +/** Renders the post as a signed-in member and returns the page HTML. */ +async function readPostAsMember( + browser: Browser, + baseURL: string, + member: Member, + slug: string, +): Promise<{ html: string; ctaCount: number }> { + return withIsolatedPage( + browser, + { baseURL, extraHTTPHeaders: { Origin: baseURL } }, + async ({ page: memberPage }) => { + await signInAsMember(memberPage, member); + const sitePost = new PostPage(memberPage); + await sitePost.gotoPost(slug); + await expect(sitePost.articleTitle).toBeVisible(); + + return { + html: await memberPage.content(), + ctaCount: await sitePost.accessCtaContent.count(), + }; + }, + ); +} + +test.describe('Ghost Admin - Post editor settings (React)', () => { + // Flag state belongs on the describe — `test.use` inside a test body has no + // effect on the fixtures that test already resolved. Delete leaves for the + // list through the router, which only the React list follows. + test.use({ labs: { editorReact: true, postsListReact: true } }); + + let memberFactory: MemberFactory; + let postFactory: PostFactory; + let tagFactory: TagFactory; + + test.beforeEach(async ({ page }) => { + memberFactory = createMemberFactory(page.request); + postFactory = createPostFactory(page.request); + tagFactory = createTagFactory(page.request); + }); + + test('new draft - every section saves as it is committed, publishes once, and reaches the API and the site', async ({ + browser, + baseURL, + page, + ghostAccountOwner, + ghostAccountAuthor, + }) => { + const stamp = Date.now(); + const title = `react-settings-draft-${stamp}`; + const body = 'A paragraph every setting hangs off.'; + const seededTitle = `Seeded URL ${stamp}`; + const seededSlug = `seeded-url-${stamp}`; + const dedupedSlug = `${seededSlug}-2`; + const stampedTag = { name: `Stamped Tag ${stamp}`, slug: `stamped-tag-${stamp}` }; + const internalTag = { name: `#stamped-${stamp}`, slug: `hash-stamped-${stamp}` }; + const metaTitle = `Search title ${stamp}`; + const metaDescription = `The description search engines read for ${stamp}.`; + const xTitle = `X title ${stamp}`; + const facebookTitle = `Facebook title ${stamp}`; + const headSnippet = ``; + const footSnippet = ``; + const imageName = `unsplash-${stamp}.png`; + const alt = `Alt text for the Unsplash pick ${stamp}`; + + // Admin edits in the timezone it booted with and gates the Unsplash button + // on the setting it booted with, so only a reload picks the new values up + await new SettingsService(page.request).updateSettings([ + { key: 'timezone', value: SITE_TIMEZONE }, + { key: 'unsplash', value: true }, + ]); + await page.reload({ waitUntil: 'load' }); + + const [seededTag, owner, second, photoUrl] = await Promise.all([ + tagFactory.create({ name: `Seeded Tag ${stamp}` }), + findStaffByEmail(page, ghostAccountOwner.email), + findStaffByEmail(page, ghostAccountAuthor.email), + uploadImage(page, imageName), + postFactory.create({ + title: seededTitle, + slug: seededSlug, + status: 'published', + lexical: buildLexicalParagraph('The post that owns the slug.'), + }), + ]); + const photo: UnsplashPhoto = { + id: `e2e-${stamp}`, + alt: `Stubbed Unsplash photo ${stamp}`, + url: photoUrl, + photographer: `Photographer ${stamp}`, + photographerUrl: `https://unsplash.com/@e2e-${stamp}`, + }; + const { escaped } = await stubUnsplash(page, photo); + + const { editor, postId } = await startDraft(page, { title, body }); + const { settings, featureImage } = editor; + + // A draft saves each committed field on its own, so every commit is awaited + await settings.openSection('tags'); + await Promise.all([waitForPostSave(page, postId), settings.tags.add(seededTag.name)]); + await Promise.all([waitForPostSave(page, postId), settings.tags.add(stampedTag.name)]); + await Promise.all([waitForPostSave(page, postId), settings.tags.add(internalTag.name)]); + + await settings.openSection('authors'); + await Promise.all([waitForPostSave(page, postId), settings.authors.add(second.name)]); + + await settings.openSection('url'); + await Promise.all([waitForPostSave(page, postId), settings.url.setSlug(seededSlug)]); + await expect(settings.url.slugInput).toHaveValue(dedupedSlug); + + await settings.openSection('meta-data'); + await Promise.all([waitForPostSave(page, postId), settings.metaData.setTitle(metaTitle)]); + await Promise.all([ + waitForPostSave(page, postId), + settings.metaData.setDescription(metaDescription), + ]); + + await settings.openSection('x-card'); + await Promise.all([waitForPostSave(page, postId), settings.xCard.setTitle(xTitle)]); + + await settings.openSection('facebook-card'); + await Promise.all([ + waitForPostSave(page, postId), + settings.facebookCard.setTitle(facebookTitle), + ]); + + await settings.openSection('code-injection'); + await Promise.all([waitForPostSave(page, postId), settings.codeInjection.setHead(headSnippet)]); + await Promise.all([waitForPostSave(page, postId), settings.codeInjection.setFoot(footSnippet)]); + + await settings.openSection('publish-date'); + await Promise.all([waitForPostSave(page, postId), settings.publishDate.setDate(LOCAL_DAY)]); + await Promise.all([waitForPostSave(page, postId), settings.publishDate.setTime(LOCAL_TIME)]); + await expect(settings.publishDate.error).toHaveCount(0); + await settings.close(); + + await featureImage.openUnsplash(); + await Promise.all([waitForPostSave(page, postId), featureImage.insertUnsplashPhoto(photo.alt)]); + await Promise.all([waitForPostSave(page, postId), featureImage.setAlt(alt)]); + + await publish(page, editor, postId); + + const saved = await readPost(page, postId, '?include=tags,authors'); + expect(saved).toMatchObject({ + status: 'published', + slug: dedupedSlug, + meta_title: metaTitle, + meta_description: metaDescription, + twitter_title: xTitle, + og_title: facebookTitle, + codeinjection_head: headSnippet, + codeinjection_foot: footSnippet, + published_at: EXPECTED_INSTANT, + feature_image_alt: alt, + }); + expect(saved.feature_image).toMatch(storedAs(imageName)); + expect( + (saved.tags as PostTag[]).map(({ name, slug, visibility }) => ({ name, slug, visibility })), + ).toEqual([ + { name: seededTag.name, slug: seededTag.slug, visibility: 'public' }, + { ...stampedTag, visibility: 'public' }, + { ...internalTag, visibility: 'internal' }, + ]); + expect((saved.authors as { id: string }[]).map((author) => author.id)).toEqual([ + owner.id, + second.id, + ]); + expect(saved.primary_author.id).toBe(owner.id); + + await withIsolatedPage(browser, { baseURL }, async ({ page: visitorPage }) => { + const { escaped: escapedFromSite } = await stubUnsplash(visitorPage, photo); + const sitePost = new PostPage(visitorPage); + await sitePost.gotoPost(dedupedSlug); + await expect(sitePost.articleTitle).toHaveText(title); + await expect(visitorPage).toHaveTitle(metaTitle); + await expect(sitePost.metaDescription).toHaveAttribute('content', metaDescription); + await expect(sitePost.socialMetaTag('og:title')).toHaveAttribute('content', facebookTitle); + await expect(sitePost.socialMetaTag('twitter:title')).toHaveAttribute('content', xTitle); + // The theme shows the primary tag; the head carries the public ones and never the internal + expect(await headTagNames(sitePost)).toEqual([seededTag.name, stampedTag.name]); + await expect(sitePost.articleTag).toHaveText(seededTag.name); + await expect(sitePost.articleAuthorName).toHaveText(`${owner.name}, ${second.name}`); + await expect(sitePost.articleHeader).toContainText(LOCAL_DAY_LABEL); + await expect(visitorPage.getByRole('img', { name: alt })).toHaveAttribute( + 'src', + storedAs(imageName), + ); + + const html = await visitorPage.content(); + expect(html).not.toContain(internalTag.name); + const headAt = html.indexOf(headSnippet); + expect(headAt).toBeGreaterThan(-1); + expect(headAt).toBeLessThan(html.indexOf('')); + // `{{ghost_foot}}` is the last thing before the body closes, after the article + const footAt = html.indexOf(footSnippet); + expect(footAt).toBeGreaterThan(html.lastIndexOf('')); + expect(footAt).toBeLessThan(html.indexOf('')); + + // The deduped slug left the seeded post where it was + await sitePost.gotoPost(seededSlug); + await expect(sitePost.articleTitle).toHaveText(seededTitle); + expect(escapedFromSite).toEqual([]); + }); + + expect(escaped).toEqual([]); + }); + + test('published post - Access, meta and X card changes land on Update, gate the site, and Delete removes the post', async ({ + browser, + baseURL, + page, + }) => { + const stamp = Date.now(); + const title = `react-settings-published-${stamp}`; + const body = 'Only members may read this.'; + const metaTitle = `Search title ${stamp}`; + const xTitle = `X title ${stamp}`; + const featureImageName = `feature-${stamp}.png`; + const xImageName = `x-card-${stamp}.png`; + const facebookImageName = `facebook-card-${stamp}.png`; + const [featureImage, xImage, facebookImage] = await Promise.all([ + uploadImage(page, featureImageName), + uploadImage(page, xImageName), + uploadImage(page, facebookImageName), + ]); + const [created, member] = await Promise.all([ + postFactory.create({ + title, + status: 'published', + feature_image: featureImage, + lexical: buildLexicalParagraph(body), + }), + memberFactory.create({ email: `free-${stamp}@example.com`, status: 'free' }), + ]); + await updatePost(page, created.id, { twitter_image: xImage, og_image: facebookImage }); + + const editor = new PostEditorPage(page, { implementation: 'react' }); + await editor.gotoPost(created.id); + const { settings } = editor; + + await settings.openSection('access'); + await settings.access.setVisibility(MEMBERS_ONLY); + await settings.openSection('meta-data'); + await settings.metaData.setTitle(metaTitle); + await settings.openSection('x-card'); + await settings.xCard.setTitle(xTitle); + await settings.xCard.removeImageButton.click(); + await expect(settings.xCard.removeImageButton).toBeHidden(); + await settings.closeSection('x-card'); + // A published post stages every settings edit until Update, so this click + // is the only save + await Promise.all([waitForPostSave(page, created.id), editor.header.updateButton.click()]); + + const updated = await readPost(page, created.id); + expect(updated).toMatchObject({ + status: 'published', + visibility: 'members', + meta_title: metaTitle, + twitter_title: xTitle, + twitter_image: null, + }); + expect(updated.og_image).toMatch(storedAs(facebookImageName)); + expect(updated.feature_image).toMatch(storedAs(featureImageName)); + + await withIsolatedPage(browser, { baseURL }, async ({ page: visitorPage }) => { + const sitePost = new PostPage(visitorPage); + await sitePost.gotoPost(created.slug); + await expect(sitePost.articleTitle).toHaveText(title); + await expect(visitorPage).toHaveTitle(metaTitle); + await expect(sitePost.accessCtaContent).toBeVisible(); + expect(await visitorPage.content()).not.toContain(body); + // With no X image of its own the card takes the feature image; the Facebook card keeps its own + await expect(sitePost.socialMetaTag('twitter:image')).toHaveAttribute( + 'content', + storedAs(featureImageName), + ); + await expect(sitePost.socialMetaTag('og:image')).toHaveAttribute( + 'content', + storedAs(facebookImageName), + ); + }); + + const asMember = await readPostAsMember(browser, baseURL!, member, created.slug); + expect(asMember.html).toContain(body); + expect(asMember.ctaCount).toBe(0); + + await settings.openSection('delete'); + await Promise.all([waitForPostDelete(page, created.id), settings.delete.deletePost()]); + + const postsPage = new PostsPage(page, { implementation: 'react' }); + await postsPage.waitForPageToFullyLoad(); + await expect(postsPage.postsListItem.first()).toBeVisible(); + await expect(postsPage.getPostByTitle(title)).toHaveCount(0); + expect((await page.request.get(`${POSTS_API}${created.id}/`)).status()).toBe(404); + await expect.poll(async () => (await page.request.get(`/${created.slug}/`)).status()).toBe(404); + }); + + test('draft - restoring an older version from Post history brings it back and saves it as the newest', async ({ + page, + }) => { + const stamp = Date.now(); + const firstTitle = `react-history-first-${stamp}`; + const firstBody = 'The paragraph the first version keeps.'; + const secondTitle = `react-history-second-${stamp}`; + const secondBody = 'The paragraph the second version replaces it with.'; + + const { editor, postId } = await startDraft(page, { title: firstTitle, body: firstBody }); + + // The history orders versions by the second written, so each save waits + // out the status hold before the next + await editor.waitForSaved(); + await Promise.all([waitForRevisionSave(page, postId), page.keyboard.press('ControlOrMeta+s')]); + await editor.waitForSaved(); + + await editor.titleInput.fill(secondTitle); + await editor.replaceBody(secondBody); + await expect(editor.lexicalEditor).not.toContainText(firstBody); + await Promise.all([waitForRevisionSave(page, postId), page.keyboard.press('ControlOrMeta+s')]); + await editor.waitForSaved(); + + const saved = await readPostHistory(page, postId); + expect(saved.revisions[0].title).toBe(secondTitle); + expect(saved.revisions[0].lexical).toContain(secondBody); + expect(saved.revisions[1].title).toBe(firstTitle); + expect(saved.revisions[1].lexical).toContain(firstBody); + + const { history } = editor.settings.postHistory; + await editor.settings.openSection('post-history'); + await expect(history.revisions).toHaveCount(saved.revisions.length); + await expect(history.revision(0).row).toContainText(postHistoryLatestText); + await expect(history.revision(0).restoreButton).toHaveCount(0); + await history.revision(1).select(); + await expect(history.previewTitle).toHaveText(firstTitle); + await history.restore(1); + + await expect(editor.titleInput).toHaveValue(firstTitle); + await expect(editor.lexicalEditor).toContainText(firstBody); + await expect(editor.lexicalEditor).not.toContainText(secondBody); + + // The restore is saved as a version of its own, ahead of the ones it chose from + const restored = await readPostHistory(page, postId); + expect(restored.title).toBe(firstTitle); + expect(restored.lexical).toContain(firstBody); + expect(restored.lexical).not.toContain(secondBody); + expect(restored.revisions).toHaveLength(saved.revisions.length + 1); + expect(restored.revisions[0].title).toBe(firstTitle); + + await page.reload(); + await expect(editor.titleInput).toHaveValue(firstTitle); + await expect(editor.lexicalEditor).toContainText(firstBody); + }); +}); diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index 7a55a0659da9..ed6c9610a2ac 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -157,6 +157,8 @@ export const addFeatureImageLabel = 'Add feature image'; export const featureImageAltLabel = 'Alt text for feature image'; export const featureImageUnsplashButton = 'Select feature image from Unsplash'; export const unsplashSearchHeading = 'Unsplash'; +// The gallery's insert control; @tryghost/kg-unsplash-selector owns the label. +export const unsplashInsertImageButton = 'Insert image'; export const removeFeatureImageButton = 'Remove feature image'; export const addFacebookImageLabel = 'Add Facebook image'; export const removeFacebookImageButton = 'Remove Facebook image'; From 0b1c424228692438cc6cd7a6fc0fd37e235002c6 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 15:37:22 +0200 Subject: [PATCH 4/5] Fixed global search acceptance test startup races (#30998) no ref Two Cmd-K acceptance tests dispatched their shortcut as soon as the sidebar search button appeared. The button is gated on the current user, while React's shortcut is registered by an effect after the Labs config arrives, so CI could observe `dispatchShortcut() === false` during startup. --- .../global-search.acceptance.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/admin/src/global-search/global-search.acceptance.test.tsx b/apps/admin/src/global-search/global-search.acceptance.test.tsx index ea3af26cb49f..37faa6419f5a 100644 --- a/apps/admin/src/global-search/global-search.acceptance.test.tsx +++ b/apps/admin/src/global-search/global-search.acceptance.test.tsx @@ -95,6 +95,13 @@ async function openAndSearch(term: string) { await globalSearchScreen.search(term); } +async function openWithShortcut() { + // The sidebar can render before config enables search and its effect registers the shortcut. + await expect.poll(() => globalSearchScreen.dispatchShortcut()).toBe(true); + // Handling the key starts a lazy import; wait for the dialog before sending another key. + await expect.element(globalSearchScreen.input()).toHaveFocus(); +} + async function closeWithEscape() { await userEvent.keyboard('{Escape}'); await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); @@ -258,8 +265,7 @@ describe('Cmd-K search', () => { fakeTags([firstTag]); fakeAdminEndpoint('GET', /^\/tags\/slug\/first-tag\//, { tags: [firstTag] }); await renderAdminApp('/tags/first-tag', flagOn); - await expect.element(globalSearchScreen.openButton()).toBeVisible(); - expect(globalSearchScreen.dispatchShortcut()).toBe(true); + await openWithShortcut(); await closeWithEscape(); await tagDetailScreen.actionsButton().click(); @@ -272,9 +278,7 @@ describe('Cmd-K search', () => { it('closes and ignores the shortcut once the sidebar is hidden', async () => { await renderAdminApp('/tags', flagOn); - await expect.element(globalSearchScreen.openButton()).toBeVisible(); - expect(globalSearchScreen.dispatchShortcut()).toBe(true); - await expect.element(globalSearchScreen.dialog()).toBeVisible(); + await openWithShortcut(); window.location.hash = '#/editor/post/p1'; From b2a5c5fb24497a10ce311b270fc267c4a5109cb1 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Fri, 25 Sep 2026 17:03:29 +0200 Subject: [PATCH 5/5] Fixed floating controls in the React post and page editor (#30971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long documents could expand the outer Admin layout and scroll the React editor controls out of view. The editor now stays bounded by the viewport, with content scrolling behind floating controls. Back navigation uses the shared PageHeader action; save status, word count, and help use button-sized pills with translucent backgrounds and blur. Session warnings reserve space below the header. The settings panel uses the main navigation’s grey surface and rounded border, with 8px spacing at the top, right, and bottom. It opens and closes over 450ms with easing and fading contents. One persistent toggle stays fixed above the panel, including in subviews; it is transparent at rest when open and grey on hover. Preview, Publish, and the footer move left with the sidebar, while a 12px content inset separates full-width cards from the panel without moving the scrollbar. Full-width cards account for the writing area’s actual width throughout transitions and wider settings subviews. Narrow screens retain an overlay panel, and reduced-motion preferences disable transitions. Fixes https://linear.app/ghost/issue/PLA-455/floating-shell Validation: - Synced with main, preserving the new global search provider and the editor height constraint. - Admin typecheck passed. - Editor unit suite: 1,211 tests across 69 files passed after the merge. - Browser acceptance: all 16 editor shell regressions passed, including full-width images beside normal/wide settings and stationary toggle behavior. Another 80 layout, global-search, dunning, and keyboard-shortcut cases passed across focused runs; the shortcut width assertion was corrected and its 7-case suite rerun successfully. - Commit hooks check formatting, lint, dependency boundaries, and secrets. - Earlier full-repository validation encountered unrelated Core cron, gift-preview timeout, and email-date failures; a full repository pass is not claimed. Current-head GitHub CI is monitored separately. - [x] I've read and followed the Contributor Guide - [x] I've explained my change - [x] I've written an automated test to prove my change works --- .../src/editor/editor-header-actions.tsx | 19 +- .../editor/editor-header.acceptance.test.tsx | 4 +- apps/admin/src/editor/editor-screen.tsx | 257 +++++-- ...ettings-code-injection.acceptance.test.tsx | 6 +- ...ngs-keyboard-shortcuts.acceptance.test.tsx | 6 +- .../editor/editor-shell.acceptance.test.tsx | 637 ++++++++++++++++++ apps/admin/src/editor/editor-status.tsx | 56 +- apps/admin/src/editor/editor.screen.ts | 13 + apps/admin/src/editor/post-editor.tsx | 83 ++- .../editor/settings/post-settings-sidebar.tsx | 49 +- .../src/editor/settings/settings-section.tsx | 12 +- .../src/editor/settings/settings-subview.tsx | 39 +- .../src/editor/use-onscreen-keyboard.test.ts | 113 ++++ .../admin/src/editor/use-onscreen-keyboard.ts | 49 ++ apps/admin/src/index.css | 7 + apps/admin/src/layout/admin-layout.tsx | 7 +- .../testing/test-data/src/selectors/editor.ts | 1 + 17 files changed, 1186 insertions(+), 172 deletions(-) create mode 100644 apps/admin/src/editor/editor-shell.acceptance.test.tsx create mode 100644 apps/admin/src/editor/use-onscreen-keyboard.test.ts create mode 100644 apps/admin/src/editor/use-onscreen-keyboard.ts diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index ec4846800f5c..dbda93a8fd2f 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -122,9 +122,14 @@ export function EditorHeaderActions({ }; return ( - + {isDraft ? ( - + Preview ) : null} @@ -235,14 +240,19 @@ function PublishActions({ {inputs.error ? ( <> {inputs.error.message} - @@ -265,6 +275,7 @@ function PublishActions({ {/* Ember routes a sent post to the update flow from its status line, not the header. */} {post.status === 'sent' ? null : ( + {children} - + ); } @@ -137,7 +154,63 @@ function EditorContent({ const [tkCount, setTkCount] = useState(0); // Closed on every editor entry, as the menu it replaces was. const [settingsOpen, setSettingsOpen] = useState(false); - const toggleSettings = useCallback(() => setSettingsOpen((open) => !open), []); + const [settingsPresent, setSettingsPresent] = useState(false); + const shellRef = useRef(null); + const settingsToggleRef = useRef(null); + const [settingsToggleWidth, setSettingsToggleWidth] = useState(0); + useLayoutEffect(() => { + const toggle = settingsToggleRef.current; + if (!toggle) { + return; + } + const measure = () => setSettingsToggleWidth(toggle.getBoundingClientRect().width); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(toggle); + return () => observer.disconnect(); + }, []); + // Keep the panel's fields and subview mounted until the closing transition ends. + // Reading animations also handles reduced motion (no animation) and reversals. + useLayoutEffect(() => { + if (settingsOpen || !settingsPresent) { + return; + } + const finishClosing = () => { + setSettingsPresent(false); + }; + const animations = shellRef.current?.getAnimations() ?? []; + if (!animations.length) { + finishClosing(); + return; + } + let cancelled = false; + void Promise.allSettled(animations.map((animation) => animation.finished)).then(() => { + if (!cancelled) { + finishClosing(); + } + }); + return () => { + cancelled = true; + }; + }, [settingsOpen, settingsPresent]); + const headerRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(0); + useLayoutEffect(() => { + const header = headerRef.current; + if (!header) { + return; + } + const measure = () => setHeaderHeight(header.getBoundingClientRect().height); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(header); + return () => observer.disconnect(); + }, []); + const toggleSettings = useCallback(() => { + settingsToggleRef.current?.focus(); + setSettingsPresent(true); + setSettingsOpen((open) => !open); + }, []); const featureImage = useFeatureImageBinding(session, session.loadedRecord, session.contentKey); const leaveGuard = useEditorLeaveGuard(session, postType); const liveVisibility = session.settings.visibility; @@ -153,75 +226,111 @@ function EditorContent({ useSaveShortcut(session.dispatchExplicit); + const settingsToggle = ( + + + + ); + return ( - - - - {/* One right-aligned group: two `ml-auto` siblings would split the free space. */} - - - - - - - - - -
- -
- {settingsOpen ? ( - + + + + + + + + + + + - ) : null} -
+ + {/* Session warnings reserve space; otherwise the document reaches behind the header. */} + +
+ +
+
+
+ + {settingsToggle} + + {settingsPresent ? ( + + ) : null} {snippetDialog} - +
); } diff --git a/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx index 27a38a0ebe01..fe90fcbef515 100644 --- a/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx @@ -33,7 +33,7 @@ const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); const LOADED_AT = '2026-01-01T00:00:00.000Z'; const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; const PAGE_ROUTE = new RegExp(`^/pages/${POST_ID}/\\?`); -// The panel's own width, and the width the wide pane widens it to. +// The space reserved for the floating panel, including its outer padding. const PANEL_WIDTH = 350; const WIDE_PANEL_WIDTH = 500; @@ -88,7 +88,7 @@ function fakeSavablePage() { } function sidebarWidthPx(): number { - return editorScreen.settingsSidebar().element().getBoundingClientRect().width; + return editorScreen.settingsSidebar().element().parentElement!.getBoundingClientRect().width; } function headEditor() { @@ -136,7 +136,7 @@ describe('Post settings code injection', () => { // The pane replaces the list it was opened from, in a widened panel. await expect(editorScreen.settingsExcerpt()).toHaveCount(0); - expect(sidebarWidthPx()).toBe(WIDE_PANEL_WIDTH); + await expect.poll(sidebarWidthPx).toBe(WIDE_PANEL_WIDTH); await editorScreen.settingsSubviewBack(settingsCodeInjectionBackButton).click(); diff --git a/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx index b0f27eca16a6..7d40c536e4cd 100644 --- a/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx @@ -109,7 +109,11 @@ describe('Post settings keyboard shortcuts', () => { await expect.element(pane).toHaveTextContent('Inserting'); expect(editorScreen.settingsShortcutRows()).toHaveLength(50); - expect(editorScreen.settingsSidebar().element().getBoundingClientRect().width).toBe(350); + await expect + .poll( + () => editorScreen.settingsSidebar().element().parentElement!.getBoundingClientRect().width, + ) + .toBe(350); }); it('shows a Mac writer the Mac glyphs', async () => { diff --git a/apps/admin/src/editor/editor-shell.acceptance.test.tsx b/apps/admin/src/editor/editor-shell.acceptance.test.tsx new file mode 100644 index 000000000000..3d670ebd6b14 --- /dev/null +++ b/apps/admin/src/editor/editor-shell.acceptance.test.tsx @@ -0,0 +1,637 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentUserResponse, + fakeAdminEndpoint, + fakeEditorChrome, + post, + renderAdminApp, + staffRole, + withoutAutosave, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); +const LONG_DOCUMENT = buildLexicalParagraph( + 'A long document keeps its editor controls in reach. '.repeat(500), +); +let originalFontSize: string; + +beforeEach(() => { + // The production Ember host sets this baseline in patterns/global.css. + // This suite measures the real control sizes; the acceptance host omits that stylesheet. + originalFontSize = document.documentElement.style.fontSize; + document.documentElement.style.fontSize = '62.5%'; +}); + +function fakeLongDocument( + postType: 'post' | 'page', + status: 'draft' | 'published' | 'scheduled' = 'draft', + lexical = LONG_DOCUMENT, +) { + fakeEditorChrome(); + const resource = `${postType}s`; + fakeAdminEndpoint('GET', new RegExp(`^/${resource}/abc123/\\?`), { + [resource]: [ + post({ + id: 'abc123', + title: 'A long document', + lexical, + status, + published_at: + status === 'scheduled' ? '2027-01-01T12:00:00.000Z' : '2026-01-01T12:00:00.000Z', + tags: [], + authors: [{ id: '1' }], + }), + ], + }); +} + +function controls(postType: 'post' | 'page') { + return [ + editorScreen.backLink(postType), + editorScreen.status(), + editorScreen.previewButton(), + editorScreen.publishButton(), + editorScreen.settingsToggle(), + editorScreen.wordCount(), + editorScreen.helpLink(), + ]; +} + +function positions(postType: 'post' | 'page') { + return controls(postType).map((control) => { + const { x, y, width, height } = control.element().getBoundingClientRect(); + return { x, y, width, height }; + }); +} + +function slowSettingsTransition() { + const style = document.createElement('style'); + style.textContent = `[style*="--editor-settings-progress"] { + transition-duration: 100s !important; + }`; + document.head.appendChild(style); + return style; +} + +function settingsTransition() { + return document + .getAnimations() + .find( + (animation) => + animation instanceof CSSTransition && + animation.transitionProperty === '--editor-settings-progress', + ); +} + +function expectTranslucentSurface(element: Element) { + const style = getComputedStyle(element); + expect(style.backgroundColor).toMatch(/(?:,\s*0\.8|\/\s*0\.8)\)$/); + expect(style.backdropFilter).toMatch(/blur\([1-9]/); + expect(style.opacity).toBe('1'); +} + +afterEach(async () => { + document.documentElement.style.fontSize = originalFontSize; + await page.viewport(1280, 800); +}); + +describe('Floating editor shell', () => { + it.each(['post', 'page'] as const)( + 'keeps %s controls in the viewport while only the document scrolls', + async (postType) => { + fakeLongDocument(postType); + await renderAdminApp(`/editor/${postType}/abc123`, FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + await expect.element(editorScreen.publishButton()).toBeEnabled(); + await expect + .poll(() => editorScreen.wordCount().element().getBoundingClientRect().bottom) + .toBeLessThanOrEqual(window.innerHeight); + + const before = positions(postType); + const pane = editorScreen.scrollPane(); + const titleTop = editorScreen.titleInput().element().getBoundingClientRect().top; + expect(pane.getBoundingClientRect().top).toBe(0); + expect(pane.scrollHeight).toBeGreaterThan(pane.clientHeight); + pane.scrollTo({ top: 700 }); + + await expect.poll(() => pane.scrollTop).toBe(700); + await expect + .poll(() => editorScreen.titleInput().element().getBoundingClientRect().top) + .toBeLessThan(titleTop - 600); + expect(positions(postType)).toEqual(before); + // The gap between the anchored controls stays part of the document: + // writing underneath it remains visible and can still receive pointer input. + const back = editorScreen.backLink(postType).element().getBoundingClientRect(); + const atHeaderGap = document.elementFromPoint( + window.innerWidth / 2, + back.top + back.height / 2, + ); + expect(editorScreen.body().element().contains(atHeaderGap)).toBe(true); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight); + }, + ); + + it('moves header actions and footer beside floating settings and scrolls each pane independently', async () => { + fakeLongDocument('post'); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const footerBefore = editorScreen.helpLink().element().getBoundingClientRect(); + const toggle = editorScreen.settingsToggle().element(); + const toggleBefore = toggle.getBoundingClientRect(); + + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + const sidebar = editorScreen.settingsSidebar().element(); + await expect + .poll(() => sidebar.parentElement!.getBoundingClientRect().width) + .toBe(sidebar.getBoundingClientRect().width + 8); + await expect.poll(() => sidebar.getBoundingClientRect().right).toBe(window.innerWidth - 8); + const sidebarBounds = sidebar.getBoundingClientRect(); + const footerAfter = editorScreen.helpLink().element().getBoundingClientRect(); + expect(footerBefore.right - footerAfter.right).toBeCloseTo(sidebarBounds.width + 16, 0); + expect(footerAfter.bottom).toBe(footerBefore.bottom); + expect(footerAfter.right).toBeLessThan(sidebarBounds.left); + expect(sidebarBounds.top).toBe(8); + expect(sidebarBounds.bottom).toBe(window.innerHeight - 8); + expect(sidebar.contains(editorScreen.settingsToggle().element())).toBe(false); + expect(editorScreen.settingsToggle().element()).toBe(toggle); + expect(toggle.getBoundingClientRect()).toEqual(toggleBefore); + const publish = editorScreen.publishButton().element().getBoundingClientRect(); + expect(publish.right).toBe(footerAfter.right); + expect(sidebarBounds.left - publish.right).toBe(24); + expect(editorScreen.scrollPane().getBoundingClientRect().right).toBe(sidebarBounds.left); + expect(editorScreen.previewButton().element().getBoundingClientRect().right).toBeLessThan( + sidebarBounds.left, + ); + expect(document.activeElement).toBe(editorScreen.settingsToggle().element()); + + const pane = editorScreen.scrollPane(); + pane.scrollTo({ top: 700 }); + await expect.poll(() => pane.scrollTop).toBe(700); + expect(sidebar.scrollTop).toBe(0); + + sidebar.scrollTo({ top: sidebar.scrollHeight }); + await expect.poll(() => sidebar.scrollTop).toBeGreaterThan(0); + expect(pane.scrollTop).toBe(700); + expect(editorScreen.helpLink().element().getBoundingClientRect().right).toBe(footerAfter.right); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight); + // The toggle remains reachable even after the settings list has scrolled. + await editorScreen.settingsToggle().click(); + await expect(editorScreen.settingsSidebar()).toHaveCount(0); + expect(document.activeElement).toBe(editorScreen.settingsToggle().element()); + expect(editorScreen.helpLink().element().getBoundingClientRect().right).toBe( + footerBefore.right, + ); + }); + + it.each([ + { admin7Pill: false, theme: 'light' }, + { admin7Pill: true, theme: 'dark' }, + ])( + 'keeps controls in a narrow $theme viewport (Admin 7: $admin7Pill) and overlays settings', + async ({ admin7Pill, theme }) => { + await page.viewport(390, 844); + fakeLongDocument('post'); + const me = currentUserResponse(); + me.users[0].accessibility = JSON.stringify({ nightShift: theme }); + await renderAdminApp('/editor/post/abc123', { + ...FLAG_ON, + labs: { editorReact: true, admin7Pill }, + boot: { browseMe: { response: me } }, + }); + await expect.element(editorScreen.body()).toBeVisible(); + await expect.element(editorScreen.publishButton()).toBeEnabled(); + await expect + .poll(() => document.documentElement.classList.contains('dark')) + .toBe(theme === 'dark'); + + for (const { x, y, width, height } of positions('post')) { + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + expect(x + width).toBeLessThanOrEqual(window.innerWidth); + expect(y + height).toBeLessThanOrEqual(window.innerHeight); + } + const back = editorScreen.backLink('post').element(); + const button = getComputedStyle(back); + for (const control of [editorScreen.status(), editorScreen.wordCount()]) { + const element = control.element(); + const style = getComputedStyle(element); + expect(style.fontSize).toBe(button.fontSize); + expect(element.getBoundingClientRect().height).toBe(back.getBoundingClientRect().height); + expect(parseFloat(style.borderRadius)).toBeGreaterThanOrEqual( + element.getBoundingClientRect().height / 2, + ); + expectTranslucentSurface(element); + } + const help = editorScreen.helpLink().element().getBoundingClientRect(); + const toggle = editorScreen.settingsToggle().element().getBoundingClientRect(); + expect(help.height).toBe(back.getBoundingClientRect().height); + expect(window.innerHeight - help.bottom).toBe(12); + expect(back.getBoundingClientRect().top).toBe(21); + expect(window.innerWidth - help.right).toBe(16); + expect(window.innerWidth - toggle.right).toBe(25); + + const before = positions('post'); + const pane = editorScreen.scrollPane(); + pane.scrollTo({ top: 700 }); + await expect.poll(() => pane.scrollTop).toBe(700); + expect(positions('post')).toEqual(before); + + const documentWidth = editorScreen.root().element().getBoundingClientRect().width; + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await expect + .poll(() => editorScreen.settingsSidebar().element().getBoundingClientRect().right) + .toBe(window.innerWidth - 8); + expect(editorScreen.root().element().getBoundingClientRect().width).toBe(documentWidth); + expect(editorScreen.settingsSidebar().element().getBoundingClientRect().top).toBe(8); + expect( + editorScreen.settingsSidebar().element().contains(editorScreen.settingsToggle().element()), + ).toBe(false); + expect( + editorScreen.settingsSidebar().element().getBoundingClientRect().bottom, + ).toBeLessThanOrEqual(window.innerHeight); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight); + }, + ); + + it('eases the sidebar and fades its contents without making header actions jump', async () => { + fakeLongDocument('post'); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const publishBefore = editorScreen.publishButton().element().getBoundingClientRect().right; + const footerBefore = editorScreen.helpLink().element().getBoundingClientRect().right; + // Hold the real transition long enough to inspect precise points on its timeline, + // independently of browser scheduling and Playwright's actionability waits. + const animationStyle = slowSettingsTransition(); + try { + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + const opening = settingsTransition()!; + opening.pause(); + opening.currentTime = 0; + const sidebar = editorScreen.settingsSidebar().element(); + const panel = sidebar.parentElement!; + const contents = sidebar.firstElementChild!; + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeCloseTo( + publishBefore, + 1, + ); + expect(panel.getBoundingClientRect().width).toBe(0); + expect(getComputedStyle(contents).opacity).toBe('0'); + expect(opening.effect!.getTiming().easing).not.toBe('linear'); + + opening.currentTime = 50_000; + const width = panel.getBoundingClientRect().width; + const sidebarWidth = sidebar.getBoundingClientRect().width + 8; + expect(width).toBeGreaterThan(0); + expect(width).toBeLessThan(sidebarWidth); + expect(Number(getComputedStyle(contents).opacity)).toBeGreaterThan(0); + expect(Number(getComputedStyle(contents).opacity)).toBeLessThan(1); + const writingPane = editorScreen.root().element().getBoundingClientRect(); + expect(writingPane.right).toBeCloseTo(window.innerWidth - width, 0); + const remainingToggle = (footerBefore - publishBefore) * (1 - width / sidebarWidth); + const publishDuring = editorScreen.publishButton().element().getBoundingClientRect().right; + const footerDuring = editorScreen.helpLink().element().getBoundingClientRect().right; + expect(publishDuring).toBeLessThan(publishBefore); + expect(footerDuring - publishDuring).toBeCloseTo(remainingToggle, 0); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + opening.finish(); + await expect.poll(() => panel.getBoundingClientRect().width).toBe(sidebarWidth); + expect(getComputedStyle(contents).opacity).toBe('1'); + + const publishOpen = editorScreen.publishButton().element().getBoundingClientRect().right; + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + const closing = settingsTransition()!; + closing.pause(); + closing.currentTime = 0; + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeCloseTo( + publishOpen, + 1, + ); + closing.currentTime = 50_000; + expect(sidebar.isConnected).toBe(true); + expect(panel.getBoundingClientRect().width).toBeGreaterThan(0); + expect(panel.getBoundingClientRect().width).toBeLessThan(sidebarWidth); + expect(Number(getComputedStyle(contents).opacity)).toBeGreaterThan(0); + expect(Number(getComputedStyle(contents).opacity)).toBeLessThan(1); + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeGreaterThan( + publishOpen, + ); + closing.finish(); + await expect(editorScreen.settingsSidebar()).toHaveCount(0); + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeCloseTo( + publishBefore, + 1, + ); + expect(document.activeElement).toBe(editorScreen.settingsToggle().element()); + } finally { + animationStyle.remove(); + } + }); + + it('reverses a closing sidebar without discarding its contents or jumping its controls', async () => { + fakeLongDocument('post'); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const animationStyle = slowSettingsTransition(); + try { + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + settingsTransition()!.finish(); + const sidebar = editorScreen.settingsSidebar().element(); + const panel = sidebar.parentElement!; + await expect + .poll(() => panel.getBoundingClientRect().width) + .toBe(sidebar.getBoundingClientRect().width + 8); + + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + const closing = settingsTransition()!; + closing.pause(); + closing.currentTime = 50_000; + const widthBefore = panel.getBoundingClientRect().width; + const publishBefore = editorScreen.publishButton().element().getBoundingClientRect().right; + // The stationary toggle remains usable from the keyboard as the sidebar recedes. + await userEvent.keyboard(' '); + await expect.poll(() => settingsTransition() !== closing).toBe(true); + const reopening = settingsTransition()!; + reopening.pause(); + reopening.currentTime = 0; + expect(editorScreen.settingsSidebar().element()).toBe(sidebar); + expect(panel.getBoundingClientRect().width).toBeCloseTo(widthBefore, 1); + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeCloseTo( + publishBefore, + 1, + ); + reopening.finish(); + await expect + .poll(() => panel.getBoundingClientRect().width) + .toBe(sidebar.getBoundingClientRect().width + 8); + expect(editorScreen.settingsSidebar().element()).toBe(sidebar); + expect(document.activeElement).toBe(editorScreen.settingsToggle().element()); + } finally { + animationStyle.remove(); + } + }); + + it('preserves document focus when writing resumes during sidebar closing', async () => { + fakeLongDocument('post'); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const animationStyle = slowSettingsTransition(); + try { + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + settingsTransition()!.finish(); + const sidebar = editorScreen.settingsSidebar().element(); + await expect + .poll(() => sidebar.parentElement!.getBoundingClientRect().width) + .toBe(sidebar.getBoundingClientRect().width + 8); + await editorScreen.settingsToggle().click(); + await expect.poll(settingsTransition).toBeDefined(); + const closing = settingsTransition()!; + closing.pause(); + closing.currentTime = 50_000; + editorScreen.titleInput().element().focus(); + closing.finish(); + await expect(editorScreen.settingsSidebar()).toHaveCount(0); + expect(document.activeElement).toBe(editorScreen.titleInput().element()); + } finally { + animationStyle.remove(); + } + }); + + it('keeps one stationary sidebar toggle across subviews and omits its tooltip', async () => { + fakeLongDocument('post'); + await renderAdminApp('/editor/post/abc123', { + ...FLAG_ON, + labs: { editorReact: true, admin7Pill: true }, + }); + await expect.element(editorScreen.body()).toBeVisible(); + const toggle = editorScreen.settingsToggle().element(); + const toggleBefore = toggle.getBoundingClientRect(); + // Header tooltips open immediately on focus; Settings deliberately has none. + editorScreen.settingsToggle().element().focus(); + expect(editorScreen.settingsToggle().element().getAttribute('aria-describedby')).toBeNull(); + await editorScreen.settingsToggle().click(); + await editorScreen.settingsSubviewRow('Code injection').click(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); + const sidebar = editorScreen.settingsSidebar().element(); + await expect.poll(() => sidebar.getBoundingClientRect().right).toBe(window.innerWidth - 8); + expect(sidebar.getBoundingClientRect().width).toBe(492); + await expect(editorScreen.settingsToggle()).toHaveCount(1); + expect(editorScreen.settingsToggle().element()).toBe(toggle); + expect(toggle.getBoundingClientRect()).toEqual(toggleBefore); + expect(sidebar.contains(editorScreen.settingsToggle().element())).toBe(false); + expect(editorScreen.publishButton().element().getBoundingClientRect().right).toBeLessThan( + sidebar.getBoundingClientRect().left, + ); + await editorScreen.settingsToggle().click(); + await expect(editorScreen.settingsSidebar()).toHaveCount(0); + expect(document.activeElement).toBe(editorScreen.settingsToggle().element()); + }); + + it('keeps a full-width image inside the writing pane beside normal and wide settings', async () => { + const imageUrl = URL.createObjectURL( + new Blob( + [ + '', + ], + { type: 'image/svg+xml' }, + ), + ); + try { + const lexical = JSON.parse(buildLexicalParagraph('Below the image')) as { + root: { children: Record[] }; + }; + lexical.root.children.unshift({ + type: 'image', + version: 1, + src: imageUrl, + alt: 'Full-width landscape', + width: 1600, + height: 400, + cardWidth: 'full', + }); + fakeLongDocument('post', 'draft', JSON.stringify(lexical)); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + const image = editorScreen.body().getByRole('img', { name: 'Full-width landscape' }); + await expect.element(image).toBeVisible(); + const pane = editorScreen.scrollPane(); + const closedWidth = image.element().getBoundingClientRect().width; + + await editorScreen.settingsToggle().click(); + const sidebar = editorScreen.settingsSidebar().element(); + await expect.poll(() => sidebar.parentElement!.getBoundingClientRect().width).toBe(350); + await expect + .poll(() => image.element().getBoundingClientRect().right) + .toBeCloseTo(pane.getBoundingClientRect().right - 12, 0); + expect(image.element().getBoundingClientRect().left).toBeCloseTo( + pane.getBoundingClientRect().left, + 0, + ); + expect(pane.scrollWidth).toBe(pane.clientWidth); + expect(pane.getBoundingClientRect().right).toBe(sidebar.getBoundingClientRect().left); + + await editorScreen.settingsSubviewRow('Code injection').click(); + await expect.poll(() => sidebar.parentElement!.getBoundingClientRect().width).toBe(500); + await expect + .poll(() => image.element().getBoundingClientRect().right) + .toBeCloseTo(pane.getBoundingClientRect().right - 12, 0); + expect(pane.scrollWidth).toBe(pane.clientWidth); + + await editorScreen.settingsToggle().click(); + await expect(editorScreen.settingsSidebar()).toHaveCount(0); + await expect + .poll(() => image.element().getBoundingClientRect().width) + .toBeCloseTo(closedWidth, 0); + // Koenig's breakout card border extends one pixel beyond the canvas. + expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth + 1); + expect(getComputedStyle(pane).overflowX).toBe('hidden'); + } finally { + URL.revokeObjectURL(imageUrl); + } + }); + + it('keeps a long save error readable inside the header on a narrow screen', async () => { + await page.viewport(390, 844); + fakeLongDocument('post'); + const message = + 'Saving failed: this post contains a value that is too long. Shorten the value and try saving again.'; + fakeAdminEndpoint( + 'PUT', + /^\/posts\/abc123\/\?/, + { + errors: [{ type: 'ValidationError', message }], + }, + { status: 422 }, + ); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + await editorScreen.body().click(); + await userEvent.keyboard('{End} more'); + await userEvent.keyboard('{Meta>}s{/Meta}'); + await expect.element(editorScreen.status()).toHaveTextContent(message); + const status = editorScreen.status().element(); + const bounds = status.getBoundingClientRect(); + expect(bounds.right).toBeLessThanOrEqual(window.innerWidth); + expect(status.scrollWidth).toBeLessThanOrEqual(status.clientWidth); + expect(status.scrollHeight).toBeLessThanOrEqual(status.clientHeight); + expect(bounds.top).toBeGreaterThanOrEqual( + editorScreen.publishButton().element().getBoundingClientRect().bottom, + ); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + }); + + it('bounds the contributor layout and keeps its controls anchored while writing', async () => { + fakeLongDocument('post'); + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: 'Contributor' })]; + await renderAdminApp('/editor/post/abc123', { + ...FLAG_ON, + boot: { browseMe: { response: me } }, + }); + await expect.element(editorScreen.body()).toBeVisible(); + const visibleControls = [ + editorScreen.backLink('post'), + editorScreen.status(), + editorScreen.settingsToggle(), + editorScreen.wordCount(), + editorScreen.helpLink(), + ]; + const before = visibleControls.map((control) => control.element().getBoundingClientRect().top); + expect(editorScreen.helpLink().element().getBoundingClientRect().bottom).toBeLessThanOrEqual( + window.innerHeight, + ); + const pane = editorScreen.scrollPane(); + pane.scrollTo({ top: 700 }); + await expect.poll(() => pane.scrollTop).toBe(700); + expect(visibleControls.map((control) => control.element().getBoundingClientRect().top)).toEqual( + before, + ); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight); + }); + + it.each([ + { status: 'published', theme: 'light' }, + { status: 'scheduled', theme: 'dark' }, + ] as const)( + 'keeps the $status action translucent and blurred against the $theme canvas', + async ({ status, theme }) => { + fakeLongDocument('post', status); + const me = currentUserResponse(); + me.users[0].accessibility = JSON.stringify({ nightShift: theme }); + await renderAdminApp('/editor/post/abc123', { + ...FLAG_ON, + boot: { browseMe: { response: me } }, + }); + await expect.element(editorScreen.body()).toBeVisible(); + const action = + status === 'published' ? editorScreen.unpublishButton() : editorScreen.unscheduleButton(); + await expect.element(action).toBeVisible(); + await expect + .poll(() => document.documentElement.classList.contains('dark')) + .toBe(theme === 'dark'); + const pane = editorScreen.scrollPane(); + pane.scrollTo({ top: 700 }); + await expect.poll(() => pane.scrollTop).toBe(700); + expectTranslucentSurface(action.element()); + }, + ); + + it.each([1280, 390])( + 'reserves space for a session warning beneath the header at %spx while keeping the footer anchored', + async (width) => { + await page.viewport(width, 800); + fakeLongDocument('post'); + fakeAdminEndpoint( + 'PUT', + /^\/posts\/abc123\/\?/, + { + errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }], + }, + { status: 401 }, + ); + await renderAdminApp('/editor/post/abc123', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + const headerBefore = editorScreen.settingsToggle().element().getBoundingClientRect(); + const footerBefore = editorScreen.helpLink().element().getBoundingClientRect(); + const pane = editorScreen.scrollPane(); + const paneHeight = pane.clientHeight; + + await editorScreen.body().click(); + await userEvent.keyboard('{End} more'); + await userEvent.keyboard('{Meta>}s{/Meta}'); + await expect.element(editorScreen.reauthBanner()).toBeVisible(); + + const banner = editorScreen.reauthBanner().element().getBoundingClientRect(); + expect(banner.top).toBeGreaterThanOrEqual(headerBefore.bottom); + expect(pane.getBoundingClientRect().top).toBeGreaterThanOrEqual(banner.bottom); + expect(pane.clientHeight).toBeLessThan(paneHeight); + expect(editorScreen.settingsToggle().element().getBoundingClientRect().top).toBe( + headerBefore.top, + ); + expect(editorScreen.helpLink().element().getBoundingClientRect().bottom).toBe( + footerBefore.bottom, + ); + + pane.scrollTo({ top: 700 }); + await expect.poll(() => pane.scrollTop).toBe(700); + expect(editorScreen.reauthBanner().element().getBoundingClientRect().top).toBe(banner.top); + expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth); + expect(banner.left).toBeGreaterThanOrEqual(0); + expect(banner.right).toBeLessThanOrEqual(window.innerWidth); + expect( + editorScreen.settingsToggle().element().getBoundingClientRect().right, + ).toBeLessThanOrEqual(window.innerWidth); + }, + ); +}); diff --git a/apps/admin/src/editor/editor-status.tsx b/apps/admin/src/editor/editor-status.tsx index 3a7cad21c707..6855b6e1f60a 100644 --- a/apps/admin/src/editor/editor-status.tsx +++ b/apps/admin/src/editor/editor-status.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; -import { Inline, Text } from '@tryghost/shade/primitives'; +import { Text } from '@tryghost/shade/primitives'; +import { buttonVariants } from '@tryghost/shade/components'; +import { useShade } from '@tryghost/shade/app'; import { formatNumber } from '@tryghost/shade/utils'; import { membersCountString, useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { editorScheduleCountdown, editorStatus } from '@tryghost/test-data/selectors/editor'; @@ -67,22 +69,18 @@ function StatusBody({ }) { switch (view.kind) { case 'problem': - return {view.message}; + return {view.message}; case 'saving': - return Saving…; + return <>Saving…; case 'new': - return New; + return <>New; case 'draft': - return {view.saved ? 'Draft - Saved' : '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. : <>Sent to {members(view.count)}; case 'scheduled': return ( - + <> Scheduled {isHovered && ( <> @@ -96,11 +94,11 @@ function StatusBody({ /> )} - + ); default: return ( - + <> {view.url ? ( + ); } } @@ -129,6 +127,7 @@ export interface EditorStatusProps { /** Where the post stands: its status, the newsletter, and the last save. */ export function EditorStatus({ state, record, isDirty }: EditorStatusProps) { + const { isAdmin7 } = useShade(); const timezone = useSiteTimezone(); const isSaving = useSavingHold(state.kind === 'saving' || state.kind === 'pending-coalesced'); const [isHovered, setIsHovered] = useState(false); @@ -149,19 +148,28 @@ export function EditorStatus({ state, record, isDirty }: EditorStatusProps) { }, [isHovered]); return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - - + + + + ); } diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 9461f8837fba..d71ddf22849f 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -14,6 +14,7 @@ import { editorFeatureImage, editorFeatureImageCaption, editorHeaderActions, + editorHelpLink, editorLeaveDialog, editorLoadError, editorPreviewButton, @@ -122,6 +123,18 @@ export const editorScreen = { /** An item in Koenig's `/` card menu, by its label. */ cardMenuItem: (label: string) => page.getByRole('menuitem', { name: label }), wordCount: () => page.getByTestId(editorWordCount), + helpLink: () => page.getByRole('link', { name: editorHelpLink }), + /** The document's own scroll surface, independent of the editor shell. */ + scrollPane: (): HTMLElement => { + const root = page.getByTestId(postEditor).element(); + const pane = Array.from(root.querySelectorAll('div')).find((element) => + ['auto', 'scroll'].includes(getComputedStyle(element).overflowY), + ); + if (!pane) { + throw new Error('The editor document has no scroll surface'); + } + return pane; + }, loadError: () => page.getByTestId(editorLoadError), reauthBanner: () => page.getByTestId(editorReauthBanner), retryReauth: () => page.getByTestId(editorReauthBanner).getByRole('button', { name: 'Retry' }), diff --git a/apps/admin/src/editor/post-editor.tsx b/apps/admin/src/editor/post-editor.tsx index 53c6251abf29..835329530b8e 100644 --- a/apps/admin/src/editor/post-editor.tsx +++ b/apps/admin/src/editor/post-editor.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Button, buttonVariants } from '@tryghost/shade/components'; import { LucideIcon, cn, formatNumber } from '@tryghost/shade/utils'; import { useFocusContext } from '@tryghost/shade/app'; import { focusKoenigEditorOnBottomClick } from '@tryghost/admin-x-framework'; @@ -16,6 +17,7 @@ import type { PostCardConfig, PostType } from './card-config'; import { FeatureImage } from './feature-image'; import { KoenigPostEditor } from './koenig-post-editor'; import { textHasTk } from './tk'; +import { useOnscreenKeyboard } from './use-onscreen-keyboard'; import type { FeatureImageBinding } from './session/feature-image-binding'; export interface PostEditorProps { @@ -109,7 +111,9 @@ export function PostEditor({ registerSecondaryApi, onTkCountChange, }: PostEditorProps) { - const { darkMode } = useFocusContext(); + const { darkMode, isAdmin7 } = useFocusContext(); + const isKeyboardOpen = useOnscreenKeyboard(); + const writingAreaRef = useRef(null); const titleRef = useRef(null); const excerptRef = useRef(null); const editorApiRef = useRef(null); @@ -121,6 +125,29 @@ export function PostEditor({ useAutosize(titleRef, title); useAutosize(excerptRef, excerpt); + useLayoutEffect(() => { + const container = writingAreaRef.current; + if (!container) { + return; + } + // Koenig's breakout cards use viewport units; subtract the space outside + // the writing area, including its inset and the animated sidebar. + const measure = () => { + container.style.setProperty( + '--kg-breakout-adjustment', + `${Math.max(0, window.innerWidth - container.clientWidth)}px`, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + window.addEventListener('resize', measure); + return () => { + observer.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + const titleHasTk = textHasTk(title); const excerptHasTk = showExcerpt && textHasTk(excerpt); @@ -258,9 +285,10 @@ export function PostEditor({ return (
- ); diff --git a/apps/admin/src/editor/settings/post-settings-sidebar.tsx b/apps/admin/src/editor/settings/post-settings-sidebar.tsx index edcb2de8f05a..c25e522cc033 100644 --- a/apps/admin/src/editor/settings/post-settings-sidebar.tsx +++ b/apps/admin/src/editor/settings/post-settings-sidebar.tsx @@ -1,6 +1,6 @@ import { Fragment, memo, type ReactNode, useEffect, useId } from 'react'; -import { Label, Separator, Switch, Textarea } from '@tryghost/shade/components'; -import { Inline, Text } from '@tryghost/shade/primitives'; +import { Label, Switch, Textarea } from '@tryghost/shade/components'; +import { Box, Inline, Text } from '@tryghost/shade/primitives'; import { cn } from '@tryghost/shade/utils'; import { canAccessSettings, @@ -187,26 +187,37 @@ export function PostSettingsSidebar({ return ( - + + ); } diff --git a/apps/admin/src/editor/settings/settings-section.tsx b/apps/admin/src/editor/settings/settings-section.tsx index f6071fa5fdc9..3dfd81365c61 100644 --- a/apps/admin/src/editor/settings/settings-section.tsx +++ b/apps/admin/src/editor/settings/settings-section.tsx @@ -1,15 +1,11 @@ import type { ReactNode } from 'react'; -import { Separator } from '@tryghost/shade/components'; import { Stack } from '@tryghost/shade/primitives'; -/** One block of the settings panel, with the rule that closes it. */ +/** One block of the settings panel. */ export function SettingsSection({ children }: { children: ReactNode }) { return ( - <> - - {children} - - - + + {children} + ); } diff --git a/apps/admin/src/editor/settings/settings-subview.tsx b/apps/admin/src/editor/settings/settings-subview.tsx index 48cce7a06f41..636b85700f1f 100644 --- a/apps/admin/src/editor/settings/settings-subview.tsx +++ b/apps/admin/src/editor/settings/settings-subview.tsx @@ -1,6 +1,6 @@ import { type ReactNode, useEffect, useRef } from 'react'; -import { Button, Separator } from '@tryghost/shade/components'; -import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Button } from '@tryghost/shade/components'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; import { LucideIcon, cn } from '@tryghost/shade/utils'; import { settingsSubviewPane } from '@tryghost/test-data/selectors/editor'; import type { SettingsSectionId } from './sections'; @@ -57,16 +57,16 @@ export function SettingsSubview({ if (isOpen) { return ( <> -
- +
+ - + {title} + -
- - - + ); } diff --git a/apps/admin/src/editor/use-onscreen-keyboard.test.ts b/apps/admin/src/editor/use-onscreen-keyboard.test.ts new file mode 100644 index 000000000000..c22d82bcb33d --- /dev/null +++ b/apps/admin/src/editor/use-onscreen-keyboard.test.ts @@ -0,0 +1,113 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useOnscreenKeyboard } from './use-onscreen-keyboard'; + +afterEach(() => { + cleanup(); + document.body.replaceChildren(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function setupViewport() { + const viewport = Object.assign(new EventTarget(), { height: 800, scale: 1 }); + vi.stubGlobal('visualViewport', viewport); + vi.stubGlobal('innerHeight', 800); + const resize = (height: number, scale = 1) => { + act(() => { + Object.assign(viewport, { height, scale }); + viewport.dispatchEvent(new Event('resize')); + }); + }; + return { viewport, resize }; +} + +function focusTextarea() { + const textarea = document.createElement('textarea'); + document.body.append(textarea); + act(() => textarea.focus()); + return textarea; +} + +describe('useOnscreenKeyboard', () => { + it('tracks a keyboard opening and closing while the title stays focused', () => { + const { resize } = setupViewport(); + const { result } = renderHook(useOnscreenKeyboard); + focusTextarea(); + expect(result.current).toBe(false); + + resize(480); + expect(result.current).toBe(true); + + resize(800); + expect(result.current).toBe(false); + }); + + it('recognizes contenteditable body focus when the viewport is already reduced', () => { + const { resize } = setupViewport(); + const { result } = renderHook(useOnscreenKeyboard); + resize(480); + expect(result.current).toBe(false); + + const editor = document.createElement('div'); + editor.tabIndex = 0; + // jsdom does not implement isContentEditable. + Object.defineProperty(editor, 'isContentEditable', { value: true }); + document.body.append(editor); + act(() => editor.focus()); + expect(result.current).toBe(true); + + act(() => editor.blur()); + expect(result.current).toBe(false); + }); + + it('does not mistake browser chrome or pinch zoom for a keyboard', () => { + const { resize } = setupViewport(); + const { result } = renderHook(useOnscreenKeyboard); + focusTextarea(); + + resize(720); + expect(result.current).toBe(false); + + resize(400, 2); + expect(result.current).toBe(false); + }); + + it('does not hide the count for a focused non-text control or readonly field', () => { + const { resize } = setupViewport(); + const { result } = renderHook(useOnscreenKeyboard); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + document.body.append(checkbox); + act(() => checkbox.focus()); + resize(480); + expect(result.current).toBe(false); + + const textarea = document.createElement('textarea'); + textarea.readOnly = true; + document.body.append(textarea); + act(() => textarea.focus()); + expect(result.current).toBe(false); + }); + + it('keeps the count visible when the visual viewport API is unavailable', () => { + vi.stubGlobal('visualViewport', undefined); + const { result } = renderHook(useOnscreenKeyboard); + focusTextarea(); + expect(result.current).toBe(false); + }); + + it('removes viewport and focus listeners when the editor unmounts', () => { + const { viewport } = setupViewport(); + const removeViewportListener = vi.spyOn(viewport, 'removeEventListener'); + const removeWindowListener = vi.spyOn(window, 'removeEventListener'); + const removeDocumentListener = vi.spyOn(document, 'removeEventListener'); + const { unmount } = renderHook(useOnscreenKeyboard); + unmount(); + + expect(removeViewportListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeWindowListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeDocumentListener).toHaveBeenCalledWith('focusin', expect.any(Function)); + expect(removeDocumentListener).toHaveBeenCalledWith('focusout', expect.any(Function)); + }); +}); diff --git a/apps/admin/src/editor/use-onscreen-keyboard.ts b/apps/admin/src/editor/use-onscreen-keyboard.ts new file mode 100644 index 000000000000..0036d984d7e1 --- /dev/null +++ b/apps/admin/src/editor/use-onscreen-keyboard.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; + +function hasEditableFocus() { + const element = document.activeElement; + if (element instanceof HTMLTextAreaElement) { + return !element.readOnly && !element.disabled; + } + if (element instanceof HTMLInputElement) { + return ( + !element.readOnly && + !element.disabled && + ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(element.type) + ); + } + return element instanceof HTMLElement && element.isContentEditable === true; +} + +/** Mobile keyboards reduce the visual viewport while leaving the layout viewport intact. */ +export function useOnscreenKeyboard() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) { + return; + } + + const update = () => { + // Ignore pinch zoom and small changes from the browser's collapsing address bar. + setIsOpen( + viewport.scale === 1 && window.innerHeight - viewport.height > 150 && hasEditableFocus(), + ); + }; + + update(); + viewport.addEventListener('resize', update); + window.addEventListener('resize', update); + document.addEventListener('focusin', update); + document.addEventListener('focusout', update); + return () => { + viewport.removeEventListener('resize', update); + window.removeEventListener('resize', update); + document.removeEventListener('focusin', update); + document.removeEventListener('focusout', update); + }; + }, []); + + return isOpen; +} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index fd43c66999f1..a116169b7502 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -6,6 +6,13 @@ @import '@tryghost/shade/styles.css'; @import './assets/fonts/inter-admin/fonts.css'; +/* One progress value keeps the settings panel and its vacated header slot in sync. */ +@property --editor-settings-progress { + syntax: ''; + inherits: true; + initial-value: 0; +} + @keyframes email-sending-arrow-rise { from { transform: translateY(115%); diff --git a/apps/admin/src/layout/admin-layout.tsx b/apps/admin/src/layout/admin-layout.tsx index 79e6a68df43c..e79351340fb4 100644 --- a/apps/admin/src/layout/admin-layout.tsx +++ b/apps/admin/src/layout/admin-layout.tsx @@ -93,7 +93,7 @@ export function AdminLayout({ children }: AdminLayoutProps) {
-
{children}
+
{children}
-
+
{children} diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index ed6c9610a2ac..01ff2a3fea1a 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -174,6 +174,7 @@ export const conflictCopyContentButton = 'Copy content'; export const conflictDiscardAndReloadButton = 'Discard and reload'; export const conflictCancelReloadButton = 'Cancel'; export const editorPreviewButton = 'Preview'; +export const editorHelpLink = 'Editor help'; export const editorPublishButton = 'Publish'; export const editorUpdateButton = 'Update'; export const editorSaveButton = 'Save';