diff --git a/apps/admin-x-framework/src/api/slugs.ts b/apps/admin-x-framework/src/api/slugs.ts index b771b27bfa0..c9ade56fe18 100644 --- a/apps/admin-x-framework/src/api/slugs.ts +++ b/apps/admin-x-framework/src/api/slugs.ts @@ -12,13 +12,15 @@ export interface GenerateSlugParams { text: string; /** The record being edited, so its own current slug is not counted as a collision */ id?: string; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; } export const useGenerateSlug = () => { const fetchApi = useFetchApi(); return useCallback( - async ({ type, text, id }: GenerateSlugParams): Promise => { + async ({ type, text, id, sessionExpiryRedirect }: GenerateSlugParams): Promise => { if (!text) { return ''; } @@ -26,7 +28,7 @@ export const useGenerateSlug = () => { // Slugified client-side first: raw reserved characters in the path (a newline as %0A) 404 at the CDN before reaching Ghost const name = encodeURIComponent(slugify(text)); const path = id ? `/slugs/${type}/${name}/${id}/` : `/slugs/${type}/${name}/`; - const data = await fetchApi(apiUrl(path)); + const data = await fetchApi(apiUrl(path), { sessionExpiryRedirect }); return data.slugs[0].slug; }, diff --git a/apps/admin-x-framework/src/api/snippets.ts b/apps/admin-x-framework/src/api/snippets.ts index 5c0b2a976a3..2d9a8d85694 100644 --- a/apps/admin-x-framework/src/api/snippets.ts +++ b/apps/admin-x-framework/src/api/snippets.ts @@ -40,8 +40,13 @@ export const useBrowseSnippets = ({ searchParams: { limit: 'all', ...searchParams, formats }, }); +// Snippet writes happen from inside the editor, which surfaces an expired +// session in place rather than navigating away from unsaved content. +const sessionExpiryRedirect = false; + export const useAddSnippet = createMutation({ method: 'POST', + sessionExpiryRedirect, path: () => '/snippets/', searchParams: () => ({ formats }), body: (snippet) => ({ snippets: [snippet] }), @@ -53,6 +58,7 @@ export const useEditSnippet = createMutation< SnippetEditableData & { id: string } >({ method: 'PUT', + sessionExpiryRedirect, path: ({ id }) => `/snippets/${id}/`, searchParams: () => ({ formats }), body: ({ id: _id, ...snippet }) => ({ snippets: [snippet] }), @@ -61,6 +67,7 @@ export const useEditSnippet = createMutation< export const useDeleteSnippet = createMutation({ method: 'DELETE', + sessionExpiryRedirect, path: (id) => `/snippets/${id}/`, invalidateQueries: { dataType }, }); diff --git a/apps/admin-x-framework/src/hooks/use-koenig-fetch-embed.ts b/apps/admin-x-framework/src/hooks/use-koenig-fetch-embed.ts index c4b8069cbb4..7beb4285f4e 100644 --- a/apps/admin-x-framework/src/hooks/use-koenig-fetch-embed.ts +++ b/apps/admin-x-framework/src/hooks/use-koenig-fetch-embed.ts @@ -1,12 +1,20 @@ import { useCallback } from 'react'; import { getGhostPaths } from '../utils/helpers'; -import { useFetchApi } from '../utils/api/fetch-api'; +import { useFetchApi, type RequestOptions } from '../utils/api/fetch-api'; interface KoenigFetchEmbedOptions { type?: string; } -export const useKoenigFetchEmbed = () => { +type EmbedRequestOptions = Pick; + +// Shared so an omitted argument keeps the returned fetcher's identity stable. +const DEFAULT_REQUEST_OPTIONS: EmbedRequestOptions = {}; + +/** The session-expiry policy applies to every lookup this fetcher makes. */ +export const useKoenigFetchEmbed = ( + requestOptions: EmbedRequestOptions = DEFAULT_REQUEST_OPTIONS, +) => { const fetchApi = useFetchApi(); return useCallback( @@ -17,8 +25,8 @@ export const useKoenigFetchEmbed = () => { oembedUrl.searchParams.set('type', type); } - return await fetchApi(oembedUrl); + return await fetchApi(oembedUrl, requestOptions); }, - [fetchApi], + [fetchApi, requestOptions], ); }; diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index 5f9552695e3..fe4ec9f4ef7 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -16,7 +16,7 @@ import useHandleError from '../../hooks/use-handle-error'; import { usePermission } from '../../hooks/use-permissions'; import { UserRoleType } from '../../api/roles'; import { useFramework } from '../../providers/framework-provider'; -import { RequestOptions, apiUrl, useFetchApi } from './fetch-api'; +import { apiUrl, useFetchApi, type RequestOptions } from './fetch-api'; export interface Meta { capabilities?: { @@ -47,11 +47,13 @@ type QueryHookOptions = Omit< > & { searchParams?: Record; defaultErrorHandler?: boolean; + /** Whether this query leaves an expired session for its caller to handle in place. */ + requestOptions?: Pick; }; export const createQuery = (options: QueryOptions) => - ({ searchParams, ...query }: QueryHookOptions = {}): Omit< + ({ searchParams, requestOptions, ...query }: QueryHookOptions = {}): Omit< UseQueryResult, 'data' > & { data: ResponseData | undefined } => { @@ -64,7 +66,7 @@ export const createQuery = ...query, enabled: hasPermission && (query.enabled ?? true), queryKey: [options.dataType, url], - queryFn: () => fetchApi(url, { ...options }), + queryFn: () => fetchApi(url, { ...options, ...requestOptions }), }); const data = useMemo( diff --git a/apps/admin-x-framework/src/utils/recipient-filter.ts b/apps/admin-x-framework/src/utils/recipient-filter.ts index 93c3eea40bb..c733361c797 100644 --- a/apps/admin-x-framework/src/utils/recipient-filter.ts +++ b/apps/admin-x-framework/src/utils/recipient-filter.ts @@ -14,6 +14,17 @@ export const PAID_SEGMENT = 'status:-free'; */ export const EVERYONE_RECIPIENT_FILTER = `${FREE_SEGMENT},${PAID_SEGMENT}`; +/** Expands the API's legacy segment sentinels into the filters used by Admin. */ +export function normalizeRecipientFilter(filter: string | null | undefined): string | null { + if (filter === 'all') { + return EVERYONE_RECIPIENT_FILTER; + } + if (!filter || filter === 'none') { + return null; + } + return filter; +} + const BASE_SEGMENTS: string[] = [FREE_SEGMENT, PAID_SEGMENT]; export interface RecipientFilterSegments { diff --git a/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts index 9c43f62e8e3..961bae6ed0f 100644 --- a/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts +++ b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts @@ -7,10 +7,24 @@ import { getFullRecipientFilter, getNewsletterRecipientFilter, getRecipientType, + normalizeRecipientFilter, parseRecipientFilter, } from '../../../src/utils/recipient-filter'; describe('recipient-filter', () => { + describe('normalizeRecipientFilter', () => { + it('expands legacy all and none sentinels', () => { + expect(normalizeRecipientFilter('all')).toBe(EVERYONE_RECIPIENT_FILTER); + expect(normalizeRecipientFilter('none')).toBeNull(); + }); + + it('preserves real filters and normalizes empty values', () => { + expect(normalizeRecipientFilter('label:vip')).toBe('label:vip'); + expect(normalizeRecipientFilter(null)).toBeNull(); + expect(normalizeRecipientFilter(undefined)).toBeNull(); + }); + }); + describe('parseRecipientFilter', () => { it('returns empty segments for null, undefined and empty filters', () => { for (const filter of [null, undefined, '']) { diff --git a/apps/admin/package.json b/apps/admin/package.json index 3997f305753..ec92bda026d 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -36,6 +36,7 @@ "@tryghost/custom-field-types": "workspace:*", "@tryghost/custom-fonts": "catalog:", "@tryghost/i18n": "workspace:*", + "@tryghost/kg-clean-basic-html": "workspace:*", "@tryghost/kg-unsplash-selector": "workspace:*", "@tryghost/koenig-lexical": "workspace:*", "@tryghost/nql": "catalog:", diff --git a/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx new file mode 100644 index 00000000000..52367e98569 --- /dev/null +++ b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + fakeAdminEndpoint, + fakePosts, + fakeSnippets, + post, + renderAdminApp, + type EndpointCapture, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const POST_ID = 'abc123'; +const FLAG_ON = { labs: { editorReact: true } }; +const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const UPLOADED = 'https://example.com/content/images/2026/09/hills.png'; + +// The autosave debounce is 3s, so these journeys outlast the default timeout. +const SLOW = 20_000; +const SAVE_POLL = { timeout: 10_000 }; + +type SavedPost = ReturnType; + +function submittedPost(capture: EndpointCapture): Record { + const body = capture.lastRequest?.body as { posts: Record[] }; + return body.posts[0]; +} + +function fakeSavablePost(overrides: Partial = {}) { + fakeSnippets([]); + fakePosts([]); + let current = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + published_at: null, + tags: [], + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + ...overrides, + }); + let saves = 0; + + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), () => ({ posts: [current] })); + + return fakeAdminEndpoint('PUT', new RegExp(`^/posts/${POST_ID}/\\?`), ({ body }) => { + saves += 1; + const submitted = (body as { posts: Partial[] }).posts[0]; + current = { ...current, ...submitted, updated_at: `2026-01-01T00:00:0${saves}.000Z` }; + return { posts: [current] }; + }); +} + +/** + * The feature image above the post title: uploading one, describing it with + * alt text, and captioning it. Every change reaches the post through the same + * save engine the body uses. + */ +describe('Post editor feature image', () => { + it( + 'saves an uploaded image as soon as it lands', + async () => { + const saveApi = fakeSavablePost(); + const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', { + images: [{ url: UPLOADED, ref: null }], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.featureImage()).toBeVisible(); + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1); + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + id: POST_ID, + title: 'Hello from React', + status: 'draft', + updated_at: LOADED_AT, + feature_image: UPLOADED, + }); + await expect.element(editorScreen.removeFeatureImage()).toBeVisible(); + }, + SLOW, + ); + + it( + 'does not insert a dropped feature image into the post body', + async () => { + const saveApi = fakeSavablePost(); + const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', { + images: [{ url: UPLOADED, ref: null }], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.featureImage()).toBeVisible(); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + + const dropzone = editorScreen + .featureImage() + .element() + .querySelector('[data-slot="image-upload-dropzone"]'); + expect(dropzone).not.toBeNull(); + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(['image'], 'hills.png', { type: 'image/png' })); + dropzone!.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + + await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1); + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + + const saved = submittedPost(saveApi); + expect(saved.feature_image).toBe(UPLOADED); + expect(saved.lexical).toBeTypeOf('string'); + expect(String(saved.lexical)).not.toContain('"type":"image"'); + }, + SLOW, + ); + + it( + 'saves alt text for the image', + async () => { + const saveApi = fakeSavablePost({ feature_image: UPLOADED }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await editorScreen.featureImageAltToggle().click(); + await editorScreen.featureImageAltInput().fill('Rolling hills'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBeGreaterThan(0); + await expect + .poll(() => submittedPost(saveApi).feature_image_alt, SAVE_POLL) + .toBe('Rolling hills'); + expect(submittedPost(saveApi)).toMatchObject({ feature_image: UPLOADED }); + }, + SLOW, + ); + + it( + 'saves the caption once it loses focus', + async () => { + const saveApi = fakeSavablePost({ feature_image: UPLOADED }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.featureImageCaption()).toBeVisible(); + await editorScreen.featureImageCaption().click(); + await userEvent.keyboard('Photo by me'); + + // The caption has reached the editor, so a save would have been sent by now. + await expect.element(editorScreen.featureImageCaption()).toHaveTextContent('Photo by me'); + await expect.poll(() => saveApi.requests.length).toBe(0); + + await editorScreen.titleInput().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ id: POST_ID, feature_image: UPLOADED }); + // Lexical wraps typed text in a `white-space: pre-wrap` span; the + // caption is stored as it serializes it. + expect(String(submittedPost(saveApi).feature_image_caption)).toContain('Photo by me'); + }, + SLOW, + ); + + it( + 'opens a post whose caption carries markup without making it unsaved', + async () => { + const saveApi = fakeSavablePost({ + feature_image: UPLOADED, + feature_image_caption: 'Photo by Jane', + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + // The caption editor has loaded and re-serialized what it was given. + await expect.element(editorScreen.featureImageCaption()).toHaveTextContent('Photo by Jane'); + await editorScreen.titleInput().click(); + + await expect.poll(() => saveApi.requests.length).toBe(0); + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + }, + SLOW, + ); + + it( + 'stages a feature image edit on a published post until it is saved explicitly', + async () => { + const saveApi = fakeSavablePost({ + feature_image: UPLOADED, + status: 'published', + published_at: '2026-01-01T00:00:00.000Z', + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await editorScreen.featureImageAltToggle().click(); + await editorScreen.featureImageAltInput().fill('Rolling hills'); + + // A published post's background saves are dropped: the sidebar stages + // these edits until Update. + await expect.element(editorScreen.featureImageAltInput()).toHaveValue('Rolling hills'); + await expect.poll(() => saveApi.requests.length).toBe(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + id: POST_ID, + status: 'published', + feature_image: UPLOADED, + feature_image_alt: 'Rolling hills', + }); + }, + SLOW, + ); + + it( + 'clears the alt text and caption along with the image', + async () => { + const saveApi = fakeSavablePost({ + feature_image: UPLOADED, + feature_image_alt: 'Rolling hills', + feature_image_caption: 'Photo by me', + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await editorScreen.removeFeatureImage().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + feature_image: null, + feature_image_alt: null, + feature_image_caption: null, + }); + await expect.element(editorScreen.featureImageInput()).toBeInTheDocument(); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor-save.acceptance.test.tsx b/apps/admin/src/editor/editor-save.acceptance.test.tsx index 54ed7132171..1bd896603cb 100644 --- a/apps/admin/src/editor/editor-save.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-save.acceptance.test.tsx @@ -9,15 +9,19 @@ import { fakeSnippets, post, renderAdminApp, + tag, + type CapturedEndpointRequest, type EndpointCapture, } from '@test-utils/acceptance'; import { editorScreen } from '@/editor/editor.screen'; import { OLD_SCHEMA_CORPUS } from '@/editor/engine/__fixtures__'; +import { deferred } from '@/utils/deferred'; const POST_ID = 'abc123'; const NEW_POST_ID = 'new789'; const FLAG_ON = { labs: { editorReact: true } }; const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const CREATED_AT = '2026-01-01T00:00:05.000Z'; // The autosave debounce is 3s, so these journeys outlast the default timeout. const SLOW = 20_000; @@ -25,9 +29,18 @@ const SAVE_POLL = { timeout: 10_000 }; type SavedPost = ReturnType; +function postIn(request: CapturedEndpointRequest | undefined): Record { + const body = request?.body as { posts: Record[] } | undefined; + return body?.posts[0] ?? {}; +} + function submittedPost(capture: EndpointCapture): Record { - const body = capture.lastRequest?.body as { posts: Record[] }; - return body.posts[0]; + return postIn(capture.lastRequest); +} + +function submittedBody(capture: EndpointCapture): string { + const lexical = submittedPost(capture).lexical; + return typeof lexical === 'string' ? lexical : ''; } function editorChrome() { @@ -55,6 +68,10 @@ function fakeSavablePost(overrides: Partial = {}) { }); let saves = 0; + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), () => ({ posts: [current] })); const saveApi = fakeAdminEndpoint('PUT', new RegExp(`^/posts/${POST_ID}/\\?`), ({ body }) => { @@ -105,7 +122,7 @@ describe('Post editor saving', () => { status: 'draft', updated_at: LOADED_AT, }); - expect(String(submittedPost(saveApi).lexical)).toContain('Hello from React and more'); + expect(submittedBody(saveApi)).toContain('Hello from React and more'); }, SLOW, ); @@ -155,13 +172,13 @@ describe('Post editor saving', () => { title: '(Untitled)', slug: 'untitled', status: 'draft', - updated_at: LOADED_AT, + updated_at: CREATED_AT, published_at: null, tags: [], }); const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => { const submitted = (body as { posts: Partial[] }).posts[0]; - created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: LOADED_AT }; + created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: CREATED_AT }; return { posts: [created] }; }); fakeAdminEndpoint('GET', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({ @@ -188,6 +205,144 @@ describe('Post editor saving', () => { SLOW, ); + it( + 'keeps typing that lands while the create is in flight and updates the new post', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', /^\/slugs\/post\/untitled\//, { slugs: [{ slug: 'untitled' }] }); + let created = post({ + id: NEW_POST_ID, + title: '(Untitled)', + slug: 'untitled', + status: 'draft', + updated_at: CREATED_AT, + published_at: null, + tags: [], + }); + const createResponse = deferred<{ posts: SavedPost[] }>(); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: CREATED_AT }; + return createResponse.promise; + }); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({ + posts: [created], + })); + const updateApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${NEW_POST_ID}/\\?`), + ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + created = { ...created, ...submitted, updated_at: '2026-01-01T00:00:09.000Z' }; + return { posts: [created] }; + }, + ); + + await renderAdminApp('/editor/post', FLAG_ON); + await expect.element(editorScreen.body()).toBeVisible(); + + await typeIntoBody('First words'); + await expect.poll(() => createApi.requests.length, SAVE_POLL).toBe(1); + + try { + await typeIntoBody(' and then some'); + expect(submittedBody(createApi)).not.toContain('and then some'); + } finally { + createResponse.resolve({ posts: [created] }); + } + + // The edit made while the create was held reaches the follow-up update. + await expect + .poll(() => submittedBody(updateApi), SAVE_POLL) + .toContain('First words and then some'); + // The first update carries the id and the token the create handed back. + expect(postIn(updateApi.requests[0])).toMatchObject({ + id: NEW_POST_ID, + updated_at: CREATED_AT, + }); + await expect.element(editorScreen.body()).toHaveTextContent('First words and then some'); + }, + SLOW, + ); + + it( + 'saves on Cmd-S and asks the server for a revision', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(saveApi.lastRequest?.url ?? '').toContain('save_revision=true'); + expect(submittedPost(saveApi)).toMatchObject({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + updated_at: LOADED_AT, + }); + expect(String(submittedPost(saveApi).lexical)).toContain('Hello from React and more'); + }, + SLOW, + ); + + it( + 'lands a renamed draft clean, with the slug the server generated', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await editorScreen.titleInput().fill('Brand New Name'); + await editorScreen.body().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + title: 'Brand New Name', + slug: 'brand-new-name', + }); + + // Nothing is left diverged, so no further save is attempted. + await editorScreen.titleInput().click(); + await editorScreen.body().click(); + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + }, + SLOW, + ); + + it( + 'reports the save in the header and settles on saved', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + await typeIntoBody(' and more'); + + await expect.element(editorScreen.status(), SAVE_POLL).toHaveTextContent('Saving'); + await expect.element(editorScreen.status(), SAVE_POLL).toHaveTextContent('Draft - Saved'); + }, + SLOW, + ); + + it( + 'leaves tags alone when it saves', + async () => { + const saveApi = fakeSavablePost({ tags: [tag({ id: 'tag1', name: 'News', slug: 'news' })] }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + expect(submittedPost(saveApi)).not.toHaveProperty('tags'); + }, + SLOW, + ); + it( 'halts on a collision and keeps the content', async () => { @@ -275,4 +430,85 @@ describe('Post editor saving', () => { }, SLOW, ); + + it( + 'still says saving stopped after the session banner is dismissed', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [ + post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + tags: [], + }), + ], + }); + fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] }, + { status: 401 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await typeIntoBody(' and more'); + + await expect.element(editorScreen.reauthBanner()).toBeVisible(); + await editorScreen.dismissReauth().click(); + + await expect.element(editorScreen.saveErrorBanner()).toHaveTextContent('session expired'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and more'); + }, + SLOW, + ); + + it( + 'does not leave the editor when the slug request finds no session', + async () => { + editorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [ + post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + tags: [], + }), + ], + }); + fakeAdminEndpoint( + 'GET', + /^\/slugs\/post\//, + { errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] }, + { status: 401 }, + ); + const saveApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] }, + { status: 401 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await editorScreen.titleInput().fill('Brand New Name'); + await editorScreen.body().click(); + + // The failing slug lookup must not navigate; the save that follows it + // is what tells the writer the session is gone. + await expect.element(editorScreen.reauthBanner()).toBeVisible(); + expect(currentRoute()).toBe(`/editor/post/${POST_ID}`); + expect(saveApi.requests.length).toBeGreaterThan(0); + }, + SLOW, + ); }); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 3f1f3d7177a..552a84c2d7b 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useState } from 'react'; import { AdminLink } from '@/shared/admin-link'; import { NotFound } from '@/shared/not-found'; import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework'; @@ -26,12 +26,17 @@ import { isEditorUser, isOwnerUser, } from '@tryghost/admin-x-framework/api/users'; -import type { CardConfigPostSource, PostType } from './card-config'; +import type { CardConfigPostSource, PostCardConfig, PostType } from './card-config'; +import { EditorStatus } from './editor-status'; import { PostEditor } from './post-editor'; +import type { EditorStatusNewsletter, EditorStatusRecord } from './post-status'; import { SessionBanners } from './session/session-banners'; +import { useFeatureImageBinding } from './session/feature-image-binding'; +import { EDITOR_REQUEST_OPTIONS } from './request-options'; import { useEditorSession, useEditorSessionKey } from './session/use-editor-session'; import { usePostCardConfig } from './use-post-card-config'; import { usePostSnippets } from './use-post-snippets'; +import { useSaveShortcut } from './use-save-shortcut'; type EditorRecord = PostEditorRecord | PageEditorRecord; @@ -54,7 +59,7 @@ function EditorLoadError({ message, onRetry }: { message: string; onRetry: () => ); } -function EditorHeader({ postType }: { postType: PostType }) { +function EditorHeader({ postType, children }: { postType: PostType; children?: ReactNode }) { const listLabel = postType === 'page' ? 'Pages' : 'Posts'; return ( @@ -65,14 +70,103 @@ function EditorHeader({ postType }: { postType: PostType }) { {listLabel} + {children} ); } -function EditorSurface({ postType, record }: { postType: PostType; record?: EditorRecord }) { +// A created post has no loaded record yet, but it is no longer new. +function statusRecordOf( + record: EditorRecord | undefined, + createdId?: string, +): EditorStatusRecord | undefined { + if (!record) { + return createdId ? { status: 'draft' } : undefined; + } + + const email = 'email' in record ? record.email : null; + // The API types the relation as a bare object; the editor read includes it. + const newsletter = + 'newsletter' in record ? (record.newsletter as EditorStatusNewsletter | null) : null; + + return { + status: record.status, + publishedAt: record.published_at, + url: record.url, + emailOnly: 'email_only' in record ? record.email_only : false, + newsletter, + emailSegment: 'email_segment' in record ? record.email_segment : null, + hasEmail: !!email, + emailStatus: email?.status ?? null, + emailCount: email?.email_count ?? 0, + }; +} + +interface EditorContentProps { + postType: PostType; + record?: EditorRecord; + createdId?: string; + cardConfig: PostCardConfig; + showExcerpt: boolean; + snippetDialog: ReactNode; +} + +// Mounted only once the boot data has resolved: the session reads the site URL +// when it is built, and normalization cannot be switched on afterwards. +function EditorContent({ + postType, + record, + createdId, + cardConfig, + showExcerpt, + snippetDialog, +}: EditorContentProps) { + const session = useEditorSession({ postType, record, siteUrl: cardConfig.siteUrl }); + const featureImage = useFeatureImageBinding(session, record); + + useSaveShortcut(session.dispatchExplicit); + + return ( + + + + + +
+ +
+ {snippetDialog} +
+ ); +} + +function EditorSurface({ + postType, + record, + createdId, +}: { + postType: PostType; + record?: EditorRecord; + createdId?: string; +}) { const { data: currentUser } = useCurrentUser(); const showExcerpt = useFeatureFlag('editorExcerpt'); - const session = useEditorSession({ postType, record }); const canManageSnippets = !!currentUser && @@ -101,24 +195,14 @@ function EditorSurface({ postType, record }: { postType: PostType; record?: Edit } return ( - - - -
- -
- {snippetDialog} -
+ ); } @@ -175,10 +259,12 @@ function EditorLoader({ postType, id }: { postType: PostType; id?: string }) { const postQuery = useEditorPost(openedId ?? '', { enabled: postType === 'post' && !!openedId, defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, }); const pageQuery = useEditorPage(openedId ?? '', { enabled: postType === 'page' && !!openedId, defaultErrorHandler: false, + requestOptions: EDITOR_REQUEST_OPTIONS, }); const query = postType === 'page' ? pageQuery : postQuery; const loaded: EditorRecord | undefined = @@ -201,7 +287,7 @@ function EditorLoader({ postType, id }: { postType: PostType; id?: string }) { }, [needsConversion, loaded, conversion?.id, convert]); if (!openedId) { - return ; + return ; } const notFound = query.error instanceof APIError && query.error.response?.status === 404; diff --git a/apps/admin/src/editor/editor-status.test.tsx b/apps/admin/src/editor/editor-status.test.tsx new file mode 100644 index 00000000000..219e5fc22df --- /dev/null +++ b/apps/admin/src/editor/editor-status.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { RecipientCount } from './editor-status'; + +const mocks = vi.hoisted(() => ({ + useMembersCount: vi.fn(() => ({ count: null })), +})); + +vi.mock('@tryghost/admin-x-framework/api/members', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMembersCount: mocks.useMembersCount, + }; +}); + +describe('RecipientCount', () => { + it('keeps descriptive copy when the member count is unavailable', () => { + const filter = 'newsletters.slug:weekly+email_disabled:0+(status:free,status:-free)'; + + render(); + + expect(screen.getByText('all members')).toBeInTheDocument(); + expect(mocks.useMembersCount).toHaveBeenCalledWith(filter); + }); +}); diff --git a/apps/admin/src/editor/editor-status.tsx b/apps/admin/src/editor/editor-status.tsx new file mode 100644 index 00000000000..3adeb622e80 --- /dev/null +++ b/apps/admin/src/editor/editor-status.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { Inline, Text } from '@tryghost/shade/primitives'; +import { formatNumber } from '@tryghost/shade/utils'; +import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { membersCountString, useMembersCount } from '@tryghost/admin-x-framework/api/members'; +import { formatPostTime } from '@/posts/list/post-time'; +import type { SaveEngineState } from './engine/save-engine'; +import { + type EditorStatusRecord, + type EditorStatusView, + deriveEditorStatus, + useScheduledBoundary, + useSavingHold, +} from './post-status'; + +function members(count: number): string { + return `${formatNumber(count)} ${count === 1 ? 'member' : 'members'}`; +} + +/** The send's audience, counted the way the publish flow counts it. */ +export function RecipientCount({ filter, segment }: { filter: string; segment: string }) { + const { count } = useMembersCount(filter); + return <>{typeof count === 'number' ? members(count) : membersCountString(segment, { count })}; +} + +function ScheduleCountdown({ + publishedAt, + emailOnly, + recipientFilter, + recipientSegment, + timezone, +}: { + publishedAt: string | null; + emailOnly: boolean; + recipientFilter: string | null; + recipientSegment: string | null; + timezone: string; +}) { + return ( + + ); +} + +function StatusBody({ + view, + timezone, + isHovered, +}: { + view: EditorStatusView; + timezone: string; + isHovered: boolean; +}) { + switch (view.kind) { + case 'problem': + return {view.message}; + case 'saving': + return Saving…; + case 'new': + return New; + case 'draft': + return {view.saved ? 'Draft - Saved' : 'Draft'}; + case 'sent': + return view.failed ? ( + Failed to send newsletter. + ) : ( + Sent to {members(view.count)} + ); + case 'scheduled': + return ( + + Scheduled + {isHovered && ( + <> + {' '} + + + )} + + ); + default: + return ( + + {view.url ? ( + + Published + + ) : ( + 'Published' + )} + {view.email === 'sending' && ` and sending to ${members(view.count)}`} + {view.email === 'sent' && ` and sent to ${members(view.count)}`} + {view.email === 'failed' && ' but failed to send newsletter.'} + + ); + } +} + +export interface EditorStatusProps { + state: SaveEngineState; + record?: EditorStatusRecord; + isDirty: boolean; +} + +/** Where the post stands: its status, the newsletter, and the last save. */ +export function EditorStatus({ state, record, isDirty }: EditorStatusProps) { + const { data: settingsData } = useBrowseSettings(); + const timezone = getSettingValue(settingsData?.settings ?? null, 'timezone') ?? 'Etc/UTC'; + const isSaving = useSavingHold(state.kind === 'saving' || state.kind === 'pending-coalesced'); + const [isHovered, setIsHovered] = useState(false); + const [, setTick] = useState(0); + + useScheduledBoundary( + record?.publishedAt, + record?.status === 'scheduled' && record.emailOnly !== true, + ); + + // The countdown only reads while hovered, so it only has to tick then. + useEffect(() => { + if (!isHovered) { + return; + } + const interval = setInterval(() => setTick((tick) => tick + 1), 1000); + return () => clearInterval(interval); + }, [isHovered]); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + ); +} diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 4c942a10501..5a983289f29 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -1,17 +1,28 @@ import { page } from 'vitest/browser'; import { + addFeatureImageLabel, editorBody, editorConflictBanner, editorExcerptInput, + editorFeatureImage, + editorFeatureImageCaption, editorLoadError, editorReauthBanner, + editorScheduleCountdown, + editorSaveErrorBanner, editorSecondaryInstance, + editorStatus, editorTitleInput, editorWordCount, + featureImageAltLabel, + featureImageTkIndicator, + featureImageUnsplashButton, pagesBackLink, postEditor, postsBackLink, + removeFeatureImageButton, tkIndicator, + toggleFeatureImageAltButton, } from '@tryghost/test-data/selectors/editor'; /** Editor screen locators and gestures for acceptance specs; no assertions. */ @@ -27,8 +38,23 @@ export const editorScreen = { reauthBanner: () => page.getByTestId(editorReauthBanner), retryReauth: () => page.getByTestId(editorReauthBanner).getByRole('button', { name: 'Retry' }), conflictBanner: () => page.getByTestId(editorConflictBanner), + status: () => page.getByTestId(editorStatus), + scheduleCountdown: () => page.getByTestId(editorScheduleCountdown), + saveErrorBanner: () => page.getByTestId(editorSaveErrorBanner), + dismissReauth: () => + page.getByTestId(editorReauthBanner).getByRole('button', { name: 'Dismiss' }), notFound: () => page.getByRole('heading', { name: 'Page not found' }), titleTkIndicator: () => page.getByTestId(tkIndicator), + + featureImage: () => page.getByTestId(editorFeatureImage), + featureImageInput: () => page.getByLabelText(addFeatureImageLabel), + featureImageUnsplashButton: () => page.getByRole('button', { name: featureImageUnsplashButton }), + removeFeatureImage: () => page.getByRole('button', { name: removeFeatureImageButton }), + featureImageAltToggle: () => page.getByRole('button', { name: toggleFeatureImageAltButton }), + featureImageAltInput: () => page.getByLabelText(featureImageAltLabel), + /** The caption's Koenig content editable. */ + featureImageCaption: () => page.getByTestId(editorFeatureImageCaption).getByRole('textbox'), + featureImageTkIndicator: () => page.getByTestId(featureImageTkIndicator), backLink: (postType: 'post' | 'page') => page.getByRole('link', { name: postType === 'page' ? pagesBackLink : postsBackLink, diff --git a/apps/admin/src/editor/engine/README.md b/apps/admin/src/editor/engine/README.md index 04cdde3b548..5431db29894 100644 --- a/apps/admin/src/editor/engine/README.md +++ b/apps/admin/src/editor/engine/README.md @@ -162,13 +162,14 @@ whose commit failed reads `derived`. `createSlugMachine({generateSlug, onListenerError})` takes the generator port (`(text: string) => Promise`) and an error sink for listener failures. -| Call | Effect | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `loaded({slug, title})` | Document boundary. Resets the machine to the post, infers the settled mode, discards in-flight and waiting work from the previous post, notifies with a `null` proposal. | -| `titleCommitted(title)` | The title was committed (blur). Resolves with a proposal; never rejects. | -| `slugEdited(input)` | The slug input was committed. Resolves with a proposal; never rejects. | -| `getState()` | Snapshot of the state above. | -| `subscribe(listener)` | `listener(state, proposal)` on every state change, `proposal` being `null` when only `pending` changed or a post loaded. Returns an unsubscribe function. | +| Call | Effect | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `loaded({slug, title})` | Document boundary. Resets the machine to the post, infers the settled mode, discards in-flight and waiting work from the previous post, notifies with a `null` proposal. | +| `saveAcknowledged(submitted, acknowledged)` | Compare-and-swaps server-normalized values without changing ownership or newer work; notifies a change with a `null` proposal. | +| `titleCommitted(title)` | The title was committed (blur). Resolves with a proposal; never rejects. | +| `slugEdited(input)` | The slug input was committed. Resolves with a proposal; never rejects. | +| `getState()` | Snapshot of the state above. | +| `subscribe(listener)` | `listener(state, proposal)` on every state change; acknowledgements, pending changes and loads have no proposal. Returns an unsubscribe function. | A listener that throws is reported to `onListenerError` and affects neither the transition nor the other listeners. @@ -195,9 +196,10 @@ Proposals are `{slug, source}`: Every proposal except `stale` is delivered to subscribers with the state it produced. Subscribers are also notified with a `null` proposal when a request -starts (`pending` becomes true) and when a post loads. A rejected manual edit is -always reported (`reverted`, `empty-result`, or `error`) so the input can be -reset to the kept slug instead of showing the rejected text. +starts (`pending` becomes true), a post loads, or an acknowledgement resyncs a +server-normalized value. A rejected manual edit is always reported (`reverted`, +`empty-result`, or `error`) so the input can be reset to the kept slug instead +of showing the rejected text. ### Rules @@ -307,9 +309,24 @@ The wiring hook owns everything the three modules deliberately do not: - A blank title held in the live projection as the default title while the input stays empty, so a post persisted under that title does not read as permanently diverged from what the writer sees. -- Routing query responses to `setSaved` and mutation responses to +- Routing query responses to `setSaved` and save responses to `saveAcknowledged`, passing the projection the request submitted and the full record the server acknowledged. +- Adopting into the live document any title or slug the save request wrote + itself — the default title for a blank one, the slug derived from the title — + and then whatever the server normalized them to. Both happen before the + acknowledgement is applied, and only where the writer has not typed past the + value since, which is the rule the rebase itself uses. Skip this and the + rebase keeps the superseded local value: the post reads as diverged from its + own saved state for the rest of the session. Adopting is not an edit, so it + must not move the version the request was built against. +- Synchronizing server-normalized title and slug acknowledgements back into the + slug machine through its ownership-preserving acknowledgement transition, so + later saves do not resend a superseded value or freeze derived slug behavior. +- Refusing to send an update with no collision token: without one the server + skips its concurrency check and the save overwrites whatever landed meanwhile. +- Requesting without the transport's session-expiry redirect, so an expired + session is surfaced in place rather than navigating away from unsaved content. - Replacing the URL once a create acquires an id, with the screen keyed on the session so the swap does not remount the editor. - Deciding what a halted queue looks like: `reauth-pending` and `conflict` are @@ -332,12 +349,14 @@ The wiring hook owns everything the three modules deliberately do not: - A fresh tracker per editing session, including a new one for each new-post session — two consecutive new posts share the null id, so a stale create acknowledgement from a previous session must never reach the current tracker. - Query data goes to `setSaved`, save responses to `saveAcknowledged`; never the reverse. - `revisionRestored` only after the restore has been saved. -- Pass the snapshot the save request was built from as `submitted`. +- Pass the editable projection the request submitted as `submitted`. The save snapshot is not that projection: it carries the identity, status and dirty bits the queue reasons about, and only the fields actually sent may act as the rebase base. ### Slug machine - Persistence. The machine does not save proposals; the caller persists `generated` and `manual` slugs. +- `saveAcknowledged(submitted, acknowledged)` compare-and-swaps normalized + title and slug values without cancelling newer work or changing ownership. - The generator port. The machine passes trimmed text as typed, whether a title or a manual candidate. The port must send `encodeURIComponent(slugify(text))` to `GET /slugs/post/:name/:id` (raw text containing a character such as a diff --git a/apps/admin/src/editor/engine/slug-machine.test.ts b/apps/admin/src/editor/engine/slug-machine.test.ts index 71f275019d2..d5166ce48bb 100644 --- a/apps/admin/src/editor/engine/slug-machine.test.ts +++ b/apps/admin/src/editor/engine/slug-machine.test.ts @@ -116,6 +116,60 @@ describe('createSlugMachine', () => { }); }); + describe('saveAcknowledged', () => { + it('adopts a normalized custom slug after the title changed', async () => { + const { machine } = createHarness(); + machine.loaded({ slug: 'my-slug', title: 'Hello' }); + await machine.titleCommitted('Renamed'); + + machine.saveAcknowledged( + { slug: 'my-slug', title: 'Renamed' }, + { slug: 'my-slug-2', title: 'Renamed' }, + ); + + expect(machine.getState()).toMatchObject({ + slug: 'my-slug-2', + mode: 'custom', + lastCommittedTitle: 'Renamed', + }); + }); + + it('preserves newer title intent while adopting the submitted source', async () => { + const later = deferred(); + const generateSlug = vi + .fn() + .mockResolvedValueOnce('brand-new-name') + .mockReturnValueOnce(later.promise); + const { machine } = createHarness(generateSlug); + machine.loaded({ slug: 'hello', title: 'Hello' }); + await machine.titleCommitted('Brand New Name'); + const pending = machine.titleCommitted('Typed Later'); + + machine.saveAcknowledged( + { slug: 'brand-new-name', title: 'Brand New Name' }, + { slug: 'brand-new-name-2', title: 'Brand New Name' }, + ); + + expect(machine.getState()).toMatchObject({ + slug: 'brand-new-name-2', + title: 'Brand New Name', + lastCommittedTitle: 'Typed Later', + pending: true, + }); + later.resolve('typed-later'); + await expect(pending).resolves.toEqual({ slug: 'typed-later', source: 'generated' }); + + machine.saveAcknowledged( + { slug: 'brand-new-name', title: 'Brand New Name' }, + { slug: 'brand-new-name-3', title: 'Brand New Name' }, + ); + expect(machine.getState()).toMatchObject({ + slug: 'typed-later', + title: 'Typed Later', + }); + }); + }); + describe('titleCommitted', () => { it('generates a slug from the committed title and emits it', async () => { const { machine, generateSlug, proposals } = createHarness(); diff --git a/apps/admin/src/editor/engine/slug-machine.ts b/apps/admin/src/editor/engine/slug-machine.ts index f777ccaf0d9..e320f1a534d 100644 --- a/apps/admin/src/editor/engine/slug-machine.ts +++ b/apps/admin/src/editor/engine/slug-machine.ts @@ -84,11 +84,13 @@ export interface SlugMachineOptions { onListenerError: (error: unknown) => void; } -/** Called on every state change; `proposal` is null when only `pending` changed or a post loaded. */ +/** Called on every state change; acknowledgements, pending changes and loads have no proposal. */ export type SlugListener = (state: SlugMachineState, proposal: SlugProposal | null) => void; export interface SlugMachine { loaded(post: LoadedPost): void; + /** Adopts server-normalized values only while the submitted source still owns the state. */ + saveAcknowledged(submitted: LoadedPost, acknowledged: LoadedPost): void; titleCommitted(title: string): Promise; slugEdited(input: string): Promise; getState(): SlugMachineState; @@ -454,6 +456,24 @@ export function createSlugMachine({ notify(null); }, + saveAcknowledged(submitted, acknowledged) { + let changed = false; + if (slug === submitted.slug && slug !== acknowledged.slug) { + slug = acknowledged.slug; + changed = true; + } + if (title === submitted.title && title !== acknowledged.title) { + title = acknowledged.title; + if (lastCommittedTitle === submitted.title) { + lastCommittedTitle = acknowledged.title; + } + changed = true; + } + if (changed) { + notify(null); + } + }, + titleCommitted(rawTitle) { return submit({ kind: 'title', value: rawTitle }); }, diff --git a/apps/admin/src/editor/feature-image-caption.tsx b/apps/admin/src/editor/feature-image-caption.tsx new file mode 100644 index 00000000000..820ef79207b --- /dev/null +++ b/apps/admin/src/editor/feature-image-caption.tsx @@ -0,0 +1,86 @@ +import { Suspense, useMemo } from 'react'; +import ErrorBoundary from '@/settings/components/error-boundary'; +import { + type EditorResource, + type KoenigInstance, + loadKoenig, +} from '@/settings/components/koenig-loader'; +import type { PostCardConfig } from './card-config'; +import { reportKoenigError } from './koenig-error'; + +export interface FeatureImageCaptionProps { + /** Paragraph-wrapped caption HTML; the editor parses it as a document. */ + html: string | null; + placeholder: string; + darkMode: boolean; + searchLinks: PostCardConfig['searchLinks']; + onChangeHtml: (html: string) => void; + onFocus: () => void; + onBlur: () => void; + onTkCountChange: (count: number) => void; + registerAPI: (api: KoenigInstance | null) => void; +} + +function CaptionMount({ + editor, + html, + placeholder, + darkMode, + searchLinks, + onChangeHtml, + onFocus, + onBlur, + onTkCountChange, + registerAPI, +}: FeatureImageCaptionProps & { editor: EditorResource }) { + const { + KoenigComposer, + KoenigComposableEditor, + HtmlOutputPlugin, + EmojiPickerPlugin, + TKCountPlugin, + MINIMAL_NODES, + MINIMAL_TRANSFORMERS, + } = editor.read(); + + return ( + + + + + + + + ); +} + +/** The feature image caption: one paragraph of basic formatting, emitted as HTML. */ +export function FeatureImageCaption(props: FeatureImageCaptionProps) { + const editor = useMemo(() => loadKoenig(), []); + + return ( +
+ + + + + +
+ ); +} diff --git a/apps/admin/src/editor/feature-image.tsx b/apps/admin/src/editor/feature-image.tsx new file mode 100644 index 00000000000..88278fd4312 --- /dev/null +++ b/apps/admin/src/editor/feature-image.tsx @@ -0,0 +1,264 @@ +import { useCallback, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { toast } from 'sonner'; +import { UnsplashSearchModal } from '@tryghost/kg-unsplash-selector'; +import { Button, LoadingIndicator } from '@tryghost/shade/components'; +import { + ImageUpload, + ImageUploadAction, + ImageUploadActions, + ImageUploadDropzone, + ImageUploadImage, + ImageUploadPreview, +} from '@tryghost/shade/patterns'; +import { Inline, Stack } from '@tryghost/shade/primitives'; +import { LucideIcon, cn } from '@tryghost/shade/utils'; +import { getImageUrl, useUploadImage } from '@tryghost/admin-x-framework/api/images'; +import { useFramework } from '@tryghost/admin-x-framework'; +import { + JSONError, + RequestEntityTooLargeError, + UnsupportedMediaTypeError, +} from '@tryghost/admin-x-framework/errors'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; +import type { KoenigInstance } from '@/settings/components/koenig-loader'; +import type { PostCardConfig } from './card-config'; +import { FeatureImageCaption } from './feature-image-caption'; + +const ACCEPTED_IMAGE_TYPES = { + 'image/gif': ['.gif'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/svg+xml': ['.svg', '.svgz'], + 'image/webp': ['.webp'], +}; + +const UNSUPPORTED_IMAGE_MESSAGE = + 'The image type you uploaded is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'; + +const ALT_MAX_LENGTH = 191; + +function uploadErrorMessage(error: unknown): string { + if (error instanceof UnsupportedMediaTypeError) { + return UNSUPPORTED_IMAGE_MESSAGE; + } + if (error instanceof RequestEntityTooLargeError) { + return 'The image you uploaded was larger than the maximum file size your server allows.'; + } + if (error instanceof JSONError && error.data?.errors[0]?.message) { + return error.data.errors[0].message; + } + return 'Couldn’t upload the feature image.'; +} + +export interface FeatureImageProps { + image: string | null; + alt: string | null; + /** Paragraph-wrapped caption HTML. */ + caption: string | null; + cardConfig: PostCardConfig; + darkMode: boolean; + onImageChange: (url: string) => void; + onImageClear: () => void; + onAltChange: (alt: string) => void; + onCaptionChange: (html: string) => void; + onCaptionBlur: () => void; + onTkCountChange: (count: number) => void; +} + +/** + * The post's feature image: uploaded from the file picker or a drop, picked + * from Unsplash, and described by either alt text or a caption. + */ +export function FeatureImage({ + image, + alt, + caption, + cardConfig, + darkMode, + onImageChange, + onImageClear, + onAltChange, + onCaptionChange, + onCaptionBlur, + onTkCountChange, +}: FeatureImageProps) { + const { mutateAsync: uploadImage, isPending } = useUploadImage(); + const { unsplashConfig } = useFramework(); + const [showUnsplash, setShowUnsplash] = useState(false); + const [isEditingAlt, setIsEditingAlt] = useState(false); + const [captionFocused, setCaptionFocused] = useState(false); + const [captionTkCount, setCaptionTkCount] = useState(0); + const captionApi = useRef(null); + + const handleUpload = useCallback( + async (file: File) => { + try { + onImageChange(getImageUrl(await uploadImage({ file }))); + } catch (error) { + toast.error(uploadErrorMessage(error)); + } + }, + [uploadImage, onImageChange], + ); + + const relayTkCount = useCallback( + (count: number) => { + setCaptionTkCount(count); + onTkCountChange(count); + }, + [onTkCountChange], + ); + + // A new identity re-registers the caption editor on every keystroke + const registerCaptionApi = useCallback((api: KoenigInstance | null) => { + captionApi.current = api; + }, []); + + const focusCaption = useCallback(() => { + captionApi.current?.focusEditor({ position: 'bottom' }); + }, []); + + const onCaptionFocus = useCallback(() => setCaptionFocused(true), []); + + const onCaptionBlurred = useCallback(() => { + setCaptionFocused(false); + onCaptionBlur(); + }, [onCaptionBlur]); + + const clearImage = () => { + setIsEditingAlt(false); + relayTkCount(0); + onImageClear(); + }; + + if (!image) { + return ( + + files[0] && void handleUpload(files[0])} + onDropRejected={() => toast.error(UNSUPPORTED_IMAGE_MESSAGE)} + > + {isPending ? ( + + ) : ( + + + )} + + {cardConfig.unsplash && ( + + + + )} + {showUnsplash && + createPortal( + setShowUnsplash(false)} + onImageInsert={(inserted) => { + if (inserted.src) { + onImageChange(inserted.src); + onCaptionChange(inserted.caption ?? ''); + } + setShowUnsplash(false); + }} + />, + document.body, + )} + + ); + } + + return ( + + + + + + + + + + + + + {isEditingAlt ? ( + onAltChange(event.target.value)} + /> + ) : ( +
+ +
+ )} + {captionTkCount > 0 && !isEditingAlt && ( + + )} + +
+
+ ); +} diff --git a/apps/admin/src/editor/koenig-error.ts b/apps/admin/src/editor/koenig-error.ts new file mode 100644 index 00000000000..578f4a35eca --- /dev/null +++ b/apps/admin/src/editor/koenig-error.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/react'; + +/** + * Reports a Lexical failure from any of the editor's Koenig instances. Never + * rethrown: Lexical attempts to recover without losing what the writer typed. + */ +export function reportKoenigError(error: unknown): void { + // eslint-disable-next-line no-console + console.error(error); + + Sentry.captureException(error, { + tags: { lexical: true }, + contexts: { koenig: { version: window['@tryghost/koenig-lexical']?.version } }, + }); +} diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index e0074784603..52013a4b0d5 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react'; import { Suspense, useCallback, useMemo } from 'react'; import { LoadingIndicator } from '@tryghost/shade/components'; import { koenigFileUploadTypes, useKoenigFileUpload } from '@tryghost/admin-x-framework/hooks'; @@ -9,6 +8,7 @@ import { loadKoenig, } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from './card-config'; +import { reportKoenigError } from './koenig-error'; const fileUploader = { useFileUpload: useKoenigFileUpload, @@ -91,27 +91,12 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) { const editor = useMemo(() => loadKoenig(), []); const { onSecondaryError } = props; - const reportError = useCallback((error: unknown) => { - // eslint-disable-next-line no-console - console.error(error); - - Sentry.captureException(error, { - tags: { lexical: true }, - contexts: { - koenig: { - version: window['@tryghost/koenig-lexical']?.version, - }, - }, - }); - // not rethrown: Lexical attempts to recover without losing user data - }, []); - const onSecondaryInstanceError = useCallback( (error: unknown) => { - reportError(error); + reportKoenigError(error); onSecondaryError?.(error); }, - [reportError, onSecondaryError], + [onSecondaryError], ); return ( @@ -128,7 +113,7 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) { {...props} editor={editor} isSecondary={false} - onError={reportError} + onError={reportKoenigError} /> { - onTkCountChange?.((titleHasTk ? 1 : 0) + (excerptHasTk ? 1 : 0) + bodyTkCount); - }, [onTkCountChange, titleHasTk, excerptHasTk, bodyTkCount]); + onTkCountChange?.( + (titleHasTk ? 1 : 0) + (excerptHasTk ? 1 : 0) + bodyTkCount + featureImageTkCount, + ); + }, [onTkCountChange, titleHasTk, excerptHasTk, bodyTkCount, featureImageTkCount]); const focusTitle = useCallback(() => { titleRef.current?.focus(); @@ -253,6 +260,19 @@ export function PostEditor({ onMouseUp={focusEditorOnPaneClick} >
+ {titleHasTk && }