Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/admin/src/app-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import {
import { ShadeApp } from '@tryghost/shade/app';

import App from './app.tsx';
import { installHistoryPopGate } from './hooks/use-history-pop-navigation-guard';
import { routes, useIsEmberOwnedRoute } from './routes.tsx';
import { useThemeContext } from './providers/theme-context';
import { ThemeProvider } from './providers/theme-provider';

// At module scope, so it is in place before `AdminAppRoot` creates the router.
installHistoryPopGate();

function ThemedAdminApp() {
const { resolvedTheme } = useThemeContext();
const { pathname } = useLocation();
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useBrowseSettings } from '@tryghost/admin-x-framework/api/settings';
import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user';
import { EmberProvider, EmberFallback, EmberRoot } from './ember-bridge';
import { AdminLayout } from './layout/admin-layout';
import { useSyncEmberFullScreen } from './layout/sidebar-visibility';
import { useEmberAuthSync, useEmberDataSync, useEmberListReturnSync } from './ember-bridge';
import { DocsBotWidgetHost } from './docsbot-widget-host';
import { useAccentColorProperties } from './hooks/use-accent-color-properties';
Expand All @@ -22,6 +23,7 @@ function App() {
useEmberAuthSync();
useEmberDataSync();
useEmberListReturnSync();
useSyncEmberFullScreen();
useAuthNotice(Boolean(currentUser));

return (
Expand Down
27 changes: 24 additions & 3 deletions apps/admin/src/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

`apps/admin/src/editor/` is the React post and page editor: the screen, the
Koenig surface it wraps, the settings sidebar beside it, and the publish and
preview flows it opens. `api.ts` is the domain's public surface — the shell
mounts the screen lazily through it and everything else here is internal.
preview flows it opens, plus the restore screen that turns a local copy of a
lost draft back into a post. `api.ts` is the domain's public surface — the shell
mounts both screens lazily through it and everything else here is internal.

## The modules

Expand All @@ -16,8 +17,10 @@ mounts the screen lazily through it and everything else here is internal.
| [`preview/`](preview/README.md) | The modal that shows a post as the site renders it or as the newsletter it would be sent as |
| `editor-screen.tsx` | The route: loads the post, builds the session, and lays out the header, the surface and the sidebar |
| `post-editor.tsx`, `koenig-post-editor.tsx` | The title, excerpt and feature image around the Koenig instances, and the Koenig integration itself |
| `editor-header-actions.tsx`, `editor-status.tsx` | The header's publish and preview controls, and the line saying where the post stands |
| `editor-header-actions.tsx`, `editor-status.tsx` | The header's publish and preview controls, and the line saying where the post stands, with a failed send's retry |
| `card-config.ts`, `use-post-card-config.ts` | What Koenig's cards are told about the site and the post they are being edited in |
| `local-revisions.ts` | Browser-local copies of drafts holding unsaved work: how they are stored, trimmed and read back |
| `restore/` | The `/restore` screen: lists this browser's local copies and creates a new draft from any of them |

Two small modules are shared across all of the above. `request-options.ts`
carries the editor's opt-out from the transport's session-expiry redirect, which
Expand All @@ -28,6 +31,24 @@ a component reading a query key it shares with a screen outside the editor opts
out too. `layering.ts` carries the z-index a confirmation dialog opened from
inside another editor surface needs in order to paint above it.

## Opening the publish flow

A draft opens the publish flow from three places: the header's Publish button,
its keyboard shortcut and the preview's Publish. A published or sent post whose
newsletter failed opens it from the status line instead, which offers "Retry
now" on an email-only send and "View details" on a published post; the flow
then starts at its email-failure step. Whether a post qualifies is decided by
the flow's own `initialEmailError()`, and the button is offered only to roles
Core lets retry an email, so an Author sees the failure without it.

Every opener stays unavailable until the publish inputs have loaded. When they
fail to load, the header shows the error with a Retry for a draft, and for a
post whose status line offers the retry. After a retry, or a publish that
emails, a published post's status line reads "Published and sending to N
members" while the email is on its way and "Published and sent to N members"
once the flow's email confirmation finds it submitted; an email-only send reads
"Sent to N members" throughout.

## Adding a settings section

1. Add the section's id to `SETTINGS_SECTION_ORDER` in `settings/sections.ts`,
Expand Down
5 changes: 3 additions & 2 deletions apps/admin/src/editor/api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Public surface of the editor domain, consumed by the admin shell
* (apps/admin/src/routes.tsx via the editor gate). Everything else in this
* (apps/admin/src/routes.tsx and the editor gate). Everything else in this
* domain is internal.
*/

// Lazy entry, not a component re-export: the shell mounts this behind
// Lazy entries, not component re-exports: the shell mounts these behind
// `lazy()`, so a static re-export would pull the chunk into the shell bundle.
export const lazyEditorScreen = () => import('./editor-screen');
export const lazyRestoreScreen = () => import('./restore/restore-screen');
79 changes: 43 additions & 36 deletions apps/admin/src/editor/editor-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { PostPreviewModal, type PostPreviewModalProps } from './preview/post-pre
import { postPreviewUrl } from './preview/preview-url';
import { PublishFlowModal } from './publish/publish-flow-modal';
import { UpdateFlowModal } from './publish/update-flow-modal';
import { buildPublishFlowPost, type PublishFlowPost } from './publish/flow-post';
import type { PublishFlowPost } from './publish/flow-post';
import { describeCompletionFailure } from './publish/completion-message';
import { usePublishInputs } from './publish/use-publish-inputs';
import { usePublishLimits } from './publish/use-publish-limits';
Expand All @@ -25,7 +25,7 @@ import type { EditorSessionHandle } from './session/use-editor-session';
import type { SaveCompletion } from './engine/save-engine';
import { usePreviewShortcut, usePublishShortcut } from './use-editor-shortcuts';

type OpenFlow = 'none' | 'publish' | 'update';
export type OpenFlow = 'none' | 'publish' | 'update';

/** The preview's props short of Publish, which only the publish controls can supply. */
type HeaderPreviewProps = Omit<PostPreviewModalProps, 'onPublish' | 'publishDisabled'>;
Expand All @@ -47,11 +47,18 @@ async function requireSaved(pending: Promise<SaveCompletion>): Promise<void> {

export interface EditorHeaderActionsProps {
session: EditorSessionHandle;
/** Built by the screen, which derives the status line's retry from it too. */
post: PublishFlowPost;
postType: PostType;
currentUser?: User;
siteUrl: string;
/** Unresolved TK markers in the title, excerpt, body and feature image. */
tkCount: number;
/** Held by the screen, because the status line opens the publish flow too. */
openFlow: OpenFlow;
onOpenFlow: (flow: OpenFlow) => void;
/** Whether the status line offers a failed send's retry, which needs the publish inputs. */
offersEmailRetry: boolean;
}

/**
Expand All @@ -60,30 +67,22 @@ export interface EditorHeaderActionsProps {
*/
export function EditorHeaderActions({
session,
post,
postType,
currentUser,
siteUrl,
tkCount,
openFlow,
onOpenFlow,
offersEmailRetry,
}: EditorHeaderActionsProps) {
const { isAdmin7 } = useShade();
const { persistedId, publishTime, title } = session;
const { persistedId } = session;
const record = session.loadedRecord;
const [previewOpen, setPreviewOpen] = useState(false);
const [openFlow, setOpenFlow] = useState<OpenFlow>('none');

const openPreview = useCallback(() => setPreviewOpen(true), []);

const post = buildPublishFlowPost({
snapshot: {
id: persistedId,
status: publishTime.status,
publishedAt: publishTime.publishedAt,
title,
},
record,
displayName: postType,
lexical: session.getLiveLexical(),
});
// Core 301-redirects a published or sent post away from /p/:uuid/ and drops the
// audience query, so Ember offers a preview only while the post is a draft.
const isDraft = post.status === 'draft';
Expand Down Expand Up @@ -157,12 +156,13 @@ export function EditorHeaderActions({
<PublishActions
isDraft={isDraft}
isSaving={isSaving}
offersEmailRetry={offersEmailRetry}
openFlow={openFlow}
post={post}
preview={preview}
session={session}
tkCount={tkCount}
onOpenFlow={setOpenFlow}
onOpenFlow={onOpenFlow}
onPreview={openPreview}
/>
)}
Expand All @@ -176,6 +176,7 @@ interface PublishActionsProps {
tkCount: number;
isDraft: boolean;
isSaving: boolean;
offersEmailRetry: boolean;
openFlow: OpenFlow;
preview: HeaderPreviewProps;
onOpenFlow: (flow: OpenFlow) => void;
Expand All @@ -192,6 +193,7 @@ function PublishActions({
tkCount,
isDraft,
isSaving,
offersEmailRetry,
openFlow,
preview,
onOpenFlow,
Expand Down Expand Up @@ -236,30 +238,34 @@ function PublishActions({
// button is the only way into the flow from there.
usePublishShortcut(openPublishFlow, isDraft && inputs.isReady && !preview.open);

// A draft's Publish and the status line's retry stay disabled until these inputs load.
const inputsError =
(isDraft || offersEmailRetry) && inputs.error ? (
<>
<Text
className="bg-background/80 text-destructive backdrop-blur-sm"
data-testid={editorPublishInputsError}
role="alert"
size="sm"
>
{inputs.error.message}
</Text>
<Button
className="bg-background/80 backdrop-blur-sm"
size={isAdmin7 ? 'default' : 'sm'}
variant="ghost"
onClick={inputs.retry}
>
Retry
</Button>
</>
) : null;

return (
<>
{isDraft ? (
<>
{inputs.error ? (
<>
<Text
className="bg-background/80 text-destructive backdrop-blur-sm"
data-testid={editorPublishInputsError}
role="alert"
size="sm"
>
{inputs.error.message}
</Text>
<Button
className="bg-background/80 backdrop-blur-sm"
size={isAdmin7 ? 'default' : 'sm'}
variant="ghost"
onClick={inputs.retry}
>
Retry
</Button>
</>
) : null}
{inputsError}
<Button
disabled={!inputs.isReady}
size={isAdmin7 ? 'default' : 'sm'}
Expand All @@ -275,6 +281,7 @@ function PublishActions({
</>
) : (
<>
{inputsError}
{/* Ember routes a sent post to the update flow from its status line, not the header. */}
{post.status === 'sent' ? null : (
<PageHeader.Action
Expand Down
65 changes: 62 additions & 3 deletions apps/admin/src/editor/editor-header.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ function restoreNewsletters() {
fakeAdminEndpoint('GET', /^\/newsletters\//, browseResponse('newsletters', [], { limit: 'all' }));
}

/** The publish flow's email confirmation reads the post with its email alone. */
function isEmailConfirmationRead(url: string): boolean {
return new URL(url).searchParams.get('include') === 'email';
}

/**
* A post that answers saves the way Ghost does: the response carries the
* submitted fields back with a fresh collision token, and the read endpoint
Expand Down Expand Up @@ -134,7 +139,13 @@ function fakeSavablePost(
slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }],
}));

fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), () => ({ posts: [current] }));
fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), ({ url }) => {
// The send has gone out by the time the flow's email confirmation reads the post.
if (current.email?.status === 'pending' && isEmailConfirmationRead(url)) {
current.email = { ...current.email, status: 'submitted', error: null };
}
return { posts: [current] };
});

const saveApi = fakeAdminEndpoint(
'PUT',
Expand All @@ -153,9 +164,9 @@ function fakeSavablePost(
const submitted = (body as { posts: Partial<SavedPost>[] }).posts[0];
current = { ...current, ...submitted, updated_at: `2026-01-01T00:00:0${saves}.000Z` };

// A send hands the email over asynchronously; the flow polls until it settles.
// Core creates a send's email pending and hands it over in the background.
if (url.includes('newsletter=')) {
current.email = { id: 'email-1', status: 'submitted', email_count: 20, opened_count: 0 };
current.email = { id: 'email-1', status: 'pending', email_count: 20, opened_count: 0 };
}

return { posts: [current] };
Expand Down Expand Up @@ -258,6 +269,27 @@ describe('Editor header actions', () => {
expect(saveApi.lastRequest?.url).toContain('email_segment=all');
});

it('follows an emailed publish to sent once the flow confirms the send', async () => {
publishChrome({ newsletters: 1 });
const saveApi = fakeSavablePost();
await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);

await expect.element(editorScreen.publishButton()).toBeEnabled();
await editorScreen.publishButton().click();
await expect
.element(publishScreen.setting('publish-type'))
.toHaveTextContent('Publish and email');
await publishScreen.continueButton().click();
await publishScreen.confirmButton().click();

await expect.element(publishScreen.complete()).toBeVisible();
expect(saveApi.lastRequest?.url).toContain('newsletter=weekly');
// The editor's own reads saw the email pending; only the confirmation found it sent.
await expect
.element(editorScreen.status())
.toHaveTextContent('Published and sent to 20 members');
});

it('schedules a draft for the time the flow chose', async () => {
publishChrome();
const saveApi = fakeSavablePost();
Expand Down Expand Up @@ -643,6 +675,33 @@ describe('Editor header actions', () => {
await expect(editorScreen.publishInputsError()).toHaveCount(0);
});

it('offers a retry when the publish inputs fail to load for a failed send', async () => {
publishChrome();
fakeSavablePost({
status: 'published',
published_at: '2026-02-01T10:00:00.000Z',
email: {
id: 'email-1',
status: 'failed',
error: 'The email service was unavailable.',
email_count: 20,
opened_count: 0,
},
});
failNewsletters();
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.publishInputsError()).toHaveTextContent('went wrong');
await expect.element(editorScreen.publishInputsError()).toHaveAttribute('role', 'alert');
await expect.element(editorScreen.viewNewsletterDetails()).toBeDisabled();

restoreNewsletters();
await editorScreen.retryPublishInputs().click();

await expect.element(editorScreen.viewNewsletterDetails()).toBeEnabled();
await expect(editorScreen.publishInputsError()).toHaveCount(0);
});

it('returns to the publish flow when the preview it opened is closed', async () => {
publishChrome();
fakeSavablePost();
Expand Down
Loading
Loading