diff --git a/apps/admin-x-framework/src/api/emails.ts b/apps/admin-x-framework/src/api/emails.ts index 3292b868a24..8ad25aa5398 100644 --- a/apps/admin-x-framework/src/api/emails.ts +++ b/apps/admin-x-framework/src/api/emails.ts @@ -1,11 +1,79 @@ -import { createMutation } from '../utils/api/hooks'; +import { createMutation, createQueryWithId } from '../utils/api/hooks'; import { postsDataType } from './posts'; import type { Email } from './content-types'; +import { z } from 'zod'; export interface EmailsResponseType { emails: Email[]; } +export const EmailBatchStatusSchema = z.enum(['pending', 'submitting', 'submitted', 'failed']); + +export const EmailBatchSchema = z.object({ + id: z.string(), + status: EmailBatchStatusSchema, +}); + +export const EmailBatchesResponseSchema = z.object({ + batches: z.array(EmailBatchSchema), +}); + +export const EmailSendingPhaseSchema = z.enum(['preparing', 'submitting']); + +export const EmailSendingProgressSchema = z.object({ + completed: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + estimated_seconds_remaining: z.number().int().nonnegative().nullable(), +}); + +export const EmailSendingStateSchema = z.discriminatedUnion('status', [ + z.object({ + status: EmailSendingPhaseSchema, + progress: EmailSendingProgressSchema, + }), + z.object({ + status: z.literal('submitted'), + progress: EmailSendingProgressSchema, + }), + z.object({ + status: z.literal('failed'), + progress: EmailSendingProgressSchema, + failed_during: EmailSendingPhaseSchema, + }), +]); + +export const EmailSendingStatusSchema = z.object({ + id: z.string(), + sending: EmailSendingStateSchema, +}); + +export const EmailStatusesResponseSchema = z.object({ + email_statuses: z.array(EmailSendingStatusSchema), +}); + +export type EmailSendingPhase = z.infer; +export type EmailSendingProgress = z.infer; +export type EmailSendingState = z.infer; +export type EmailSendingStatus = z.infer; +export type EmailStatusesResponseType = z.infer; +export type EmailBatch = z.infer; +export type EmailBatchesResponseType = z.infer; + +const emailStatusesDataType = 'EmailStatusesResponseType'; +const emailBatchesDataType = 'EmailBatchesResponseType'; + +export const useBrowseEmailBatches = createQueryWithId({ + dataType: emailBatchesDataType, + path: (id) => `/emails/${id}/batches/`, + parseResponse: (data) => EmailBatchesResponseSchema.parse(data), +}); + +export const useEmailSendingStatus = createQueryWithId({ + dataType: emailStatusesDataType, + path: (id) => `/emails/${id}/status/`, + parseResponse: (data) => EmailStatusesResponseSchema.parse(data), +}); + /** * Retry a failed email send. * diff --git a/apps/admin-x-framework/src/api/feedback.ts b/apps/admin-x-framework/src/api/feedback.ts index 9c1decf2ec8..86635cd1bad 100644 --- a/apps/admin-x-framework/src/api/feedback.ts +++ b/apps/admin-x-framework/src/api/feedback.ts @@ -20,9 +20,9 @@ export interface FeedbackResponseType { feedback: FeedbackItem[]; } -const dataType = 'FeedbackResponseType'; +export const feedbackDataType = 'FeedbackResponseType'; export const usePostFeedbackQuery = createQueryWithId({ - dataType, + dataType: feedbackDataType, path: (id) => `/feedback/${id}/`, }); diff --git a/apps/admin-x-framework/src/api/links.ts b/apps/admin-x-framework/src/api/links.ts index 0ab384cd24b..c7ae85ae59e 100644 --- a/apps/admin-x-framework/src/api/links.ts +++ b/apps/admin-x-framework/src/api/links.ts @@ -39,8 +39,10 @@ export type useBulkEditLinksParameters = { editedUrl: string; }; +export const linksDataType = 'LinkResponseType'; + export const useTopLinks = createQuery({ - dataType: 'LinkResponseType', + dataType: linksDataType, path: '/links/', }); diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index b0123aa0c0b..d57de299f8f 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -195,6 +195,7 @@ export function useMemberCount() { // The Ember members-count-cache's TTL; the framework default staleTime (5min) // is too stale for publish-flow recipient counts. const MEMBERS_COUNT_STALE_TIME = 60 * 1000; +const noOpMembersCountRefetch = () => Promise.resolve(); const useBrowseMembersCountQuery = createQuery({ dataType, @@ -205,6 +206,11 @@ export interface MembersCountResult { /** `null` while loading and for roles that cannot browse members. */ count: number | null; isLoading: boolean; + isFetching: boolean; + /** Preserved for flows where an unreadable count must block a destructive action. */ + error: unknown; + /** Retries the count without forcing every descriptive count consumer to handle errors. */ + refetch: () => Promise; } /** @@ -214,8 +220,10 @@ export interface MembersCountResult { * for 60 seconds. As in Ember, roles that cannot manage members get * `count: null` without a request, a nullish filter counts as 0 without a * request, and request errors resolve to 0 with no error toast. While the - * current user is still loading the result is `{count: null, isLoading: true}` - * so callers can tell it apart from a role that cannot browse members. + * current user is still loading the result has `count: null` and + * `isLoading: true`, so callers can tell it apart from a role that cannot + * browse members. The request error and retry are also exposed for callers + * such as publish limits that cannot safely treat an unreadable count as zero. */ export function useMembersCount(filter: string | null | undefined): MembersCountResult { const { data: currentUser } = useCurrentUser(); @@ -231,16 +239,31 @@ export function useMembersCount(filter: string | null | undefined): MembersCount }); if (currentUser === undefined) { - return { count: null, isLoading: true }; + return { + count: null, + isLoading: true, + isFetching: false, + error: null, + refetch: noOpMembersCountRefetch, + }; } if (!enabled || result.isError) { - return { count: canFetch ? 0 : null, isLoading: false }; + return { + count: canFetch ? 0 : null, + isLoading: false, + isFetching: enabled && result.isFetching, + error: enabled ? result.error : null, + refetch: enabled ? result.refetch : noOpMembersCountRefetch, + }; } return { count: result.data?.meta?.pagination.total ?? null, isLoading: result.isLoading, + isFetching: result.isFetching, + error: result.error, + refetch: result.refetch, }; } diff --git a/apps/admin-x-framework/src/api/newsletters.ts b/apps/admin-x-framework/src/api/newsletters.ts index e6c300f6985..2322109d41a 100644 --- a/apps/admin-x-framework/src/api/newsletters.ts +++ b/apps/admin-x-framework/src/api/newsletters.ts @@ -92,10 +92,17 @@ export const useBrowseNewsletters = createInfiniteQuery< path: '/newsletters/', parseResponse: (data) => NewslettersResponseSchema.parse(data), defaultSearchParams: { include: 'count.active_members,count.posts', limit: '50' }, - defaultNextPageParams: (lastPage, otherParams) => ({ - ...otherParams, - page: (lastPage.meta?.pagination.next || 1).toString(), - }), + defaultNextPageParams: (lastPage, otherParams) => { + const nextPage = lastPage.meta?.pagination.next; + if (!nextPage) { + return undefined; + } + + return { + ...otherParams, + page: nextPage.toString(), + }; + }, returnData: (originalData) => { const { pages } = originalData as InfiniteData; const newsletters = pages.flatMap((page) => page.newsletters); diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index b9db9c07907..fb5e680f276 100644 --- a/apps/admin-x-framework/src/api/stats.ts +++ b/apps/admin-x-framework/src/api/stats.ts @@ -229,8 +229,8 @@ const memberCountHistoryDataType = 'MemberCountHistoryResponseType'; const topPostsStatsDataType = 'TopPostsStatsResponseType'; const postReferrersDataType = 'PostReferrersResponseType'; const newsletterStatsDataType = 'NewsletterStatsResponseType'; -const newsletterBasicStatsDataType = 'NewsletterBasicStatsResponseType'; -const newsletterClickStatsDataType = 'NewsletterClickStatsResponseType'; +export const newsletterBasicStatsDataType = 'NewsletterBasicStatsResponseType'; +export const newsletterClickStatsDataType = 'NewsletterClickStatsResponseType'; const newsletterSubscriberStatsDataType = 'NewsletterSubscriberStatsResponseType'; const postGrowthStatsDataType = 'PostGrowthStatsResponseType'; diff --git a/apps/admin-x-framework/src/api/tiers.ts b/apps/admin-x-framework/src/api/tiers.ts index c88babce05e..bf153065900 100644 --- a/apps/admin-x-framework/src/api/tiers.ts +++ b/apps/admin-x-framework/src/api/tiers.ts @@ -34,10 +34,17 @@ const dataType = 'TiersResponseType'; export const useBrowseTiers = createInfiniteQuery({ dataType, path: '/tiers/', - defaultNextPageParams: (lastPage, otherParams) => ({ - ...otherParams, - page: (lastPage.meta?.pagination.next || 1).toString(), - }), + defaultNextPageParams: (lastPage, otherParams) => { + const nextPage = lastPage.meta?.pagination.next; + if (!nextPage) { + return undefined; + } + + return { + ...otherParams, + page: nextPage.toString(), + }; + }, returnData: (originalData) => { const { pages } = originalData as InfiniteData; const tiers = pages.flatMap((page) => page.tiers); diff --git a/apps/admin-x-framework/test/unit/api/emails.test.tsx b/apps/admin-x-framework/test/unit/api/emails.test.tsx index 17be24205ad..8ca6821c166 100644 --- a/apps/admin-x-framework/test/unit/api/emails.test.tsx +++ b/apps/admin-x-framework/test/unit/api/emails.test.tsx @@ -1,11 +1,127 @@ -import { act } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; -import { useRetryEmail } from '../../../src/api/emails'; +import { + useBrowseEmailBatches, + useEmailSendingStatus, + useRetryEmail, +} from '../../../src/api/emails'; import { postsDataType } from '../../../src/api/posts'; import { withMockFetch } from '../../utils/mock-fetch'; describe('emails api', () => { + it('reads filtered email batches via the batches endpoint', async () => { + await withMockFetch( + { + json: { batches: [{ id: 'batch-1', status: 'submitting' }] }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useBrowseEmailBatches('email-1', { + searchParams: { filter: 'status:submitting', fields: 'id,status', limit: '1' }, + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const batchRequest = (mock.calls as Array>).find( + ([url]) => String(url).includes('/emails/email-1/batches/'), + ); + expect(batchRequest).toBeDefined(); + const [url, options] = batchRequest!; + const requestUrl = new URL(url as string); + expect(requestUrl.pathname).toBe('/ghost/api/admin/emails/email-1/batches/'); + expect(requestUrl.searchParams.get('filter')).toBe('status:submitting'); + expect(requestUrl.searchParams.get('fields')).toBe('id,status'); + expect(requestUrl.searchParams.get('limit')).toBe('1'); + expect(options?.method).toBe('GET'); + expect(result.current.data?.batches).toEqual([{ id: 'batch-1', status: 'submitting' }]); + }, + ); + }); + + it('rejects malformed email batch responses', async () => { + await withMockFetch( + { + json: { batches: [{ id: 'batch-1', status: 'unknown' }] }, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => + useBrowseEmailBatches('email-1', { defaultErrorHandler: false }), + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toBeUndefined(); + }, + ); + }); + + it('reads an email sending status via the status endpoint', async () => { + await withMockFetch( + { + json: { + users: [{ id: 'user-1', roles: [] }], + email_statuses: [ + { + id: 'email-1', + sending: { + status: 'submitting', + progress: { + completed: 500, + total: 1000, + estimated_seconds_remaining: 30, + }, + }, + }, + ], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => useEmailSendingStatus('email-1')); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const statusRequest = (mock.calls as Array>).find( + ([url]) => String(url).includes('/emails/email-1/status/'), + ); + expect(statusRequest).toBeDefined(); + const [url, options] = statusRequest!; + expect(new URL(url as string).pathname).toBe('/ghost/api/admin/emails/email-1/status/'); + expect(options?.method).toBe('GET'); + expect(result.current.data?.email_statuses[0]?.sending).toEqual({ + status: 'submitting', + progress: { + completed: 500, + total: 1000, + estimated_seconds_remaining: 30, + }, + }); + }, + ); + }); + + it('rejects malformed email sending status responses', async () => { + await withMockFetch( + { + json: {}, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => + useEmailSendingStatus('email-1', { defaultErrorHandler: false }), + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toBeUndefined(); + }, + ); + }); + it('retries a failed email via the retry endpoint', async () => { await withMockFetch( { diff --git a/apps/admin-x-framework/test/unit/api/members.test.tsx b/apps/admin-x-framework/test/unit/api/members.test.tsx index de5ebaf3a9b..f796708f0be 100644 --- a/apps/admin-x-framework/test/unit/api/members.test.tsx +++ b/apps/admin-x-framework/test/unit/api/members.test.tsx @@ -355,7 +355,8 @@ describe('members api', () => { queryClient, }); - expect(result.current).toEqual({ count: null, isLoading: true }); + expect(result.current).toMatchObject({ count: null, isLoading: true, error: null }); + expect(result.current.refetch).toBeTypeOf('function'); } finally { globalThis.fetch = originalFetch; } @@ -373,7 +374,11 @@ describe('members api', () => { await Promise.resolve(); }); - expect(result.current).toEqual({ count: null, isLoading: false }); + expect(result.current).toMatchObject({ count: null, isLoading: false, error: null }); + expect(result.current.refetch).toBeTypeOf('function'); + await act(async () => { + await result.current.refetch(); + }); expect(mockFetch.calls).toHaveLength(0); }); }); @@ -390,12 +395,16 @@ describe('members api', () => { await Promise.resolve(); }); - expect(result.current).toEqual({ count: 0, isLoading: false }); + expect(result.current).toMatchObject({ count: 0, isLoading: false, error: null }); + expect(result.current.refetch).toBeTypeOf('function'); + await act(async () => { + await result.current.refetch(); + }); expect(mockFetch.calls).toHaveLength(0); }); }); - it('resolves request errors to a count of 0', async () => { + it('resolves request errors to a count of 0 while preserving retry state', async () => { const queryClient = createQueryClientWithCurrentUser([ { id: 'role-1', name: 'Administrator' }, ]); @@ -407,9 +416,9 @@ describe('members api', () => { queryClient, }); - await waitFor(() => { - expect(result.current).toEqual({ count: 0, isLoading: false }); - }); + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current).toMatchObject({ count: 0, isLoading: false }); + expect(result.current.refetch).toBeTypeOf('function'); }, ); }); diff --git a/apps/admin/src/editor/publish/README.md b/apps/admin/src/editor/publish/README.md index 9cbee50a121..8fc7e78f140 100644 --- a/apps/admin/src/editor/publish/README.md +++ b/apps/admin/src/editor/publish/README.md @@ -127,7 +127,7 @@ The machine never mutates a post, so it needs no snapshot-and-rollback around a ## Limits -`checkLimits()` runs the two host checks concurrently and returns — and stores — a typed result. It clears any result from a previous run first. +`checkLimits()` runs the two host checks concurrently and returns — and stores — a typed result. It clears any result from a previous run first. Both checks settle before it returns or rethrows a settings-refresh failure, so a slower publishing block cannot land after the options step becomes ready. The sending check awaits `refreshSettings()` before anything else, so a hold applied since the editor opened is seen; a failed refresh rejects `checkLimits()`. It then evaluates the email limit, skipping it for authors and contributors, who cannot read email counts. A rejection becomes a `sending-limit` block carrying the host's message. Only if the limit passes is the verification hold read, so a site under its email limit still surfaces a hold; a hold without host-specific copy uses the default message. @@ -140,3 +140,77 @@ Both blocks feed the state directly. An email block disables the email publish t `isDirty` compares the publish type, scheduling, newsletter and recipient filter against the values the machine started with; selecting the value that was already there is not a change. The scheduled time counts only while scheduling is on or the user has actually chosen a time, so turning scheduling on and back off leaves the state clean. `reset()` restores every option and re-arms the automatic type fallback that `setPublishType()` disables. It takes a fresh scheduled time from the current clock, since the floor the machine was created with may itself have passed. + +# Publish flow + +`PublishFlowModal` is the screen that machine drives. It renders the three steps of Ghost's publish flow — options, confirm, complete — plus the email-failure step, over a fullscreen Shade dialog. + +It is self-contained: the caller supplies the post projection, the site and user inputs, the site timezone and a `dispatch` function, and gets back a flow that publishes. The caller passes the save engine's `dispatch` unchanged; the modal never touches the engine, the editor session or the router. `usePublishInputs()` assembles the site and user inputs from the API for callers that have no better source. + +The stateful journey is keyed by post id. If a mounted caller replaces the post, the gates, options machine, limits readiness, failures and completion state all start again for the new post. + +## Steps + +The flow is a four-way branch, taken in this order: + +| Condition | Step | +| ---------------------------------------- | ---------------------------- | +| An email failed, at open or after saving | `CompleteWithEmailErrorStep` | +| The publish landed | `CompleteStep` | +| The user asked for the final review | `ConfirmStep` | +| Otherwise | `OptionsStep` | + +`OptionsStep` is an accordion of the three settings — publish type, email recipients, publish time — with at most one section open, plus the read-only row describing a send the post already had. Its continue button waits for `checkLimits()`, since a block landing late demotes the publish type and the user must not carry a stale choice into the review. `ConfirmStep` captures the publish intent on entry, so the copy on the button and in the sentence cannot change while the save is in flight. `CompleteStep` shows the post as a bookmark card and, for a schedule, offers the revert. + +## Gates + +Two interstitials can stand in front of the flow. A post with unresolved TK markers gets the TK reminder; a post whose public preview has no effect gets the public-preview warning, but only behind the `paywallImprovements` flag. They never stack: a TK count suppresses the preview warning. `getPublicPreviewWarning()` is the pure predicate behind the second one, and reads the editor's unsaved body when the caller passes it. + +## Publishing + +Confirming runs `onBeforePublish` (the editor's pre-save cleanup), dispatches the command from `toDispatch()`, and branches on the completion the engine returns: + +| Completion | Result | +| ----------------------- | ------------------------------------------------------------------- | +| `saved` | The email confirmation runs when the publish emails immediately | +| `needs-retry` | Back to confirm with the re-auth message; the user retries in place | +| `failed` (`conflict`) | The collision message, in place | +| `failed` (`host-limit`) | The host's message, with the upgrade phrase rendered as a link | +| `failed` (`validation`) | The validation message, in place | +| `dropped`/`superseded` | The post is no longer publishable from here | + +No completion closes the modal or navigates. Reaching the complete step writes the celebration handoff (`ghost-last-published-post` or `ghost-last-scheduled-post`), and calls `onCompleted` so the caller can navigate; where the user lands is the caller's decision, not this component's. + +## Email confirmation + +A publish that emails immediately is not done when the save acknowledges: the email is submitted asynchronously. The flow hands the post to `createEmailConfirmation()` and waits, and the confirm button stays in its running state throughout — the publish is not finished, and a button reading "Published & sent" would invite a second dispatch. + +A `failed` outcome moves to the email-error step with the message the API stored. A `cancelled` one completes nothing: cancellation only happens when the flow is being torn down, so treating it as success would write the celebration handoff and tell the caller to navigate after the user had already closed the modal. Every other outcome completes the flow — a timeout, an unpublish, or no email at all are all "nothing left to wait for" — with `not-needed` reporting no email, so the caller does not route to analytics for a send that never happened. + +A reload that throws — a transport failure, or the 401 the redirect opt-out below turns into a rejection — completes the flow with a note instead. The post is published by that point and only the email's fate is unknown, so the alternatives are both wrong: claiming the email failed would invent a fact, and leaving the button running would strand the user on a disabled control for a publish that already succeeded. + +The email's id is only knowable from a reload, so the poller's reload records it for the retry. For the same reason the flow polls rather than short-circuiting on a known email: the acknowledged save result carries no email, and the pre-save one would resolve the confirmation to "not needed" immediately. Closing the flow cancels the poll and marks every pending pre-save, save, confirmation and retry continuation as abandoned, so none can complete the post journey after the caller closes it. + +## Requests + +The shared editor `EDITOR_REQUEST_OPTIONS` opts requests out of the transport's session-expiry redirect wherever the framework supports a per-call option. + +The two requests the flow issues directly — the poller's reload and the published-post count — opt out, as do its settings and config queries. The poller is the most important case: it fires once a second immediately after a save, over an editor that may still hold unsaved work, so a single 401 must not navigate away and lose it. + +`createInfiniteQuery` does not yet accept transport options, so newsletter, tier and label queries remain redirect-capable; `useMembersCount`, `useCurrentUser` and `useRetryEmail` also own their request options. Flow-owned queries disable the global error handler. `usePublishInputs()` returns its query or validation error plus a retry callback instead of leaving callers with an unexplained permanent loading state. + +## Update flow + +`UpdateFlowModal` is the counterpart for a post that is already published, scheduled or sent. It describes what happened and offers the one action available at that point: reverting to a draft, dispatched as `toRevertDispatch()`. + +It reads the newsletter from the post rather than from the options machine, because the machine only ever exposes a selectable newsletter: a post sent to a since-archived one would be described against the site's default instead. + +Its email copy also follows the persisted post rather than the draft-only machine. A scheduled post will email when it has a newsletter and no email record yet; a published or sent post counts as emailed only when it is a post with a non-failed email. A scheduled post with an existing email describes that record separately as a previous send. + +That reading depends on what the caller supplies. `newsletterName` and `newsletterStatus` need a post read that includes the newsletter relation, and the earlier-send sentence needs `emailCreatedAt`; the flow's own reads ask only for `include: 'email'`, and the framework's `Email` type carries no created date yet. Without those fields the copy degrades rather than lying — the newsletter goes unnamed, and the sentence drops its date. + +## Not yet ported + +The size of a newsletter is not shown. The options step keeps the slot the warning belongs in, but nothing measures the rendered email yet, so a send over the 100kB clipping threshold goes unflagged. + +The host limit ports are optional and unset, so `checkLimits()` finds no blocks unless a caller supplies them. diff --git a/apps/admin/src/editor/publish/api-response-schemas.test.ts b/apps/admin/src/editor/publish/api-response-schemas.test.ts new file mode 100644 index 00000000000..b6788af2985 --- /dev/null +++ b/apps/admin/src/editor/publish/api-response-schemas.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + confirmationResponseSchema, + publishedPostCountResponseSchema, +} from '@/editor/publish/api-response-schemas'; + +describe('publish API response schemas', () => { + it('accepts the confirmation projection used by the email poller', () => { + expect( + confirmationResponseSchema.parse({ + posts: [ + { + id: 'post-1', + status: 'published', + email: { + id: 'email-1', + email_count: 10, + opened_count: 2, + status: 'submitted', + }, + }, + ], + }), + ).toMatchObject({ posts: [{ status: 'published', email: { status: 'submitted' } }] }); + }); + + it.each([ + ['an empty post collection', { posts: [] }], + ['an unknown post status', { posts: [{ status: 'publishing', email: null }] }], + [ + 'an incomplete email record', + { posts: [{ status: 'published', email: { id: 'email-1', status: 'submitted' } }] }, + ], + ])('rejects %s', (_name, response) => { + expect(confirmationResponseSchema.safeParse(response).success).toBe(false); + }); + + it('accepts a non-negative published post total', () => { + expect( + publishedPostCountResponseSchema.parse({ meta: { pagination: { total: 41 } } }), + ).toMatchObject({ meta: { pagination: { total: 41 } } }); + }); + + it.each([ + ['a missing total', { meta: { pagination: {} } }], + ['a string total', { meta: { pagination: { total: '41' } } }], + ['a negative total', { meta: { pagination: { total: -1 } } }], + ])('rejects %s', (_name, response) => { + expect(publishedPostCountResponseSchema.safeParse(response).success).toBe(false); + }); +}); diff --git a/apps/admin/src/editor/publish/api-response-schemas.ts b/apps/admin/src/editor/publish/api-response-schemas.ts new file mode 100644 index 00000000000..de0e9799c84 --- /dev/null +++ b/apps/admin/src/editor/publish/api-response-schemas.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const emailSchema = z.looseObject({ + id: z.string(), + opened_count: z.number().int().nonnegative(), + email_count: z.number().int().nonnegative(), + status: z.enum(['pending', 'submitting', 'submitted', 'failed']), + error: z.string().nullable().optional(), + track_opens: z.boolean().optional(), + track_clicks: z.boolean().optional(), +}); + +const confirmationPostSchema = z.looseObject({ + status: z.enum(['published', 'draft', 'scheduled', 'sent']), + email: emailSchema.nullable().optional(), +}); + +export const confirmationResponseSchema = z.looseObject({ + posts: z.array(confirmationPostSchema).min(1), +}); + +export const publishedPostCountResponseSchema = z.looseObject({ + meta: z.looseObject({ + pagination: z.looseObject({ + total: z.number().int().nonnegative(), + }), + }), +}); diff --git a/apps/admin/src/editor/publish/celebration-handoff.ts b/apps/admin/src/editor/publish/celebration-handoff.ts new file mode 100644 index 00000000000..547f4c6a6fb --- /dev/null +++ b/apps/admin/src/editor/publish/celebration-handoff.ts @@ -0,0 +1,31 @@ +/** + * The editor→list handoff read by `apps/admin/src/posts/list/post-publish-celebration.ts`. + * Key names and payload shape match `publish-flow.js#setCompleted` exactly. + */ + +const KEYS = { + published: 'ghost-last-published-post', + scheduled: 'ghost-last-scheduled-post', +} as const; + +export interface PublishCelebrationHandoff { + postId: string; + /** 'post' or 'page' — Ember's `displayName`. */ + displayName: string; + isScheduled: boolean; +} + +export function writePublishCelebration({ + postId, + displayName, + isScheduled, +}: PublishCelebrationHandoff): void { + try { + localStorage.setItem( + isScheduled ? KEYS.scheduled : KEYS.published, + JSON.stringify({ id: postId, type: displayName }), + ); + } catch { + // A full or blocked localStorage costs the celebration, not the publish. + } +} diff --git a/apps/admin/src/editor/publish/completion-message.test.ts b/apps/admin/src/editor/publish/completion-message.test.ts new file mode 100644 index 00000000000..ffba862ec4b --- /dev/null +++ b/apps/admin/src/editor/publish/completion-message.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + CONFLICT_MESSAGE, + DROPPED_MESSAGE, + REAUTH_MESSAGE, + UNREACHABLE_MESSAGE, + describeCompletionFailure, +} from '@/editor/publish/completion-message'; +import type { SaveCompletion, SaveErrorKind } from '@/editor/engine/save-engine'; + +function failed(kind: SaveErrorKind, message = 'boom'): SaveCompletion { + return { kind: 'failed', error: { kind, message }, executedAs: 'publish' }; +} + +describe('describeCompletionFailure', () => { + it('returns nothing for a save that landed', () => { + expect( + describeCompletionFailure({ + kind: 'saved', + result: { id: '1', status: 'published', updatedAt: 'now' }, + executedAs: 'publish', + }), + ).toBeNull(); + }); + + it('sends a re-auth interruption back to confirm with an explanation', () => { + expect(describeCompletionFailure({ kind: 'needs-retry' })).toEqual({ message: REAUTH_MESSAGE }); + expect(describeCompletionFailure(failed('session-invalid'))).toEqual({ + message: REAUTH_MESSAGE, + }); + }); + + it('maps the engine error kinds onto the flow copy', () => { + expect(describeCompletionFailure(failed('validation', 'Title is too long'))).toEqual({ + message: 'Validation failed: Title is too long', + }); + expect(describeCompletionFailure(failed('transport'))).toEqual({ + message: UNREACHABLE_MESSAGE, + }); + expect(describeCompletionFailure(failed('conflict'))).toEqual({ message: CONFLICT_MESSAGE }); + expect(describeCompletionFailure(failed('unknown', 'Something broke'))).toEqual({ + message: 'Something broke', + }); + }); + + it('splits a host limit so the upgrade phrase can be linked', () => { + const failure = describeCompletionFailure( + failed('host-limit', 'You have reached your limit, please upgrade to continue.'), + ); + + expect(failure?.parts).toEqual([ + { text: 'You have reached your limit, ', kind: 'text' }, + { text: 'please upgrade', kind: 'upgrade' }, + { text: ' to continue.', kind: 'text' }, + ]); + }); + + it('treats a dropped or superseded command as no longer publishable', () => { + expect(describeCompletionFailure({ kind: 'dropped', reason: 'not-draft' })).toEqual({ + message: DROPPED_MESSAGE, + }); + expect(describeCompletionFailure({ kind: 'superseded', by: 'publish' })).toEqual({ + message: DROPPED_MESSAGE, + }); + }); +}); diff --git a/apps/admin/src/editor/publish/completion-message.ts b/apps/admin/src/editor/publish/completion-message.ts new file mode 100644 index 00000000000..93db3c5e4d4 --- /dev/null +++ b/apps/admin/src/editor/publish/completion-message.ts @@ -0,0 +1,70 @@ +import { splitUpgradeMessage } from './publish-options'; +import type { LimitMessagePart } from './publish-options'; +import type { SaveCompletion } from '@/editor/engine/save-engine'; + +export const UNREACHABLE_MESSAGE = + 'Unable to connect, please check your internet connection and try again.'; +export const CONFLICT_MESSAGE = + 'Someone else has edited this post since you opened it. Reload the editor to get their changes before publishing.'; +export const REAUTH_MESSAGE = + 'Your session expired. Sign in again in a new tab, then try publishing again.'; +export const UNKNOWN_MESSAGE = 'Unknown Error'; +export const DROPPED_MESSAGE = 'This post can no longer be published from here. Reload the editor.'; + +export interface CompletionFailure { + message: string; + /** Set for a host limit, so "please upgrade" can be rendered as a link. */ + parts?: LimitMessagePart[]; +} + +/** Turns an unexpected rejected promise into safe inline copy. */ +export function describeRejectedAction(error: unknown): CompletionFailure { + if (error instanceof Error && error.message) { + return { message: error.message }; + } + + if (typeof error === 'string' && error) { + return { message: error }; + } + + return { message: UNKNOWN_MESSAGE }; +} + +/** + * Turns a non-success completion into the confirm step's inline error. + * Ported from `publish-flow/confirm.js` :108-138, re-expressed over the + * engine's completion kinds rather than raw transport errors. + */ +export function describeCompletionFailure(completion: SaveCompletion): CompletionFailure | null { + if (completion.kind === 'saved') { + return null; + } + + if (completion.kind === 'needs-retry') { + return { message: REAUTH_MESSAGE }; + } + + if (completion.kind === 'dropped' || completion.kind === 'superseded') { + return { message: DROPPED_MESSAGE }; + } + + const { error } = completion; + + switch (error.kind) { + case 'validation': + return { message: `Validation failed: ${error.message || UNKNOWN_MESSAGE}` }; + case 'transport': + return { message: UNREACHABLE_MESSAGE }; + case 'conflict': + return { message: CONFLICT_MESSAGE }; + case 'session-invalid': + return { message: REAUTH_MESSAGE }; + case 'host-limit': + return { + message: error.message || UNKNOWN_MESSAGE, + parts: splitUpgradeMessage(error.message || UNKNOWN_MESSAGE), + }; + default: + return { message: error.message || UNKNOWN_MESSAGE }; + } +} diff --git a/apps/admin/src/editor/publish/components/complete-step.tsx b/apps/admin/src/editor/publish/components/complete-step.tsx new file mode 100644 index 00000000000..639e5726e56 --- /dev/null +++ b/apps/admin/src/editor/publish/components/complete-step.tsx @@ -0,0 +1,158 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { + publishBackToDashboard, + publishCompleteNote, + publishFlowComplete, + publishRevertToDraft, +} from '@tryghost/test-data/selectors/editor'; +import { PostBookmark } from './post-bookmark'; +import { + formatScheduledCompletion, + formatSiteDateTime, + recipientsConfirmLabel, +} from '@/editor/publish/publish-copy'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; +import type { PublishOptionsState } from '@/editor/publish/publish-options'; + +export interface CompleteStepProps { + post: PublishFlowPost; + state: PublishOptionsState; + captured: { + willPublish: boolean; + willEmail: boolean; + willOnlyEmail: boolean; + isScheduled: boolean; + }; + timezone: string; + siteTitle?: string; + /** Published-post total including this one; null for pages, schedules and email-only. */ + postCount: number | null; + /** When the publish landed, standing in for the publish time the server stamped. */ + completedAt: string | null; + /** Shown when the publish landed but something after it could not be confirmed. */ + note?: string | null; + onRevertToDraft?: () => void; +} + +function RevertToDraft({ onRevertToDraft }: { onRevertToDraft?: () => void }) { + if (!onRevertToDraft) { + return null; + } + + return ( + + Need to make a change?{' '} + + + ); +} + +export function CompleteStep({ + post, + state, + captured, + timezone, + siteTitle, + postCount, + completedAt, + note, + onRevertToDraft, +}: CompleteStepProps) { + const { count } = useMembersCount(state.fullRecipientFilter); + const emailOnly = captured.willOnlyEmail; + // A schedule publishes at the chosen time; anything else just published. + const publishedAt = captured.isScheduled + ? state.scheduledAt + : (completedAt ?? post.publishedAt ?? state.scheduledAt); + + const deliveryVerb = emailOnly ? 'sent' : captured.willEmail ? 'published and sent' : 'published'; + // With a note the send is the unknown, so nothing here may claim one landed. + const unconfirmed = Boolean(note); + + return ( + + {note ? ( + + {note} + + ) : null} + + {captured.isScheduled ? ( + <> + All set! Your{' '} + {emailOnly ? 'email' : post.displayName} will be {deliveryVerb}{' '} + {formatScheduledCompletion(publishedAt, timezone)}. + + ) : ( + <> + {emailOnly && unconfirmed ? null : ( + Boom. It’s out there. + )} + {emailOnly ? ( + unconfirmed ? ( + <>Your {post.displayName} has been created. + ) : ( + 'Your email has been sent.' + ) + ) : post.displayName === 'post' && postCount ? ( + <> + That’s {formatNumber(postCount)} {postCount === 1 ? 'post' : 'posts'} published, + keep going! + + ) : ( + <>Your {post.displayName} has been published. + )} + + )} + + + {emailOnly ? ( + + {unconfirmed ? null : ( + + Your post {captured.isScheduled ? 'will be' : 'was'} sent to{' '} + + {recipientsConfirmLabel({ + recipientType: getRecipientType(state.recipientFilter), + count, + })} + + {state.onlyDefaultNewsletter ? null : ( + <> + {' '} + of {state.newsletter?.name} + + )}{' '} + on {formatSiteDateTime(publishedAt, timezone)}. + + )} + {captured.isScheduled ? : null} + + ) : ( + + + {captured.isScheduled ? ( + + ) : ( + + + Back to dashboard + + + )} + + )} + + ); +} diff --git a/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx new file mode 100644 index 00000000000..4037754abf7 --- /dev/null +++ b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx @@ -0,0 +1,76 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; +import { isPartialEmailFailure } from '@/editor/publish/email-confirmation'; +import { + publishEmailErrorStep, + publishRetryEmailButton, + publishRetryError, +} from '@tryghost/test-data/selectors/editor'; +import type { ConfirmStatus } from '@/editor/publish/use-publish-flow'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; + +export interface CompleteWithEmailErrorStepProps { + post: PublishFlowPost; + emailErrorMessage: string; + willOnlyEmail: boolean; + mailgunConfigured: boolean; + status: ConfirmStatus; + retryFailure: string | null; + onRetry: () => void; +} + +export function CompleteWithEmailErrorStep({ + post, + emailErrorMessage, + willOnlyEmail, + mailgunConfigured, + status, + retryFailure, + onRetry, +}: CompleteWithEmailErrorStepProps) { + const partial = isPartialEmailFailure(emailErrorMessage); + + return ( + + + Uh-oh.{' '} + {willOnlyEmail + ? 'Your post has been created but the email failed to send.' + : `Your ${post.displayName} has been published but the email failed to send.`} + + + + {emailErrorMessage} + {mailgunConfigured ? null : ( + <> +
+
+ If the error persists, please verify your email settings. + + )} +
+ + {retryFailure ? ( + + {retryFailure} + + ) : null} + +
+ +
+
+ ); +} diff --git a/apps/admin/src/editor/publish/components/confirm-step.tsx b/apps/admin/src/editor/publish/components/confirm-step.tsx new file mode 100644 index 00000000000..16a29640df7 --- /dev/null +++ b/apps/admin/src/editor/publish/components/confirm-step.tsx @@ -0,0 +1,144 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; +import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { LimitMessage } from './limit-message'; +import { + publishBackToSettings, + publishConfirmButton, + publishConfirmError, + publishFlowConfirm, +} from '@tryghost/test-data/selectors/editor'; +import { + confirmButtonText, + confirmPublishType, + confirmRunningText, + formatSiteDateTime, + recipientsConfirmLabel, +} from '@/editor/publish/publish-copy'; +import type { CompletionFailure } from '@/editor/publish/completion-message'; +import type { ConfirmStatus } from '@/editor/publish/use-publish-flow'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; +import type { PublishOptionsState } from '@/editor/publish/publish-options'; + +export interface ConfirmStepProps { + post: PublishFlowPost; + state: PublishOptionsState; + /** Captured on entering this step so saving cannot change the copy. */ + captured: { willPublish: boolean; willEmail: boolean; willOnlyEmail: boolean }; + timezone: string; + status: ConfirmStatus; + failure: CompletionFailure | null; + onConfirm: () => void; + onBack: () => void; +} + +function FailureMessage({ failure }: { failure: CompletionFailure }) { + if (!failure.parts) { + return <>{failure.message}; + } + + return ; +} + +export function ConfirmStep({ + post, + state, + captured, + timezone, + status, + failure, + onConfirm, + onBack, +}: ConfirmStepProps) { + const { count } = useMembersCount(state.fullRecipientFilter); + const publishType = confirmPublishType(captured); + const showNewsletterName = !state.onlyDefaultNewsletter && state.newsletter?.name; + const recipients = recipientsConfirmLabel({ + recipientType: getRecipientType(state.recipientFilter), + count, + }); + + const buttonText = { + idle: confirmButtonText({ + publishType, + isScheduled: state.isScheduled, + scheduledAt: state.scheduledAt, + displayName: post.displayName, + timezone, + }), + running: confirmRunningText(publishType, state.isScheduled), + }; + + return ( + + + + Ready, set, publish. + + + Share it with the world. + + + + + {state.isScheduled ? ( + <> + On {formatSiteDateTime(state.scheduledAt, timezone)} your + + ) : ( + 'Your' + )}{' '} + {post.displayName} + {captured.willPublish ? ( + <> will be published on your site{captured.willEmail ? ', and delivered to' : '.'} + ) : null} + {captured.willEmail ? ( + <> + {captured.willPublish ? ' ' : ' will be delivered to '} + {recipients} + {showNewsletterName ? ( + <> + {' '} + of {state.newsletter?.name} + + ) : null} + {captured.willPublish ? '.' : ','} + {captured.willPublish ? null : ( + <> + {' '} + and will not be published on your site. + + )} + + ) : null} + + + {failure ? ( + + + + ) : null} + + + + + + + ); +} diff --git a/apps/admin/src/editor/publish/components/email-recipients-boundary.test.ts b/apps/admin/src/editor/publish/components/email-recipients-boundary.test.ts new file mode 100644 index 00000000000..8ac5e083ff4 --- /dev/null +++ b/apps/admin/src/editor/publish/components/email-recipients-boundary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { parseRecipientSegments } from './email-recipients-boundary'; + +describe('parseRecipientSegments', () => { + it('keeps valid tiers while labels are unavailable', () => { + expect( + parseRecipientSegments({ tiers: [{ slug: 'gold', name: 'Gold', active: true }] }, undefined), + ).toEqual({ + tiers: [{ slug: 'gold', name: 'Gold', active: true }], + labels: [], + }); + }); + + it('keeps valid labels while tiers are malformed', () => { + expect( + parseRecipientSegments( + { tiers: [{ slug: 42, name: 'Gold', active: true }] }, + { labels: [{ slug: 'vip', name: 'VIP' }] }, + ), + ).toEqual({ + tiers: [], + labels: [{ slug: 'vip', name: 'VIP' }], + }); + }); +}); diff --git a/apps/admin/src/editor/publish/components/email-recipients-boundary.ts b/apps/admin/src/editor/publish/components/email-recipients-boundary.ts new file mode 100644 index 00000000000..6b6ba9f5806 --- /dev/null +++ b/apps/admin/src/editor/publish/components/email-recipients-boundary.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +const tiersBoundarySchema = z.looseObject({ + tiers: z.array(z.looseObject({ slug: z.string(), name: z.string(), active: z.boolean() })), +}); +const labelsBoundarySchema = z.looseObject({ + labels: z.array(z.looseObject({ slug: z.string(), name: z.string() })), +}); + +/** Keeps either independently loaded segment collection when the other boundary fails. */ +export function parseRecipientSegments(tiersData: unknown, labelsData: unknown) { + const tiers = tiersBoundarySchema.safeParse(tiersData); + const labels = labelsBoundarySchema.safeParse(labelsData); + + return { + tiers: tiers.success ? tiers.data.tiers : [], + labels: labels.success ? labels.data.labels : [], + }; +} diff --git a/apps/admin/src/editor/publish/components/email-recipients-options.tsx b/apps/admin/src/editor/publish/components/email-recipients-options.tsx new file mode 100644 index 00000000000..122b29ab692 --- /dev/null +++ b/apps/admin/src/editor/publish/components/email-recipients-options.tsx @@ -0,0 +1,175 @@ +import { + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tryghost/shade/components'; +import { Stack } from '@tryghost/shade/primitives'; +import { useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { getNewsletterRecipientFilter } from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { publishNewsletterSelect } from '@tryghost/test-data/selectors/editor'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { useBrowseLabelsInfinite } from '@tryghost/admin-x-framework/api/labels'; +import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; +import { useEffect, useMemo } from 'react'; +import { z } from 'zod'; +import { parseRecipientSegments } from './email-recipients-boundary'; +import { RecipientSelect, type SegmentOption } from './recipient-select'; +import type { NewsletterInput, PublishOptionsState } from '@/editor/publish/publish-options'; + +export interface EmailRecipientsOptionsProps { + state: PublishOptionsState; + onSetNewsletter: (newsletter: NewsletterInput | null) => void; + onSetRecipientFilter: (filter: string | null) => void; +} + +const stripeBoundarySchema = z.object({ + settingsData: z.looseObject({ + settings: z.array(z.looseObject({ key: z.string(), value: z.unknown() })), + }), + configData: z.looseObject({ config: z.looseObject({ stripeDirect: z.boolean() }) }), +}); +export function EmailRecipientsOptions({ + state, + onSetNewsletter, + onSetRecipientFilter, +}: EmailRecipientsOptionsProps) { + const { data: settingsData } = useBrowseSettings({ + defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, + }); + const { data: configData } = useBrowseConfig({ + defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, + }); + const tiersQuery = useBrowseTiers({ + defaultErrorHandler: false, + searchParams: { filter: 'type:paid', limit: 'all' }, + }); + const labelsQuery = useBrowseLabelsInfinite({ + defaultErrorHandler: false, + searchParams: { limit: 'all' }, + }); + const { + data: labelsData, + fetchNextPage: fetchNextLabelPage, + hasNextPage: hasNextLabelPage, + isError: labelsError, + isFetchingNextPage: isFetchingNextLabelPage, + } = labelsQuery; + const { + data: tiersData, + fetchNextPage: fetchNextTierPage, + hasNextPage: hasNextTierPage, + isError: tiersError, + isFetchingNextPage: isFetchingNextTierPage, + } = tiersQuery; + + // Core caps `limit=all`, so the response can still contain a next page. + // Exhaust both collections before exposing segments to avoid a partial list. + useEffect(() => { + if (hasNextLabelPage && !isFetchingNextLabelPage && !labelsError) { + void fetchNextLabelPage(); + } + }, [fetchNextLabelPage, hasNextLabelPage, isFetchingNextLabelPage, labelsError]); + + useEffect(() => { + if (hasNextTierPage && !isFetchingNextTierPage && !tiersError) { + void fetchNextTierPage(); + } + }, [fetchNextTierPage, hasNextTierPage, isFetchingNextTierPage, tiersError]); + + const labelsSettled = + !labelsQuery.isLoading && !isFetchingNextLabelPage && (!hasNextLabelPage || labelsError); + const tiersSettled = + !tiersQuery.isLoading && !isFetchingNextTierPage && (!hasNextTierPage || tiersError); + + const stripeBoundary = stripeBoundarySchema.safeParse({ settingsData, configData }); + const paidAvailable = stripeBoundary.success + ? (() => { + const { settings, config } = { + settings: stripeBoundary.data.settingsData.settings, + config: stripeBoundary.data.configData.config, + }; + const hasSetting = (key: string) => + settings.some((setting) => setting.key === key && Boolean(setting.value)); + const hasDirectKeys = + hasSetting('stripe_secret_key') && hasSetting('stripe_publishable_key'); + const hasConnectKeys = + hasSetting('stripe_connect_secret_key') && hasSetting('stripe_connect_publishable_key'); + + return config.stripeDirect ? hasDirectKeys : hasConnectKeys || hasDirectKeys; + })() + : false; + + const segmentOptions = useMemo(() => { + if (!labelsSettled || !tiersSettled) { + return []; + } + + const { tiers, labels } = parseRecipientSegments( + tiersError ? undefined : tiersData, + labelsError ? undefined : labelsData, + ); + // A single paid tier adds nothing to a paid/free split, so Ember hides it. + const tierOptions = + tiers.length > 1 + ? [...tiers] + .sort((a, b) => Number(b.active) - Number(a.active)) + .map((tier) => ({ segment: `tier:${tier.slug}`, name: tier.name })) + : []; + + return [ + ...tierOptions, + ...labels.map((label) => ({ + segment: `label:${label.slug}`, + name: label.name, + })), + ]; + }, [labelsData, labelsError, labelsSettled, tiersData, tiersError, tiersSettled]); + + const newsletterRecipientFilter = state.newsletter + ? getNewsletterRecipientFilter({ + slug: state.newsletter.slug, + visibility: state.newsletter.visibility, + }) + : null; + + return ( + + + + {state.newsletters.length > 1 ? ( + + + + + ) : null} + + ); +} diff --git a/apps/admin/src/editor/publish/components/gate-dialog.tsx b/apps/admin/src/editor/publish/components/gate-dialog.tsx new file mode 100644 index 00000000000..9e72f415df4 --- /dev/null +++ b/apps/admin/src/editor/publish/components/gate-dialog.tsx @@ -0,0 +1,41 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@tryghost/shade/components'; +import type { ReactNode } from 'react'; + +export interface GateDialogProps { + testId: string; + title: string; + children: ReactNode; + onContinue: () => void; + onBack: () => void; +} + +/** + * The pre-publish interstitials (TK reminders, public-preview warnings): + * continue into the flow, or go back to the editor. + */ +export function GateDialog({ testId, title, children, onContinue, onBack }: GateDialogProps) { + return ( + !open && onBack()}> + + + {title} + {children} + + + + + + + + ); +} diff --git a/apps/admin/src/editor/publish/components/limit-message-helpers.ts b/apps/admin/src/editor/publish/components/limit-message-helpers.ts new file mode 100644 index 00000000000..fff5a0a1a67 --- /dev/null +++ b/apps/admin/src/editor/publish/components/limit-message-helpers.ts @@ -0,0 +1,30 @@ +import { DEFAULT_UPGRADE_ROUTE } from '@tryghost/admin-x-framework/api/config'; +import { z } from 'zod'; + +const upgradeUrlSchema = z + .string() + .min(1) + .regex(/^(?:#?\/|https?:\/\/)/); +const upgradeConfigSchema = z.looseObject({ + config: z.looseObject({ + hostSettings: z + .looseObject({ + billing: z.looseObject({ upgradeUrl: upgradeUrlSchema.optional() }).optional(), + }) + .optional(), + }), +}); + +export function upgradeHref(route: string): string { + return route.startsWith('/') ? `#${route}` : route; +} + +export function upgradeHrefFromConfig(data: unknown): string { + const parsed = upgradeConfigSchema.safeParse(data); + const configured = parsed.success + ? parsed.data.config.hostSettings?.billing?.upgradeUrl + : undefined; + const route = configured ? configured.replace(/^#/, '') : DEFAULT_UPGRADE_ROUTE; + + return upgradeHref(route); +} diff --git a/apps/admin/src/editor/publish/components/limit-message.test.ts b/apps/admin/src/editor/publish/components/limit-message.test.ts new file mode 100644 index 00000000000..790aef0bcd2 --- /dev/null +++ b/apps/admin/src/editor/publish/components/limit-message.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + upgradeHref, + upgradeHrefFromConfig, +} from '@/editor/publish/components/limit-message-helpers'; + +describe('upgradeHref', () => { + it('turns an Admin route into a hash href', () => { + expect(upgradeHref('/pro/billing/plans')).toBe('#/pro/billing/plans'); + }); + + it('preserves an absolute host billing URL', () => { + expect(upgradeHref('https://billing.example.com/upgrade')).toBe( + 'https://billing.example.com/upgrade', + ); + }); + + it('reads a validated host route from config', () => { + expect( + upgradeHrefFromConfig({ + config: { hostSettings: { billing: { upgradeUrl: '#/billing/plans' } } }, + }), + ).toBe('#/billing/plans'); + }); + + it('falls back when the configured upgrade URL is malformed', () => { + expect( + upgradeHrefFromConfig({ + config: { hostSettings: { billing: { upgradeUrl: 42 } } }, + }), + ).toBe('#/pro'); + }); + + it('falls back when the configured upgrade URL uses an unsafe scheme', () => { + expect( + upgradeHrefFromConfig({ + config: { hostSettings: { billing: { upgradeUrl: 'javascript:alert(1)' } } }, + }), + ).toBe('#/pro'); + }); +}); diff --git a/apps/admin/src/editor/publish/components/limit-message.tsx b/apps/admin/src/editor/publish/components/limit-message.tsx new file mode 100644 index 00000000000..24742f893d4 --- /dev/null +++ b/apps/admin/src/editor/publish/components/limit-message.tsx @@ -0,0 +1,27 @@ +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; +import { upgradeHrefFromConfig } from './limit-message-helpers'; +import type { LimitMessagePart } from '@/editor/publish/publish-options'; + +/** Renders a host limit message, linking the upgrade phrase without injecting markup. */ +export function LimitMessage({ parts }: { parts: LimitMessagePart[] }) { + const { data: configData } = useBrowseConfig({ + defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, + }); + const href = upgradeHrefFromConfig(configData); + + return ( + <> + {parts.map((part) => + part.kind === 'upgrade' ? ( + + {part.text} + + ) : ( + {part.text} + ), + )} + + ); +} diff --git a/apps/admin/src/editor/publish/components/options-step.tsx b/apps/admin/src/editor/publish/components/options-step.tsx new file mode 100644 index 00000000000..fb39618fc94 --- /dev/null +++ b/apps/admin/src/editor/publish/components/options-step.tsx @@ -0,0 +1,229 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, formatNumber } from '@tryghost/shade/utils'; +import { + getRecipientType, + normalizeRecipientFilter, +} from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { useState } from 'react'; +import { + publishAlreadySent, + publishContinueButton, + publishEmailSizeWarning, + publishFlowOptions, + publishLimitsError, + publishSettingEmailRecipients, + publishSettingPublishAt, + publishSettingPublishType, +} from '@tryghost/test-data/selectors/editor'; +import { EmailRecipientsOptions } from './email-recipients-options'; +import { PublishAtOptions } from './publish-at-options'; +import { LimitMessage } from './limit-message'; +import { PublishSetting, PublishSettingNote } from './publish-setting'; +import { PublishTypeOptions } from './publish-type-options'; +import { recipientsRowLabel, relativeTime } from '@/editor/publish/publish-copy'; +import type { + NewsletterInput, + PublishOptionsState, + PublishType, +} from '@/editor/publish/publish-options'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; + +type Section = 'publishType' | 'emailRecipients' | 'publishAt'; + +export interface OptionsStepProps { + post: PublishFlowPost; + state: PublishOptionsState; + timezone: string; + /** True when the site turned newsletters off; hides the historic send row. */ + emailDisabledInSettings: boolean; + /** The limit checks can demote the publish type, so review waits for them. */ + limitsChecked: boolean; + /** A failed limit read keeps Continue disabled and offers a retry. */ + limitsFailure: string | null; + onSetPublishType: (publishType: PublishType) => void; + onSetNewsletter: (newsletter: NewsletterInput | null) => void; + onSetRecipientFilter: (filter: string | null) => void; + onToggleScheduled: (isScheduled: boolean) => void; + onSetScheduledAt: (date: Date) => void; + onContinue: () => void; + onRetryLimits: () => void; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function RecipientsRowTitle({ state }: { state: PublishOptionsState }) { + const { count } = useMembersCount(state.fullRecipientFilter); + + if (!state.recipientFilter) { + return <>Not sent as newsletter; + } + + return ( + <> + {recipientsRowLabel({ + recipientType: getRecipientType(state.recipientFilter), + count, + newsletterName: state.onlyDefaultNewsletter ? null : (state.newsletter?.name ?? null), + })} + + ); +} + +export function OptionsStep({ + post, + state, + timezone, + emailDisabledInSettings, + limitsChecked, + limitsFailure, + onSetPublishType, + onSetNewsletter, + onSetRecipientFilter, + onToggleScheduled, + onSetScheduledAt, + onContinue, + onRetryLimits, +}: OptionsStepProps) { + const [openSection, setOpenSection] = useState
(null); + const toggle = (section: Section) => () => + setOpenSection((current) => (current === section ? null : section)); + + const publishBlocked = state.publishBlock !== null; + const selectedType = state.publishTypeOptions.find( + (option) => option.value === state.publishType, + ); + const historicEmail = post.email; + const historicRecipientType = getRecipientType(normalizeRecipientFilter(post.emailSegment)); + + return ( + + + + Ready, set, publish. + + + Share it with the world. + + + + {limitsFailure ? ( + + + {limitsFailure} + + + + ) : null} + + + + + {state.publishBlock ? ( + + + + ) : null} + + {state.emailUnavailable ? null : ( + } + open={openSection === 'emailRecipients'} + testId={publishSettingEmailRecipients} + title={ + state.publishType === 'publish' || publishBlocked ? ( + 'Not sent as newsletter' + ) : ( + + ) + } + onToggle={toggle('emailRecipients')} + > + + + )} + + {historicEmail && !emailDisabledInSettings ? ( + } + testId={publishAlreadySent} + title={[ + historicEmail.status === 'failed' ? 'Retry sending to' : 'Already sent to', + formatNumber(historicEmail.email_count ?? 0), + // The segment word is dropped for "all", as in the row above it. + historicRecipientType === 'all' || historicRecipientType === 'none' + ? null + : historicRecipientType, + historicEmail.email_count === 1 ? 'subscriber' : 'subscribers', + state.onlyDefaultNewsletter || !post.newsletterName + ? null + : `of ${post.newsletterName}`, + ] + .filter(Boolean) + .join(' ')} + disabled + /> + ) : null} + + } + open={openSection === 'publishAt'} + testId={publishSettingPublishAt} + title={state.isScheduled ? capitalize(relativeTime(state.scheduledAt)) : 'Right now'} + onToggle={toggle('publishAt')} + > + + + + + {publishBlocked ? null : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/admin/src/editor/publish/components/post-bookmark.tsx b/apps/admin/src/editor/publish/components/post-bookmark.tsx new file mode 100644 index 00000000000..6a462020bc4 --- /dev/null +++ b/apps/admin/src/editor/publish/components/post-bookmark.tsx @@ -0,0 +1,43 @@ +import { Stack, Text } from '@tryghost/shade/primitives'; +import { publishCompleteBookmark } from '@tryghost/test-data/selectors/editor'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; + +export interface PostBookmarkProps { + post: PublishFlowPost; + siteTitle?: string; +} + +/** The link-preview card Ember renders as `GhPostBookmark`. */ +export function PostBookmark({ post, siteTitle }: PostBookmarkProps) { + return ( + + {post.featureImage ? ( +
+ ) : null} + + + {post.title} + + {post.excerpt ? ( + + {post.excerpt} + + ) : null} + {siteTitle ? ( + + {siteTitle} + + ) : null} + + + ); +} diff --git a/apps/admin/src/editor/publish/components/publish-at-options.tsx b/apps/admin/src/editor/publish/components/publish-at-options.tsx new file mode 100644 index 00000000000..915510ac90b --- /dev/null +++ b/apps/admin/src/editor/publish/components/publish-at-options.tsx @@ -0,0 +1,134 @@ +import moment from 'moment-timezone'; +import { + Calendar, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, + RadioGroup, + RadioGroupItem, +} from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { useState } from 'react'; +import { publishScheduleDate, publishScheduleTime } from '@tryghost/test-data/selectors/editor'; +import { siteCalendarDay } from '@/editor/publish/publish-copy'; +import type { PublishOptionsState } from '@/editor/publish/publish-options'; + +const DATE_FORMAT = 'YYYY-MM-DD'; +const TIME_FORMAT = 'HH:mm'; + +export interface PublishAtOptionsProps { + state: PublishOptionsState; + timezone: string; + onToggleScheduled: (isScheduled: boolean) => void; + onSetScheduledAt: (date: Date) => void; +} + +/** The picker works in the site's timezone; the machine stores UTC. */ +function inSiteTimezone(iso: string, timezone: string): moment.Moment { + return moment.tz(iso, timezone); +} + +export function PublishAtOptions({ + state, + timezone, + onToggleScheduled, + onSetScheduledAt, +}: PublishAtOptionsProps) { + const scheduled = inSiteTimezone(state.scheduledAt, timezone); + // Null while the field is not being edited, so machine-side changes show through. + const [timeDraft, setTimeDraft] = useState(null); + const [calendarOpen, setCalendarOpen] = useState(false); + + const commitDate = (selected: Date | undefined) => { + if (!selected) { + return; + } + + // Read back the same way `siteCalendarDay` writes: local fields carry the + // site-timezone day. + const next = scheduled.clone().set({ + year: selected.getFullYear(), + month: selected.getMonth(), + date: selected.getDate(), + }); + + setCalendarOpen(false); + onSetScheduledAt(next.toDate()); + }; + + const commitTime = (value: string) => { + const normalized = /^\d:\d\d$/.test(value) ? `0${value}` : value; + const [hour, minute] = normalized.split(':').map((part) => parseInt(part, 10)); + + // An unparseable time reverts to the scheduled one, as the Ember field does. + setTimeDraft(null); + + if (!/^\d\d:\d\d$/.test(normalized) || hour > 23 || minute > 59) { + return; + } + + onSetScheduledAt(scheduled.clone().set({ hour, minute }).toDate()); + }; + + return ( + + onToggleScheduled(value === 'schedule')} + > + + + + + + + + + + + {state.isScheduled ? ( + + + + + + + + + + + commitTime(event.target.value)} + onChange={(event) => setTimeDraft(event.target.value)} + /> + + + + {scheduled.format('z')} + + + + + ) : null} + + ); +} diff --git a/apps/admin/src/editor/publish/components/publish-setting.tsx b/apps/admin/src/editor/publish/components/publish-setting.tsx new file mode 100644 index 00000000000..9d221a9adcc --- /dev/null +++ b/apps/admin/src/editor/publish/components/publish-setting.tsx @@ -0,0 +1,72 @@ +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, cn } from '@tryghost/shade/utils'; +import type { ReactNode } from 'react'; + +export interface PublishSettingProps { + testId: string; + icon: ReactNode; + /** The collapsed summary line. */ + title: ReactNode; + open?: boolean; + /** A disabled row shows its summary and never expands. */ + disabled?: boolean; + onToggle?: () => void; + children?: ReactNode; + /** Rendered under the row whether or not it is expanded (warnings, read-only notes). */ + footer?: ReactNode; +} + +export function PublishSetting({ + testId, + icon, + title, + open = false, + disabled = false, + onToggle, + children, + footer, +}: PublishSettingProps) { + const interactive = !disabled && Boolean(onToggle); + + return ( + + + {open && children ?
{children}
: null} + {footer} +
+ ); +} + +export function PublishSettingNote({ children, testId }: { children: ReactNode; testId?: string }) { + return ( + + {children} + + ); +} diff --git a/apps/admin/src/editor/publish/components/publish-type-options.tsx b/apps/admin/src/editor/publish/components/publish-type-options.tsx new file mode 100644 index 00000000000..7550eafe84c --- /dev/null +++ b/apps/admin/src/editor/publish/components/publish-type-options.tsx @@ -0,0 +1,71 @@ +import { Label, RadioGroup, RadioGroupItem } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { publishTypeError as publishTypeErrorTestId } from '@tryghost/test-data/selectors/editor'; +import type { PublishOptionsState, PublishType } from '@/editor/publish/publish-options'; + +const MAILGUN_DOCS = 'https://docs.ghost.org/newsletters/#bulk-email-configuration'; + +export interface PublishTypeOptionsProps { + state: PublishOptionsState; + onChange: (publishType: PublishType) => void; +} + +function EmailUnavailableNote({ state }: { state: PublishOptionsState }) { + const reason = state.emailDisabledReason; + + if (reason === 'sending-limit' || reason === 'email-verification') { + return ( + + {state.emailBlock?.message} + + ); + } + + if (reason === 'no-members') { + return ( + + + Add members + {' '} + to start sending newsletters! + + ); + } + + if (reason === 'no-mailgun') { + return ( + + Set up{' '} + + Mailgun + {' '} + to start sending newsletters! + + ); + } + + return null; +} + +export function PublishTypeOptions({ state, onChange }: PublishTypeOptionsProps) { + return ( + + onChange(value as PublishType)} + > + {state.publishTypeOptions.map((option) => ( + + + + + ))} + + + + ); +} diff --git a/apps/admin/src/editor/publish/components/recipient-select.tsx b/apps/admin/src/editor/publish/components/recipient-select.tsx new file mode 100644 index 00000000000..4db742c889b --- /dev/null +++ b/apps/admin/src/editor/publish/components/recipient-select.tsx @@ -0,0 +1,179 @@ +import { + FREE_SEGMENT, + PAID_SEGMENT, + buildRecipientFilter, + parseRecipientFilter, +} from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { Checkbox, Label } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { useState } from 'react'; +import { + publishRecipientFree, + publishRecipientPaid, + publishRecipientSegments, + publishRecipientSpecific, +} from '@tryghost/test-data/selectors/editor'; + +export interface SegmentOption { + /** The NQL segment, e.g. `tier:gold` or `label:vip`. */ + segment: string; + name: string; +} + +export interface RecipientSelectProps { + filter: string | null; + /** The newsletter's own audience filter, used to scope the free/paid counts. */ + newsletterRecipientFilter: string | null; + paidAvailable: boolean; + segmentOptions: SegmentOption[]; + onChange: (filter: string | null) => void; +} + +function SegmentCount({ filter }: { filter: string | null }) { + const { count } = useMembersCount(filter); + + if (count === null) { + return null; + } + + return ( + + ({formatNumber(count)}) + + ); +} + +/** + * Ported from `gh-members-recipient-select`: free/paid checkboxes plus an + * optional "Specific people" segment selection, all expressed as one NQL + * recipient filter. + */ +export function RecipientSelect({ + filter, + newsletterRecipientFilter, + paidAvailable, + segmentOptions, + onChange, +}: RecipientSelectProps) { + const segments = parseRecipientFilter(filter); + // Remembers a selection across an off/on toggle, and keeps the picker open + // when "Specific people" is checked with nothing selected yet. + const [forceSpecific, setForceSpecific] = useState(false); + const [previousSpecific, setPreviousSpecific] = useState(null); + + const specificChecked = forceSpecific || segments.specific.length > 0; + + const update = (base: string[], specific: string[]) => { + onChange(buildRecipientFilter({ base, specific }, { paidAvailable })); + }; + + const toggleBase = (segment: string) => { + const base = segments.base.includes(segment) + ? segments.base.filter((item) => item !== segment) + : [...segments.base, segment]; + + update(base, segments.specific); + }; + + const toggleSpecific = () => { + if (forceSpecific && segments.specific.length === 0) { + setForceSpecific(false); + return; + } + + setForceSpecific(false); + + if (specificChecked) { + setPreviousSpecific(segments.specific); + update(segments.base, []); + return; + } + + if (previousSpecific) { + update(segments.base, previousSpecific); + return; + } + + setForceSpecific(true); + }; + + const toggleSegment = (segment: string) => { + const specific = segments.specific.includes(segment) + ? segments.specific.filter((item) => item !== segment) + : [...segments.specific, segment]; + + if (specific.length === 0) { + setPreviousSpecific(null); + setForceSpecific(true); + } + + update(segments.base, specific); + }; + + const scopedFilter = (segment: string) => + newsletterRecipientFilter ? `${newsletterRecipientFilter}+${segment}` : segment; + + return ( + + + + toggleBase(FREE_SEGMENT)} + /> + + + + {paidAvailable ? ( + + toggleBase(PAID_SEGMENT)} + /> + + + ) : null} + + {segmentOptions.length > 0 ? ( + + + + + ) : null} + + + {specificChecked ? ( + + + Selection + + {segmentOptions.map((option) => ( + + toggleSegment(option.segment)} + /> + + + ))} + + ) : null} + + ); +} diff --git a/apps/admin/src/editor/publish/flow-post.ts b/apps/admin/src/editor/publish/flow-post.ts new file mode 100644 index 00000000000..52382acb902 --- /dev/null +++ b/apps/admin/src/editor/publish/flow-post.ts @@ -0,0 +1,37 @@ +import type { Email, PostStatus } from '@tryghost/admin-x-framework/api/posts'; + +/** + * What the publish flow needs from the post being published. A projection, not + * the API record: the flow reads it and never writes to it. + */ +export interface PublishFlowPost { + id: string; + /** 'post' or 'page' — Ember's `displayName`, used verbatim in copy. */ + displayName: 'post' | 'page'; + status: PostStatus; + title: string; + excerpt?: string | null; + /** The post's front-end URL, for the complete step's bookmark. */ + url?: string | null; + featureImage?: string | null; + publishedAt?: string | null; + visibility?: string | null; + tiers?: ReadonlyArray<{ slug: string }>; + /** Persisted newsletter slug and segment; the machine seeds its picker from them. */ + newsletter?: string | null; + /** The post's own newsletter, which may since have been archived. */ + newsletterName?: string | null; + newsletterStatus?: string | null; + emailSegment?: string | null; + /** The durable `email_only` flag; scheduled email-only posts still have `status: scheduled`. */ + emailOnly?: boolean; + email?: Email | null; + /** When the post's email was created, for the update flow's historic sentence. */ + emailCreatedAt?: string | null; + /** The unsaved body when the editor has one; read only by the public-preview predicate. */ + lexical?: string | null; +} + +export function isPage(post: PublishFlowPost): boolean { + return post.displayName === 'page'; +} diff --git a/apps/admin/src/editor/publish/public-preview-warning.test.ts b/apps/admin/src/editor/publish/public-preview-warning.test.ts new file mode 100644 index 00000000000..98c4437f341 --- /dev/null +++ b/apps/admin/src/editor/publish/public-preview-warning.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { getPublicPreviewWarning } from '@/editor/publish/public-preview-warning'; + +function lexical(children: unknown[]): string { + return JSON.stringify({ root: { children } }); +} + +const paragraph = (text: string) => ({ + type: 'paragraph', + children: [{ type: 'text', text }], +}); +const paywall = { type: 'paywall' }; + +describe('getPublicPreviewWarning', () => { + it('returns nothing without a paywall card', () => { + expect( + getPublicPreviewWarning({ lexical: lexical([paragraph('Hello')]), visibility: 'paid' }), + ).toBeNull(); + }); + + it('warns that a public post ignores the preview', () => { + expect( + getPublicPreviewWarning({ + lexical: lexical([paragraph('Above'), paywall, paragraph('Below')]), + visibility: 'public', + }), + ).toBe('public-access'); + }); + + it('warns when nothing sits above the paywall', () => { + expect( + getPublicPreviewWarning({ + lexical: lexical([paywall, paragraph('Below')]), + visibility: 'paid', + }), + ).toBe('no-content-before'); + }); + + it('warns when nothing sits below the paywall', () => { + expect( + getPublicPreviewWarning({ + lexical: lexical([paragraph('Above'), paywall, paragraph(' ')]), + visibility: 'members', + }), + ).toBe('no-content-after'); + }); + + it('counts a card as content but an empty paragraph as nothing', () => { + expect( + getPublicPreviewWarning({ + lexical: lexical([{ type: 'image' }, paywall, paragraph('Below')]), + visibility: 'paid', + }), + ).toBeNull(); + }); + + it('does not count a malformed object as content', () => { + expect( + getPublicPreviewWarning({ + lexical: lexical([paragraph('Above'), paywall, {}]), + visibility: 'paid', + }), + ).toBe('no-content-after'); + }); + + it('is silent on unparseable or absent content', () => { + expect(getPublicPreviewWarning({ lexical: 'not json', visibility: 'paid' })).toBeNull(); + expect(getPublicPreviewWarning({ lexical: null, visibility: 'paid' })).toBeNull(); + }); + + it.each([ + ['a primitive root', { root: 'text' }], + ['a non-array child collection', { root: { children: [{ type: 'paragraph', children: {} }] } }], + ])('is silent on malformed Lexical data with %s', (_name, value) => { + expect(getPublicPreviewWarning({ lexical: value, visibility: 'paid' })).toBeNull(); + }); + + it('ignores primitive top-level nodes when evaluating content', () => { + expect( + getPublicPreviewWarning({ + lexical: { root: { children: ['text', paywall, paragraph('Below')] } }, + visibility: 'paid', + }), + ).toBe('no-content-before'); + }); +}); diff --git a/apps/admin/src/editor/publish/public-preview-warning.ts b/apps/admin/src/editor/publish/public-preview-warning.ts new file mode 100644 index 00000000000..20685e650c4 --- /dev/null +++ b/apps/admin/src/editor/publish/public-preview-warning.ts @@ -0,0 +1,122 @@ +/** + * Ported from `apps/ember-admin/app/utils/public-preview-warning.js`. The + * caller gates the result on the `paywallImprovements` flag, as Ember does. + */ + +import { z } from 'zod'; + +export type PublicPreviewWarning = 'public-access' | 'no-content-before' | 'no-content-after'; + +const lexicalNodeSchema = z.looseObject({ + type: z.string(), + text: z.unknown().optional(), + children: z.array(z.unknown()).optional(), +}); + +const lexicalStateSchema = z.looseObject({ + root: z.looseObject({ + children: z.array(z.unknown()), + }), +}); + +export interface PublicPreviewWarningPost { + /** The unsaved body when the editor has one, else the persisted body. */ + lexical?: string | object | null; + visibility?: string | null; +} + +function parseLexicalState( + lexical: string | object | null | undefined, +): z.infer | null { + if (!lexical) { + return null; + } + + try { + const candidate: unknown = typeof lexical === 'string' ? JSON.parse(lexical) : lexical; + const parsed = lexicalStateSchema.safeParse(candidate); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function hasContent(node: unknown): boolean { + const parsed = lexicalNodeSchema.safeParse(node); + + if (!parsed.success) { + return false; + } + + const candidate = parsed.data; + + if (candidate.type === 'paywall' || candidate.type === 'linebreak') { + return false; + } + + if (typeof candidate.text === 'string') { + return Boolean(candidate.text.trim()); + } + + if (candidate.type === 'text' || candidate.type === 'extended-text') { + return false; + } + + if (Array.isArray(candidate.children)) { + return candidate.children.some(hasContent); + } + + return candidate.type !== 'paragraph' && candidate.type !== 'root'; +} + +export function getPublicPreviewWarning( + post: PublicPreviewWarningPost, +): PublicPreviewWarning | null { + const state = parseLexicalState(post.lexical); + const children = state?.root?.children; + + if (!children) { + return null; + } + + const publicPreviewIndex = children.findIndex((node) => { + const parsed = lexicalNodeSchema.safeParse(node); + return parsed.success && parsed.data.type === 'paywall'; + }); + + if (publicPreviewIndex === -1) { + return null; + } + + if (post.visibility === 'public') { + return 'public-access'; + } + + if (!children.slice(0, publicPreviewIndex).some(hasContent)) { + return 'no-content-before'; + } + + if (!children.slice(publicPreviewIndex + 1).some(hasContent)) { + return 'no-content-after'; + } + + return null; +} + +export const PUBLIC_PREVIEW_WARNING_COPY: Record< + PublicPreviewWarning, + { title: string; body: string } +> = { + 'no-content-before': { + title: 'Nothing above the public preview', + body: 'Add some content above the public preview so everyone has something to read before the paywall.', + }, + 'no-content-after': { + title: 'Nothing below the public preview', + body: 'Add some content below the public preview for subscribers who have access to the full post.', + }, + 'public-access': { + title: 'Public preview has no effect', + body: 'This post is public, so everyone can read the full post and the public preview won’t have any effect.', + }, +}; diff --git a/apps/admin/src/editor/publish/publish-copy.test.ts b/apps/admin/src/editor/publish/publish-copy.test.ts new file mode 100644 index 00000000000..b131c286b2b --- /dev/null +++ b/apps/admin/src/editor/publish/publish-copy.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + confirmButtonText, + confirmPublishType, + confirmRunningText, + recipientsConfirmLabel, + recipientsRowLabel, + siteCalendarDay, +} from '@/editor/publish/publish-copy'; + +const UTC = 'Etc/UTC'; + +describe('recipientsRowLabel', () => { + it('prefixes "All" only for a plural or unknown count', () => { + expect(recipientsRowLabel({ recipientType: 'all', count: 1234 })).toBe('All 1,234 subscribers'); + expect(recipientsRowLabel({ recipientType: 'all', count: 1 })).toBe('1 subscriber'); + expect(recipientsRowLabel({ recipientType: 'all', count: null })).toBe('All subscribers'); + }); + + it('names the segment for every other recipient type', () => { + expect(recipientsRowLabel({ recipientType: 'free', count: 12 })).toBe('12 free subscribers'); + expect(recipientsRowLabel({ recipientType: 'paid', count: 3 })).toBe('3 paid subscribers'); + expect(recipientsRowLabel({ recipientType: 'specific', count: 2 })).toBe( + '2 specific subscribers', + ); + }); + + it('capitalizes the segment when the count is unknown', () => { + expect(recipientsRowLabel({ recipientType: 'free', count: null })).toBe('Free subscribers'); + }); + + it('appends the newsletter name when one is given', () => { + expect(recipientsRowLabel({ recipientType: 'all', count: 5, newsletterName: 'Weekly' })).toBe( + 'All 5 subscribers of Weekly', + ); + }); +}); + +describe('recipientsConfirmLabel', () => { + it('always prefixes "all", unlike the collapsed row', () => { + expect(recipientsConfirmLabel({ recipientType: 'all', count: 1 })).toBe('all 1 subscriber'); + expect(recipientsConfirmLabel({ recipientType: 'paid', count: 40 })).toBe( + '40 paid subscribers', + ); + }); +}); + +describe('confirm button copy', () => { + it('names the post type for a publish-only confirm', () => { + expect( + confirmButtonText({ + publishType: 'publish', + isScheduled: false, + scheduledAt: '2026-09-02T10:00:00.000Z', + displayName: 'page', + timezone: UTC, + }), + ).toBe('Publish page, right now'); + }); + + it('appends the scheduled date instead of "right now"', () => { + expect( + confirmButtonText({ + publishType: 'publish+send', + isScheduled: true, + scheduledAt: '2026-09-02T10:00:00.000Z', + displayName: 'post', + timezone: UTC, + }), + ).toBe('Publish & send, on September 2nd'); + }); + + it('keeps the underlying idle copy but takes the schedule running copy', () => { + expect(confirmRunningText('send', false)).toBe('Sending'); + expect(confirmRunningText('publish+send', false)).toBe('Publishing & sending'); + expect(confirmRunningText('send', true)).toBe('Scheduling'); + }); +}); + +describe('siteCalendarDay', () => { + // 20:00 UTC on the 3rd is already 08:00 on the 4th in Auckland, so an + // implementation handing the picker the instant lands a day early. + it('carries the site-timezone day in the local fields a date picker reads', () => { + const day = siteCalendarDay('2026-09-03T20:00:00.000Z', 'Pacific/Auckland'); + + expect([day.getFullYear(), day.getMonth(), day.getDate()]).toEqual([2026, 8, 4]); + }); + + it('keeps a day that both zones agree on', () => { + const day = siteCalendarDay('2026-09-03T12:00:00.000Z', 'Etc/UTC'); + + expect([day.getFullYear(), day.getMonth(), day.getDate()]).toEqual([2026, 8, 3]); + }); +}); + +describe('confirmPublishType', () => { + it('derives the type from the captured intent', () => { + expect(confirmPublishType({ willPublish: true, willEmail: true, willOnlyEmail: false })).toBe( + 'publish+send', + ); + expect(confirmPublishType({ willPublish: false, willEmail: true, willOnlyEmail: true })).toBe( + 'send', + ); + expect(confirmPublishType({ willPublish: true, willEmail: false, willOnlyEmail: false })).toBe( + 'publish', + ); + }); +}); diff --git a/apps/admin/src/editor/publish/publish-copy.ts b/apps/admin/src/editor/publish/publish-copy.ts new file mode 100644 index 00000000000..25e8ddb578c --- /dev/null +++ b/apps/admin/src/editor/publish/publish-copy.ts @@ -0,0 +1,160 @@ +import moment from 'moment-timezone'; +import { formatNumber } from '@tryghost/shade/utils'; +import type { RecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; + +export type PostDisplayName = 'post' | 'page'; + +/** The publish flow always counts in "subscribers", whatever the newsletter count. */ +function subscribers(count: number | null | undefined): string { + return count === 1 ? 'subscriber' : 'subscribers'; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +export interface RecipientLabelInputs { + recipientType: RecipientType; + /** Null when the current role cannot read member counts. */ + count: number | null | undefined; + /** Appended as "of " only when the site has more than one newsletter. */ + newsletterName?: string | null; +} + +function joinWords(words: Array): string { + return words.filter((word): word is string => Boolean(word)).join(' '); +} + +/** + * The collapsed recipients row, e.g. "All 1,234 subscribers of Weekly". + * Ported from `publish-flow/options.hbs` :78-96, including its "All" prefix + * only appearing for a plural or unknown count. + */ +export function recipientsRowLabel({ + recipientType, + count, + newsletterName, +}: RecipientLabelInputs): string { + const unknown = count === null || count === undefined; + const isAll = recipientType === 'all'; + + return joinWords([ + isAll && (unknown || count > 1) ? 'All' : null, + unknown ? null : formatNumber(count), + isAll ? null : unknown ? capitalize(recipientType) : recipientType, + subscribers(count), + newsletterName ? `of ${newsletterName}` : null, + ]); +} + +/** + * The recipient phrase in the confirm sentence, e.g. "all 1,234 subscribers". + * Ported from `publish-flow/confirm.hbs` :31-47; unlike the collapsed row, the + * "all" prefix here is unconditional. + */ +export function recipientsConfirmLabel({ recipientType, count }: RecipientLabelInputs): string { + const unknown = count === null || count === undefined; + const isAll = recipientType === 'all'; + + return joinWords([ + isAll ? 'all' : null, + unknown ? null : formatNumber(count), + isAll ? null : recipientType, + subscribers(count), + ]); +} + +export type ConfirmPublishType = 'publish+send' | 'publish' | 'send'; + +// Ember's `buttonTextMap`, less its success copy: the flow replaces the confirm +// step with the complete step, so a success state on this button never renders. +const BUTTON_TEXT = { + 'publish+send': { idle: 'Publish & send', running: 'Publishing & sending' }, + send: { idle: 'Send email', running: 'Sending' }, + publish: { idle: 'Publish', running: 'Publishing' }, + // No idle text: a schedule keeps the underlying publish type's idle copy. + schedule: { running: 'Scheduling' }, +} as const; + +export interface ConfirmButtonInputs { + publishType: ConfirmPublishType; + isScheduled: boolean; + scheduledAt: string; + displayName: PostDisplayName; + timezone: string; +} + +/** `publish-flow/confirm.js` :72-89. */ +export function confirmButtonText({ + publishType, + isScheduled, + scheduledAt, + displayName, + timezone, +}: ConfirmButtonInputs): string { + let text: string = BUTTON_TEXT[publishType].idle; + + if (publishType === 'publish') { + text += ` ${displayName}`; + } + + if (isScheduled) { + text += `, on ${moment.tz(scheduledAt, timezone).format('MMMM Do')}`; + } else { + text += ', right now'; + } + + return text; +} + +export function confirmRunningText(publishType: ConfirmPublishType, isScheduled: boolean): string { + return BUTTON_TEXT[isScheduled ? 'schedule' : publishType].running; +} + +/** `publish-flow/confirm.js` :60-70 — derived from the state captured at entry. */ +export function confirmPublishType({ + willPublish, + willEmail, + willOnlyEmail, +}: { + willPublish: boolean; + willEmail: boolean; + willOnlyEmail: boolean; +}): ConfirmPublishType { + if (willPublish && willEmail) { + return 'publish+send'; + } + if (willOnlyEmail) { + return 'send'; + } + return 'publish'; +} + +/** + * The site-timezone calendar day, carried in a Date's LOCAL fields. Date pickers + * read a Date through its local getters, so handing them the instant itself + * lands on the wrong day whenever the site and browser zones disagree. + */ +export function siteCalendarDay(iso: string, timezone: string): Date { + const time = moment.tz(iso, timezone); + return new Date(time.year(), time.month(), time.date()); +} + +/** `gh-format-post-time` with `relative=true`: plain `moment().from(now)`. */ +export function relativeTime(iso: string, now?: Date): string { + return moment(iso).from(now ? moment(now) : moment.utc()); +} + +export function formatSiteDateTime(iso: string, timezone: string): string { + return moment.tz(iso, timezone).format('D MMM YYYY [at] HH:mm'); +} + +/** The complete step's "today"/"on " variant. */ +export function formatScheduledCompletion(iso: string, timezone: string): string { + const scheduled = moment.tz(iso, timezone); + const day = scheduled.isSame(moment.tz(timezone), 'day') + ? 'today' + : `on ${scheduled.format('MMMM Do')}`; + + return `${day} at ${scheduled.format('HH:mm')}`; +} diff --git a/apps/admin/src/editor/publish/publish-flow-modal.tsx b/apps/admin/src/editor/publish/publish-flow-modal.tsx new file mode 100644 index 00000000000..477607e289b --- /dev/null +++ b/apps/admin/src/editor/publish/publish-flow-modal.tsx @@ -0,0 +1,245 @@ +import { Button, Dialog, DialogContent, DialogTitle } from '@tryghost/shade/components'; +import { Inline, Stack } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { useState } from 'react'; +import { + publicPreviewWarningDialog, + publishFlowModal, + publishFlowPreviewButton, + tkReminderDialog, +} from '@tryghost/test-data/selectors/editor'; +import { CompleteStep } from './components/complete-step'; +import { CompleteWithEmailErrorStep } from './components/complete-with-email-error-step'; +import { ConfirmStep } from './components/confirm-step'; +import { GateDialog } from './components/gate-dialog'; +import { OptionsStep } from './components/options-step'; +import { PUBLIC_PREVIEW_WARNING_COPY, getPublicPreviewWarning } from './public-preview-warning'; +import { usePublishFlow, type PublishDispatcher } from './use-publish-flow'; +import type { PublishFlowPost } from './flow-post'; +import type { PublishLimitPorts, PublishSiteInput, PublishUserInput } from './publish-options'; + +// A fullscreen surface: the flow owns the screen, like Ember's total overlay. +const FULLSCREEN = + 'top-0 left-0 h-[100dvh] w-full max-w-full translate-0 grid-rows-[1fr] gap-0 overflow-y-auto rounded-none border-0 p-0 shadow-none sm:rounded-none'; + +export interface PublishFlowModalProps { + post: PublishFlowPost; + site: PublishSiteInput; + user: PublishUserInput; + limits?: PublishLimitPorts; + /** The publish machine's clock, injected for tests. */ + now?: () => Date; + timezone: string; + siteTitle?: string; + /** Gates the flow behind a reminder when the body still has TK markers. */ + tkCount?: number; + /** The `paywallImprovements` lab; the public-preview gate is off without it. */ + paywallImprovements?: boolean; + /** The caller supplies the save engine's dispatch. */ + dispatch: PublishDispatcher; + onBeforePublish?: () => Promise; + onClose: () => void; + onPreview?: () => void; + onRevertToDraft?: () => void; + onCompleted?: (info: { postId: string; isScheduled: boolean; hasEmail: boolean }) => void; +} + +export function PublishFlowModal({ post, ...props }: PublishFlowModalProps) { + return ; +} + +/** A post change is a new journey; no gate, failure, or completion state carries across it. */ +function KeyedPublishFlowModal({ + post, + site, + user, + limits, + now, + timezone, + siteTitle, + tkCount = 0, + paywallImprovements = false, + dispatch, + onBeforePublish, + onClose, + onPreview, + onRevertToDraft, + onCompleted, +}: PublishFlowModalProps) { + const [gatesPassed, setGatesPassed] = useState(false); + const previewWarning = paywallImprovements ? getPublicPreviewWarning(post) : null; + + // Ember checks the TK gate first and only reaches the preview warning when + // there are no TKs, so the two never stack. + if (!gatesPassed && tkCount > 0) { + return ( + setGatesPassed(true)} + > + Looks like you’ve got some unfinished business. There {tkCount === 1 ? 'is' : 'are'}{' '} + + {formatNumber(tkCount)} TK {tkCount === 1 ? 'reminder' : 'reminders'} + {' '} + left in your post. + + ); + } + + if (!gatesPassed && previewWarning) { + return ( + setGatesPassed(true)} + > + {PUBLIC_PREVIEW_WARNING_COPY[previewWarning].body} + + ); + } + + return ( + + ); +} + +type PublishFlowDialogProps = Omit; + +function PublishFlowDialog({ + post, + site, + user, + limits, + now, + timezone, + siteTitle, + dispatch, + onBeforePublish, + onClose, + onPreview, + onRevertToDraft, + onCompleted, +}: PublishFlowDialogProps) { + const flow = usePublishFlow({ + post, + site, + user, + limits, + now, + dispatch, + onBeforePublish, + onCompleted, + }); + const { machine, state, step } = flow; + const close = () => { + flow.cancel(); + onClose(); + }; + + const transition = + (apply: (value: T) => void) => + (value: T) => { + apply(value); + flow.refresh(); + }; + + return ( + !open && close()}> + event.preventDefault()} + > + Publish + + + {step === 'complete' ? null : ( + <> + + {flow.emailErrorMessage || !onPreview ? null : ( + + )} + + )} + + + {step === 'email-error' && flow.emailErrorMessage ? ( + void flow.retryEmail()} + /> + ) : step === 'complete' ? ( + + ) : step === 'confirm' ? ( + void flow.confirmPublish()} + /> + ) : ( + machine.setNewsletter(value))} + onSetPublishType={transition((value) => machine.setPublishType(value))} + onSetRecipientFilter={transition((value) => machine.setRecipientFilter(value))} + onSetScheduledAt={transition((value) => machine.setScheduledAt(value))} + onToggleScheduled={transition((value) => machine.setIsScheduled(value))} + /> + )} + + + + ); +} diff --git a/apps/admin/src/editor/publish/publish-flow.acceptance.test.tsx b/apps/admin/src/editor/publish/publish-flow.acceptance.test.tsx new file mode 100644 index 00000000000..aa3ae7efe74 --- /dev/null +++ b/apps/admin/src/editor/publish/publish-flow.acceptance.test.tsx @@ -0,0 +1,1219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { StrictMode } from 'react'; + +import { fakeAdminEndpoint, fakeLabels, fakeTiers } from '@test-utils/acceptance'; +import { TestWrapper } from '@test-utils/fixtures/query-client'; +import '@/index.css'; + +import { PublishFlowModal } from '@/editor/publish/publish-flow-modal'; +import { UpdateFlowModal } from '@/editor/publish/update-flow-modal'; +import { publishScreen } from '@/editor/publish/publish.screen'; +import type { PublishFlowPost } from '@/editor/publish/flow-post'; +import type { + PublishDispatch, + PublishSiteInput, + PublishUserInput, +} from '@/editor/publish/publish-options'; +import type { SaveCompletion, SaveErrorKind } from '@/editor/engine/save-engine'; + +const POST_ID = 'post-1'; +const EMAIL_ID = 'email-1'; +const EVERYONE = 'status:free,status:-free'; +// The email poller waits a second between reads, so these journeys outlast the default timeout. +const SLOW = 25_000; + +const SITE: PublishSiteInput = { + membersEnabled: true, + mailgunConfigured: true, + editorDefaultEmailRecipients: 'visibility', + editorDefaultEmailRecipientsFilter: null, + memberCount: 20, + newsletters: [ + { slug: 'weekly', name: 'Weekly', status: 'active', visibility: 'members', sortOrder: 0 }, + ], +}; + +const USER: PublishUserInput = { isAdmin: true, isAuthorOrContributor: false }; + +afterEach(() => { + localStorage.removeItem('ghost-last-published-post'); + localStorage.removeItem('ghost-last-scheduled-post'); +}); + +function draft(overrides: Partial = {}): PublishFlowPost { + return { + id: POST_ID, + displayName: 'post', + status: 'draft', + title: 'Hello from React', + excerpt: 'A short summary', + url: 'https://example.com/hello-from-react/', + visibility: 'public', + publishedAt: null, + ...overrides, + }; +} + +function saved(status: PublishFlowPost['status'] = 'published'): SaveCompletion { + return { + kind: 'saved', + result: { id: POST_ID, status, updatedAt: '2026-09-02T10:00:00.000Z' }, + executedAs: status === 'scheduled' ? 'schedule' : 'publish', + }; +} + +function failed(kind: SaveErrorKind, message: string): SaveCompletion { + return { kind: 'failed', error: { kind, message }, executedAs: 'publish' }; +} + +/** Member counts for every recipient probe the flow makes. */ +function fakeMemberCounts(total: number) { + return fakeAdminEndpoint('GET', /^\/members\/\?.*filter=/, { + members: [], + meta: { pagination: { page: 1, limit: 1, pages: 1, total, next: null, prev: null } }, + }); +} + +/** The published-post total the complete step counts up from. */ +function fakePublishedCount(total: number) { + return fakeAdminEndpoint('GET', /^\/posts\/\?/, { + posts: [], + meta: { pagination: { page: 1, limit: 1, pages: 1, total, next: null, prev: null } }, + }); +} + +/** What the email poller reads back after the save. */ +function fakeEmailPolling(...states: Array<{ status: string; error?: string | null }>) { + let index = 0; + + return fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), () => { + const email = states[Math.min(index, states.length - 1)]; + index += 1; + + return { + posts: [ + { + id: POST_ID, + status: 'published', + email: { id: EMAIL_ID, email_count: 20, opened_count: 0, ...email }, + }, + ], + }; + }); +} + +function completesWith(completion: SaveCompletion) { + return vi.fn((command: PublishDispatch): Promise => { + void command; + return Promise.resolve(completion); + }); +} + +async function renderPublishFlow( + props: Partial> = {}, +) { + const dispatch = completesWith(saved()); + const onCompleted = vi.fn(); + const renderModal = (nextProps: Partial> = {}) => ( + + {}} + onCompleted={onCompleted} + {...props} + {...nextProps} + /> + + ); + + const rendered = await render(renderModal()); + + return { + dispatch, + onCompleted, + rerender: (nextProps: Partial>) => + rendered.rerender(renderModal(nextProps)), + unmount: () => rendered.unmount(), + }; +} + +describe('Publish flow', () => { + beforeEach(() => { + localStorage.clear(); + fakeMemberCounts(20); + fakePublishedCount(41); + fakeEmailPolling({ status: 'submitted' }); + fakeTiers([]); + fakeLabels([]); + }); + + it( + 'publishes and emails a draft, then hands the celebration to the list', + async () => { + const { dispatch, onCompleted } = await renderPublishFlow(); + + await expect.element(publishScreen.options()).toBeInTheDocument(); + await publishScreen.continueButton().click(); + + await expect.element(publishScreen.confirm()).toBeInTheDocument(); + await expect + .element(publishScreen.confirmButton()) + .toHaveTextContent('Publish & send, right now'); + + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: EVERYONE }, + }); + expect(JSON.parse(localStorage.getItem('ghost-last-published-post') ?? 'null')).toEqual({ + id: POST_ID, + type: 'post', + }); + await expect.element(publishScreen.complete()).toHaveTextContent('Boom. It’s out there.'); + await expect + .element(publishScreen.complete()) + .toHaveTextContent('That’s 42 posts published, keep going!'); + expect(onCompleted).toHaveBeenCalledTimes(1); + expect(onCompleted).toHaveBeenCalledWith({ + postId: POST_ID, + isScheduled: false, + hasEmail: true, + }); + }, + SLOW, + ); + + it( + 'holds the confirm button through the email poll so the publish cannot be dispatched twice', + async () => { + // Two polls, so the flow is still waiting when the assertions run. + fakeEmailPolling({ status: 'pending' }, { status: 'submitted' }); + const { dispatch } = await renderPublishFlow(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .poll(() => publishScreen.confirmButton().element().textContent, { timeout: 2000 }) + .toContain('Publishing & sending'); + await expect + .poll(() => publishScreen.confirmButton().element().hasAttribute('disabled'), { + timeout: 2000, + }) + .toBe(true); + expect(dispatch).toHaveBeenCalledTimes(1); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledTimes(1); + }, + SLOW, + ); + + it( + 'completes nothing when the flow is torn down mid-poll', + async () => { + fakeEmailPolling({ status: 'pending' }); + const { dispatch, onCompleted, unmount } = await renderPublishFlow(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + // The save has landed and the poll is running. + await expect.poll(() => dispatch.mock.calls.length).toBe(1); + + await unmount(); + + // Long enough for two poll ticks to have landed had the run continued. + await new Promise((resolve) => { + setTimeout(resolve, 2500); + }); + expect(onCompleted).not.toHaveBeenCalled(); + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + }, + SLOW, + ); + + it('completes nothing when the flow is torn down during the save', async () => { + let finishDispatch: (completion: SaveCompletion) => void = () => {}; + const dispatch = vi.fn( + () => + new Promise((resolve) => { + finishDispatch = resolve; + }), + ); + const { onCompleted, unmount } = await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.poll(() => dispatch.mock.calls.length).toBe(1); + + await unmount(); + finishDispatch(saved()); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(onCompleted).not.toHaveBeenCalled(); + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + }); + + it('cannot return to settings or dispatch twice while the save is running', async () => { + let finishDispatch: (completion: SaveCompletion) => void = () => {}; + const dispatch = vi.fn( + () => + new Promise((resolve) => { + finishDispatch = resolve; + }), + ); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.poll(() => dispatch.mock.calls.length).toBe(1); + await expect.element(publishScreen.backToSettings()).toBeDisabled(); + + publishScreen + .backToSettings() + .element() + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + publishScreen + .confirmButton() + .element() + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + await expect.element(publishScreen.confirm()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledTimes(1); + + finishDispatch(saved()); + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('publishes without emailing when the publish-only type is chosen', async () => { + const { dispatch } = await renderPublishFlow(); + + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Publish only').click(); + await publishScreen.continueButton().click(); + + await expect + .element(publishScreen.confirmButton()) + .toHaveTextContent('Publish post, right now'); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ kind: 'publish', options: {} }); + }); + + it('schedules a draft and hands over the scheduled celebration key', async () => { + const { dispatch } = await renderPublishFlow(); + + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + + const command = dispatch.mock.calls[0][0]; + expect(command.kind).toBe('schedule'); + expect(command).toMatchObject({ + options: { emailOnly: false, newsletter: 'weekly', emailSegment: EVERYONE }, + }); + const publishedAt = command.kind === 'schedule' ? command.options.publishedAt : ''; + expect(Date.parse(publishedAt)).toBeGreaterThan(Date.now()); + // The server rejects a sub-second publish time. + expect(publishedAt).toMatch(/T\d\d:\d\d:\d\d\.000Z$/); + expect(localStorage.getItem('ghost-last-scheduled-post')).not.toBeNull(); + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + }); + + // One pinned instant, two zones a day apart: whatever zone the runner uses, it + // agrees with at most one of them, so a browser-day mapping fails at least one. + it.each([ + ['Pacific/Auckland', '2026-09-04', '4'], + ['Pacific/Honolulu', '2026-09-03', '3'], + ])( + 'keeps the calendar on the site timezone day the field shows (%s)', + async (timezone, date, day) => { + await renderPublishFlow({ timezone, now: () => new Date('2026-09-03T20:00:00.000Z') }); + + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + + await expect.element(publishScreen.scheduleDate()).toHaveValue(date); + await publishScreen.scheduleDate().click(); + + const selected = page.getByRole('gridcell', { selected: true }); + await expect.element(selected).toHaveTextContent(day); + + // Committing the day the calendar highlights must not move the date. + await selected.click(); + await expect.element(publishScreen.scheduleDate()).toHaveValue(date); + }, + ); + + it( + 'sends without publishing when the email-only type is chosen', + async () => { + fakeEmailPolling({ status: 'submitted' }); + const { dispatch } = await renderPublishFlow(); + + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.continueButton().click(); + + await expect + .element(publishScreen.confirm()) + .toHaveTextContent('and will not be published on your site.'); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: true, newsletter: 'weekly', emailSegment: EVERYONE }, + }); + }, + SLOW, + ); + + it('cannot continue with Email only after clearing every recipient', async () => { + await renderPublishFlow(); + + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + + await expect.element(publishScreen.continueButton()).toBeDisabled(); + publishScreen + .continueButton() + .element() + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + await expect.element(publishScreen.options()).toBeInTheDocument(); + }); + + it('loads every page before exposing tier and label recipients', async () => { + const pagination = (pageNumber: number) => ({ + page: pageNumber, + limit: 100, + pages: 2, + total: 2, + next: pageNumber === 1 ? 2 : null, + prev: pageNumber === 2 ? 1 : null, + }); + const tiersApi = fakeAdminEndpoint('GET', /^\/tiers\/\?/, ({ url }) => { + const pageNumber = Number(new URL(url).searchParams.get('page') ?? '1'); + + return { + tiers: [ + pageNumber === 1 + ? { slug: 'first-tier', name: 'First tier', active: true } + : { slug: 'last-tier', name: 'Last tier', active: true }, + ], + meta: { pagination: pagination(pageNumber) }, + }; + }); + const labelsApi = fakeAdminEndpoint('GET', /^\/labels\/\?/, ({ url }) => { + const pageNumber = Number(new URL(url).searchParams.get('page') ?? '1'); + + return { + labels: [ + pageNumber === 1 + ? { slug: 'first-label', name: 'First label' } + : { slug: 'last-label', name: 'Last label' }, + ], + meta: { pagination: pagination(pageNumber) }, + }; + }); + + await renderPublishFlow(); + await publishScreen.setting('email-recipients').click(); + await expect.poll(() => tiersApi.requests.length).toBe(2); + await expect.poll(() => labelsApi.requests.length).toBe(2); + await expect.element(page.getByLabelText('Specific people')).toBeInTheDocument(); + await page.getByLabelText('Specific people').click(); + await expect.element(page.getByLabelText('First tier')).toBeInTheDocument(); + await expect.element(page.getByLabelText('Last tier')).toBeInTheDocument(); + await expect.element(page.getByLabelText('First label')).toBeInTheDocument(); + await expect.element(page.getByLabelText('Last label')).toBeInTheDocument(); + expect(new URL(tiersApi.requests[1].url).searchParams.get('page')).toBe('2'); + expect(new URL(labelsApi.requests[1].url).searchParams.get('page')).toBe('2'); + }); + + it('gates the flow behind the TK reminder', async () => { + await renderPublishFlow({ tkCount: 2 }); + + await expect.element(publishScreen.tkReminder()).toHaveTextContent('2 TK reminders'); + await page.getByRole('button', { name: 'Continue to publish' }).click(); + + await expect.element(publishScreen.options()).toBeInTheDocument(); + }); + + it('warns about an ineffective public preview before opening the flow', async () => { + await renderPublishFlow({ + paywallImprovements: true, + post: draft({ + visibility: 'public', + lexical: JSON.stringify({ + root: { + children: [ + { type: 'paragraph', children: [{ type: 'text', text: 'a' }] }, + { type: 'paywall' }, + { type: 'paragraph', children: [{ type: 'text', text: 'b' }] }, + ], + }, + }), + }), + }); + + await expect + .element(publishScreen.publicPreviewWarning()) + .toHaveTextContent('Public preview has no effect'); + }); + + it('keeps the user on confirm when re-auth interrupts the publish', async () => { + const dispatch = completesWith({ kind: 'needs-retry' }); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.confirmError()).toHaveTextContent('Your session expired'); + await expect.element(publishScreen.confirm()).toBeInTheDocument(); + }); + + it('explains a collision instead of completing', async () => { + const dispatch = completesWith(failed('conflict', 'Saving failed! Someone else is editing')); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.confirmError()) + .toHaveTextContent('Someone else has edited this post'); + }); + + it('recovers when the publish dispatcher rejects unexpectedly', async () => { + const dispatch = vi.fn(() => Promise.reject(new Error('The save engine stopped'))); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.confirmError()).toHaveTextContent('The save engine stopped'); + await expect + .poll(() => publishScreen.confirmButton().element().hasAttribute('disabled')) + .toBe(false); + }); + + it('links the upgrade phrase in a host limit without completing', async () => { + const dispatch = completesWith( + failed('host-limit', 'Your plan is full, please upgrade to publish more.'), + ); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.confirmError()).toHaveTextContent('Your plan is full'); + await expect + .element(publishScreen.confirmError().getByRole('link', { name: 'please upgrade' })) + .toBeInTheDocument(); + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + }); + + it('blocks the options step on a publishing limit and links the upgrade phrase', async () => { + await renderPublishFlow({ + limits: { + checkPublishingLimit: () => + Promise.reject( + new Error('You have reached your member limit, please upgrade your plan.'), + ), + }, + }); + + await expect + .element(publishScreen.options()) + .toHaveTextContent('You have reached your member limit'); + await expect + .element(publishScreen.options().getByRole('link', { name: 'please upgrade' })) + .toBeInTheDocument(); + // A blocked publish offers no way forward. + await expect.element(publishScreen.continueButton()).not.toBeInTheDocument(); + }); + + it('rechecks limit readiness when the mounted flow moves to another post', async () => { + let finishSecondCheck: () => void = () => {}; + const secondCheck = () => + new Promise((resolve) => { + finishSecondCheck = resolve; + }); + const rendered = await renderPublishFlow(); + + await expect + .poll(() => publishScreen.continueButton().element().hasAttribute('disabled')) + .toBe(false); + await publishScreen.continueButton().click(); + await expect.element(publishScreen.confirm()).toBeInTheDocument(); + + await rendered.rerender({ + post: draft({ id: 'post-2' }), + limits: { checkPublishingLimit: secondCheck }, + }); + + await expect.element(publishScreen.options()).toBeInTheDocument(); + await expect + .poll(() => publishScreen.continueButton().element().hasAttribute('disabled')) + .toBe(true); + finishSecondCheck(); + await expect + .poll(() => publishScreen.continueButton().element().hasAttribute('disabled')) + .toBe(false); + }); + + it('blocks on an unreadable limit and retries it safely', async () => { + let attempt = 0; + const refreshSettings = vi.fn(() => { + attempt += 1; + return attempt === 1 ? Promise.reject(new Error('Settings are offline')) : Promise.resolve(); + }); + await renderPublishFlow({ + limits: { refreshSettings }, + }); + + await expect.element(publishScreen.limitsError()).toHaveTextContent('Settings are offline'); + await expect.element(publishScreen.continueButton()).toBeDisabled(); + await publishScreen.limitsError().getByRole('button', { name: 'Try again' }).click(); + await expect + .poll(() => publishScreen.continueButton().element().hasAttribute('disabled')) + .toBe(false); + expect(refreshSettings).toHaveBeenCalledTimes(2); + }); + + it('completes once when React StrictMode replays effect cleanup', async () => { + const dispatch = completesWith(saved()); + const onCompleted = vi.fn(); + let releaseSettings: () => void = () => {}; + const refreshSettings = vi.fn( + () => + new Promise((resolve) => { + releaseSettings = resolve; + }), + ); + const checkSendingLimit = vi.fn(() => Promise.resolve()); + const checkPublishingLimit = vi.fn(() => Promise.resolve()); + + await render( + + + {}} + onCompleted={onCompleted} + /> + + , + ); + + await expect.poll(() => refreshSettings.mock.calls.length).toBe(1); + expect(checkPublishingLimit).toHaveBeenCalledTimes(1); + await expect.element(publishScreen.continueButton()).toBeDisabled(); + releaseSettings(); + await expect + .poll(() => publishScreen.continueButton().element().hasAttribute('disabled')) + .toBe(false); + expect(checkSendingLimit).toHaveBeenCalledTimes(1); + expect(checkPublishingLimit).toHaveBeenCalledTimes(1); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(onCompleted).toHaveBeenCalledTimes(1); + }); + + it( + 'completes with a note when the email cannot be confirmed either way', + async () => { + // The poller's reload fails, the way a 401 does with the redirect opted out. + fakeAdminEndpoint( + 'GET', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ message: 'Authorization failed' }] }, + { status: 401 }, + ); + const { dispatch, onCompleted } = await renderPublishFlow(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.completeNote()) + .toHaveTextContent('couldn’t confirm the newsletter was sent'); + await expect.element(publishScreen.complete()).toHaveTextContent('Boom. It’s out there.'); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(onCompleted).toHaveBeenCalledTimes(1); + }, + SLOW, + ); + + it( + 'never claims an email-only send landed when it could not be confirmed', + async () => { + fakeAdminEndpoint( + 'GET', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ message: 'Authorization failed' }] }, + { status: 401 }, + ); + await renderPublishFlow(); + + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.completeNote()) + .toHaveTextContent('couldn’t confirm the newsletter was sent'); + // Nothing on the step may assert a send, or celebrate one. + await expect + .element(publishScreen.complete()) + .toHaveTextContent('Your post has been created'); + await expect.element(publishScreen.complete()).not.toHaveTextContent('has been sent'); + await expect.element(publishScreen.complete()).not.toHaveTextContent('was sent to'); + await expect.element(publishScreen.complete()).not.toHaveTextContent('Boom'); + }, + SLOW, + ); + + it( + 'never claims an email-only send landed when the reload has no email', + async () => { + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [{ id: POST_ID, status: 'published', email: null }], + }); + const { onCompleted } = await renderPublishFlow(); + + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.completeNote()) + .toHaveTextContent('couldn’t confirm the newsletter was sent'); + await expect + .element(publishScreen.complete()) + .toHaveTextContent('Your post has been created'); + await expect.element(publishScreen.complete()).not.toHaveTextContent('email has been sent'); + expect(onCompleted).toHaveBeenCalledWith({ + postId: POST_ID, + isScheduled: false, + hasEmail: false, + }); + }, + SLOW, + ); + + it('shows a validation failure in place', async () => { + const dispatch = completesWith(failed('validation', 'Title cannot be longer than 255')); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.confirmError()) + .toHaveTextContent('Validation failed: Title cannot be longer than 255'); + await expect.element(publishScreen.confirm()).toBeInTheDocument(); + }); + + it('says a dropped command is no longer publishable', async () => { + const dispatch = completesWith({ kind: 'dropped', reason: 'not-draft' }); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.confirmError()) + .toHaveTextContent('can no longer be published from here'); + }); + + it('says a superseded command is no longer publishable', async () => { + const dispatch = completesWith({ kind: 'superseded', by: 'publish' }); + await renderPublishFlow({ dispatch }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect + .element(publishScreen.confirmError()) + .toHaveTextContent('can no longer be published from here'); + }); + + it( + 'offers a retry when the email fails after a successful publish', + async () => { + fakeEmailPolling({ status: 'failed', error: 'Sending failed' }, { status: 'submitted' }); + const retryApi = fakeAdminEndpoint('PUT', `/emails/${EMAIL_ID}/retry/`, { emails: [] }); + await renderPublishFlow(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.emailError()).toHaveTextContent('Sending failed'); + await publishScreen.retryEmailButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(retryApi.requests).toHaveLength(1); + }, + SLOW, + ); + + it('reports a retry failure when the failed email has no id', async () => { + await renderPublishFlow({ + post: draft({ + status: 'published', + email: { email_count: 0, opened_count: 0, status: 'failed', error: 'Sending failed' }, + }), + }); + + await publishScreen.retryEmailButton().click(); + + await expect + .element(publishScreen.emailError().getByRole('alert')) + .toHaveTextContent('Unknown Error occurred when attempting to resend'); + }); + + it('describes an at-open failed email-only post as created, not published', async () => { + await renderPublishFlow({ + post: draft({ + status: 'sent', + email: { + id: EMAIL_ID, + email_count: 20, + opened_count: 0, + status: 'failed', + error: 'Sending failed', + }, + }), + }); + + await expect + .element(publishScreen.emailError()) + .toHaveTextContent('Your post has been created but the email failed to send.'); + await expect.element(publishScreen.emailError()).not.toHaveTextContent('has been published'); + }); + + it('takes a draft with a failed historic email through the normal publish dispatch', async () => { + const { dispatch } = await renderPublishFlow({ + post: draft({ + email: { + id: EMAIL_ID, + email_count: 20, + opened_count: 0, + status: 'failed', + error: 'Sending failed', + }, + }), + }); + + await expect.element(publishScreen.options()).toBeInTheDocument(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false }, + }); + }); + + it.each([null, 'all'])( + 'describes a historic %s segment without using the current default', + async (emailSegment) => { + await renderPublishFlow({ + post: draft({ + email: { id: EMAIL_ID, email_count: 12, opened_count: 0, status: 'submitted' }, + emailSegment, + }), + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await expect + .element(publishScreen.alreadySent()) + .toHaveTextContent('Already sent to 12 subscribers'); + await expect.element(publishScreen.alreadySent()).not.toHaveTextContent('free'); + await expect.element(publishScreen.alreadySent()).not.toHaveTextContent('specific'); + await expect.element(publishScreen.alreadySent()).not.toHaveTextContent('none'); + }, + ); +}); + +describe('Update flow', () => { + beforeEach(() => { + fakeMemberCounts(20); + }); + + it('reverts a published post to a draft', async () => { + const dispatch = completesWith(saved('draft')); + const onClose = vi.fn(); + + await render( + + + , + ); + + await expect.element(publishScreen.updateFlowTitle()).toHaveTextContent('has been published'); + await publishScreen.revertToDraft().click(); + + expect(dispatch).toHaveBeenCalledWith({ kind: 'revert' }); + await expect.poll(() => onClose.mock.calls.length).toBe(1); + }); + + it('reverts once when React StrictMode replays effect cleanup', async () => { + const dispatch = completesWith(saved('draft')); + const onClose = vi.fn(); + const onReverted = vi.fn(); + + await render( + + + + + , + ); + + await publishScreen.revertToDraft().click(); + + await expect.poll(() => onClose.mock.calls.length).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(onReverted).toHaveBeenCalledTimes(1); + }); + + it('recovers when the revert dispatcher rejects unexpectedly', async () => { + const dispatch = vi.fn(() => Promise.reject(new Error('The revert stopped'))); + const onClose = vi.fn(); + + await render( + + + , + ); + + await publishScreen.revertToDraft().click(); + + await expect + .element(publishScreen.updateFlow().getByRole('alert')) + .toHaveTextContent('The revert stopped'); + await expect + .poll(() => publishScreen.revertToDraft().element().hasAttribute('disabled')) + .toBe(false); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('abandons a pending revert when the update flow closes', async () => { + let finishDispatch: (completion: SaveCompletion) => void = () => {}; + const dispatch = vi.fn( + () => + new Promise((resolve) => { + finishDispatch = resolve; + }), + ); + const onClose = vi.fn(); + const onReverted = vi.fn(); + + await render( + + + , + ); + + await publishScreen.revertToDraft().click(); + await expect.poll(() => dispatch.mock.calls.length).toBe(1); + await publishScreen.updateFlow().getByRole('button', { name: 'Close' }).click(); + expect(onClose).toHaveBeenCalledTimes(1); + + finishDispatch(saved('draft')); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(onReverted).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('abandons a pending revert when the mounted update flow changes posts', async () => { + let finishDispatch: (completion: SaveCompletion) => void = () => {}; + const dispatch = vi.fn( + () => + new Promise((resolve) => { + finishDispatch = resolve; + }), + ); + const onClose = vi.fn(); + const onReverted = vi.fn(); + const modal = (post: PublishFlowPost) => ( + + + + ); + const rendered = await render( + modal(draft({ status: 'published', publishedAt: '2026-09-01T09:00:00.000Z' })), + ); + + await publishScreen.revertToDraft().click(); + await expect.poll(() => dispatch.mock.calls.length).toBe(1); + await rendered.rerender( + modal( + draft({ + id: 'post-2', + status: 'scheduled', + publishedAt: '2026-09-10T09:00:00.000Z', + }), + ), + ); + await expect.element(publishScreen.updateFlowTitle()).toHaveTextContent('scheduled'); + + finishDispatch(saved('draft')); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(onReverted).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + await expect.element(publishScreen.updateFlow().getByRole('alert')).not.toBeInTheDocument(); + }); + + it('names a since-archived newsletter a scheduled post was already sent to', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowPreviousEmail()) + .toHaveTextContent('previously emailed to 12 subscribers of Retired Weekly'); + await expect + .element(publishScreen.updateFlowPreviousEmail()) + .toHaveTextContent('on 1 Sep 2026 at 09:00'); + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('published on your site'); + }); + + it('describes the audience for a scheduled email that has not been sent yet', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('published and sent to 20 subscribers'); + }); + + it('does not claim a scheduled email-only post will be published', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('will be sent to 20 subscribers'); + await expect + .element(publishScreen.updateFlowConfirmation()) + .not.toHaveTextContent('published and sent'); + }); + + it('does not count the current default newsletter for a missing persisted newsletter', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('published and sent to subscribers of Retired Weekly'); + await expect + .element(publishScreen.updateFlowConfirmation()) + .not.toHaveTextContent('20 subscribers'); + }); + + it('does not replace a missing persisted segment with the current site default', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('published and sent to subscribers'); + await expect + .element(publishScreen.updateFlowConfirmation()) + .not.toHaveTextContent('20 subscribers'); + }); + + it('does not claim that a failed published email was sent', async () => { + await render( + + {}} + /> + , + ); + + await expect + .element(publishScreen.updateFlowConfirmation()) + .toHaveTextContent('published on your site'); + }); +}); diff --git a/apps/admin/src/editor/publish/publish-options.test.ts b/apps/admin/src/editor/publish/publish-options.test.ts index 5b44762b291..2d5b4f73299 100644 --- a/apps/admin/src/editor/publish/publish-options.test.ts +++ b/apps/admin/src/editor/publish/publish-options.test.ts @@ -956,6 +956,32 @@ describe('checkLimits', () => { await expect(create({ limits }).checkLimits()).rejects.toThrow('offline'); }); + it('waits for the publishing check before rejecting a settings refresh', async () => { + let finishPublishingCheck: () => void = () => {}; + const limits = ports({ + refreshSettings: vi.fn(() => Promise.reject(new Error('offline'))), + checkPublishingLimit: vi.fn( + () => + new Promise((_resolve, reject) => { + finishPublishingCheck = () => reject(new Error('over member limit')); + }), + ), + }); + const machine = create({ limits }); + const checking = machine.checkLimits(); + let settled = false; + void checking.catch(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + finishPublishingCheck(); + + await expect(checking).rejects.toThrow('offline'); + expect(machine.getState().publishBlock?.message).toBe('over member limit'); + }); + it('blocks email on the sending limit and skips the verification read', async () => { const limits = ports({ checkSendingLimit: vi.fn(() => diff --git a/apps/admin/src/editor/publish/publish-options.ts b/apps/admin/src/editor/publish/publish-options.ts index a2d23f2e1e5..9ee2baf5e6e 100644 --- a/apps/admin/src/editor/publish/publish-options.ts +++ b/apps/admin/src/editor/publish/publish-options.ts @@ -561,7 +561,7 @@ export function createPublishOptions({ emailBlock = null; publishBlock = null; - await Promise.all([runSendingCheck(), runPublishingCheck()]); + const [sendingResult] = await Promise.allSettled([runSendingCheck(), runPublishingCheck()]); // A block that lands after the user picked an email type still demotes that pick. if (!publishTypeTouched || emailDisabled()) { @@ -572,6 +572,12 @@ export function createPublishOptions({ initial = { ...initial, publishType }; } + // A settings refresh failure remains observable to callers, but only after + // the publishing check has settled so no late block can race the UI ready. + if (sendingResult.status === 'rejected') { + throw sendingResult.reason; + } + return { emailBlock, publishBlock }; }, diff --git a/apps/admin/src/editor/publish/publish.screen.ts b/apps/admin/src/editor/publish/publish.screen.ts new file mode 100644 index 00000000000..c9b9ce4e9dc --- /dev/null +++ b/apps/admin/src/editor/publish/publish.screen.ts @@ -0,0 +1,62 @@ +import { page } from 'vitest/browser'; +import { + publicPreviewWarningDialog, + publishAlreadySent, + publishBackToSettings, + publishConfirmButton, + publishCompleteNote, + publishConfirmError, + publishContinueButton, + publishEmailErrorStep, + publishFlowComplete, + publishFlowConfirm, + publishFlowModal, + publishFlowOptions, + publishLimitsError, + publishRecipientFree, + publishRetryEmailButton, + publishRevertToDraft, + publishScheduleDate, + publishSettingEmailRecipients, + publishSettingPublishAt, + publishSettingPublishType, + tkReminderDialog, + updateFlowModal, + updateFlowConfirmation, + updateFlowPreviousEmail, + updateFlowTitle, +} from '@tryghost/test-data/selectors/editor'; + +const SETTINGS = { + 'publish-type': publishSettingPublishType, + 'email-recipients': publishSettingEmailRecipients, + 'publish-at': publishSettingPublishAt, +} as const; + +/** Publish and update flow locators and gestures for acceptance specs; no assertions. */ +export const publishScreen = { + root: () => page.getByTestId(publishFlowModal), + options: () => page.getByTestId(publishFlowOptions), + confirm: () => page.getByTestId(publishFlowConfirm), + complete: () => page.getByTestId(publishFlowComplete), + completeNote: () => page.getByTestId(publishCompleteNote), + emailError: () => page.getByTestId(publishEmailErrorStep), + /** The collapsed row's toggle button. */ + setting: (name: keyof typeof SETTINGS) => page.getByTestId(SETTINGS[name]).getByRole('button'), + scheduleDate: () => page.getByTestId(publishScheduleDate), + continueButton: () => page.getByTestId(publishContinueButton), + recipientFree: () => page.getByTestId(publishRecipientFree), + confirmButton: () => page.getByTestId(publishConfirmButton), + backToSettings: () => page.getByTestId(publishBackToSettings), + confirmError: () => page.getByTestId(publishConfirmError), + limitsError: () => page.getByTestId(publishLimitsError), + alreadySent: () => page.getByTestId(publishAlreadySent), + retryEmailButton: () => page.getByTestId(publishRetryEmailButton), + revertToDraft: () => page.getByTestId(publishRevertToDraft), + tkReminder: () => page.getByTestId(tkReminderDialog), + publicPreviewWarning: () => page.getByTestId(publicPreviewWarningDialog), + updateFlow: () => page.getByTestId(updateFlowModal), + updateFlowConfirmation: () => page.getByTestId(updateFlowConfirmation), + updateFlowPreviousEmail: () => page.getByTestId(updateFlowPreviousEmail), + updateFlowTitle: () => page.getByTestId(updateFlowTitle), +}; diff --git a/apps/admin/src/editor/publish/update-flow-modal.tsx b/apps/admin/src/editor/publish/update-flow-modal.tsx new file mode 100644 index 00000000000..893ff20915f --- /dev/null +++ b/apps/admin/src/editor/publish/update-flow-modal.tsx @@ -0,0 +1,255 @@ +import { Banner, Button, Dialog, DialogContent, DialogTitle } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { + getFullRecipientFilter, + getNewsletterRecipientFilter, + normalizeRecipientFilter, +} from '@tryghost/admin-x-framework/utils/recipient-filter'; +import { + publishRevertToDraft, + updateFlowConfirmation, + updateFlowModal, + updateFlowPreviousEmail, + updateFlowTitle, +} from '@tryghost/test-data/selectors/editor'; +import { createPublishOptions } from './publish-options'; +import { + describeCompletionFailure, + describeRejectedAction, + type CompletionFailure, +} from './completion-message'; +import { formatSiteDateTime } from './publish-copy'; +import type { PublishDispatcher } from './use-publish-flow'; +import type { PublishFlowPost } from './flow-post'; +import type { PublishSiteInput, PublishUserInput } from './publish-options'; +import type { SaveCompletion } from '@/editor/engine/save-engine'; + +const FULLSCREEN = + 'top-0 left-0 h-[100dvh] w-full max-w-full translate-0 grid-rows-[1fr] gap-0 overflow-y-auto rounded-none border-0 p-0 shadow-none sm:rounded-none'; + +export interface UpdateFlowModalProps { + post: PublishFlowPost; + site: PublishSiteInput; + user: PublishUserInput; + timezone: string; + dispatch: PublishDispatcher; + onClose: () => void; + /** Called after the revert lands, so the caller can leave or refresh. */ + onReverted?: () => void; +} + +function pluralSubscribers(count: number | null | undefined): string { + if (count === null || count === undefined) { + return 'subscribers'; + } + return `${formatNumber(count)} ${count === 1 ? 'subscriber' : 'subscribers'}`; +} + +export function UpdateFlowModal({ post, ...props }: UpdateFlowModalProps) { + return ; +} + +function KeyedUpdateFlowModal({ + post, + site, + user, + timezone, + dispatch, + onClose, + onReverted, +}: UpdateFlowModalProps) { + // Read once, like the publish flow's machine: keyed on the post, not on prop identity. + const inputs = useRef({ post, site, user }); + inputs.current = { post, site, user }; + + const machine = useMemo(() => { + const current = inputs.current; + + return createPublishOptions({ + post: { ...current.post, isPage: current.post.displayName === 'page' }, + site: current.site, + user: current.user, + }); + }, [post.id]); + const state = machine.getState(); + const isScheduled = post.status === 'scheduled'; + const isSent = post.status === 'sent'; + const emailOnly = post.emailOnly === true || isSent; + const willEmail = isScheduled && Boolean(post.newsletter) && !post.email; + const hasBeenEmailed = + post.displayName === 'post' && + (post.status === 'sent' || post.status === 'published') && + Boolean(post.email && post.email.status !== 'failed'); + const persistedNewsletter = site.newsletters.find( + (newsletter) => newsletter.slug === post.newsletter, + ); + const persistedSegment = normalizeRecipientFilter(post.emailSegment); + const scheduledRecipientFilter = + willEmail && persistedNewsletter && persistedSegment + ? getFullRecipientFilter(getNewsletterRecipientFilter(persistedNewsletter), persistedSegment) + : null; + const { count: queriedCount } = useMembersCount(scheduledRecipientFilter); + const count = scheduledRecipientFilter ? queriedCount : null; + const [failure, setFailure] = useState(null); + const [running, setRunning] = useState(false); + const runningRef = useRef(false); + const activeRef = useRef(true); + // The post's own newsletter, not the picker's: a send to a since-archived + // newsletter must still be named, or it reads as a send to the default one. + const showNewsletterName = !state.onlyDefaultNewsletter || post.newsletterStatus === 'archived'; + const close = () => { + if (!activeRef.current) { + return; + } + activeRef.current = false; + onClose(); + }; + + useEffect(() => { + // Admin runs under StrictMode, which replays cleanup before this setup. + activeRef.current = true; + return () => { + activeRef.current = false; + }; + }, []); + + const revert = async () => { + if (runningRef.current) { + return; + } + runningRef.current = true; + setFailure(null); + setRunning(true); + + let completion: SaveCompletion; + + try { + completion = await dispatch(machine.toRevertDispatch()); + } catch (error) { + if (activeRef.current) { + setFailure(describeRejectedAction(error)); + setRunning(false); + } + runningRef.current = false; + return; + } + + if (!activeRef.current) { + runningRef.current = false; + return; + } + + const completionFailure = describeCompletionFailure(completion); + + if (completionFailure) { + setFailure(completionFailure); + setRunning(false); + runningRef.current = false; + return; + } + + setRunning(false); + runningRef.current = false; + onReverted?.(); + if (activeRef.current) { + close(); + } + }; + + const publishedAt = post.publishedAt; + + return ( + !open && close()}> + event.preventDefault()} + > + {isScheduled ? 'Unschedule' : 'Unpublish'} + + + {isSent ? null : ( + + )} + + + + This {post.displayName} {isSent ? 'was' : 'has been'}{' '} + + {post.status} + {isSent ? ' by email' : ''} + + + + + Your {post.displayName} {isScheduled ? 'will be' : 'was'}{' '} + {hasBeenEmailed || willEmail ? ( + <> + {emailOnly ? 'sent to' : 'published and sent to'}{' '} + + {isScheduled + ? pluralSubscribers(count) + : pluralSubscribers(post.email?.email_count ?? null)} + + {showNewsletterName && post.newsletterName ? ( + <> + {' '} + of {post.newsletterName} + + ) : null} + + ) : ( + 'published on your site' + )} + {publishedAt ? <> on {formatSiteDateTime(publishedAt, timezone)}. : '.'} + + + {isScheduled && post.email ? ( + + This post was previously emailed to{' '} + {pluralSubscribers(post.email.email_count ?? null)} + {showNewsletterName && post.newsletterName ? ( + <> + {' '} + of {post.newsletterName} + + ) : null} + {post.emailCreatedAt ? ( + <> on {formatSiteDateTime(post.emailCreatedAt, timezone)}. + ) : ( + '.' + )} + + ) : null} + + {failure ? ( + + {failure.message} + + ) : null} + + {isScheduled || !emailOnly ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/admin/src/editor/publish/use-publish-flow.ts b/apps/admin/src/editor/publish/use-publish-flow.ts new file mode 100644 index 00000000000..49aa553327d --- /dev/null +++ b/apps/admin/src/editor/publish/use-publish-flow.ts @@ -0,0 +1,526 @@ +import { apiUrl } from '@tryghost/admin-x-framework/helpers'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { useFetchApi } from '@tryghost/admin-x-framework/hooks'; +import { useRetryEmail } from '@tryghost/admin-x-framework/api/emails'; +import { + confirmationResponseSchema, + publishedPostCountResponseSchema, +} from './api-response-schemas'; +import { createEmailConfirmation } from './email-confirmation'; +import { createPublishOptions } from './publish-options'; +import { + describeCompletionFailure, + describeRejectedAction, + type CompletionFailure, +} from './completion-message'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; +import { writePublishCelebration } from './celebration-handoff'; +import type { EmailConfirmationOutcome } from './email-confirmation'; +import type { PublishFlowPost } from './flow-post'; +import type { + PublishDispatch, + PublishLimitPorts, + PublishOptionsMachine, + PublishOptionsState, + PublishSiteInput, + PublishUserInput, +} from './publish-options'; +import type { SaveCompletion } from '@/editor/engine/save-engine'; + +export type PublishStep = 'options' | 'confirm' | 'complete' | 'email-error'; +export type ConfirmStatus = 'idle' | 'running' | 'success' | 'failure'; + +export type PublishDispatcher = (dispatch: PublishDispatch) => Promise; + +export interface PublishFlowOptions { + post: PublishFlowPost; + site: PublishSiteInput; + user: PublishUserInput; + limits?: PublishLimitPorts; + /** The machine's clock, injected for tests. */ + now?: () => Date; + dispatch: PublishDispatcher; + onBeforePublish?: () => Promise; + onCompleted?: (info: { postId: string; isScheduled: boolean; hasEmail: boolean }) => void; +} + +export interface PublishFlow { + machine: PublishOptionsMachine; + state: PublishOptionsState; + step: PublishStep; + confirmStatus: ConfirmStatus; + failure: CompletionFailure | null; + emailErrorMessage: string | null; + /** The site's published count including this post, for the complete step's copy. */ + postCount: number | null; + /** When the publish landed, standing in for the publish time the server stamped. */ + completedAt: string | null; + /** False until `checkLimits()` settles; the options step cannot be left before then. */ + limitsChecked: boolean; + /** A failed limit check blocks review until the user retries it successfully. */ + limitsFailure: string | null; + /** Set when the publish landed but its email could not be confirmed either way. */ + emailNote: string | null; + /** Publish intent captured on entering confirm, so saving cannot change the copy. */ + captured: { + willPublish: boolean; + willEmail: boolean; + willOnlyEmail: boolean; + isScheduled: boolean; + }; + /** Re-renders after a machine transition; the machine has no subscription. */ + refresh: () => void; + retryLimits: () => void; + toConfirm: () => void; + toOptions: () => void; + confirmPublish: () => Promise; + retryEmail: () => Promise; + retryStatus: ConfirmStatus; + retryFailure: string | null; + /** Abandons any asynchronous continuation before the caller closes the modal. */ + cancel: () => void; +} + +const UNKNOWN_EMAIL_ERROR = 'Unknown error'; +const UNKNOWN_RETRY_ERROR = 'Unknown Error occurred when attempting to resend'; +export const EMAIL_UNCONFIRMED = + 'We couldn’t confirm the newsletter was sent. Check the post’s email status from the posts list.'; + +function initialEmailError(post: PublishFlowPost): string | null { + const didEmailFail = + post.displayName === 'post' && + (post.status === 'published' || post.status === 'sent') && + post.email?.status === 'failed'; + + return didEmailFail ? (post.email?.error ?? UNKNOWN_EMAIL_ERROR) : null; +} + +export function usePublishFlow({ + post, + site, + user, + limits, + now, + dispatch, + onBeforePublish, + onCompleted, +}: PublishFlowOptions): PublishFlow { + const fetchApi = useFetchApi(); + const { mutateAsync: retryEmailRequest } = useRetryEmail(); + const [, refresh] = useReducer((tick: number) => tick + 1, 0); + + // The machine reads its inputs once, so it is keyed on the post rather than on + // the identity of props a re-rendering caller rebuilds. + const inputs = useRef({ post, site, user, limits, now }); + inputs.current = { post, site, user, limits, now }; + + const machine = useMemo(() => { + const current = inputs.current; + + return createPublishOptions({ + post: { ...current.post, isPage: current.post.displayName === 'page' }, + site: current.site, + user: current.user, + limits: current.limits, + now: current.now, + }); + }, [post.id]); + + // The email is created by the save, so its id is only knowable from a reload. + const emailIdRef = useRef(post.email?.id ?? null); + + const confirmation = useMemo( + () => + createEmailConfirmation({ + reload: async (postId) => { + const data = confirmationResponseSchema.parse( + await fetchApi( + apiUrl(`/posts/${postId}/`, { include: 'email' }), + EDITOR_REQUEST_OPTIONS, + ), + ); + const reloaded = data.posts.at(0); + + if (!reloaded) { + throw new Error('The published post was missing from its reload response.'); + } + + emailIdRef.current = reloaded.email?.id ?? emailIdRef.current; + return { status: reloaded.status, email: reloaded.email ?? null }; + }, + retry: async (emailId) => { + await retryEmailRequest(emailId); + }, + }), + [fetchApi, retryEmailRequest], + ); + + const [step, setStep] = useState(() => + initialEmailError(post) ? 'email-error' : 'options', + ); + const [confirmStatus, setConfirmStatus] = useState('idle'); + const [failure, setFailure] = useState(null); + const [emailErrorMessage, setEmailErrorMessage] = useState(() => + initialEmailError(post), + ); + const [postCount, setPostCount] = useState(null); + const [completedAt, setCompletedAt] = useState(null); + const [checkedMachine, setCheckedMachine] = useState(null); + const [limitsFailure, setLimitsFailure] = useState(null); + const [emailNote, setEmailNote] = useState(null); + const [retryStatus, setRetryStatus] = useState('idle'); + const [retryFailure, setRetryFailure] = useState(null); + const [captured, setCaptured] = useState(() => { + if (initialEmailError(post)) { + return { + willPublish: post.status === 'published' && post.emailOnly !== true, + willEmail: true, + willOnlyEmail: post.emailOnly === true || post.status === 'sent', + isScheduled: false, + }; + } + + const initialState = machine.getState(); + return { + willPublish: initialState.willPublish, + willEmail: initialState.willEmail, + willOnlyEmail: initialState.willOnlyEmail, + isScheduled: initialState.isScheduled, + }; + }); + const activeRef = useRef(true); + const completedRef = useRef(false); + const publishRunningRef = useRef(false); + const retryRunningRef = useRef(false); + const limitCheckGenerationRef = useRef(0); + const limitCheckRef = useRef<{ + machine: PublishOptionsMachine; + promise: Promise; + } | null>(null); + + const checkLimits = useCallback(async () => { + const generation = limitCheckGenerationRef.current + 1; + limitCheckGenerationRef.current = generation; + setCheckedMachine(null); + setLimitsFailure(null); + const existing = limitCheckRef.current; + const check = + existing?.machine === machine + ? existing + : { machine, promise: machine.checkLimits().then(() => undefined) }; + limitCheckRef.current = check; + + try { + await check.promise; + } catch (error) { + if (activeRef.current && generation === limitCheckGenerationRef.current) { + const { message } = describeRejectedAction(error); + setLimitsFailure(`Couldn’t check publishing limits. ${message}`); + refresh(); + } + return; + } finally { + if (limitCheckRef.current === check) { + limitCheckRef.current = null; + } + } + + if (activeRef.current && generation === limitCheckGenerationRef.current) { + setCheckedMachine(machine); + refresh(); + } + }, [machine]); + + // A schedule chosen before the editor sat idle may now be in the past. + useEffect(() => { + machine.resetPastScheduledAt(); + void checkLimits(); + + return () => { + limitCheckGenerationRef.current += 1; + }; + }, [checkLimits, machine]); + + const confirmationRef = useRef(confirmation); + confirmationRef.current = confirmation; + const cancel = useCallback(() => { + activeRef.current = false; + confirmationRef.current.cancel(); + limitCheckGenerationRef.current += 1; + }, []); + + // StrictMode replays this effect's cleanup before its second setup. Restore + // activity on setup so that development mode does not leave the flow inert. + useEffect(() => { + activeRef.current = true; + return cancel; + }, [cancel]); + + const state = machine.getState(); + const limitsChecked = checkedMachine === machine; + + const fetchPostCount = useCallback(async () => { + // No count is shown for pages, scheduled posts, or email-only posts. + if (post.displayName === 'page' || state.isScheduled || !state.willPublish) { + setPostCount(null); + return; + } + + try { + const data = publishedPostCountResponseSchema.parse( + await fetchApi( + apiUrl('/posts/', { filter: `status:published+id:-'${post.id}'`, limit: '1' }), + EDITOR_REQUEST_OPTIONS, + ), + ); + if (activeRef.current) { + setPostCount(data.meta.pagination.total + 1); + } + } catch { + if (activeRef.current) { + setPostCount(null); + } + } + }, [fetchApi, post.displayName, post.id, state.isScheduled, state.willPublish]); + + const toConfirm = useCallback(() => { + if (!limitsChecked || !state.canPublish) { + return; + } + setCaptured({ + willPublish: state.willPublish, + willEmail: state.willEmail, + willOnlyEmail: state.willOnlyEmail, + isScheduled: state.isScheduled, + }); + setFailure(null); + setConfirmStatus('idle'); + setStep('confirm'); + void fetchPostCount(); + }, [fetchPostCount, limitsChecked, state]); + + const toOptions = useCallback(() => { + if (publishRunningRef.current) { + return; + } + setStep('options'); + setConfirmStatus('idle'); + }, []); + + const complete = useCallback( + (isScheduled: boolean, hasEmail: boolean) => { + if (!activeRef.current || completedRef.current) { + return; + } + completedRef.current = true; + setEmailErrorMessage(null); + setConfirmStatus('success'); + setStep('complete'); + // The server stamps the publish time; this is the closest the client has. + setCompletedAt(new Date().toISOString()); + try { + writePublishCelebration({ postId: post.id, displayName: post.displayName, isScheduled }); + } finally { + onCompleted?.({ postId: post.id, isScheduled, hasEmail }); + } + }, + [onCompleted, post.displayName, post.id], + ); + + const applyEmailOutcome = useCallback( + (outcome: EmailConfirmationOutcome, isScheduled: boolean): void => { + if (!activeRef.current) { + return; + } + + if (outcome.kind === 'failed') { + setEmailErrorMessage(outcome.error ?? UNKNOWN_EMAIL_ERROR); + setStep('email-error'); + setConfirmStatus('idle'); + return; + } + + // Cancellation means the flow is being torn down, so nothing is completed + // and the caller is never told to navigate. + if (outcome.kind === 'cancelled') { + setConfirmStatus('idle'); + return; + } + + if (outcome.kind !== 'submitted') { + setEmailNote(EMAIL_UNCONFIRMED); + } + complete(isScheduled, outcome.kind !== 'not-needed'); + }, + [complete], + ); + + const confirmPublish = useCallback(async () => { + if (publishRunningRef.current) { + return; + } + + publishRunningRef.current = true; + setFailure(null); + setConfirmStatus('running'); + + const command = machine.toDispatch(); + + if (!command) { + publishRunningRef.current = false; + setFailure({ message: 'This post can no longer be published from here. Reload the editor.' }); + setConfirmStatus('failure'); + return; + } + + const { isScheduled, willEmailImmediately, willEmail } = state; + + try { + await onBeforePublish?.(); + } catch (error) { + if (activeRef.current) { + publishRunningRef.current = false; + setFailure(describeRejectedAction(error)); + setConfirmStatus('failure'); + } + return; + } + + if (!activeRef.current) { + return; + } + + let completion: SaveCompletion; + + try { + completion = await dispatch(command); + } catch (error) { + if (activeRef.current) { + publishRunningRef.current = false; + setFailure(describeRejectedAction(error)); + setConfirmStatus('failure'); + } + return; + } + + if (!activeRef.current) { + return; + } + const completionFailure = describeCompletionFailure(completion); + + if (completionFailure) { + publishRunningRef.current = false; + setFailure(completionFailure); + setConfirmStatus('failure'); + // A re-auth interruption sends the user back to confirm and try again. + setStep('confirm'); + return; + } + + // Stays 'running' across the email poll: the publish is not finished until + // the email is submitted, and the button must not invite a second dispatch. + if (willEmailImmediately) { + let outcome: EmailConfirmationOutcome; + + try { + // No `currentPost`: the acknowledged result carries no email, and the + // pre-save one would short-circuit the poll to "not needed". + outcome = await confirmation.confirm(post.id); + } catch { + if (!activeRef.current) { + return; + } + // The post is published either way; only the email's fate is unknown, + // so the flow completes rather than stranding a disabled button. + setEmailNote(EMAIL_UNCONFIRMED); + complete(isScheduled, true); + return; + } + + applyEmailOutcome(outcome, isScheduled); + return; + } + + complete(isScheduled, willEmail); + }, [ + applyEmailOutcome, + complete, + confirmation, + dispatch, + machine, + onBeforePublish, + post.id, + state, + ]); + + const retryEmail = useCallback(async () => { + const emailId = emailIdRef.current; + + if (retryRunningRef.current) { + return; + } + + if (!emailId) { + setRetryFailure(UNKNOWN_RETRY_ERROR); + setRetryStatus('failure'); + return; + } + + retryRunningRef.current = true; + setRetryFailure(null); + setRetryStatus('running'); + + try { + const outcome = await confirmation.retryAndConfirm(post.id, emailId); + + if (!activeRef.current) { + return; + } + + if (outcome.kind === 'failed' || outcome.kind === 'cancelled') { + retryRunningRef.current = false; + if (outcome.kind === 'failed') { + setEmailErrorMessage(outcome.error ?? UNKNOWN_EMAIL_ERROR); + } + setRetryStatus('idle'); + return; + } + + if (outcome.kind !== 'submitted') { + setEmailNote(EMAIL_UNCONFIRMED); + } + setRetryStatus('success'); + complete(false, outcome.kind !== 'not-needed'); + } catch (error) { + if (activeRef.current) { + retryRunningRef.current = false; + setRetryFailure(error instanceof Error ? error.message : UNKNOWN_RETRY_ERROR); + setRetryStatus('failure'); + } + } + }, [complete, confirmation, post.id]); + + return { + machine, + state, + step, + confirmStatus, + failure, + emailErrorMessage, + postCount, + completedAt, + limitsChecked, + limitsFailure, + emailNote, + captured, + refresh, + retryLimits: () => void checkLimits(), + toConfirm, + toOptions, + confirmPublish, + retryEmail, + retryStatus, + retryFailure, + cancel, + }; +} diff --git a/apps/admin/src/editor/publish/use-publish-inputs.acceptance.test.tsx b/apps/admin/src/editor/publish/use-publish-inputs.acceptance.test.tsx new file mode 100644 index 00000000000..1f8e31e51e9 --- /dev/null +++ b/apps/admin/src/editor/publish/use-publish-inputs.acceptance.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook } from 'vitest-browser-react'; + +import { fakeAdminEndpoint, newsletter } from '@test-utils/acceptance'; +import { TestWrapper } from '@test-utils/fixtures/query-client'; + +import { usePublishInputs } from '@/editor/publish/use-publish-inputs'; + +const pagination = (pageNumber = 1, pages = 1) => ({ + page: pageNumber, + limit: 100, + pages, + total: pages, + next: pageNumber < pages ? pageNumber + 1 : null, + prev: pageNumber > 1 ? pageNumber - 1 : null, +}); + +const publishNewsletter = (slug: string) => + newsletter({ + slug, + name: slug, + status: 'active', + visibility: 'members', + sort_order: 0, + }); + +function fakeBoundaryInputs() { + const settings = fakeAdminEndpoint('GET', /^\/settings\/\?/, { + settings: [ + { key: 'members_signup_access', value: 'all' }, + { key: 'editor_default_email_recipients', value: 'visibility' }, + { key: 'timezone', value: 'Etc/UTC' }, + ], + }); + const config = fakeAdminEndpoint('GET', /^\/config\/(?:\?.*)?$/, { + config: { mailgunIsConfigured: true }, + }); + const currentUser = fakeAdminEndpoint('GET', /^\/users\/me\/\?include=roles$/, { + users: [{ roles: [{ name: 'Administrator' }] }], + }); + + return { settings, config, currentUser }; +} + +function fakeNewsletters() { + return fakeAdminEndpoint('GET', /^\/newsletters\/\?/, { + newsletters: [publishNewsletter('weekly')], + meta: { pagination: pagination() }, + }); +} + +function fakeMemberCount(total: number, status = 200) { + return fakeAdminEndpoint( + 'GET', + /^\/members\/\?.*filter=/, + status === 200 + ? { members: [], meta: { pagination: { ...pagination(), total } } } + : { errors: [{ message: 'Members are offline' }] }, + { status }, + ); +} + +describe('usePublishInputs', () => { + it('blocks on a member-count error and becomes ready after retry', async () => { + const inputs = fakeBoundaryInputs(); + fakeNewsletters(); + const failedMembers = fakeMemberCount(0, 500); + const hook = await renderHook(() => usePublishInputs(), { wrapper: TestWrapper }); + + await expect.poll(() => inputs.settings.requests.length).toBe(1); + await expect.poll(() => inputs.config.requests.length).toBe(1); + await expect.poll(() => inputs.currentUser.requests.length).toBe(1); + await expect + .poll(() => hook.result.current.error?.message ?? '') + .toContain('Something went wrong while loading members'); + expect(hook.result.current.isReady).toBe(false); + expect(failedMembers.requests).toHaveLength(1); + + const retriedMembers = fakeMemberCount(500); + let releaseConfig: () => void = () => {}; + const configHeld = new Promise((resolve) => { + releaseConfig = resolve; + }); + const retriedConfig = fakeAdminEndpoint('GET', /^\/config\/(?:\?.*)?$/, async () => { + await configHeld; + return { config: { mailgunIsConfigured: true } }; + }); + await hook.act(() => hook.result.current.retry()); + + await expect.poll(() => retriedMembers.requests.length).toBe(1); + await expect.poll(() => retriedConfig.requests.length).toBe(1); + await expect.poll(() => hook.result.current.site.memberCount).toBe(500); + expect(hook.result.current.isReady).toBe(false); + + releaseConfig(); + await expect.poll(() => hook.result.current.isReady).toBe(true); + expect(hook.result.current.error).toBeNull(); + }); + + it('loads every newsletter page before becoming ready', async () => { + fakeBoundaryInputs(); + fakeMemberCount(20); + let releaseLastPage: () => void = () => {}; + const lastPageHeld = new Promise((resolve) => { + releaseLastPage = resolve; + }); + const newslettersApi = fakeAdminEndpoint('GET', /^\/newsletters\/\?/, async ({ url }) => { + const pageNumber = Number(new URL(url).searchParams.get('page') ?? '1'); + if (pageNumber === 2) { + await lastPageHeld; + } + + return { + newsletters: [publishNewsletter(pageNumber === 1 ? 'first' : 'last')], + meta: { pagination: pagination(pageNumber, 2) }, + }; + }); + const hook = await renderHook(() => usePublishInputs(), { wrapper: TestWrapper }); + + await expect.poll(() => newslettersApi.requests.length).toBe(2); + expect(hook.result.current.isReady).toBe(false); + + releaseLastPage(); + await expect.poll(() => hook.result.current.isReady).toBe(true); + expect(hook.result.current.site.newsletters.map(({ slug }) => slug)).toEqual(['first', 'last']); + expect(newslettersApi.requests).toHaveLength(2); + expect(new URL(newslettersApi.requests[1].url).searchParams.get('page')).toBe('2'); + }); +}); diff --git a/apps/admin/src/editor/publish/use-publish-inputs.test.ts b/apps/admin/src/editor/publish/use-publish-inputs.test.ts new file mode 100644 index 00000000000..dfb6ba15d8f --- /dev/null +++ b/apps/admin/src/editor/publish/use-publish-inputs.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { assemblePublishInputs } from '@/editor/publish/use-publish-inputs'; + +function boundary(overrides: Record = {}) { + return { + settingsData: { + settings: [ + { key: 'members_signup_access', value: 'all' }, + { key: 'editor_default_email_recipients', value: 'filter' }, + { key: 'editor_default_email_recipients_filter', value: 'status:-free' }, + { key: 'timezone', value: 'Europe/Amsterdam' }, + ], + }, + configData: { config: { mailgunIsConfigured: true } }, + newslettersData: { + newsletters: [ + { + slug: 'weekly', + name: 'Weekly', + status: 'active', + visibility: 'members', + sort_order: 2, + }, + ], + }, + currentUser: { roles: [{ name: 'Administrator' }] }, + memberCount: 12, + ...overrides, + }; +} + +describe('assemblePublishInputs', () => { + it('validates and projects the API-backed publish inputs', () => { + expect(assemblePublishInputs(boundary())).toEqual({ + site: { + membersEnabled: true, + mailgunConfigured: true, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:-free', + memberCount: 12, + newsletters: [ + { + slug: 'weekly', + name: 'Weekly', + status: 'active', + visibility: 'members', + sortOrder: 2, + }, + ], + }, + user: { isAdmin: true, isAuthorOrContributor: false }, + timezone: 'Europe/Amsterdam', + isValid: true, + }); + }); + + it('fails closed on an unknown default-recipient setting', () => { + const data = boundary(); + const settingsData = data.settingsData as { settings: Array<{ key: string; value: unknown }> }; + settingsData.settings[1].value = 'everybody'; + + expect(assemblePublishInputs(data).isValid).toBe(false); + }); + + it('uses visibility when the default-recipient setting is absent', () => { + const data = boundary(); + data.settingsData.settings = data.settingsData.settings.filter( + (setting) => setting.key !== 'editor_default_email_recipients', + ); + + const assembled = assemblePublishInputs(data); + expect(assembled.isValid).toBe(true); + expect(assembled.site.editorDefaultEmailRecipients).toBe('visibility'); + }); + + it.each([ + ['settings', { settingsData: { settings: 'invalid' } }], + ['config', { configData: { config: { mailgunIsConfigured: 'yes' } } }], + ['newsletters', { newslettersData: { newsletters: [{ slug: 'missing-fields' }] } }], + ['current user', { currentUser: { roles: 'Administrator' } }], + ['member count', { memberCount: '12' }], + ])('fails closed for malformed %s data', (_name, override) => { + expect(assemblePublishInputs(boundary(override)).isValid).toBe(false); + }); +}); diff --git a/apps/admin/src/editor/publish/use-publish-inputs.ts b/apps/admin/src/editor/publish/use-publish-inputs.ts new file mode 100644 index 00000000000..2a3ae085ac4 --- /dev/null +++ b/apps/admin/src/editor/publish/use-publish-inputs.ts @@ -0,0 +1,256 @@ +import { useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { useCallback, useEffect, useMemo } from 'react'; +import { z } from 'zod'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; +import type { PublishSiteInput, PublishUserInput } from './publish-options'; + +const settingValueSchema = z.union([z.string(), z.boolean(), z.number(), z.null()]); +const settingSchema = z.looseObject({ key: z.string(), value: settingValueSchema }); +const defaultRecipientsSchema = z.enum(['disabled', 'visibility', 'filter']); +const newsletterSchema = z + .looseObject({ + slug: z.string(), + name: z.string(), + status: z.string(), + visibility: z.string(), + sort_order: z.number().optional(), + }) + // The API's serialized field is intentionally snake_case at this boundary. + .transform(({ sort_order: sortOrder, ...newsletter }) => ({ ...newsletter, sortOrder })); + +const publishInputsBoundarySchema = z.object({ + settingsData: z.looseObject({ settings: z.array(settingSchema) }), + configData: z.looseObject({ + config: z.looseObject({ mailgunIsConfigured: z.boolean().optional() }), + }), + newslettersData: z.looseObject({ newsletters: z.array(newsletterSchema) }), + currentUser: z.looseObject({ + roles: z.array(z.looseObject({ name: z.string() })), + }), + memberCount: z.number().int().nonnegative().nullable(), +}); + +const DEFAULT_SITE: PublishSiteInput = { + membersEnabled: true, + mailgunConfigured: false, + editorDefaultEmailRecipients: 'visibility', + editorDefaultEmailRecipientsFilter: null, + memberCount: null, + newsletters: [], +}; +const DEFAULT_USER: PublishUserInput = { isAdmin: false, isAuthorOrContributor: false }; + +function stringSetting(settings: z.infer[], key: string): string | null { + const value = settings.find((setting) => setting.key === key)?.value; + return typeof value === 'string' ? value : null; +} + +export interface AssembledPublishInputs { + site: PublishSiteInput; + user: PublishUserInput; + timezone: string; + isValid: boolean; +} + +/** Validates API-backed values before projecting the small publish-machine input. */ +export function assemblePublishInputs(boundaryData: { + settingsData: unknown; + configData: unknown; + newslettersData: unknown; + currentUser: unknown; + memberCount: unknown; +}): AssembledPublishInputs { + const parsed = publishInputsBoundarySchema.safeParse(boundaryData); + + if (!parsed.success) { + return { site: DEFAULT_SITE, user: DEFAULT_USER, timezone: 'Etc/UTC', isValid: false }; + } + + const { settingsData, configData, newslettersData, currentUser, memberCount } = parsed.data; + const settings = settingsData.settings; + const defaultRecipientsValue = settings.find( + (setting) => setting.key === 'editor_default_email_recipients', + )?.value; + const defaultRecipients = + defaultRecipientsValue === null || defaultRecipientsValue === undefined + ? defaultRecipientsSchema.safeParse('visibility') + : defaultRecipientsSchema.safeParse(defaultRecipientsValue); + + if (!defaultRecipients.success) { + return { site: DEFAULT_SITE, user: DEFAULT_USER, timezone: 'Etc/UTC', isValid: false }; + } + const roles = new Set(currentUser.roles.map((role) => role.name)); + + // Both sources count: self-hosters configure Mailgun in settings, hosts inject it via config. + const configuredInSettings = Boolean( + stringSetting(settings, 'mailgun_api_key') && + stringSetting(settings, 'mailgun_domain') && + stringSetting(settings, 'mailgun_base_url'), + ); + + return { + site: { + membersEnabled: stringSetting(settings, 'members_signup_access') !== 'none', + mailgunConfigured: configuredInSettings || configData.config.mailgunIsConfigured === true, + editorDefaultEmailRecipients: defaultRecipients.data, + editorDefaultEmailRecipientsFilter: stringSetting( + settings, + 'editor_default_email_recipients_filter', + ), + memberCount, + newsletters: newslettersData.newsletters, + }, + user: { + isAdmin: roles.has('Owner') || roles.has('Administrator'), + isAuthorOrContributor: roles.has('Author') || roles.has('Contributor'), + }, + timezone: stringSetting(settings, 'timezone') ?? 'Etc/UTC', + isValid: true, + }; +} + +export interface PublishInputs { + site: PublishSiteInput; + user: PublishUserInput; + timezone: string; + /** False until every input has loaded; the machine reads its inputs once. */ + isReady: boolean; + /** A query or validation failure that the caller can render in place. */ + error: Error | null; + /** Retries each API input owned by this adapter. */ + retry: () => void; +} + +function publishInputError(error: unknown): Error | null { + if (!error) { + return null; + } + + return error instanceof Error ? error : new Error('The publish settings could not be loaded.'); +} + +/** + * Assembles the publish machine's site and user inputs from the API. The + * machine reads them once at creation, so a caller must not build it until + * `isReady`. + */ +export function usePublishInputs(): PublishInputs { + const settingsQuery = useBrowseSettings({ + defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, + }); + const configQuery = useBrowseConfig({ + defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, + }); + const newslettersQuery = useBrowseNewsletters({ + defaultErrorHandler: false, + searchParams: { limit: 'all' }, + }); + const { + fetchNextPage: fetchNextNewsletterPage, + hasNextPage: hasNextNewsletterPage, + isError: newslettersError, + isFetchingNextPage: isFetchingNextNewsletterPage, + } = newslettersQuery; + + // Core caps `limit=all`, so the response can still contain a next page. + // The publish machine must see every newsletter before it chooses a default. + useEffect(() => { + if (hasNextNewsletterPage && !isFetchingNextNewsletterPage && !newslettersError) { + void fetchNextNewsletterPage(); + } + }, [ + fetchNextNewsletterPage, + hasNextNewsletterPage, + isFetchingNextNewsletterPage, + newslettersError, + ]); + // `useCurrentUser` takes no options; it is a shared boot query, not the flow's. + const currentUserQuery = useCurrentUser(); + // Site-wide total, the way Ember's publish options read it. + const { + count: memberCount, + isLoading: memberCountLoading, + isFetching: memberCountFetching, + error: memberCountError, + refetch: refetchMemberCount, + } = useMembersCount(''); + const settingsData = settingsQuery.data; + const configData = configQuery.data; + const newslettersData = newslettersQuery.data; + const currentUser = currentUserQuery.data; + + const assembled = useMemo( + () => + assemblePublishInputs({ + settingsData, + configData, + newslettersData, + currentUser, + memberCount, + }), + [settingsData, configData, newslettersData, currentUser, memberCount], + ); + const isLoading = + settingsQuery.isLoading || + settingsQuery.isFetching || + configQuery.isLoading || + configQuery.isFetching || + newslettersQuery.isLoading || + newslettersQuery.isFetching || + newslettersQuery.hasNextPage || + newslettersQuery.isFetchingNextPage || + currentUserQuery.isLoading || + currentUserQuery.isFetching || + memberCountLoading || + memberCountFetching; + const error = useMemo(() => { + const queryError = + settingsQuery.error ?? + configQuery.error ?? + newslettersQuery.error ?? + currentUserQuery.error ?? + memberCountError; + + if (queryError) { + return publishInputError(queryError); + } + + if (!isLoading && !assembled.isValid) { + return new Error('The publish settings response was invalid.'); + } + + return null; + }, [ + assembled.isValid, + configQuery.error, + currentUserQuery.error, + isLoading, + memberCountError, + newslettersQuery.error, + settingsQuery.error, + ]); + const retry = useCallback(() => { + void Promise.all([ + settingsQuery.refetch(), + configQuery.refetch(), + newslettersQuery.refetch(), + currentUserQuery.refetch(), + refetchMemberCount(), + ]); + }, [configQuery, currentUserQuery, newslettersQuery, refetchMemberCount, settingsQuery]); + + return { + site: assembled.site, + user: assembled.user, + timezone: assembled.timezone, + isReady: assembled.isValid && !isLoading && !error, + error, + retry, + }; +} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 7929d856620..3893608d854 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -8,6 +8,16 @@ @custom-variant admin7 (&:where(.admin7, .admin7 *)); +@keyframes email-sending-arrow-rise { + from { + transform: translateY(115%); + } + + to { + transform: translateY(-115%); + } +} + /* The shell owns rollout eligibility. Admin overlays mount beside #root, so mirror typography into Shade portals and the three legacy overlay hosts. Do not set body fonts or change component weights, sizes, or line heights. */ @@ -93,6 +103,7 @@ must be generated in this Tailwind lane; the font files themselves load with settings/custom-fonts.css in the settings chunk. */ @theme { + --animate-email-sending-arrow-rise: email-sending-arrow-rise 1200ms linear infinite; --font-cardo: Cardo; --font-manrope: Manrope; --font-merriweather: Merriweather; diff --git a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx index 1e8134521ff..2b2ea4d4d7c 100644 --- a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx +++ b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx @@ -1,5 +1,6 @@ import GiftLinkModal from '@/posts/analytics/modals/gift-link-modal'; import PostShareModal from '@/shared/analytics/post-share-modal'; +import EmailSendingStatusBanner from '@/posts/analytics/email-sending-status/email-sending-status-banner'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { AlertDialog, @@ -40,9 +41,7 @@ import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-con import { getSiteTimezone } from '@tryghost/admin-x-framework/utils/get-site-timezone'; import { giftAccessLabel } from '@/posts/analytics/utils/gift-link'; import { - hasBeenEmailed, isEmailOnly, - isPublishedAndEmailed, isPublishedOnly, trackEvent, useActiveVisitors, @@ -55,6 +54,7 @@ import { import { useCanManageGiftLink } from '@/posts/analytics/hooks/use-can-manage-gift-link'; import { useDeletePost } from '@tryghost/admin-x-framework/api/posts'; import { useHandleError } from '@tryghost/admin-x-framework/hooks'; +import { useEmailSendingStatusContext } from '@/posts/analytics/email-sending-status/email-sending-status-context'; interface PostAnalyticsHeaderProps { currentTab?: string; @@ -72,12 +72,17 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false); const { settings, site, statsConfig } = useAnalyticsData(); const { post, isPostLoading, postId } = usePostAnalytics(); + const { hasNewsletterAnalytics, status: emailSendingStatus } = useEmailSendingStatusContext(); const canManageGiftLink = useCanManageGiftLink(post); const editorPath = `/editor/post/${postId}`; // Whether the editor needs a hash navigation depends on the `editorReact` flag. const editorIsEmberOwned = useIsEmberOwnedRoute(editorPath); const siteTimezone = getSiteTimezone(settings); + const isPublishedPost = post?.status === 'published'; + const hasFailedEmail = emailSendingStatus?.sending.status === 'failed'; + const showPublishedOnSite = isPublishedPost && (!hasNewsletterAnalytics || hasFailedEmail); + const showPublishedAndSent = isPublishedPost && hasNewsletterAnalytics && !hasFailedEmail; // Track once per open — canManageGiftLink can flip while the modal is open // (current-user query resolving), which must not re-fire the event. @@ -119,7 +124,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c tabs.push('Web'); } } - if (hasBeenEmailed(post)) { + if (hasNewsletterAnalytics) { tabs.push('Newsletter'); } // Only show Growth tab if member source tracking is enabled @@ -128,7 +133,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c } return tabs; - }, [post, webAnalyticsEnabled, membersTrackSources]); + }, [post, webAnalyticsEnabled, membersTrackSources, hasNewsletterAnalytics]); const handleDeletePost = () => { if (!post) { @@ -287,15 +292,16 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c
{isEmailOnly(post) && `Sent on ${formatDisplayDate(post.published_at, siteTimezone)} at ${formatDisplayTime(post.published_at, siteTimezone)}`} - {isPublishedOnly(post) && + {showPublishedOnSite && `Published on your site on ${formatDisplayDate(post.published_at, siteTimezone)} at ${formatDisplayTime(post.published_at, siteTimezone)}`} - {isPublishedAndEmailed(post) && + {showPublishedAndSent && `Published and sent on ${formatDisplayDate(post.published_at, siteTimezone)} at ${formatDisplayTime(post.published_at, siteTimezone)}`}
)}
)} + diff --git a/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-banner.tsx b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-banner.tsx new file mode 100644 index 00000000000..39a5249cfa2 --- /dev/null +++ b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-banner.tsx @@ -0,0 +1,153 @@ +import { Banner, Button } from '@tryghost/shade/components'; +import { Inline, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, formatNumber } from '@tryghost/shade/utils'; +import { useEmailSendingStatusContext } from './email-sending-status-context'; +import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; +import type { EmailSendingState } from '@tryghost/admin-x-framework/api/emails'; +import type { ReactNode } from 'react'; + +const formatEta = (seconds: number): string => { + if (seconds > 80) { + const minutes = Math.round(seconds / 60); + return `About ${formatNumber(minutes)} ${minutes === 1 ? 'minute' : 'minutes'} left`; + } + if (seconds > 40) { + return 'About 1 minute left'; + } + return 'Less than 1 minute left'; +}; + +const HalfFullGlyph = () => ( + +); + +const StatusGlyph = ({ sending }: { sending: EmailSendingState }) => { + if (sending.status === 'preparing') { + return ( + + + + ); + } + + if (sending.status === 'failed') { + return ( + + + ); + } + + return ( + + + + + ); +}; + +const activeDetail = (sending: Exclude): ReactNode => { + const { completed, total, estimated_seconds_remaining: eta } = sending.progress; + const estimate = eta === null ? null : formatEta(eta); + + if (total === 0) { + return estimate; + } + + return ( + <> + {formatNumber(completed)} + {` of ${formatNumber(total)}`} + {estimate && ` · ${estimate}`} + + ); +}; + +const failureDetail = ( + sending: Extract, + error?: string | null, +) => { + const { completed, total } = sending.progress; + const sent = sending.failed_during === 'submitting' ? completed : 0; + const progress = + sent > 0 + ? `${formatNumber(sent)} of ${formatNumber(total)} emails were sent.` + : total > 0 + ? `None of the ${formatNumber(total)} emails were sent.` + : 'No emails were sent.'; + + return error ? `${progress} ${error}` : progress; +}; + +const EmailSendingStatusBanner = () => { + const { post } = usePostAnalytics(); + const { status, hasUnknownDeliveryOutcome, isRetrying, retrySending } = + useEmailSendingStatusContext(); + const sending = status?.sending; + + if (!sending || sending.status === 'submitted') { + return null; + } + + const isFailed = sending.status === 'failed'; + const hasSentEmails = isFailed + ? !hasUnknownDeliveryOutcome && + sending.failed_during === 'submitting' && + sending.progress.completed > 0 + : false; + const title = isFailed + ? hasSentEmails + ? 'Some emails failed to send' + : 'Emails failed to send' + : sending.status === 'preparing' + ? 'Preparing emails' + : 'Sending emails'; + const detail = isFailed + ? hasUnknownDeliveryOutcome + ? post?.email?.error || 'Something went wrong while sending this email.' + : failureDetail(sending, post?.email?.error) + : activeDetail(sending); + const retryLabel = hasSentEmails ? 'Send remaining emails' : 'Retry sending email'; + + return ( + + + + + + + {title} + + {detail && ( + + {' · '} + {detail} + + )} + + + {isFailed && !hasUnknownDeliveryOutcome && ( + + )} + + + ); +}; + +export default EmailSendingStatusBanner; diff --git a/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-context.ts b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-context.ts new file mode 100644 index 00000000000..9265f3d641a --- /dev/null +++ b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-context.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import type { EmailSendingStatus } from '@tryghost/admin-x-framework/api/emails'; + +export interface EmailSendingStatusContextValue { + status: EmailSendingStatus | undefined; + isStatusLoading: boolean; + isNewsletterDataHidden: boolean; + newsletterDataHiddenReason: 'sending' | 'failed' | null; + hasNewsletterAnalytics: boolean; + hasUnknownDeliveryOutcome: boolean; + isRetrying: boolean; + retrySending: () => Promise; +} + +export const EmailSendingStatusContext = createContext( + undefined, +); + +export const useEmailSendingStatusContext = (): EmailSendingStatusContextValue => { + const context = useContext(EmailSendingStatusContext); + if (!context) { + throw new Error( + 'useEmailSendingStatusContext must be used within an EmailSendingStatusProvider', + ); + } + return context; +}; diff --git a/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx new file mode 100644 index 00000000000..07646d97ca6 --- /dev/null +++ b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx @@ -0,0 +1,190 @@ +import { EmailSendingStatusContext } from './email-sending-status-context'; +import { APIError } from '@tryghost/admin-x-framework/errors'; +import { feedbackDataType } from '@tryghost/admin-x-framework/api/feedback'; +import { hasBeenEmailed } from '@tryghost/admin-x-framework'; +import { linksDataType } from '@tryghost/admin-x-framework/api/links'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; +import { + newsletterBasicStatsDataType, + newsletterClickStatsDataType, +} from '@tryghost/admin-x-framework/api/stats'; +import { + useBrowseEmailBatches, + useEmailSendingStatus, + useRetryEmail, +} from '@tryghost/admin-x-framework/api/emails'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useFeatureFlag, useHandleError } from '@tryghost/admin-x-framework/hooks'; +import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; +import { useQueryClient } from '@tanstack/react-query'; + +const STATUS_POLL_INTERVAL = import.meta.env.MODE === 'test' ? 50 : 2000; +const NEWSLETTER_DATA_TYPES = new Set([ + postsDataType, + linksDataType, + newsletterBasicStatsDataType, + newsletterClickStatsDataType, + feedbackDataType, +]); + +const EmailSendingStatusProvider = ({ children }: { children: ReactNode }) => { + const { post, refetchPost } = usePostAnalytics(); + const queryClient = useQueryClient(); + const enabled = useFeatureFlag('improveSendingUI'); + const emailId = post?.email?.id; + const emailStatus = post?.email?.status; + const hasPublishedEmail = + Boolean(emailId) && (post?.status === 'published' || post?.status === 'sent'); + const shouldQuery = enabled && hasPublishedEmail && Boolean(emailStatus); + + const statusQuery = useEmailSendingStatus(emailId ?? '', { + enabled: (query) => { + const queriedStatus = query.state.data?.email_statuses[0]?.sending.status; + const missingBackend = + !query.state.data && + query.state.error instanceof APIError && + query.state.error.response?.status === 404; + const submittedBeforeStatusLoaded = !query.state.data && emailStatus === 'submitted'; + return ( + shouldQuery && + !missingBackend && + !submittedBeforeStatusLoaded && + queriedStatus !== 'submitted' + ); + }, + defaultErrorHandler: false, + refetchInterval: (query) => { + const status = query.state.data?.email_statuses[0]?.sending.status; + return status === 'preparing' || status === 'submitting' ? STATUS_POLL_INTERVAL : false; + }, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + retry: false, + }); + const { mutateAsync: retryEmail, isPending: isRetryMutationPending } = useRetryEmail(); + const { refetch: refetchStatus } = statusQuery; + const handleError = useHandleError(); + const [isRetryRefreshPending, setIsRetryRefreshPending] = useState(false); + + const status = statusQuery.data?.email_statuses[0]; + const sendingStatus = status?.sending.status; + const shouldQueryBatches = Boolean(enabled && emailId && sendingStatus === 'failed'); + const batchesQuery = useBrowseEmailBatches(emailId ?? '', { + enabled: shouldQueryBatches, + searchParams: { filter: 'status:submitting', fields: 'id,status', limit: '1' }, + defaultErrorHandler: false, + refetchOnWindowFocus: true, + retry: false, + staleTime: 0, + }); + const hasUnknownDeliveryOutcome = Boolean( + shouldQueryBatches && + (batchesQuery.isFetching || + batchesQuery.isError || + !batchesQuery.data || + batchesQuery.data.batches.some((batch) => batch.status === 'submitting')), + ); + const lastHandledSendingState = useRef(null); + const retryInFlight = useRef(false); + const [refreshedSubmittedEmailId, setRefreshedSubmittedEmailId] = useState(null); + + useEffect(() => { + if (!emailId || !sendingStatus) { + return; + } + + const sendingStateKey = `${emailId}:${sendingStatus}`; + if (lastHandledSendingState.current === sendingStateKey) { + return; + } + lastHandledSendingState.current = sendingStateKey; + + if (sendingStatus !== 'submitted') { + setRefreshedSubmittedEmailId((currentEmailId) => + currentEmailId === emailId ? null : currentEmailId, + ); + } + + if (sendingStatus === 'failed') { + void refetchPost(); + return; + } + + if (sendingStatus === 'submitted') { + void Promise.all( + [...NEWSLETTER_DATA_TYPES].map((dataType) => + queryClient.invalidateQueries({ queryKey: [dataType] }), + ), + ).then(() => setRefreshedSubmittedEmailId(emailId)); + } + }, [emailId, queryClient, refetchPost, sendingStatus]); + + const isStatusLoading = shouldQuery && statusQuery.isLoading && !status; + const isRefreshingSubmittedData = Boolean( + emailId && sendingStatus === 'submitted' && refreshedSubmittedEmailId !== emailId, + ); + const isNewsletterDataHidden = Boolean( + enabled && status && (status.sending.status !== 'submitted' || isRefreshingSubmittedData), + ); + const newsletterDataHiddenReason: 'sending' | 'failed' | null = isNewsletterDataHidden + ? status?.sending.status === 'failed' + ? 'failed' + : 'sending' + : null; + const hasNewsletterAnalytics = Boolean( + post && + (post.status === 'published' || post.status === 'sent') && + (hasBeenEmailed(post) || (enabled && status && post.email)), + ); + + const retrySending = useCallback(async () => { + if (!emailId || retryInFlight.current) { + return; + } + + retryInFlight.current = true; + setIsRetryRefreshPending(true); + try { + await retryEmail(emailId); + await refetchStatus({ throwOnError: true }); + } catch (error) { + handleError(error); + } finally { + retryInFlight.current = false; + setIsRetryRefreshPending(false); + } + }, [emailId, handleError, refetchStatus, retryEmail]); + + const isRetrying = isRetryMutationPending || isRetryRefreshPending; + + const value = useMemo( + () => ({ + status, + isStatusLoading, + isNewsletterDataHidden, + newsletterDataHiddenReason, + hasNewsletterAnalytics, + hasUnknownDeliveryOutcome, + isRetrying, + retrySending, + }), + [ + status, + isStatusLoading, + isNewsletterDataHidden, + newsletterDataHiddenReason, + hasNewsletterAnalytics, + hasUnknownDeliveryOutcome, + isRetrying, + retrySending, + ], + ); + + return ( + + {children} + + ); +}; + +export default EmailSendingStatusProvider; diff --git a/apps/admin/src/posts/analytics/email-sending-status/pending-send-empty.tsx b/apps/admin/src/posts/analytics/email-sending-status/pending-send-empty.tsx new file mode 100644 index 00000000000..d324235b34f --- /dev/null +++ b/apps/admin/src/posts/analytics/email-sending-status/pending-send-empty.tsx @@ -0,0 +1,40 @@ +import { EmptyIndicator } from '@tryghost/shade/components'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { useEmailSendingStatusContext } from './email-sending-status-context'; +import type { ReactNode } from 'react'; + +interface PendingSendEmptyProps { + children?: ReactNode; + className?: string; + description: string; + failedDescription?: string; + failedTitle?: string; + title: string; +} + +const PendingSendEmpty = ({ + children, + className, + description, + failedDescription = 'There is no newsletter performance data for this post', + failedTitle = 'No newsletter data available', + title, +}: PendingSendEmptyProps) => { + const { isNewsletterDataHidden, newsletterDataHiddenReason } = useEmailSendingStatusContext(); + + if (!isNewsletterDataHidden) { + return children ?
{children}
: null; + } + + return ( + + + + ); +}; + +export default PendingSendEmpty; diff --git a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx index 3f38916b1cc..73e72283487 100644 --- a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx +++ b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx @@ -1,5 +1,5 @@ import { HttpResponse, http } from 'msw'; -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import { test as baseTest, beforeEach, describe, expect, vi } from 'vitest'; import { post, type Post } from '@tryghost/test-data'; import type { QueryClient } from '@tanstack/react-query'; @@ -8,6 +8,20 @@ import { serverFixture } from '@test-utils/fixtures/msw'; import { queryClientFixtures, type TestWrapperComponent } from '@test-utils/fixtures/query-client'; import { usePostSuccessModal } from '@/posts/analytics/hooks/use-post-success-modal'; +const { mockUseFeatureFlag } = vi.hoisted(() => ({ + mockUseFeatureFlag: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/hooks', async () => { + const actual = await vi.importActual( + '@tryghost/admin-x-framework/hooks', + ); + return { + ...actual, + useFeatureFlag: (flag: string) => mockUseFeatureFlag(flag) as boolean, + }; +}); + // Mock the shared analytics data hook (not HTTP) vi.mock('@/shared/analytics/use-analytics-data', () => ({ useAnalyticsData: vi.fn(), @@ -47,6 +61,7 @@ const test = baseTest.extend<{ describe('usePostSuccessModal', () => { beforeEach(() => { vi.clearAllMocks(); + mockUseFeatureFlag.mockReturnValue(false); // Default mocks mockUseAnalyticsData.mockReturnValue({ @@ -424,6 +439,68 @@ describe('usePostSuccessModal', () => { }); }); + test('uses in-progress copy for a post and email when improved sending UI is enabled', async ({ + server, + wrapper, + }) => { + mockUseFeatureFlag.mockReturnValue(true); + mockPosts(server, [ + buildPost({ + email: { email_count: 100, opened_count: 0 }, + newsletter: { id: 'newsletter-123', name: 'Weekly Newsletter' }, + }), + ]); + mockLocalStorage.getItem.mockReturnValue(JSON.stringify({ id: 'post-123', type: 'post' })); + + const { result } = renderHook(() => usePostSuccessModal(), { wrapper }); + + await waitFor(() => expect(result.current.modalProps).toBeTruthy()); + render(result.current.modalProps?.description); + expect( + screen.getByText(/Your post was published on your site and is being sent to/), + ).toBeTruthy(); + }); + + test('uses completed copy when the legacy flow has already observed submission', async ({ + server, + wrapper, + }) => { + mockUseFeatureFlag.mockReturnValue(true); + mockPosts(server, [ + buildPost({ + email: { email_count: 100, opened_count: 0, status: 'submitted' }, + newsletter: { id: 'newsletter-123', name: 'Weekly Newsletter' }, + }), + ]); + mockLocalStorage.getItem.mockReturnValue(JSON.stringify({ id: 'post-123', type: 'post' })); + + const { result } = renderHook(() => usePostSuccessModal(), { wrapper }); + + await waitFor(() => expect(result.current.modalProps).toBeTruthy()); + render(result.current.modalProps?.description); + expect(screen.getByText(/Your post was published on your site and sent to/)).toBeTruthy(); + }); + + test('uses in-progress copy for an email-only send when improved sending UI is enabled', async ({ + server, + wrapper, + }) => { + mockUseFeatureFlag.mockReturnValue(true); + mockPosts(server, [ + buildPost({ + email_only: true, + email: { email_count: 50, opened_count: 0 }, + }), + ]); + mockLocalStorage.getItem.mockReturnValue(JSON.stringify({ id: 'post-123', type: 'post' })); + + const { result } = renderHook(() => usePostSuccessModal(), { wrapper }); + + await waitFor(() => expect(result.current.modalProps).toBeTruthy()); + render(result.current.modalProps?.description); + expect(screen.getByText(/Your email is being sent to/)).toBeTruthy(); + }); + test('handles loading state', ({ server, wrapper }) => { mockPosts(server, []); diff --git a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.ts b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.ts index 25a94688da4..f468cb041d6 100644 --- a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.ts +++ b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.ts @@ -3,6 +3,7 @@ import { type Post, useBrowsePosts } from '@tryghost/admin-x-framework/api/posts import { formatNumber } from '@tryghost/shade/utils'; import { useEffect, useMemo, useState } from 'react'; import { useAnalyticsData } from '@/shared/analytics/use-analytics-data'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; interface PublishedPostData { id: string; @@ -24,6 +25,7 @@ export const usePostSuccessModal = () => { const [publishedPostData, setPublishedPostData] = useState(null); const [postCount, setPostCount] = useState(null); const { site } = useAnalyticsData(); + const improveSendingUI = useFeatureFlag('improveSendingUI'); // Fetch the published post data if we have it const { data: postResponse } = useBrowsePosts({ @@ -72,15 +74,20 @@ export const usePostSuccessModal = () => { } const showPostCount = !!postCount; + const isEmailStillSending = improveSendingUI && post.email?.status !== 'submitted'; // Build description with React elements to match Ember modal format with bold text const getDescription = () => { const parts = []; if (post.email_only) { - parts.push('Your email was sent to'); + parts.push(isEmailStillSending ? 'Your email is being sent to' : 'Your email was sent to'); } else if (post.email?.email_count) { - parts.push('Your post was published on your site and sent to'); + parts.push( + isEmailStillSending + ? 'Your post was published on your site and is being sent to' + : 'Your post was published on your site and sent to', + ); } else { parts.push('Your post was published on your site'); } @@ -156,7 +163,7 @@ export const usePostSuccessModal = () => { author: getAuthorsText(post.authors), onClose: handleClose, }; - }, [post, isModalOpen, postCount, site?.title]); + }, [post, isModalOpen, postCount, site?.title, improveSendingUI]); useEffect(() => { const checkForPublishedPost = () => { diff --git a/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx b/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx index 54ec6a714e5..c98ca6bc044 100644 --- a/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx +++ b/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx @@ -30,6 +30,8 @@ import { formatMemberName, getMemberInitials } from '@/members/api'; import { useNavigate, useParams } from '@tryghost/admin-x-framework'; import { usePostFeedback } from '@/posts/analytics/hooks/use-post-feedback'; import { useState } from 'react'; +import PendingSendEmpty from '@/posts/analytics/email-sending-status/pending-send-empty'; +import { useEmailSendingStatusContext } from '@/posts/analytics/email-sending-status/email-sending-status-context'; interface FeedbackProps { feedbackStats: { @@ -42,6 +44,7 @@ interface FeedbackProps { const Feedback: React.FC = ({ feedbackStats }) => { const { postId } = useParams(); const navigate = useNavigate(); + const { isNewsletterDataHidden } = useEmailSendingStatusContext(); const [activeFeedbackTab, setActiveFeedbackTab] = useState<'positive' | 'negative'>('positive'); const ITEMS_PER_PAGE = 9; @@ -70,92 +73,104 @@ const Feedback: React.FC = ({ feedbackStats }) => { Feedback What did your readers think? - {feedbackStats.totalFeedback > 0 ? ( + {isNewsletterDataHidden ? ( -
- setActiveFeedbackTab(value as 'positive' | 'negative')} - > - - -
- - - More like this - - - {formatPercentage( - feedbackStats.positiveFeedback / feedbackStats.totalFeedback, - )} - -
-
- -
- - - Less like this - - - {formatPercentage( - feedbackStats.negativeFeedback / feedbackStats.totalFeedback, - )} - -
-
-
-
- Date -
- - {isLoading ? ( - - ) : paginatedFeedback && paginatedFeedback.length > 0 ? ( -
- {paginatedFeedback.map((item) => ( -
{ - navigate(`/members/${item.member.id}`); - }} - > -
- - {item.member?.avatar_image && ( - - )} - - {getMemberInitials(item.member)} - - - {formatMemberName(item.member)} -
-
- {formatTimestamp(item.created_at)} + + + ) : feedbackStats.totalFeedback > 0 ? ( + + +
+ setActiveFeedbackTab(value as 'positive' | 'negative')} + > + + +
+ + + More like this + + + {formatPercentage( + feedbackStats.positiveFeedback / feedbackStats.totalFeedback, + )} + +
+
+ +
+ + + Less like this + + + {formatPercentage( + feedbackStats.negativeFeedback / feedbackStats.totalFeedback, + )} + +
+
+
+
+ Date +
+ + {isLoading ? ( + + ) : paginatedFeedback && paginatedFeedback.length > 0 ? ( +
+ {paginatedFeedback.map((item) => ( +
{ + navigate(`/members/${item.member.id}`); + }} + > +
+ + {item.member?.avatar_image && ( + + )} + + {getMemberInitials(item.member)} + + + {formatMemberName(item.member)} +
+
+ {formatTimestamp(item.created_at)} +
+ ))} +
+ ) : ( +
+
+ No {activeFeedbackTab === 'positive' ? 'positive' : 'negative'} feedback yet
- ))} -
- ) : ( -
-
- No {activeFeedbackTab === 'positive' ? 'positive' : 'negative'} feedback yet
-
- )} + )} +
) : ( @@ -163,7 +178,7 @@ const Feedback: React.FC = ({ feedbackStats }) => {
When someone does, you'll see their response here.
)} - {feedbackStats.totalFeedback > 0 && ( + {feedbackStats.totalFeedback > 0 && !isNewsletterDataHidden && (
+ {!isNewsletterDataHidden && ( + + )}
{isNewsletterStatsLoading ? ( @@ -126,111 +131,117 @@ const NewsletterOverview: React.FC = ({
) : ( - -
-
- -
- Open rate - -
-
- -
- Click rate - -
-
-
- {!fullWidth && } -
- -
-
- -
- {!fullWidth && } -
-
- - Top clicked links in this email - - Members + + +
+
+ +
+ Open rate + +
+
+ +
+ Click rate + +
+
+
+ {!fullWidth && } +
+
+
- {topLinks.length > 0 ? ( - - - {topLinks.slice(0, fullWidth ? 10 : 5).map((link) => { - const percentage = stats.clicked > 0 ? link.count / stats.clicked : 0; - return ( - - - - - - - - {formatNumber(link.count || 0)} - - - {formatPercentage(percentage)} - - - - ); - })} - - - ) : ( -
- You have no links in your post. +
+ {!fullWidth && } +
+
+ + Top clicked links in this email + + Members
- )} + + {topLinks.length > 0 ? ( + + + {topLinks.slice(0, fullWidth ? 10 : 5).map((link) => { + const percentage = stats.clicked > 0 ? link.count / stats.clicked : 0; + return ( + + + + + + + + {formatNumber(link.count || 0)} + + + {formatPercentage(percentage)} + + + + ); + })} + + + ) : ( +
+ You have no links in your post. +
+ )} +
-
- {/* */} +
)} diff --git a/apps/admin/src/posts/analytics/overview/overview.tsx b/apps/admin/src/posts/analytics/overview/overview.tsx index 433e066f761..cad459fe6b4 100644 --- a/apps/admin/src/posts/analytics/overview/overview.tsx +++ b/apps/admin/src/posts/analytics/overview/overview.tsx @@ -31,12 +31,7 @@ import { getRangeForStartDate, sanitizeChartData, } from '@/shared/analytics/chart-helpers'; -import { - hasBeenEmailed, - isPublishedOnly, - useNavigate, - useTinybirdQuery, -} from '@tryghost/admin-x-framework'; +import { isPublishedOnly, useNavigate, useTinybirdQuery } from '@tryghost/admin-x-framework'; import { useActiveGiftLink } from '@tryghost/admin-x-framework/api/gift-links'; import { useAnalyticsData } from '@/shared/analytics/use-analytics-data'; import { @@ -50,6 +45,7 @@ import { useCanManageGiftLink } from '@/posts/analytics/hooks/use-can-manage-gif import { useEffect, useMemo, useState } from 'react'; import { useGiftLinkUsage } from '@/posts/analytics/hooks/use-gift-link-usage'; import { usePostReferrers } from '@/posts/analytics/hooks/use-post-referrers'; +import { useEmailSendingStatusContext } from '@/posts/analytics/email-sending-status/email-sending-status-context'; const Overview: React.FC = () => { const navigate = useNavigate(); @@ -61,6 +57,7 @@ const Overview: React.FC = () => { const membersTrackSources = useMembersTrackSources(); const paidMembersEnabled = usePaidMembersEnabled(); const webAnalyticsEnabled = useWebAnalyticsEnabled(); + const { hasNewsletterAnalytics, isStatusLoading } = useEmailSendingStatusContext(); // Gift link card: only for eligible posts. Read the active link (without // minting) to scope the usage count to the current token, matching the modal. @@ -165,9 +162,8 @@ const Overview: React.FC = () => { const kpiIsLoading = isConfigLoading || isTotalsLoading || isPostLoading || chartLoading; const chartIsLoading = isPostLoading || isConfigLoading || chartLoading; - // Use the utility function from admin-x-framework const showNewsletterSection = - hasBeenEmailed(post as Post) && emailTrackOpensEnabled && emailTrackClicksEnabled; + hasNewsletterAnalytics && emailTrackOpensEnabled && emailTrackClicksEnabled; const showWebSection = !post?.email_only && webAnalyticsEnabled; const showGrowthSection = membersTrackSources; const showGiftLinkCard = Boolean(canManageGiftLink && post && webAnalyticsEnabled); @@ -208,7 +204,7 @@ const Overview: React.FC = () => { )} {showNewsletterSection && ( diff --git a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx index 6f8228b1d94..be7f547be25 100644 --- a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx +++ b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; +import { focusManager } from '@tanstack/react-query'; import { currentRoute, @@ -21,6 +22,7 @@ import { postAnalyticsScreen } from './post-analytics.screen'; const POST_ID = '64d623b64676110001e897d9'; const POST_UUID = '0d5cea22-f4d5-4b23-a0f7-1d9c46ae5f2a'; const NEWSLETTER_ID = '64d623b64676110001e897aa'; +const EMAIL_ID = '64d623b64676110001e897ab'; function daysAgo(days: number): string { const date = new Date(); @@ -28,7 +30,7 @@ function daysAgo(days: number): string { return date.toISOString().slice(0, 10); } -function seededPost() { +function seededPost(overrides: Partial> = {}) { return post({ id: POST_ID, uuid: POST_UUID, @@ -38,9 +40,16 @@ function seededPost() { visibility: 'public', published_at: `${daysAgo(10)}T10:00:00.000Z`, url: 'https://example.com/attack-of-the-clones/', - email: { email_count: 1000, opened_count: 400, status: 'submitted' }, + email: { id: EMAIL_ID, email_count: 1000, opened_count: 400, status: 'submitted' }, count: { clicks: 60, positive_feedback: 0, negative_feedback: 0 }, newsletter: { id: NEWSLETTER_ID }, + ...overrides, + }); +} + +function fakeSubmittingBatches(batches: Array<{ id: string; status: string }> = []) { + return fakeAdminEndpoint('GET', new RegExp(`^/emails/${EMAIL_ID}/batches/(?:\\?|$)`), { + batches, }); } @@ -50,8 +59,11 @@ function seededPost() { * and the Tinybird KPI + active-visitors pipes. Tab-specific endpoints are * declared per test. */ -function seedPostAnalyticsWorld() { - const postsApi = fakePosts([seededPost()]); +function seedPostAnalyticsWorld( + postOverrides: Partial> = {}, + postResponse: Parameters[0] = [seededPost(postOverrides)], +) { + const postsApi = fakePosts(postResponse); fakeAdminStats.postReferrers(POST_ID, [ { source: 'Google', @@ -66,7 +78,7 @@ function seedPostAnalyticsWorld() { stats: [{ date: daysAgo(1), mrr: 50000 }], totals: [{ currency: 'usd', mrr: 50000 }], }); - fakeAdminEndpoint('GET', /^\/links\//, { + const linksApi = fakeAdminEndpoint('GET', /^\/links\//, { links: [ { post_id: POST_ID, @@ -89,6 +101,7 @@ function seedPostAnalyticsWorld() { const topLocationsApi = fakeTinybirdPipe('api_top_locations', [{ location: 'US', visits: 200 }]); return { postsApi, + linksApi, topSourcesApi, topLocationsApi, kpisApi: fakeTinybirdPipe('api_kpis', [ @@ -128,10 +141,403 @@ function seedEmptyPostAnalyticsWorld() { } describe('Post analytics overview', () => { + it('shows sending progress and withholds newsletter figures with the URL override', async () => { + const postOverrides = { + email: { id: EMAIL_ID, email_count: 0, opened_count: 0, status: 'submitting' }, + } as const; + let postRequestCount = 0; + const { linksApi, postsApi } = seedPostAnalyticsWorld(postOverrides, () => { + postRequestCount += 1; + return [ + seededPost( + postRequestCount === 1 + ? postOverrides + : { + email: { + id: EMAIL_ID, + email_count: 1000, + opened_count: 400, + status: 'submitted', + }, + }, + ), + ]; + }); + let detailedPostRequestCount = 0; + const detailedPostsApi = fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/`), () => { + detailedPostRequestCount += 1; + return { + posts: [ + seededPost( + detailedPostRequestCount === 1 + ? postOverrides + : { + email: { + id: EMAIL_ID, + email_count: 1000, + opened_count: 400, + status: 'submitted', + }, + count: { clicks: 60, positive_feedback: 0, negative_feedback: 0 }, + }, + ), + ], + }; + }); + let basicStatsRequestCount = 0; + const basicStatsApi = fakeAdminEndpoint('GET', /^\/stats\/newsletter-basic-stats\//, () => { + basicStatsRequestCount += 1; + return { + stats: + basicStatsRequestCount === 1 + ? [] + : [ + { + post_id: POST_ID, + post_title: 'Attack of the Clones', + send_date: `${daysAgo(10)}T10:00:00.000Z`, + sent_to: 1000, + total_opens: 400, + open_rate: 0.4, + }, + ], + meta: {}, + }; + }); + const clickStatsApi = fakeAdminEndpoint('GET', /^\/stats\/newsletter-click-stats\//, () => { + return { + stats: [ + { + post_id: POST_ID, + total_clicks: 60, + click_rate: 0.06, + email_count: 1000, + }, + ], + meta: {}, + }; + }); + let statusRequestCount = 0; + let completeSending = false; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, () => { + statusRequestCount += 1; + return { + email_statuses: [ + { + id: EMAIL_ID, + sending: !completeSending + ? { + status: 'submitting', + progress: { completed: 500, total: 1000, estimated_seconds_remaining: 30 }, + } + : { + status: 'submitted', + progress: { completed: 1000, total: 1000, estimated_seconds_remaining: 0 }, + }, + }, + ], + }; + }); + + await renderAdminApp(`/posts/analytics/${POST_ID}?labs=improveSendingUI`, { + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(page.getByText('Sending emails')).toBeVisible(); + await expect.element(page.getByText(/500 of 1,000/)).toBeVisible(); + await expect.element(page.getByText('This newsletter is still sending')).toBeVisible(); + await expect.element(postAnalyticsScreen.uniqueVisitors()).toHaveTextContent('250'); + + await postAnalyticsScreen.newsletterTab().click(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}/newsletter`); + await expect.element(page.getByText('Newsletter clicks')).not.toBeInTheDocument(); + await expect + .element(page.getByText('Sends, opens and clicks will appear once every email has been sent')) + .toBeVisible(); + await expect.element(page.getByRole('button', { name: /View members/ }).first()).toBeDisabled(); + + completeSending = true; + const pendingStatusRequestCount = statusRequestCount; + await expect + .poll(() => statusRequestCount, { timeout: 3500 }) + .toBeGreaterThan(pendingStatusRequestCount); + await expect.element(postAnalyticsScreen.emailSendingStatusBanner()).not.toBeInTheDocument(); + await expect.poll(() => postsApi.requests.length).toBeGreaterThan(1); + await expect.poll(() => detailedPostsApi.requests.length).toBeGreaterThan(1); + await expect.poll(() => basicStatsApi.requests.length).toBeGreaterThan(1); + await expect.poll(() => clickStatsApi.requests.length).toBeGreaterThan(0); + await expect.poll(() => linksApi.requests.length).toBeGreaterThan(1); + await expect.element(page.getByText('1,000').first()).toBeVisible(); + await expect.element(page.getByText('400').first()).toBeVisible(); + await expect.element(page.getByRole('button', { name: /View members/ }).first()).toBeEnabled(); + }); + + it('moves a failed send and its retry action into the banner', async () => { + const postOverrides = { + email: { + id: EMAIL_ID, + email_count: 250, + opened_count: 0, + status: 'failed', + error: 'Mailgun rejected the batch.', + }, + } as const; + seedPostAnalyticsWorld(postOverrides); + fakeSubmittingBatches(); + let statusRequestCount = 0; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, () => { + statusRequestCount += 1; + return { + email_statuses: [ + { + id: EMAIL_ID, + sending: + statusRequestCount === 1 + ? { + status: 'failed', + failed_during: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: null }, + } + : statusRequestCount === 2 + ? { + status: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: 30 }, + } + : { + status: 'submitted', + progress: { completed: 1000, total: 1000, estimated_seconds_remaining: 0 }, + }, + }, + ], + }; + }); + const retryApi = fakeAdminEndpoint('PUT', `/emails/${EMAIL_ID}/retry/`, { + emails: [{ id: EMAIL_ID, email_count: 250, opened_count: 0, status: 'submitting' }], + }); + + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(page.getByText('Some emails failed to send')).toBeVisible(); + await expect.element(page.getByText(/^Published on your site on/)).toBeVisible(); + await expect.element(page.getByText(/^Published and sent on/)).not.toBeInTheDocument(); + await expect.element(page.getByText(/Mailgun rejected the batch/)).toBeVisible(); + await expect.element(page.getByText('No newsletter data available')).toBeVisible(); + + await page.getByRole('button', { name: 'Send remaining emails' }).click(); + await expect.poll(() => retryApi.requests.length).toBe(1); + await expect.element(page.getByText('Sending emails')).toBeVisible(); + await expect.poll(() => statusRequestCount, { timeout: 3500 }).toBeGreaterThan(2); + await expect.element(postAnalyticsScreen.emailSendingStatusBanner()).not.toBeInTheDocument(); + }); + + it('shows a generic failure without retry when a batch has an unknown delivery outcome', async () => { + seedPostAnalyticsWorld({ + email: { + id: EMAIL_ID, + email_count: 250, + opened_count: 0, + status: 'failed', + error: 'An error occurred, and your newsletter was only partially sent.', + }, + }); + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, { + email_statuses: [ + { + id: EMAIL_ID, + sending: { + status: 'failed', + failed_during: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }); + const batchesApi = fakeSubmittingBatches([{ id: 'batch-1', status: 'submitting' }]); + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(page.getByText('Emails failed to send')).toBeVisible(); + await expect.element(page.getByText(/only partially sent/)).toBeVisible(); + await expect + .element( + postAnalyticsScreen.emailSendingStatusBanner().getByRole('button', { name: /send|retry/i }), + ) + .not.toBeInTheDocument(); + await expect + .poll(() => + new URL(batchesApi.lastRequest?.url ?? 'http://localhost').searchParams.get('filter'), + ) + .toBe('status:submitting'); + const batchRequestUrl = new URL(batchesApi.lastRequest!.url); + expect(batchRequestUrl.searchParams.get('fields')).toBe('id,status'); + expect(batchRequestUrl.searchParams.get('limit')).toBe('1'); + }); + + it('refreshes the failure reason and does not count prepared recipients as sent', async () => { + const postOverrides = { + email: { id: EMAIL_ID, email_count: 0, opened_count: 0, status: 'submitting' }, + } as const; + let postRequestCount = 0; + const { postsApi } = seedPostAnalyticsWorld(postOverrides, () => { + postRequestCount += 1; + return [ + seededPost( + postRequestCount === 1 + ? postOverrides + : { + email: { + id: EMAIL_ID, + email_count: 0, + opened_count: 0, + status: 'failed', + error: 'Preparation failed.', + }, + }, + ), + ]; + }); + fakeSubmittingBatches(); + let statusRequestCount = 0; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, () => { + statusRequestCount += 1; + return { + email_statuses: [ + { + id: EMAIL_ID, + sending: + statusRequestCount === 1 + ? { + status: 'preparing', + progress: { completed: 100, total: 1000, estimated_seconds_remaining: 30 }, + } + : { + status: 'failed', + failed_during: 'preparing', + progress: { + completed: 250, + total: 1000, + estimated_seconds_remaining: null, + }, + }, + }, + ], + }; + }); + + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(page.getByText('Preparing emails')).toBeVisible(); + await expect.poll(() => statusRequestCount, { timeout: 3500 }).toBeGreaterThan(1); + await expect.poll(() => postsApi.requests.length).toBeGreaterThan(1); + await expect.element(page.getByText('Emails failed to send')).toBeVisible(); + await expect + .element(page.getByText(/None of the 1,000 emails were sent\. Preparation failed\./)) + .toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Retry sending email' })).toBeVisible(); + }); + + it('falls back to the production analytics UI when the status endpoint is unavailable', async () => { + seedPostAnalyticsWorld({ + email: { id: EMAIL_ID, email_count: 1000, opened_count: 400, status: 'submitting' }, + }); + const statusApi = fakeAdminEndpoint( + 'GET', + `/emails/${EMAIL_ID}/status/`, + { errors: [{ message: 'Resource not found' }] }, + { status: 404 }, + ); + + const app = await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(page.getByText('Newsletter performance')).toBeVisible(); + await expect + .element(page.getByText('This newsletter is still sending')) + .not.toBeInTheDocument(); + await expect.element(postAnalyticsScreen.emailSendingStatusBanner()).not.toBeInTheDocument(); + await expect.poll(() => statusApi.requests.length).toBe(1); + await app.unmount(); + }); + + it('recovers from a transient status error on focus', async () => { + seedPostAnalyticsWorld({ + email: { id: EMAIL_ID, email_count: 1000, opened_count: 400, status: 'submitting' }, + }); + const failedStatusApi = fakeAdminEndpoint( + 'GET', + `/emails/${EMAIL_ID}/status/`, + { errors: [{ message: 'Bad gateway' }] }, + { status: 502 }, + ); + + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.poll(() => failedStatusApi.requests.length).toBe(1); + + const recoveredStatusApi = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, { + email_statuses: [ + { + id: EMAIL_ID, + sending: { + status: 'submitting', + progress: { completed: 500, total: 1000, estimated_seconds_remaining: 30 }, + }, + }, + ], + }); + focusManager.setFocused(false); + focusManager.setFocused(true); + + await expect.poll(() => recoveredStatusApi.requests.length).toBeGreaterThan(0); + await expect.element(page.getByText('Sending emails')).toBeVisible(); + focusManager.setFocused(undefined); + }); + + it('does not show sending UI or retry a failed email after the post is unpublished', async () => { + seedPostAnalyticsWorld({ + status: 'draft', + email: { id: EMAIL_ID, email_count: 0, opened_count: 0, status: 'failed' }, + }); + const statusApi = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, { + email_statuses: [ + { + id: EMAIL_ID, + sending: { + status: 'failed', + failed_during: 'preparing', + progress: { completed: 0, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }); + + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: true }, + boot: webAnalyticsBootOverrides(), + }); + + await expect.element(postAnalyticsScreen.emailSendingStatusBanner()).not.toBeInTheDocument(); + await expect.element(postAnalyticsScreen.newsletterTab()).not.toBeInTheDocument(); + expect(statusApi.requests).toHaveLength(0); + }); + it('applies the Admin 7 chrome on post analytics', async () => { seedPostAnalyticsWorld(); await renderAdminApp(`/posts/analytics/${POST_ID}`, { - labs: { admin7PageChrome: true }, + labs: { admin7PageChrome: true, improveSendingUI: false }, boot: webAnalyticsBootOverrides(), }); await expect.element(postAnalyticsScreen.postTitle('Attack of the Clones')).toBeVisible(); @@ -160,7 +566,10 @@ describe('Post analytics overview', () => { it('keeps the post context when switching to the web tab', async () => { const { kpisApi } = seedPostAnalyticsWorld(); - await renderAdminApp(`/posts/analytics/${POST_ID}`, { boot: webAnalyticsBootOverrides() }); + await renderAdminApp(`/posts/analytics/${POST_ID}`, { + labs: { improveSendingUI: false }, + boot: webAnalyticsBootOverrides(), + }); await expect.element(postAnalyticsScreen.postTitle('Attack of the Clones')).toBeVisible(); await expect.element(postAnalyticsScreen.uniqueVisitors()).toHaveTextContent('250'); diff --git a/apps/admin/src/posts/analytics/post-analytics.screen.ts b/apps/admin/src/posts/analytics/post-analytics.screen.ts index 82583f607b0..6e3fd26fe20 100644 --- a/apps/admin/src/posts/analytics/post-analytics.screen.ts +++ b/apps/admin/src/posts/analytics/post-analytics.screen.ts @@ -17,6 +17,7 @@ export const postAnalyticsScreen = { page.getByTestId(sel.webPerformance).getByRole('button', { name: 'View more' }), uniqueVisitors: () => page.getByTestId(sel.uniqueVisitors), growthCard: () => page.getByTestId(sel.growth), + emailSendingStatusBanner: () => page.getByTestId(sel.emailSendingStatusBanner), growthViewMoreButton: () => page.getByTestId(sel.growth).getByRole('button', { name: 'View more' }), diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts index bc4c3ff6f87..fc7a1c4e472 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts +++ b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts @@ -32,6 +32,7 @@ export type PostAnalyticsContextType = { postId: string; post: Post | undefined; isPostLoading: boolean; + refetchPost: () => Promise; range: number; setRange: (value: number) => void; }; diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-provider.tsx b/apps/admin/src/posts/analytics/providers/post-analytics-provider.tsx index 9941944b204..bb30c956f57 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-provider.tsx +++ b/apps/admin/src/posts/analytics/providers/post-analytics-provider.tsx @@ -1,6 +1,6 @@ import { POST_ANALYTICS_INCLUDE, STATS_RANGES } from '@/shared/analytics/constants'; import { PostAnalyticsContext } from '@/posts/analytics/providers/post-analytics-context'; -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useCallback, useState } from 'react'; import { useBrowsePosts } from '@tryghost/admin-x-framework/api/posts'; import { useParams } from '@tryghost/admin-x-framework'; @@ -18,12 +18,19 @@ const PostAnalyticsProvider = ({ children }: { children: ReactNode }) => { // Fetch post data with all required includes. The gift-link modal reuses // POST_ANALYTICS_INCLUDE for the same query key, so both read one cached post. - const { data: { posts: [post] } = { posts: [] }, isLoading: isPostLoading } = useBrowsePosts({ + const { + data: { posts: [post] } = { posts: [] }, + isLoading: isPostLoading, + refetch, + } = useBrowsePosts({ searchParams: { filter: `id:${postId}`, include: POST_ANALYTICS_INCLUDE, }, }); + const refetchPost = useCallback(async () => { + await refetch(); + }, [refetch]); return ( { postId: postId, post: post, isPostLoading, + refetchPost, range, setRange, }} diff --git a/apps/admin/src/posts/analytics/routes.tsx b/apps/admin/src/posts/analytics/routes.tsx index 4a58be3de85..3af02553f8a 100644 --- a/apps/admin/src/posts/analytics/routes.tsx +++ b/apps/admin/src/posts/analytics/routes.tsx @@ -5,14 +5,21 @@ import { type RouteObject, lazyComponent } from '@tryghost/admin-x-framework'; // lazy composes the provider around the screen so neither chunk loads before // the route is visited. `lazy:` is preserved for per-view code-splitting. export const lazyPostAnalyticsRoot = async () => { - const [{ default: PostAnalyticsProvider }, { default: PostAnalytics }] = await Promise.all([ + const [ + { default: PostAnalyticsProvider }, + { default: EmailSendingStatusProvider }, + { default: PostAnalytics }, + ] = await Promise.all([ import('./providers/post-analytics-provider'), + import('./email-sending-status/email-sending-status-provider'), import('./post-analytics'), ]); return { element: ( - + + + ), }; diff --git a/apps/ember-admin/app/components/editor/publish-management.js b/apps/ember-admin/app/components/editor/publish-management.js index d1635a01707..389687113ee 100644 --- a/apps/ember-admin/app/components/editor/publish-management.js +++ b/apps/ember-admin/app/components/editor/publish-management.js @@ -256,9 +256,10 @@ export default class PublishManagement extends Component { // perform any post-save cleanup for the editor yield this.args.afterPublish(result); - // if emailed, wait until it has been submitted so we can show a failure message if needed if (willEmailImmediately && this.publishOptions.post.email) { - yield this.confirmEmailTask.perform(); + if (!this.feature.improveSendingUI) { + yield this.confirmEmailTask.perform(); + } } return result; diff --git a/apps/ember-admin/app/routes/tag.js b/apps/ember-admin/app/routes/tag.js index 3bfe85f05e3..7b412f670f2 100644 --- a/apps/ember-admin/app/routes/tag.js +++ b/apps/ember-admin/app/routes/tag.js @@ -25,9 +25,86 @@ export default class TagRoute extends AuthenticatedRoute { // React owns this URL when the flag is on. Keep the Ember route from // loading and rendering a second tag editor behind the React screen. - if (this.feature.tagDetailsReact === true) { - transition.abort(); + if (this.feature.tagDetailsReact !== true) { + return; } + + transition.abort(); + + const reactRouteUrl = this._reactRouteUrl(transition); + + // Ember and React share window.location.hash, and an aborted + // transition never reaches updateURL. A URL intent (cold load, hash + // change, React-driven navigation) already has the browser URL + // pointing here, so React renders and there is nothing to do. A named + // intent (the Cmd-K search modal's tag results) has no URL yet - + // without writing one the click is a silent no-op. + if (!transition.intent?.url) { + this._navigateToReactRoute(reactRouteUrl); + } + + this._parkOnReactFallback(reactRouteUrl); + } + + // Built by hand rather than with `router.urlFor`, whose output depends on + // the configured location. `transition.to.params` is only populated for a + // transition passing string params - one passing a tag model serializes + // too late to read here, so fall back to the list rather than a bad URL. + _reactRouteUrl(transition) { + const {name, params} = transition.to ?? {}; + + if (name === 'tag.new') { + return '/tags/new'; + } + if (name === 'tag' && typeof params?.tag_slug === 'string') { + return `/tags/${encodeURIComponent(params.tag_slug)}`; + } + + return '/tags'; + } + + // Aborting stops Ember rendering this screen, but it also leaves the + // router believing it is still on the route we came from. That desync is + // only invisible until you navigate back to the very same URL: Ember + // compares it against the route it thinks it is on, finds no difference, + // and runs no transition at all. Park on `react-fallback` - the empty + // catch-all Ember already uses for URLs React owns - to keep the router's + // state honest. The fallback must use the real tag path: parking on + // `tag` briefly sends React to an unknown URL, and restoring the hash + // with replaceState does not notify React Router. That leaves the browser + // showing React's 404 until the next reload. + // See PostsRoute#_parkOnReactFallback for the full rationale (replace + // semantics, the parked-path guard, and URL restoration). + _parkOnReactFallback(reactRouteUrl) { + const fallbackPath = reactRouteUrl.replace(/^\//, ''); + const parkedPath = this.router.currentRouteName === 'react-fallback' + ? this.router.currentRoute?.params?.path + : null; + + if (parkedPath === fallbackPath) { + return; + } + + const url = window.location.hash; + const state = window.history.state; + + this.router.replaceWith('react-fallback', fallbackPath) + .finally(() => this._restoreUrl(url, state)); + } + + // Parking writes the fallback route's own path, so the captured URL goes + // back afterwards. `replaceState`: no history entry, and no `hashchange` + // to re-enter routing. The captured history state goes back with it - + // react-router keeps `{usr, key, idx}` there, and parking would otherwise + // drop it on the URL-intent path where React created the entry. + _restoreUrl(url, state) { + window.history.replaceState(state, '', url); + } + + // Seam so tests can assert the navigation without a real hash location - + // Ember acceptance tests run with `location: 'none'`. + _navigateToReactRoute(url) { + window.location.hash = url; } model(params) { diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 5f45733b82e..d2ea8a92c83 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -102,6 +102,7 @@ export default class FeatureService extends Service { @feature('csvContentImporter') csvContentImporter; @feature('postsListReact') postsListReact; @feature('editorReact') editorReact; + @feature('improveSendingUI') improveSendingUI; _user = null; _featureFlagOverridesRevision = 0; diff --git a/apps/ember-admin/tests/acceptance/editor/publish-flow-test.js b/apps/ember-admin/tests/acceptance/editor/publish-flow-test.js index c81bc4c3a40..26dd08c3d6c 100644 --- a/apps/ember-admin/tests/acceptance/editor/publish-flow-test.js +++ b/apps/ember-admin/tests/acceptance/editor/publish-flow-test.js @@ -1,6 +1,6 @@ import loginAsRole from '../../helpers/login-as-role'; import moment from 'moment-timezone'; -import {blur, click, fillIn, find, findAll, triggerEvent, waitFor} from '@ember/test-helpers'; +import {blur, click, currentURL, fillIn, find, findAll, triggerEvent, waitFor, waitUntil} from '@ember/test-helpers'; import {clickTrigger, removeMultipleOption, selectChoose} from 'ember-power-select/test-support/helpers'; import {disableMailgun, enableMailgun} from '../../helpers/mailgun'; import {disableMembers, enableMembers} from '../../helpers/members'; @@ -784,7 +784,61 @@ describe('Acceptance: Publish flow', function () { }); it('handles server error when confirming'); - it('handles email sending error'); + + it('uses the analytics sending flow when improved sending UI is enabled', async function () { + enableLabsFlag(this.server, 'improveSendingUI'); + + await loginAsRole('Administrator', this.server); + const post = this.server.create('post', {status: 'draft'}); + const email = this.server.create('email', {status: 'pending'}); + + this.server.put('/posts/:id/', function ({posts}, {params}) { + return posts.find(params.id).update({ + status: 'published', + email + }); + }); + await visit(`/editor/post/${post.id}`); + await click('[data-test-button="publish-flow"]'); + await click('[data-test-button="continue"]'); + await click('[data-test-button="confirm-publish"]'); + + await waitUntil(() => currentURL() === `/posts/analytics/${post.id}`); + expect(find('[data-test-modal="publish-flow"]'), 'publish flow closed after save').not.to.exist; + expect(email.status, 'legacy poll did not reload the failed email').to.equal('pending'); + }); + + it('preserves the legacy email failure flow when improved sending UI is disabled', async function () { + await loginAsRole('Administrator', this.server); + const post = this.server.create('post', {status: 'draft'}); + const email = this.server.create('email', {status: 'pending'}); + + this.server.put('/posts/:id/', function ({posts}, {params}) { + return posts.find(params.id).update({ + status: 'published', + email + }); + }); + this.server.get('/posts/:id/', function ({posts}, {params}) { + const savedPost = posts.find(params.id); + if (savedPost.status === 'published') { + email.update({ + status: 'failed', + error: 'Mailgun rejected the batch.' + }); + } + return savedPost; + }); + + await visit(`/editor/post/${post.id}`); + await click('[data-test-button="publish-flow"]'); + await click('[data-test-button="continue"]'); + await click('[data-test-button="confirm-publish"]'); + + await waitFor('.gh-publish-title .red'); + expect(find('.gh-publish-confirmation'), 'email error') + .to.contain.trimmed.text('Mailgun rejected the batch.'); + }); it('defaults to publish-only when default recipients is "Usually nobody"', async function () { // Set default recipients to "Usually nobody" (filter with null filter) diff --git a/apps/ember-admin/tests/acceptance/tag-react-flag-test.js b/apps/ember-admin/tests/acceptance/tag-react-flag-test.js new file mode 100644 index 00000000000..202b91151cc --- /dev/null +++ b/apps/ember-admin/tests/acceptance/tag-react-flag-test.js @@ -0,0 +1,119 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession} from 'ember-simple-auth/test-support'; +import {enableLabsFlag} from '../helpers/labs-flag'; +import {expect} from 'chai'; +import {find, settled, visit} from '@ember/test-helpers'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; + +// The `tagDetailsReact` flag hands /tags/:slug to the React app. Ember's side +// of that handshake is the tag route's beforeModel: it aborts so the Ember tag +// editor stays unrendered, and drives window.location.hash so navigations +// Ember itself starts (the Cmd-K search modal) still land somewhere — an +// aborted transition never reaches updateURL, and the two apps share the hash. + +// `visit()` rejects with TransitionAborted whenever the route aborts, which is +// the whole point of the flag being on. Swallow only that rejection so the +// assertions below can run; anything else still fails the test. +async function visitExpectingAbort(url) { + try { + await visit(url); + } catch (error) { + if (error?.message !== 'TransitionAborted' && error?.name !== 'TransitionAborted') { + throw error; + } + } + await settled(); +} + +describe('Acceptance: tag React flag', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + + let role = this.server.create('role', {name: 'Administrator'}); + this.server.create('user', {roles: [role]}); + this.server.create('tag', {name: 'My tag', slug: 'my-tag'}); + + return await authenticateSession(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when the flag is off', function () { + it('renders the Ember tag editor', async function () { + await visit('/tags/my-tag'); + + expect(find('[data-test-screen-title]'), 'Ember tag editor title').to.exist; + }); + }); + + describe('when the flag is on', function () { + beforeEach(function () { + enableLabsFlag(this.server, 'tagDetailsReact'); + }); + + it('does not render the Ember tag editor', async function () { + await visitExpectingAbort('/tags/my-tag'); + + expect(find('[data-test-screen-title]'), 'Ember tag editor title').to.not.exist; + }); + + // The regression this guards: the Cmd-K search modal transitions by + // route name. Without supplying a URL the click is a silent no-op and + // the user is stranded on the previous screen. + it('navigates React when Ember initiates a tag transition', async function () { + const route = this.owner.lookup('route:tag'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/posts'); + this.owner.lookup('service:router').transitionTo('tag', 'my-tag'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/tags/my-tag'); + }); + + it('navigates React when Ember initiates a new-tag transition', async function () { + const route = this.owner.lookup('route:tag.new'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/posts'); + this.owner.lookup('service:router').transitionTo('tag.new'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/tags/new'); + }); + + // A URL that already points at the tag must be left exactly as it is — + // React is already rendering it. + it('does not rewrite a URL-initiated navigation', async function () { + const route = this.owner.lookup('route:tag'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags/my-tag'); + + expect(navigate.called, '_navigateToReactRoute called').to.be.false; + }); + + // Aborting alone leaves the router still reporting the route it came + // from, so returning to that same URL later would be a no-op + // transition that renders nothing. Parking on the catch-all at the + // actual tag path keeps both routers truthful. + it('parks the router on the React fallback at the tag path', async function () { + const router = this.owner.lookup('service:router'); + + await visitExpectingAbort('/tags/my-tag'); + + expect(router.currentRouteName, 'currentRouteName after aborting').to.equal('react-fallback'); + expect(router.currentRoute?.params?.path, 'fallback path after aborting').to.equal('tags/my-tag'); + }); + }); +}); diff --git a/apps/ember-admin/tests/unit/routes/tag-test.js b/apps/ember-admin/tests/unit/routes/tag-test.js index c48b9ccbc99..d5a13b8e066 100644 --- a/apps/ember-admin/tests/unit/routes/tag-test.js +++ b/apps/ember-admin/tests/unit/routes/tag-test.js @@ -11,7 +11,10 @@ describe('Unit: Route: tag', function () { sinon.restore(); }); - it('aborts the Ember transition when React owns the tag detail route', function () { + // The router is stubbed per-method on the real instance: wholesale service + // stubs miss the surface other injected services touch during the route's + // own instantiation. + function setupRoute(owner, {flagValue, routeName = 'route:tag'}) { const requireAuthentication = sinon.spy(); class SessionStub extends Service { isAuthenticated = true; @@ -19,37 +22,112 @@ describe('Unit: Route: tag', function () { requireAuthentication = requireAuthentication; } class FeatureStub extends Service { - tagDetailsReact = true; + tagDetailsReact = flagValue; } - this.owner.register('service:session', SessionStub); - this.owner.register('service:feature', FeatureStub); + owner.register('service:session', SessionStub); + owner.register('service:feature', FeatureStub); + + const router = owner.lookup('service:router'); + // `finally` runs its callback synchronously so the URL restoration the + // real transition promise triggers is exercised, not just stubbed away. + const settled = {finally(callback) { + callback(); + return settled; + }}; + sinon.stub(router, 'replaceWith').returns(settled); + + const route = owner.lookup(routeName); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + const replaceState = sinon.stub(window.history, 'replaceState'); - const route = this.owner.lookup('route:tag'); - const transition = {abort: sinon.spy()}; + return {route, router, navigate, replaceState, requireAuthentication}; + } + + it('aborts the Ember transition when React owns the tag detail route', function () { + const {route, router, navigate, requireAuthentication} = setupRoute(this.owner, {flagValue: true}); + // A URL intent, so beforeModel does not rewrite the hash itself. + const transition = { + abort: sinon.spy(), + intent: {url: '/tags/my-tag'}, + to: {name: 'tag', params: {tag_slug: 'my-tag'}} + }; route.beforeModel(transition); expect(requireAuthentication.calledOnce).to.be.true; - expect(transition.abort.calledOnce).to.be.true; + expect(transition.abort.calledOnce, 'transition aborted').to.be.true; + expect(navigate.called, 'no hash rewrite for a URL intent').to.be.false; + expect(router.replaceWith.calledWith('react-fallback', 'tags/my-tag'), 'parked on react-fallback').to.be.true; }); - it('keeps Ember ownership when the feature flag is not a boolean', function () { - class SessionStub extends Service { - isAuthenticated = true; - user = {isAuthorOrContributor: false}; - requireAuthentication = sinon.spy(); - } - class FeatureStub extends Service { - tagDetailsReact = 'true'; - } - this.owner.register('service:session', SessionStub); - this.owner.register('service:feature', FeatureStub); + // The Cmd-K search modal navigates by route name, so the abort leaves no + // URL behind for React to render - the click was a silent no-op. + it('writes the React URL for a named transition', function () { + const {route, router, navigate} = setupRoute(this.owner, {flagValue: true}); + const transition = { + abort: sinon.spy(), + intent: {}, + to: {name: 'tag', params: {tag_slug: 'my-tag'}} + }; + + route.beforeModel(transition); - const route = this.owner.lookup('route:tag'); - const transition = {abort: sinon.spy()}; + expect(navigate.calledOnceWith('/tags/my-tag'), 'hash written').to.be.true; + expect(router.replaceWith.calledWith('react-fallback', 'tags/my-tag'), 'parked on react-fallback').to.be.true; + }); + + it('writes the create URL for a named transition into tag.new', function () { + const {route, router, navigate} = setupRoute(this.owner, {flagValue: true, routeName: 'route:tag.new'}); + const transition = {abort: sinon.spy(), intent: {}, to: {name: 'tag.new', params: {}}}; + + route.beforeModel(transition); + + expect(navigate.calledOnceWith('/tags/new'), 'hash written').to.be.true; + expect(router.replaceWith.calledWith('react-fallback', 'tags/new'), 'parked on react-fallback').to.be.true; + }); + + it('restores the URL together with the history state react-router keeps', function () { + const {route, replaceState} = setupRoute(this.owner, {flagValue: true}); + const state = {usr: null, key: 'abc123', idx: 4}; + + route._restoreUrl('#/tags/my-tag', state); + + expect(replaceState.calledOnceWith(state, '', '#/tags/my-tag'), 'state restored with URL').to.be.true; + }); + + it('restores the captured URL after parking', function () { + const {route, replaceState} = setupRoute(this.owner, {flagValue: true}); + const state = {usr: null, key: 'abc123', idx: 4}; + sinon.stub(window.history, 'state').value(state); + const transition = { + abort: sinon.spy(), + intent: {url: '/tags/my-tag'}, + to: {name: 'tag', params: {tag_slug: 'my-tag'}} + }; + + route.beforeModel(transition); + + expect(replaceState.calledOnceWith(state, '', window.location.hash), 'URL and state restored').to.be.true; + }); + + it('does not park twice on the same fallback path', function () { + const {route, router} = setupRoute(this.owner, {flagValue: true}); + sinon.stub(router, 'currentRouteName').value('react-fallback'); + sinon.stub(router, 'currentRoute').value({params: {path: 'tags/my-tag'}}); + + route._parkOnReactFallback('/tags/my-tag'); + + expect(router.replaceWith.called, 'no re-parking').to.be.false; + }); + + it('keeps Ember ownership when the feature flag is not a boolean', function () { + const {route, router, navigate} = setupRoute(this.owner, {flagValue: 'true'}); + const transition = {abort: sinon.spy(), intent: {}, to: {name: 'tag', params: {tag_slug: 'my-tag'}}}; route.beforeModel(transition); - expect(transition.abort.called).to.be.false; + expect(transition.abort.called, 'transition not aborted').to.be.false; + expect(navigate.called, 'no hash rewrite').to.be.false; + expect(router.replaceWith.called, 'no parking').to.be.false; }); }); diff --git a/packages/testing/test-data/src/builders/post.ts b/packages/testing/test-data/src/builders/post.ts index c8581ba41e7..073393aa7dc 100644 --- a/packages/testing/test-data/src/builders/post.ts +++ b/packages/testing/test-data/src/builders/post.ts @@ -58,7 +58,9 @@ export interface Post { // before the setting changed keeps the flags it went out with, and that is // what decides whether the Opens/Clicks columns show. email?: { + id?: string; email_count: number; + error?: string | null; opened_count: number; status?: string; track_opens?: boolean; diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index 44278fc51a8..fd3f863dba0 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -32,6 +32,44 @@ export const postPreviewNewsletterMissing = 'post-preview-newsletter-missing'; export const postPreviewSaveFailed = 'post-preview-save-failed'; export const featureImageTkIndicator = 'feature-image-tk-indicator'; +// publish flow testids +export const publishFlowModal = 'publish-flow-modal'; +export const publishFlowOptions = 'publish-flow-options'; +export const publishFlowConfirm = 'publish-flow-confirm'; +export const publishFlowComplete = 'publish-flow-complete'; +export const publishFlowPreviewButton = 'publish-flow-preview'; +export const publishSettingPublishType = 'publish-setting-publish-type'; +export const publishSettingEmailRecipients = 'publish-setting-email-recipients'; +export const publishSettingPublishAt = 'publish-setting-publish-at'; +export const publishAlreadySent = 'publish-already-sent'; +export const publishTypeError = 'publish-type-error'; +export const publishEmailSizeWarning = 'publish-email-size-warning'; +export const publishNewsletterSelect = 'publish-newsletter-select'; +export const publishRecipientFree = 'publish-recipient-free'; +export const publishRecipientPaid = 'publish-recipient-paid'; +export const publishRecipientSpecific = 'publish-recipient-specific'; +export const publishRecipientSegments = 'publish-recipient-segments'; +export const publishScheduleDate = 'publish-schedule-date'; +export const publishScheduleTime = 'publish-schedule-time'; +export const publishContinueButton = 'publish-continue'; +export const publishConfirmButton = 'publish-confirm'; +export const publishConfirmError = 'publish-confirm-error'; +export const publishLimitsError = 'publish-limits-error'; +export const publishBackToSettings = 'publish-back-to-settings'; +export const publishCompleteBookmark = 'publish-complete-bookmark'; +export const publishCompleteNote = 'publish-complete-note'; +export const publishBackToDashboard = 'publish-back-to-dashboard'; +export const publishRevertToDraft = 'publish-revert-to-draft'; +export const publishEmailErrorStep = 'publish-email-error-step'; +export const publishRetryEmailButton = 'publish-retry-email'; +export const publishRetryError = 'publish-retry-error'; +export const tkReminderDialog = 'tk-reminder-dialog'; +export const publicPreviewWarningDialog = 'public-preview-warning-dialog'; +export const updateFlowModal = 'update-flow-modal'; +export const updateFlowTitle = 'update-flow-title'; +export const updateFlowConfirmation = 'update-flow-confirmation'; +export const updateFlowPreviousEmail = 'update-flow-previous-email'; + // accessible names export const postsBackLink = 'Posts'; export const pagesBackLink = 'Pages'; diff --git a/packages/testing/test-data/src/selectors/post-analytics.ts b/packages/testing/test-data/src/selectors/post-analytics.ts index 5e8cebdabc5..bebbbda9daa 100644 --- a/packages/testing/test-data/src/selectors/post-analytics.ts +++ b/packages/testing/test-data/src/selectors/post-analytics.ts @@ -17,6 +17,7 @@ export const giftLinkViews = 'gift-link-views'; export const giftLinkCardVisitors = 'gift-link-card-visitors'; export const statsFilterContainer = 'stats-filter-container'; export const statsFilterClearButton = 'stats-filter-clear-button'; +export const emailSendingStatusBanner = 'email-sending-status-banner'; /** Per-row testid prefixes; append the source domain / country code. */ export const sourceRowPrefix = 'source-row-'; export const locationRowPrefix = 'location-row-';