diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts index cbf14912c41..618352db554 100644 --- a/apps/admin-x-framework/src/api/config.ts +++ b/apps/admin-x-framework/src/api/config.ts @@ -74,6 +74,10 @@ export type Config = { disabled: boolean; error?: string; }; + limitCustomFields?: { + disabled: boolean; + error?: string; + }; publicSiteAccess?: { disabled: boolean; // Copy shown in the pre-launch banner when public site access is disabled. diff --git a/apps/admin-x-framework/src/api/images.ts b/apps/admin-x-framework/src/api/images.ts index 45a6198bb0d..c0b016ffa7a 100644 --- a/apps/admin-x-framework/src/api/images.ts +++ b/apps/admin-x-framework/src/api/images.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { createMutation } from '../utils/api/hooks'; export interface ImagesResponseType { @@ -18,4 +19,10 @@ export const useUploadImage = createMutation }, }); -export const getImageUrl = (response: ImagesResponseType) => response.images[0].url; +const UploadedImageResponseSchema = z.object({ + // Storage adapters may return relative paths as well as absolute URLs. + images: z.array(z.object({ url: z.string().min(1) })).min(1), +}); + +export const getImageUrl = (response: unknown): string => + UploadedImageResponseSchema.parse(response).images[0].url; diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index bc3954c9b91..5a1ddc3598f 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -1,6 +1,7 @@ import { FIELD_TYPES, FIELD_TYPE_IDS, + type MemberAccess, partTypesOf, subFieldsOf, type FieldKind, @@ -37,10 +38,48 @@ export type MemberCustomField = { // Browse hides archived fields by default (most surfaces only want active // ones); Settings opts in via filter and splits on this. status: 'active' | 'archived'; + access: { member: MemberCustomFieldAccess }; created_at: string; updated_at: string | null; }; +// The levels themselves are the shared vocabulary; what a publisher is told they mean +// is presentation, and stays below. +export type MemberCustomFieldAccess = MemberAccess; + +export const MEMBER_CUSTOM_FIELD_ACCESS_OPTIONS: { + value: MemberCustomFieldAccess; + label: string; + description: string; +}[] = [ + { + value: 'none', + label: 'Only staff', + description: 'Members never see this field or what you record in it', + }, + { + value: 'read', + label: 'Members can view', + description: 'Shown in their account, but only staff can change it', + }, + { + value: 'write', + label: 'Members can edit', + description: 'Members fill this in and keep it up to date themselves', + }, +]; + +/** + * The words a publisher reads for a level. + * + * A level this build does not know shows as itself rather than falling back to the + * closed label: the fallback would tell a publisher a field is staff-only when the + * server may be treating it as open, and a label that reassures is worse than one + * that reads oddly. Reachable only from a Core newer than this Admin. + */ +export const memberAccessLabel = (access: MemberCustomFieldAccess): string => + MEMBER_CUSTOM_FIELD_ACCESS_OPTIONS.find((option) => option.value === access)?.label ?? access; + /** * The user-type catalog: the presentation layer over the shared field types. * @@ -323,10 +362,16 @@ export const useBrowseMemberCustomFieldsIncludingArchived = ( ) => useBrowseMemberCustomFields({ ...options, searchParams: { filter: 'status:[active,archived]' } }); -// The backend mints the key from the name, so create takes just a name and a type. +/** Everything a new field is created from. The backend mints the key from the name. */ +export type NewMemberCustomField = Pick; + +/** A change to one field, addressed by key. Anything omitted is left as it is. */ +export type MemberCustomFieldEdit = Pick & + Partial>; + export const useCreateMemberCustomField = createMutation< MemberCustomFieldsResponseType, - Pick + NewMemberCustomField >({ method: 'POST', path: () => '/members/metafields/custom/', @@ -357,12 +402,12 @@ export const useCreateMemberCustomField = createMutation< }, }); -// Keys are immutable after creation (the API rejects changes); `name` and -// `status` are the editable surface — a status flip to 'active' is how an -// archived field is reactivated. +// Keys are immutable after creation (the API rejects changes); `name`, `status` and +// `access` are the editable surface — a status flip to 'active' is how an archived +// field is reactivated, and an access change is what opens a field to members. export const useEditMemberCustomField = createMutation< MemberCustomFieldsResponseType, - Pick & Partial> + MemberCustomFieldEdit >({ method: 'PUT', path: (field) => `/members/metafields/custom/${field.key}/`, diff --git a/apps/admin-x-framework/test/unit/api/images.test.ts b/apps/admin-x-framework/test/unit/api/images.test.ts new file mode 100644 index 00000000000..35b5a921d65 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/images.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { getImageUrl } from '../../../src/api/images'; + +describe('getImageUrl', () => { + it.each(['https://example.com/image.png', '/content/images/image.png'])( + 'accepts an uploaded image URL (%s)', + (url) => { + expect(getImageUrl({ images: [{ url, ref: null }] })).toBe(url); + }, + ); + + it.each([ + null, + {}, + { images: [] }, + { images: [{}] }, + { images: [{ url: 123 }] }, + { images: [{ url: true }] }, + { images: [{ url: '' }] }, + ])('rejects a malformed upload response (%j)', (response) => { + expect(() => getImageUrl(response)).toThrow(); + }); +}); diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts index eadadba450d..46bedde77d6 100644 --- a/apps/admin/src/editor/card-config.test.ts +++ b/apps/admin/src/editor/card-config.test.ts @@ -119,6 +119,56 @@ describe('buildPostCardConfig', () => { expect(cardConfig.deleteSnippet).toBe(ports.deleteSnippet); }); + it('carries the site images the social cards fall back to', () => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ + ...baseSettings, + og_image: 'site-og.png', + twitter_image: 'site-twitter.png', + cover_image: 'cover.png', + }), + }), + ports, + ); + + expect(cardConfig).toMatchObject({ + siteOgImage: 'site-og.png', + siteTwitterImage: 'site-twitter.png', + siteCoverImage: 'cover.png', + }); + }); + + it('reports a site image the settings do not carry as none', () => { + const cardConfig = buildPostCardConfig(sources(), ports); + + expect(cardConfig).toMatchObject({ + siteOgImage: null, + siteTwitterImage: null, + siteCoverImage: null, + }); + }); + + it.each([123, true])('ignores non-string site images (%s)', (value) => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ + ...baseSettings, + og_image: value, + twitter_image: value, + cover_image: value, + }), + }), + ports, + ); + + expect(cardConfig).toMatchObject({ + siteOgImage: null, + siteTwitterImage: null, + siteCoverImage: null, + }); + }); + it('drops Unsplash when the integration is off', () => { const cardConfig = buildPostCardConfig( sources({ settings: settingsFrom({ ...baseSettings, unsplash: false }) }), diff --git a/apps/admin/src/editor/card-config.ts b/apps/admin/src/editor/card-config.ts index 0cfb4cabdf0..2b3a79fd113 100644 --- a/apps/admin/src/editor/card-config.ts +++ b/apps/admin/src/editor/card-config.ts @@ -66,6 +66,9 @@ export interface PostCardConfig extends PostCardConfigPorts { membersEnabled: boolean; siteTitle: string; siteDescription: string; + siteOgImage: string | null; + siteTwitterImage: string | null; + siteCoverImage: string | null; siteUrl: string; siteUuid: string; stripeEnabled: boolean; @@ -99,6 +102,11 @@ export function getCardVisibilitySettings( return isPage ? 'web only' : 'web and email'; } +function imageSetting(settings: Setting[], key: string): string | null { + const value = getSettingValue(settings, key); + return typeof value === 'string' ? value : null; +} + export function buildPostCardConfig( sources: PostCardConfigSources, ports: PostCardConfigPorts, @@ -124,6 +132,9 @@ export function buildPostCardConfig( searchLinks: ports.searchLinks, siteTitle: getSettingValue(settings, 'title') ?? '', siteDescription: getSettingValue(settings, 'description') ?? '', + siteOgImage: imageSetting(settings, 'og_image'), + siteTwitterImage: imageSetting(settings, 'twitter_image'), + siteCoverImage: imageSetting(settings, 'cover_image'), siteUrl: getHomepageUrl(site), siteUuid: site.site_uuid, stripeEnabled: checkStripeEnabled(settings, config), diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 1d3c19a96f2..e2266b52763 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -214,6 +214,7 @@ function EditorContent({ ; + +function submittedPost(capture: EndpointCapture): Record { + const body = capture.lastRequest?.body as { posts: Record[] } | undefined; + return body?.posts[0] ?? {}; +} + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +function editorChrome() { + fakeSnippets([]); + fakePosts([]); + // The header's publish inputs and preview read these beyond the boot table. + fakeMembers([]); + fakeNewsletters([]); + fakeTiers([]); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); +} + +/** A post that answers saves the way Ghost does: submitted fields back, fresh token. */ +function fakeSavablePost(overrides: Partial = {}) { + editorChrome(); + 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, + codeinjection_head: null, + codeinjection_foot: null, + tags: [], + ...overrides, + }); + let saves = 0; + + fakeAdminEndpoint('GET', ROUTE, () => ({ posts: [current] })); + + return fakeAdminEndpoint('PUT', ROUTE, ({ 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 same post fixture served on the pages collection the page editor reads. */ +function fakeSavablePage() { + editorChrome(); + fakePages([]); + const 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, + codeinjection_head: null, + codeinjection_foot: null, + tags: [], + }); + + fakeAdminEndpoint('GET', PAGE_ROUTE, () => ({ pages: [current] })); + fakeAdminEndpoint('PUT', PAGE_ROUTE, () => ({ pages: [current] })); +} + +function sidebarWidthPx(): number { + return editorScreen.settingsSidebar().element().getBoundingClientRect().width; +} + +function headEditor() { + return editorScreen.settingsCodeInjection(codeInjectionHeadLabel); +} + +function footEditor() { + return editorScreen.settingsCodeInjection(codeInjectionFootLabel); +} + +async function openCodeInjection() { + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); + await expect.element(headEditor()).toBeVisible(); + await expect.element(footEditor()).toBeVisible(); +} + +/** + * Playwright clears a contenteditable by writing to the DOM, which races + * CodeMirror's reconciliation. Clear through its own keymap first. + */ +async function typeInto(editor: ReturnType, code: string) { + await editor.click(); + await userEvent.keyboard('{ControlOrMeta>}a{/ControlOrMeta}'); + await userEvent.keyboard('{Backspace}'); + await expect.poll(() => editor.element().textContent).toBe(''); + if (!code) { + return; + } + await editor.fill(code); + await expect.poll(() => (editor.element() as HTMLElement).innerText).toBe(code); +} + +/** + * The sidebar's Code injection pane: the header and footer code this post adds + * to the page it renders on. + */ +describe('Post settings code injection', () => { + it( + 'opens the pane over the section list and comes back from it', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + // The pane replaces the list it was opened from, in a widened panel. + await expect(editorScreen.settingsExcerpt()).toHaveCount(0); + expect(sidebarWidthPx()).toBe(WIDE_PANEL_WIDTH); + + await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + // The panel goes back to the width the section list is shown at. + await expect.poll(sidebarWidthPx).toBe(PANEL_WIDTH); + }, + SLOW, + ); + + it( + 'closes the pane on Escape from outside the editors', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + // Opening a pane leaves the writer on its back button. + await expect.element(editorScreen.settingsSubviewBack(BACK_LABEL)).toHaveFocus(); + await userEvent.keyboard('{Escape}'); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + }, + SLOW, + ); + + it( + 'keeps the pane open on Escape inside an editor', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await headEditor().click(); + await userEvent.keyboard('{Escape}'); + + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); + await expect.element(headEditor()).toBeVisible(); + }, + SLOW, + ); + + it( + 'moves between the editors on the Tab that follows Escape', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await headEditor().click(); + await expect.element(headEditor()).toHaveFocus(); + + // Without Escape first, Tab indents the code rather than leaving. + await userEvent.keyboard('{Escape}'); + await userEvent.tab(); + await expect.element(footEditor()).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + await userEvent.tab({ shift: true }); + await expect.element(headEditor()).toHaveFocus(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); + }, + SLOW, + ); + + it( + 'names a page’s editors for a page', + async () => { + fakeSavablePage(); + await renderAdminApp(`/editor/page/${POST_ID}`, FLAG_ON); + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + + await expect + .element(editorScreen.settingsCodeInjection(codeInjectionPageHeadLabel)) + .toBeVisible(); + await expect + .element(editorScreen.settingsCodeInjection(codeInjectionPageFootLabel)) + .toBeVisible(); + await expect(editorScreen.settingsCodeInjection(codeInjectionHeadLabel)).toHaveCount(0); + }, + SLOW, + ); + + it( + 'shows the code the post was saved with', + async () => { + const head = ''; + const foot = ''; + fakeSavablePost({ codeinjection_head: head, codeinjection_foot: foot }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await expect.poll(() => (headEditor().element() as HTMLElement).innerText).toBe(head); + await expect.poll(() => (footEditor().element() as HTMLElement).innerText).toBe(foot); + }, + SLOW, + ); + + it( + 'persists a draft’s header and footer code on the blur that ends each edit', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await typeInto(headEditor(), ''); + await footEditor().click(); + + // A field save has no debounce, so it lands well inside the autosave's 3s. + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + codeinjection_head: '', + }); + + await typeInto(footEditor(), ''); + await headEditor().click(); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(2); + expect(submittedPost(saveApi)).toMatchObject({ + codeinjection_foot: '', + }); + // The editors keep what the writer typed once the save is answered. + await expect + .poll(() => (headEditor().element() as HTMLElement).innerText) + .toBe(''); + await expect + .poll(() => (footEditor().element() as HTMLElement).innerText) + .toBe(''); + }, + SLOW, + ); + + it( + 'stages a published post’s header code until Update', + async () => { + const saveApi = fakeSavablePost({ status: 'published', published_at: PUBLISHED_AT }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await typeInto(headEditor(), ''); + await footEditor().click(); + + await expect.element(editorScreen.updateButton()).toBeEnabled(); + await expect.poll(unsavedChangesGuarded).toBe(true); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + codeinjection_head: '', + status: 'published', + }); + }, + SLOW, + ); + + it( + 'persists the focused editor when the writer closes the pane', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + await typeInto(headEditor(), ''); + await expect.element(headEditor()).toHaveFocus(); + await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi).codeinjection_head).toBe(''); + + await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await expect.element(headEditor()).toHaveTextContent(''); + }, + SLOW, + ); + + it.each(['codeinjection_head', 'codeinjection_foot'] as const)( + 'clears saved %s code to null on blur', + async (field) => { + const saveApi = fakeSavablePost({ + codeinjection_head: '', + codeinjection_foot: '', + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openCodeInjection(); + + const editor = field === 'codeinjection_head' ? headEditor() : footEditor(); + const otherEditor = field === 'codeinjection_head' ? footEditor() : headEditor(); + await typeInto(editor, ''); + await otherEditor.click(); + + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)[field]).toBeNull(); + await expect.poll(() => editor.element().textContent).toBe(''); + }, + SLOW, + ); + + it( + 'gives a contributor the pane their role can write', + async () => { + // A contributor may only open a draft they authored. + const saveApi = fakeSavablePost({ authors: [{ id: CURRENT_USER_ID }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, asRole('Contributor')); + await openCodeInjection(); + + await typeInto(headEditor(), ''); + await footEditor().click(); + + await expect + .poll(() => submittedPost(saveApi).codeinjection_head, POLL) + .toBe(''); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx new file mode 100644 index 00000000000..fb41bfe2d8d --- /dev/null +++ b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentUserResponse, + fakeAdminEndpoint, + fakeMembers, + fakeNewsletters, + fakePosts, + fakeSnippets, + fakeTiers, + post, + renderAdminApp, + staffRole, + unsavedChangesGuarded, + type EndpointCapture, + type StaffRoleName, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const POST_ID = 'abc123'; +const CURRENT_USER_ID = '1'; +const FLAG_ON = { labs: { editorReact: true } }; +const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; +const ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); +const BACK_LABEL = 'Close Facebook card panel'; +const UPLOADED = 'https://example.com/content/images/2026/09/hills.png'; +const FEATURE = 'https://example.com/content/images/2026/09/coast.png'; +// The site fixture's own description, which the card falls back to last. +const SITE_DESCRIPTION = 'Thoughts, stories and ideas.'; + +// A settings save waits on the engine's queue, so these journeys outlast the default timeout. +const SLOW = 20_000; +const POLL = { timeout: 10_000 }; +// Under the 3s autosave debounce, so only an undebounced field save can satisfy it. +const FIELD_POLL = { timeout: 2_000 }; + +type SavedPost = ReturnType; + +function submittedPost(capture: EndpointCapture): Record { + const body = capture.lastRequest?.body as { posts: Record[] } | undefined; + return body?.posts[0] ?? {}; +} + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +function editorChrome() { + fakeSnippets([]); + fakePosts([]); + // The header's publish inputs and preview read these beyond the boot table. + fakeMembers([]); + fakeNewsletters([]); + fakeTiers([]); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); +} + +/** A post that answers saves the way Ghost does: submitted fields back, fresh token. */ +function fakeSavablePost(overrides: Partial = {}) { + editorChrome(); + 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, + custom_excerpt: null, + excerpt: null, + meta_title: null, + meta_description: null, + og_image: null, + og_title: null, + og_description: null, + feature_image: null, + tags: [], + ...overrides, + }); + let saves = 0; + + fakeAdminEndpoint('GET', ROUTE, () => ({ posts: [current] })); + + return fakeAdminEndpoint('PUT', ROUTE, ({ 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] }; + }); +} + +async function openFacebookCard() { + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await editorScreen.settingsSubviewRow('Facebook card').click(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); +} + +/** + * The sidebar's Facebook card pane: the image, title and description Facebook + * is given instead of the post's own, and the card they produce. + */ +describe('Post settings Facebook card', () => { + it( + 'opens the pane over the section list and comes back from it', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + // The pane replaces the list it was opened from. + await expect(editorScreen.settingsExcerpt()).toHaveCount(0); + await expect.element(editorScreen.settingsFacebookTitle()).toBeVisible(); + await expect + .element(editorScreen.settingsSidebar()) + .toHaveAttribute('aria-label', 'Facebook card'); + + await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow('Facebook card')).toBeVisible(); + }, + SLOW, + ); + + it( + 'saves an uploaded Facebook 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 openFacebookCard(); + + await userEvent.upload( + editorScreen.settingsFacebookImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); + // A field save has no debounce, so it lands well inside the autosave's 3s. + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ og_image: UPLOADED }); + await expect.element(editorScreen.removeSettingsFacebookImage()).toBeVisible(); + }, + SLOW, + ); + + it( + 'clears the Facebook image the writer removes', + async () => { + const saveApi = fakeSavablePost({ og_image: UPLOADED }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await editorScreen.removeSettingsFacebookImage().click(); + + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ og_image: null }); + await expect.element(editorScreen.settingsFacebookImageInput()).toBeInTheDocument(); + }, + SLOW, + ); + + it( + 'persists a draft’s Facebook title and description on the blur that ends each edit', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await editorScreen.settingsFacebookTitle().fill('A better title for Facebook'); + await editorScreen.settingsFacebookDescription().click(); + + // A field save has no debounce, so it lands well inside the autosave's 3s. + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ og_title: 'A better title for Facebook' }); + + await editorScreen.settingsFacebookDescription().fill('What this post is about'); + await editorScreen.settingsFacebookTitle().click(); + + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(2); + expect(submittedPost(saveApi)).toMatchObject({ + og_description: 'What this post is about', + }); + }, + SLOW, + ); + + it( + 'stages a published post’s Facebook title until Update', + async () => { + const saveApi = fakeSavablePost({ status: 'published', published_at: PUBLISHED_AT }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await editorScreen.settingsFacebookTitle().fill('A better title for Facebook'); + await editorScreen.settingsFacebookDescription().click(); + + await expect.element(editorScreen.updateButton()).toBeEnabled(); + await expect.poll(unsavedChangesGuarded).toBe(true); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + og_title: 'A better title for Facebook', + status: 'published', + }); + }, + SLOW, + ); + + it( + 'offers the post’s own title and excerpt until the Facebook fields carry their own', + async () => { + fakeSavablePost({ custom_excerpt: 'The excerpt this post already has' }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await expect + .element(editorScreen.settingsFacebookTitle()) + .toHaveAttribute('placeholder', 'Hello from React'); + await expect + .element(editorScreen.settingsFacebookDescription()) + .toHaveAttribute('placeholder', 'The excerpt this post already has'); + + const preview = editorScreen.settingsFacebookPreview(); + await expect.element(preview).toHaveTextContent('test.com'); + await expect.element(preview).toHaveTextContent('Hello from React'); + await expect.element(preview).toHaveTextContent('The excerpt this post already has'); + + await editorScreen.settingsFacebookTitle().fill('A better title for Facebook'); + await editorScreen.settingsFacebookDescription().fill('What this post is about'); + + await expect.element(preview).toHaveTextContent('A better title for Facebook'); + await expect.element(preview).toHaveTextContent('What this post is about'); + await expect.element(preview).not.toHaveTextContent('The excerpt this post already has'); + }, + SLOW, + ); + + it( + 'previews the feature image the writer is looking at, and follows it as it changes', + async () => { + fakeSavablePost({ feature_image: FEATURE }); + fakeAdminEndpoint('POST', '/images/upload/', { images: [{ url: UPLOADED, ref: null }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + // The card falls back to it while the pane's own dropzone is still empty. + await expect + .element(editorScreen.settingsFacebookPreviewImage()) + .toHaveAttribute('src', FEATURE); + await expect.element(editorScreen.settingsFacebookImageInput()).toBeInTheDocument(); + + const pane = editorScreen.settingsSubviewPane().element(); + + await editorScreen.removeFeatureImage().click(); + + await expect(editorScreen.settingsFacebookPreviewImage()).toHaveCount(0); + + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'coast.png', { type: 'image/png' }), + ); + + await expect + .element(editorScreen.settingsFacebookPreviewImage()) + .toHaveAttribute('src', UPLOADED); + // The open pane followed the canvas rather than being rebuilt around it. + expect(pane.isConnected).toBe(true); + }, + SLOW, + ); + + it( + 'falls back to the site’s own description for a post that has none', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await expect + .element(editorScreen.settingsFacebookDescription()) + .toHaveAttribute('placeholder', SITE_DESCRIPTION); + await expect + .element(editorScreen.settingsFacebookPreview()) + .toHaveTextContent(SITE_DESCRIPTION); + }, + SLOW, + ); + + it( + 'refuses to save a Facebook title longer than the field holds', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openFacebookCard(); + + await editorScreen.settingsFacebookTitle().fill('a'.repeat(301)); + await editorScreen.settingsFacebookDescription().click(); + + await expect + .element(editorScreen.settingsSubviewPane().getByRole('alert')) + .toHaveTextContent('Facebook Title cannot be longer than 300 characters.'); + await expect + .element(editorScreen.settingsFacebookTitle()) + .toHaveAttribute('aria-invalid', 'true'); + // Refused where the writer is typing rather than as a save they did not ask for. + await expect.poll(unsavedChangesGuarded).toBe(true); + await expect(editorScreen.saveErrorBanner()).toHaveCount(0); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect + .element(editorScreen.saveErrorBanner()) + .toHaveTextContent('Facebook Title cannot be longer than 300 characters.'); + expect(saveApi.requests).toHaveLength(0); + }, + SLOW, + ); + + it( + 'gives a contributor the pane their role can write', + async () => { + // A contributor may only open a draft they authored. + const saveApi = fakeSavablePost({ authors: [{ id: CURRENT_USER_ID }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, asRole('Contributor')); + await openFacebookCard(); + + await editorScreen.settingsFacebookTitle().fill('A contributor’s Facebook title'); + await editorScreen.settingsFacebookDescription().click(); + + await expect + .poll(() => submittedPost(saveApi).og_title, FIELD_POLL) + .toBe('A contributor’s Facebook title'); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx new file mode 100644 index 00000000000..7549e129549 --- /dev/null +++ b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx @@ -0,0 +1,210 @@ +import { describe, expect, it, onTestFinished } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentUserResponse, + fakeAdminEndpoint, + fakeMembers, + fakeNewsletters, + fakePosts, + fakeSnippets, + fakeTiers, + post, + renderAdminApp, + staffRole, + type StaffRoleName, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const POST_ID = 'abc123'; +const CURRENT_USER_ID = '1'; +const FLAG_ON = { labs: { editorReact: true } }; +const ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); +const BACK_LABEL = 'Close keyboard shortcuts panel'; +const ROW_LABEL = 'Keyboard shortcuts'; +const MAC_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; +const WINDOWS_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +// A full-app render outlasts the default timeout. +const SLOW = 20_000; + +/** The pane reads the platform as it renders, so the agent has to be in place first. */ +function onPlatform(userAgent: string) { + Object.defineProperty(navigator, 'userAgent', { configurable: true, get: () => userAgent }); + onTestFinished(() => { + Reflect.deleteProperty(navigator, 'userAgent'); + }); +} + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +function fakeEditablePost(overrides: Partial> = {}) { + fakeSnippets([]); + fakePosts([]); + // The header's publish inputs and preview read these beyond the boot table. + fakeMembers([]); + fakeNewsletters([]); + fakeTiers([]); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); + + const current = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + tags: [], + ...overrides, + }); + + fakeAdminEndpoint('GET', ROUTE, () => ({ posts: [current] })); + fakeAdminEndpoint('PUT', ROUTE, () => ({ posts: [current] })); +} + +async function openShortcuts() { + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); +} + +/** + * The sidebar's Keyboard shortcuts pane: the reference list of every chord and + * slash command the editor answers to, in the writer's own platform glyphs. + */ +describe('Post settings keyboard shortcuts', () => { + it( + 'opens the pane over the section list and comes back from it', + async () => { + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + // The pane replaces the list it was opened from. + await expect(editorScreen.settingsExcerpt()).toHaveCount(0); + await expect.element(editorScreen.settingsSidebar()).toHaveAttribute('aria-label', ROW_LABEL); + await expect.element(page.getByRole('heading', { level: 2, name: ROW_LABEL })).toBeVisible(); + + await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + }, + SLOW, + ); + + it( + 'lists every shortcut under the group it belongs to, without widening the panel', + async () => { + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + const pane = editorScreen.settingsSubviewPane(); + await expect.element(pane).toHaveTextContent('Formatting'); + await expect.element(pane).toHaveTextContent('Editing'); + await expect.element(pane).toHaveTextContent('Application'); + await expect.element(pane).toHaveTextContent('Inserting'); + + expect(editorScreen.settingsShortcutRows()).toHaveLength(50); + expect(editorScreen.settingsSidebar().element().getBoundingClientRect().width).toBe(350); + }, + SLOW, + ); + + it( + 'shows a Mac writer the Mac glyphs', + async () => { + onPlatform(MAC_AGENT); + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + const rows = editorScreen.settingsShortcutRows(); + expect(rows).toContain('Bold⌘B'); + expect(rows).toContain('Strike through⌃⌥U'); + expect(rows).toContain('Inline code⌃⇧K'); + expect(rows).toContain('Toggle card edit mode⌘↩'); + expect(rows).toContain('Publish⌘⇧P'); + expect(rows).toContain('Image/image'); + expect(rows).toContain('Divider---or/hr'); + }, + SLOW, + ); + + it( + 'names the modifier a glyph stands for when the writer hovers it', + async () => { + onPlatform(MAC_AGENT); + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + await userEvent.hover(page.getByRole('img', { name: 'Command', exact: true }).first()); + + // The tooltip opens after Radix's hover delay. + await expect.element(page.getByText('Command'), { timeout: SLOW }).toBeVisible(); + }, + SLOW, + ); + + it( + 'shows everyone else the key names instead', + async () => { + onPlatform(WINDOWS_AGENT); + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + const rows = editorScreen.settingsShortcutRows(); + expect(rows).toContain('BoldCtrlB'); + expect(rows).toContain('Strike throughCtrlAltU'); + expect(rows).toContain('Inline codeCtrlShiftK'); + expect(rows).toContain('Toggle card edit modeCtrlEnter'); + expect(rows).toContain('PublishCtrlShiftP'); + // A slash command is the same text whatever the writer is typing it on. + expect(rows).toContain('Image/image'); + }, + SLOW, + ); + + it( + 'closes the pane on Escape and returns focus to the row', + async () => { + fakeEditablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openShortcuts(); + + await userEvent.keyboard('{Escape}'); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toHaveFocus(); + }, + SLOW, + ); + + it( + 'gives a contributor the same reference list', + async () => { + onPlatform(MAC_AGENT); + // A contributor may only open a draft they authored. + fakeEditablePost({ authors: [{ id: CURRENT_USER_ID }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, asRole('Contributor')); + await openShortcuts(); + + expect(editorScreen.settingsShortcutRows()).toHaveLength(50); + expect(editorScreen.settingsShortcutRows()).toContain('Bold⌘B'); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx index 20a203ebfcb..7f79da77c59 100644 --- a/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx @@ -141,8 +141,9 @@ describe('Post settings tags', () => { await openSidebar(); await openTagList(); - // No row shows a post count, so the read does not ask for the join. - await expect.poll(() => tagsApi.lastRequest?.url, POLL).not.toContain('count.posts'); + await expect.poll(() => tagsApi.lastRequest?.url, POLL).toBeDefined(); + // Ghost rejects an explicitly empty include instead of treating it as absent. + expect(new URL(tagsApi.lastRequest!.url).searchParams.get('include')).not.toBe(''); await editorScreen.settingsTagOption('Sport').click(); diff --git a/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx new file mode 100644 index 00000000000..10bc43d2174 --- /dev/null +++ b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx @@ -0,0 +1,491 @@ +import { describe, expect, it } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentUserResponse, + fakeAdminEndpoint, + fakeMembers, + fakeNewsletters, + fakePosts, + fakeSnippets, + fakeTiers, + post, + renderAdminApp, + staffRole, + unsavedChangesGuarded, + type EndpointCapture, + type StaffRoleName, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const POST_ID = 'abc123'; +const CURRENT_USER_ID = '1'; +const FLAG_ON = { labs: { editorReact: true } }; +const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; +const ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); +const BACK_LABEL = 'Close X card panel'; +const UPLOADED = 'https://example.com/content/images/2026/09/hills.png'; +const FEATURE = 'https://example.com/content/images/2026/09/coast.png'; +// The site fixture's own description, which the card falls back to last. +const SITE_DESCRIPTION = 'Thoughts, stories and ideas.'; + +// A settings save waits on the engine's queue, so these journeys outlast the default timeout. +const SLOW = 20_000; +const POLL = { timeout: 10_000 }; +// Under the 3s autosave debounce, so only an undebounced field save can satisfy it. +const FIELD_POLL = { timeout: 2_000 }; + +type SavedPost = ReturnType; + +function submittedPost(capture: EndpointCapture): Record { + const body = capture.lastRequest?.body as { posts: Record[] } | undefined; + return body?.posts[0] ?? {}; +} + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +function editorChrome() { + fakeSnippets([]); + fakePosts([]); + // The header's publish inputs and preview read these beyond the boot table. + fakeMembers([]); + fakeNewsletters([]); + fakeTiers([]); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); +} + +/** A post that answers saves the way Ghost does: submitted fields back, fresh token. */ +function fakeSavablePost(overrides: Partial = {}) { + editorChrome(); + 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, + custom_excerpt: null, + excerpt: null, + meta_title: null, + meta_description: null, + twitter_image: null, + twitter_title: null, + twitter_description: null, + feature_image: null, + tags: [], + ...overrides, + }); + let saves = 0; + + fakeAdminEndpoint('GET', ROUTE, () => ({ posts: [current] })); + + return fakeAdminEndpoint('PUT', ROUTE, ({ 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] }; + }); +} + +function fakeImageUpload() { + return fakeAdminEndpoint('POST', '/images/upload/', { + images: [{ url: UPLOADED, ref: null }], + }); +} + +async function openXCard() { + await editorScreen.settingsToggle().click(); + await expect.element(editorScreen.settingsSidebar()).toBeVisible(); + await editorScreen.settingsSubviewRow('X card').click(); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); +} + +/** + * The sidebar's X card pane: the image, title and description X is given + * instead of the post's own. + */ +describe('Post settings X card', () => { + it( + 'opens the pane over the section list and comes back from it', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + // The pane replaces the list it was opened from. + await expect(editorScreen.settingsExcerpt()).toHaveCount(0); + await expect.element(editorScreen.settingsXTitle()).toBeVisible(); + await expect.element(editorScreen.settingsXDescription()).toBeVisible(); + await expect.element(editorScreen.settingsXImage()).toBeVisible(); + + await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + + await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); + await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow('X card')).toBeVisible(); + }, + SLOW, + ); + + it( + 'saves an uploaded X image as soon as it lands', + async () => { + const saveApi = fakeSavablePost(); + const uploadApi = fakeImageUpload(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await userEvent.upload( + editorScreen.settingsXImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); + // A field save has no debounce, so it lands well inside the autosave's 3s. + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ twitter_image: UPLOADED }); + await expect.element(editorScreen.removeSettingsXImage()).toBeVisible(); + }, + SLOW, + ); + + it( + 'clears the X image the post already had', + async () => { + const saveApi = fakeSavablePost({ twitter_image: UPLOADED }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.removeSettingsXImage().click(); + + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi).twitter_image).toBeNull(); + await expect.element(editorScreen.settingsXImage()).toHaveTextContent('Add X image'); + }, + SLOW, + ); + + it( + 'persists a draft’s X title and description on the blur that ends each edit', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.settingsXTitle().fill('A better title for X'); + await editorScreen.settingsXDescription().click(); + + // A field save has no debounce, so it lands well inside the autosave's 3s. + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ twitter_title: 'A better title for X' }); + + await editorScreen.settingsXDescription().fill('What this post is about on X'); + await editorScreen.settingsXTitle().click(); + + await expect.poll(() => saveApi.requests.length, FIELD_POLL).toBe(2); + expect(submittedPost(saveApi)).toMatchObject({ + twitter_description: 'What this post is about on X', + }); + }, + SLOW, + ); + + it( + 'stages a published post’s X title until Update', + async () => { + const saveApi = fakeSavablePost({ status: 'published', published_at: PUBLISHED_AT }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.settingsXTitle().fill('A better title for X'); + await editorScreen.settingsXDescription().click(); + + await expect.element(editorScreen.updateButton()).toBeEnabled(); + await expect.poll(unsavedChangesGuarded).toBe(true); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + twitter_title: 'A better title for X', + status: 'published', + }); + }, + SLOW, + ); + + it( + 'stands in the post’s own title and excerpt until the X fields carry their own', + async () => { + fakeSavablePost({ custom_excerpt: 'The excerpt this post already has' }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await expect + .element(editorScreen.settingsXTitle()) + .toHaveAttribute('placeholder', 'Hello from React'); + await expect + .element(editorScreen.settingsXDescription()) + .toHaveAttribute('placeholder', 'The excerpt this post already has'); + + const preview = editorScreen.settingsXPreview(); + await expect.element(preview).toHaveTextContent('test.com'); + await expect.element(preview).toHaveTextContent('Hello from React'); + await expect.element(preview).toHaveTextContent('The excerpt this post already has'); + + await editorScreen.settingsXTitle().fill('A better title for X'); + await editorScreen.settingsXDescription().fill('What this post is about on X'); + + await expect.element(preview).toHaveTextContent('A better title for X'); + await expect.element(preview).toHaveTextContent('What this post is about on X'); + await expect.element(preview).not.toHaveTextContent('The excerpt this post already has'); + }, + SLOW, + ); + + it( + 'falls back to the excerpt the server generated for the post', + async () => { + fakeSavablePost({ excerpt: 'The first words of the post itself' }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await expect + .element(editorScreen.settingsXDescription()) + .toHaveAttribute('placeholder', 'The first words of the post itself'); + await expect + .element(editorScreen.settingsXPreview()) + .toHaveTextContent('The first words of the post itself'); + }, + SLOW, + ); + + it( + 'falls back to the site’s own description for a post that has none', + async () => { + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await expect + .element(editorScreen.settingsXDescription()) + .toHaveAttribute('placeholder', SITE_DESCRIPTION); + await expect.element(editorScreen.settingsXPreview()).toHaveTextContent(SITE_DESCRIPTION); + }, + SLOW, + ); + + it( + 'previews the feature image the writer is looking at, and follows it as it changes', + async () => { + fakeSavablePost({ feature_image: FEATURE }); + fakeImageUpload(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + // The card falls back to it while the pane's own dropzone is still empty. + await expect.element(editorScreen.settingsXPreviewImage()).toHaveAttribute('src', FEATURE); + await expect.element(editorScreen.settingsXImageInput()).toBeInTheDocument(); + + const pane = editorScreen.settingsSubviewPane().element(); + + await editorScreen.removeFeatureImage().click(); + + await expect(editorScreen.settingsXPreviewImage()).toHaveCount(0); + + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'coast.png', { type: 'image/png' }), + ); + + await expect.element(editorScreen.settingsXPreviewImage()).toHaveAttribute('src', UPLOADED); + // The open pane followed the canvas rather than being rebuilt around it. + expect(pane.isConnected).toBe(true); + }, + SLOW, + ); + + it( + 'stages a published post’s X image until Update', + async () => { + const saveApi = fakeSavablePost({ status: 'published', published_at: PUBLISHED_AT }); + const uploadApi = fakeImageUpload(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await userEvent.upload( + editorScreen.settingsXImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); + await expect.element(editorScreen.removeSettingsXImage()).toBeVisible(); + await expect.poll(unsavedChangesGuarded).toBe(true); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + expect(submittedPost(saveApi)).toMatchObject({ + twitter_image: UPLOADED, + status: 'published', + }); + }, + SLOW, + ); + + it( + 'reports an upload the server refuses and leaves the field as it was', + async () => { + const saveApi = fakeSavablePost(); + const uploadApi = fakeAdminEndpoint( + 'POST', + '/images/upload/', + { errors: [] }, + { status: 415 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await userEvent.upload( + editorScreen.settingsXImageInput().element(), + new File(['image'], 'notes.txt', { type: 'image/png' }), + ); + + await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); + await expect + .element(page.getByText('The image type you uploaded is not supported.', { exact: false })) + .toBeVisible(); + await expect.element(editorScreen.settingsXImage()).toHaveTextContent('Add X image'); + expect(saveApi.requests).toHaveLength(0); + }, + SLOW, + ); + + it( + 'reports a malformed upload response without saving its image value', + async () => { + const saveApi = fakeSavablePost(); + fakeAdminEndpoint('POST', '/images/upload/', { images: [{ url: 123, ref: null }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await userEvent.upload( + editorScreen.settingsXImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + await expect.element(page.getByText('Couldn’t upload the X image.')).toBeVisible(); + await expect.element(editorScreen.settingsXImage()).toHaveTextContent('Add X image'); + expect(saveApi.requests).toHaveLength(0); + }, + SLOW, + ); + + it( + 'falls back to the meta fields where the post has none of its own', + async () => { + fakeSavablePost({ + title: '', + meta_title: 'In search results', + meta_description: 'What search engines are told', + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await expect + .element(editorScreen.settingsXTitle()) + .toHaveAttribute('placeholder', 'In search results'); + await expect + .element(editorScreen.settingsXDescription()) + .toHaveAttribute('placeholder', 'What search engines are told'); + }, + SLOW, + ); + + it( + 'refuses to save an X title longer than the field holds', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.settingsXTitle().fill('a'.repeat(301)); + await editorScreen.settingsXDescription().click(); + + await expect + .element(editorScreen.settingsSubviewPane().getByRole('alert')) + .toHaveTextContent('Twitter Title cannot be longer than 300 characters.'); + await expect.element(editorScreen.settingsXTitle()).toHaveAttribute('aria-invalid', 'true'); + // Refused where the writer is typing rather than as a save they did not ask for. + await expect.poll(unsavedChangesGuarded).toBe(true); + await expect(editorScreen.saveErrorBanner()).toHaveCount(0); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect + .element(editorScreen.saveErrorBanner()) + .toHaveTextContent('Twitter Title cannot be longer than 300 characters.'); + expect(saveApi.requests).toHaveLength(0); + }, + SLOW, + ); + + it( + 'refuses to save an X description longer than the field holds', + async () => { + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.settingsXDescription().fill('a'.repeat(501)); + await editorScreen.settingsXTitle().click(); + + await expect + .element(editorScreen.settingsSubviewPane().getByRole('alert')) + .toHaveTextContent('Twitter Description cannot be longer than 500 characters.'); + await expect + .element(editorScreen.settingsXDescription()) + .toHaveAttribute('aria-invalid', 'true'); + await expect.poll(unsavedChangesGuarded).toBe(true); + expect(saveApi.requests).toHaveLength(0); + + await userEvent.keyboard('{Meta>}s{/Meta}'); + + await expect + .element(editorScreen.saveErrorBanner()) + .toHaveTextContent('Twitter Description cannot be longer than 500 characters.'); + expect(saveApi.requests).toHaveLength(0); + }, + SLOW, + ); + + it( + 'gives a contributor the pane their role can write', + async () => { + // A contributor may only open a draft they authored. + const saveApi = fakeSavablePost({ authors: [{ id: CURRENT_USER_ID }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, asRole('Contributor')); + await openXCard(); + + await editorScreen.settingsXTitle().fill('A contributor’s X title'); + await editorScreen.settingsXDescription().click(); + + await expect + .poll(() => submittedPost(saveApi).twitter_title, POLL) + .toBe('A contributor’s X title'); + }, + SLOW, + ); +}); diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 117a4648e85..dfca9cfb155 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -1,6 +1,8 @@ import { page } from 'vitest/browser'; import { + addFacebookImageLabel, addFeatureImageLabel, + addXImageLabel, conflictCancelReloadButton, conflictCopyContentButton, conflictDiscardAndReloadButton, @@ -44,7 +46,9 @@ import { postHistoryRevisionList, postSettingsSidebar, postsBackLink, + removeFacebookImageButton, removeFeatureImageButton, + removeXImageButton, settingsAuthorChip, settingsAuthorsError, settingsAuthorsList, @@ -55,6 +59,10 @@ import { settingsDeleteDialog, settingsDeleteError, settingsExcerptInput, + settingsFacebookDescriptionInput, + settingsFacebookPreview, + settingsFacebookPreviewImage, + settingsFacebookTitleInput, settingsFeaturedToggle, settingsMenuToggle, settingsPublishDate, @@ -64,6 +72,12 @@ import { settingsMetaDescriptionInput, settingsMetaTitleInput, settingsSerpPreview, + settingsShortcutRow, + settingsXDescriptionInput, + settingsXImage, + settingsXPreview, + settingsXPreviewImage, + settingsXTitleInput, restoreRevisionButton, settingsPostHistoryButton, settingsShowTitleToggle, @@ -215,6 +229,28 @@ export const editorScreen = { settingsMetaTitle: () => page.getByTestId(settingsMetaTitleInput), settingsMetaDescription: () => page.getByTestId(settingsMetaDescriptionInput), settingsSerpPreview: () => page.getByTestId(settingsSerpPreview), + /** CodeMirror exposes its content as a textbox named by the editor's label. */ + settingsCodeInjection: (label: string) => + page.getByRole('textbox', { name: new RegExp(`^${label}`) }), + /** Each keyboard-shortcut row as its label followed by the keys shown against it. */ + settingsShortcutRows: (): string[] => + page + .getByTestId(settingsShortcutRow) + .elements() + .map((row) => row.textContent ?? ''), + settingsXImage: () => page.getByTestId(settingsXImage), + settingsXImageInput: () => page.getByLabelText(addXImageLabel), + removeSettingsXImage: () => page.getByRole('button', { name: removeXImageButton }), + settingsXTitle: () => page.getByTestId(settingsXTitleInput), + settingsXDescription: () => page.getByTestId(settingsXDescriptionInput), + settingsXPreview: () => page.getByTestId(settingsXPreview), + settingsXPreviewImage: () => page.getByTestId(settingsXPreviewImage), + settingsFacebookTitle: () => page.getByTestId(settingsFacebookTitleInput), + settingsFacebookDescription: () => page.getByTestId(settingsFacebookDescriptionInput), + settingsFacebookPreview: () => page.getByTestId(settingsFacebookPreview), + settingsFacebookPreviewImage: () => page.getByTestId(settingsFacebookPreviewImage), + settingsFacebookImageInput: () => page.getByLabelText(addFacebookImageLabel), + removeSettingsFacebookImage: () => page.getByRole('button', { name: removeFacebookImageButton }), settingsPostHistory: () => page.getByTestId(settingsPostHistoryButton), postHistoryModal: () => page.getByTestId(postHistoryModal), diff --git a/apps/admin/src/editor/feature-image.tsx b/apps/admin/src/editor/feature-image.tsx index 88278fd4312..c05256bf7c5 100644 --- a/apps/admin/src/editor/feature-image.tsx +++ b/apps/admin/src/editor/feature-image.tsx @@ -15,41 +15,18 @@ 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 { + ACCEPTED_IMAGE_TYPES, + UNSUPPORTED_IMAGE_MESSAGE, + uploadErrorMessage, +} from '@/shared/images/image-upload'; 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.'; -} +const IMAGE_SUBJECT = 'feature image'; export interface FeatureImageProps { image: string | null; @@ -96,7 +73,7 @@ export function FeatureImage({ try { onImageChange(getImageUrl(await uploadImage({ file }))); } catch (error) { - toast.error(uploadErrorMessage(error)); + toast.error(uploadErrorMessage(error, IMAGE_SUBJECT)); } }, [uploadImage, onImageChange], diff --git a/apps/admin/src/editor/session/editor-session.test.ts b/apps/admin/src/editor/session/editor-session.test.ts index 02c38506fe0..6c5df800aa1 100644 --- a/apps/admin/src/editor/session/editor-session.test.ts +++ b/apps/admin/src/editor/session/editor-session.test.ts @@ -8,6 +8,7 @@ import { type EditorSessionOptions, type EditorWritePayload, } from './editor-session'; +import { META_TITLE_MAX, META_TITLE_TOO_LONG } from './settings-fields'; import type { EditorRecord } from './projection'; type SaveEngineModule = typeof import('@/editor/engine/save-engine'); @@ -1209,6 +1210,23 @@ describe('createEditorSession', () => { expect(session.isDirty()).toBe(!persists); }); + it('refuses a field save and an explicit save on the same over-long value', async () => { + const { session, state } = harness({ record: record() }); + + session.patchFields({ meta_title: 'a'.repeat(META_TITLE_MAX + 1) }); + session.commitField(); + await settle(); + + expect(engineSpy.dispatched).toEqual([]); + expect(state.updates).toHaveLength(0); + + expect(await session.dispatchExplicit()).toMatchObject({ + kind: 'failed', + error: { kind: 'validation', message: META_TITLE_TOO_LONG }, + }); + expect(state.updates).toHaveLength(0); + }); + it('stages a field on a published post until an explicit save', async () => { const { session, state } = harness({ record: record({ status: 'published', published_at: PUBLISHED_AT, featured: false }), diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index 3ceee06d4e5..7f8c8dae212 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -35,6 +35,7 @@ import { identityFor, publishedAtInFuture, settingsFieldError, + validatedFieldsOf, type EditorSettingsPatch, type SettingsFieldKey, type ValidatedSettingsFields, @@ -410,12 +411,7 @@ export function createEditorSession({ ...request, projection, authoredFrom: { title: live.title, slug: live.slug }, - validated: { - visibility: live.visibility, - tiers: live.tiers, - meta_title: live.meta_title, - meta_description: live.meta_description, - }, + validated: validatedFieldsOf(live), builtAtVersion: version, payload, options: { @@ -548,7 +544,7 @@ export function createEditorSession({ // Invalid settings stay staged rather than dispatching a field save. if ( status !== 'draft' || - settingsFieldError(live) || + settingsFieldError(validatedFieldsOf(live)) || authorsEmptied() || publishedAtInFuture(status, livePublishedAt()) ) { diff --git a/apps/admin/src/editor/session/settings-fields.test.ts b/apps/admin/src/editor/session/settings-fields.test.ts index 499aef5d4aa..350f41f5ae0 100644 --- a/apps/admin/src/editor/session/settings-fields.test.ts +++ b/apps/admin/src/editor/session/settings-fields.test.ts @@ -4,12 +4,32 @@ import { META_DESCRIPTION_TOO_LONG, META_TITLE_MAX, META_TITLE_TOO_LONG, + OG_DESCRIPTION_MAX, + OG_DESCRIPTION_TOO_LONG, + OG_TITLE_MAX, + OG_TITLE_TOO_LONG, TIERS_REQUIRED, + VALIDATED_SETTINGS_FIELD_KEYS, + X_DESCRIPTION_MAX, + X_DESCRIPTION_TOO_LONG, + X_TITLE_MAX, + X_TITLE_TOO_LONG, overLength, settingsFieldError, + validatedFieldsOf, + type ValidatedSettingsFields, } from './settings-fields'; -const VALID = { visibility: 'public', tiers: [], meta_title: null, meta_description: null }; +const VALID = { + visibility: 'public', + tiers: [], + meta_title: null, + meta_description: null, + og_title: null, + og_description: null, + twitter_title: null, + twitter_description: null, +}; describe('overLength', () => { it('counts a multibyte character once', () => { @@ -22,6 +42,26 @@ describe('overLength', () => { }); }); +describe('validatedFieldsOf', () => { + it('takes the keys the validator reads and leaves the rest behind', () => { + const live = { ...VALID, meta_title: 'Meta', custom_excerpt: 'Excerpt', featured: true }; + + expect(validatedFieldsOf(live)).toEqual({ ...VALID, meta_title: 'Meta' }); + expect(Object.keys(validatedFieldsOf(live))).toEqual([...VALIDATED_SETTINGS_FIELD_KEYS]); + }); + + it('is the whole of what the validator reads, so a removed key stops being checked', () => { + const live = { ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX + 1) }; + const validated = validatedFieldsOf(live); + // The same projection, built as the list without that key would build it. + const withoutMetaTitle = { ...validated }; + delete (withoutMetaTitle as Partial).meta_title; + + expect(settingsFieldError(validated)).toBe(META_TITLE_TOO_LONG); + expect(settingsFieldError(withoutMetaTitle)).toBeNull(); + }); +}); + describe('settingsFieldError', () => { it('passes fields that break no rule', () => { expect(settingsFieldError(VALID)).toBeNull(); @@ -47,10 +87,50 @@ describe('settingsFieldError', () => { ).toBe(META_DESCRIPTION_TOO_LONG); }); + it('refuses a Facebook title past the column width', () => { + expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX) })).toBeNull(); + expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX + 1) })).toBe( + OG_TITLE_TOO_LONG, + ); + }); + + it('refuses a Facebook description past the column width', () => { + expect( + settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX) }), + ).toBeNull(); + expect( + settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX + 1) }), + ).toBe(OG_DESCRIPTION_TOO_LONG); + }); + + it('refuses an X title past the column width', () => { + expect(settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX) })).toBeNull(); + expect(settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX + 1) })).toBe( + X_TITLE_TOO_LONG, + ); + }); + + it('refuses an X description past the column width', () => { + expect( + settingsFieldError({ ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX) }), + ).toBeNull(); + expect( + settingsFieldError({ ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX + 1) }), + ).toBe(X_DESCRIPTION_TOO_LONG); + }); + it('names the field the message is about', () => { expect(META_TITLE_TOO_LONG).toBe('Meta Title cannot be longer than 300 characters.'); expect(META_DESCRIPTION_TOO_LONG).toBe( 'Meta Description cannot be longer than 500 characters.', ); + expect(OG_TITLE_TOO_LONG).toBe('Facebook Title cannot be longer than 300 characters.'); + expect(OG_DESCRIPTION_TOO_LONG).toBe( + 'Facebook Description cannot be longer than 500 characters.', + ); + expect(X_TITLE_TOO_LONG).toBe('Twitter Title cannot be longer than 300 characters.'); + expect(X_DESCRIPTION_TOO_LONG).toBe( + 'Twitter Description cannot be longer than 500 characters.', + ); }); }); diff --git a/apps/admin/src/editor/session/settings-fields.ts b/apps/admin/src/editor/session/settings-fields.ts index 886834910b1..2258b4036c5 100644 --- a/apps/admin/src/editor/session/settings-fields.ts +++ b/apps/admin/src/editor/session/settings-fields.ts @@ -59,9 +59,17 @@ export function identityFor(key: SettingsFieldKey, value: unknown): unknown { /** The column widths the schema gives these fields. */ export const META_TITLE_MAX = 300; export const META_DESCRIPTION_MAX = 500; +export const OG_TITLE_MAX = 300; +export const OG_DESCRIPTION_MAX = 500; +export const X_TITLE_MAX = 300; +export const X_DESCRIPTION_MAX = 500; export const META_TITLE_TOO_LONG = `Meta Title cannot be longer than ${META_TITLE_MAX} characters.`; export const META_DESCRIPTION_TOO_LONG = `Meta Description cannot be longer than ${META_DESCRIPTION_MAX} characters.`; +export const OG_TITLE_TOO_LONG = `Facebook Title cannot be longer than ${OG_TITLE_MAX} characters.`; +export const OG_DESCRIPTION_TOO_LONG = `Facebook Description cannot be longer than ${OG_DESCRIPTION_MAX} characters.`; +export const X_TITLE_TOO_LONG = `Twitter Title cannot be longer than ${X_TITLE_MAX} characters.`; +export const X_DESCRIPTION_TOO_LONG = `Twitter Description cannot be longer than ${X_DESCRIPTION_MAX} characters.`; /** `visibility: 'tiers'` with no tiers: the write contract drops the visibility. */ export function tiersIncomplete( @@ -91,10 +99,29 @@ export function overLength(value: string | null, max: number): boolean { return Array.from(value ?? '').length > max; } -export type ValidatedSettingsFields = Pick< - EditorSettingsFields, - 'visibility' | 'tiers' | 'meta_title' | 'meta_description' ->; +/** The settings keys the validator reads, and all a prepared save carries for it. */ +export const VALIDATED_SETTINGS_FIELD_KEYS = [ + 'visibility', + 'tiers', + 'meta_title', + 'meta_description', + 'og_title', + 'og_description', + 'twitter_title', + 'twitter_description', +] as const; + +export type ValidatedSettingsFieldKey = (typeof VALIDATED_SETTINGS_FIELD_KEYS)[number]; + +/** The validator's whole input, so reading an unlisted key does not compile. */ +export type ValidatedSettingsFields = Pick; + +/** The validator's own view of the live document. */ +export function validatedFieldsOf(fields: ValidatedSettingsFields): ValidatedSettingsFields { + return Object.fromEntries( + VALIDATED_SETTINGS_FIELD_KEYS.map((key) => [key, fields[key]]), + ) as ValidatedSettingsFields; +} /** The first rule the settings fields break, in the post validator's order. */ export function settingsFieldError(fields: ValidatedSettingsFields): string | null { @@ -107,5 +134,17 @@ export function settingsFieldError(fields: ValidatedSettingsFields): string | nu if (overLength(fields.meta_description, META_DESCRIPTION_MAX)) { return META_DESCRIPTION_TOO_LONG; } + if (overLength(fields.og_title, OG_TITLE_MAX)) { + return OG_TITLE_TOO_LONG; + } + if (overLength(fields.og_description, OG_DESCRIPTION_MAX)) { + return OG_DESCRIPTION_TOO_LONG; + } + if (overLength(fields.twitter_title, X_TITLE_MAX)) { + return X_TITLE_TOO_LONG; + } + if (overLength(fields.twitter_description, X_DESCRIPTION_MAX)) { + return X_DESCRIPTION_TOO_LONG; + } return null; } diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index d2e72dca654..bf2b9801ddf 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -251,7 +251,7 @@ ignoring case and accents, and it leaves out anyone already credited. Arrow keys move the highlight, Enter takes the highlighted row and so does Tab once something has been typed, Escape closes the list and keeps the term, and both clicking away and moving focus out of the field close it and discard the term. A -chip goes with its own remove button, and Backspace in an empty field drops the +chip is removed by clicking it, and Backspace in an empty field drops the last one and opens the list on the staff it can offer again. A pick that empties the row under the highlight moves it to the last row rather than losing it. @@ -345,6 +345,59 @@ carries one, else the site's own host and path with the post's slug. Titles and descriptions are truncated to what a result shows, counting whole Unicode characters and the ellipsis toward the limit. +## X card + +The card X renders for the post is a pane, and every role that can open the +panel can open it. Its image, title and description are the post's `twitter_` +fields: the title and description are staged as the writer types and committed +on the blur that ends the edit, and an uploaded or removed image is committed as +it lands rather than waiting for a blur. Committing is not saving, so the save +policy above still decides: a draft persists all three, and every other status +stages them until Update. A field cleared back to empty is stored as no value. +The image comes from the file picker or a drop; there is no Unsplash picker +here, and an upload the server refuses is reported without changing the field. + +Nothing here is required, and each line falls back rather than emptying. The +title is the X title, else the meta title, else the title the writer is looking +at, else `(Untitled)`. The description is the X description, else the post's +excerpt, else its meta description, else the excerpt the server generated for +it, else the site's own description. The image is the X image, else the post's +feature image, else the site's X image and cover image. Those fallbacks are what +the two inputs show as placeholders, truncated to 40 and 150 characters, and +what the card under them previews: the title whole, the description truncated to +140, and the site's address without its scheme. + +The lengths that are limits are the column widths, 300 for the title and 500 for +the description. Past one of those the field says so where the writer is typing +and nothing is saved — not the field itself, and not a save the writer asks for, +which is refused with the same message. + +## Facebook card + +The card Facebook shows for the post is a pane, and every role that can open the +panel can open it. Its image, title and description are the post's `og_` fields: +the title and description are staged as the writer types and committed on the +blur that ends the edit, and an uploaded or removed image is committed as it +lands rather than waiting for a blur. Committing is not saving, so the save +policy above still decides: a draft persists all three, and every other status +stages them until Update. A field cleared back to empty is stored as no value. +The image comes from the file picker or a drop; there is no Unsplash picker here. + +Nothing here is required, and each line falls back rather than emptying. The +title is the Facebook title, else the meta title, else the title the writer is +looking at, else `(Untitled)`. The description is the Facebook description, else +the post's excerpt, else its meta description, else the excerpt the server +generated for it, else the site's own description. The image is the Facebook +image, else the post's feature image, else the site's social image and cover +image. Those fallbacks are what the two inputs show as placeholders, truncated to +40 and 150 characters, and what the card under them previews, truncated to 140 +and shown against the site's address without its scheme. + +The lengths that are limits are the column widths, 300 for the title and 500 for +the description. Past one of those the field says so where the writer is typing +and nothing is saved — not the field itself, and not a save the writer asks for, +which is refused with the same message. + ## Post history The row opens the post's saved versions, and it is absent whenever there is @@ -392,6 +445,37 @@ left holding the save engine's slug wait. The restore's save carries the slug the post already holds, and the URL section accepts the next manual edit normally. +## Code injection + +The row opens a pane holding the header and footer code this post injects into +the page it renders on, each an HTML editor labelled with the theme helper it +lands in. Every role that can open the panel can write both fields. A page's +editors are named for a page rather than a post. + +The two fields are settings fields like any other: staged as the writer types, +committed on the blur that ends the edit, and persisted or held back by the +panel's save policy. Closing the pane commits the editor the writer was in, and +a field cleared back to empty is stored as no value, as the excerpt is. A post +saved before that convention holds an empty string rather than no value, so +clearing such a field back to empty counts as a change until the next save. + +Escape inside either editor leaves the pane open. An open completion list or a +selection wider than the cursor takes it first; otherwise it frees the editor's +Tab, so the next Tab moves on to the footer editor and out of the pane rather +than indenting. The back button, or Escape from anywhere else in the pane, +still closes the pane. + +## Keyboard shortcuts + +A pane every role that can open the panel can open, and the one thing in the +sidebar that edits nothing: the chords and slash commands the editor answers to, +grouped as Formatting, Editing, Application and Inserting, with the keys shown +against each. The modifiers are drawn as the writer's own platform draws them — +the Mac glyphs for a Mac writer, the key names for everyone else — read from the +user agent as the pane renders. Hovering a glyph names the key it stands for; +a key already shown as its name carries no tooltip. A slash command reads the +same wherever it is typed. + ## Open and closed The toggle sits in the editor header, and the panel starts closed on every diff --git a/apps/admin/src/editor/settings/authors-options.test.ts b/apps/admin/src/editor/settings/authors-options.test.ts index 666a370dd80..35ee2d111af 100644 --- a/apps/admin/src/editor/settings/authors-options.test.ts +++ b/apps/admin/src/editor/settings/authors-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { User } from '@tryghost/admin-x-framework/api/users'; -import { authorSuggestions, matchesAuthor, selectedAuthors } from './authors-options'; +import { matchesAuthor, selectedAuthors, toAuthorOption } from './authors-options'; function user(overrides: Partial): User { return { @@ -36,29 +36,17 @@ describe('matchesAuthor', () => { }); }); -describe('authorSuggestions', () => { - it('offers everyone who is not already an author', () => { - const suggestions = authorSuggestions( - [JANE, JOSE], - [{ id: '1', name: 'Jane Doe', email: '' }], - '', - ); - - expect(suggestions.map(({ id }) => id)).toEqual(['2']); - }); - - it('narrows the offer by the typed term', () => { - expect(authorSuggestions([JANE, JOSE], [], 'jane').map(({ id }) => id)).toEqual(['1']); +describe('toAuthorOption', () => { + it('keeps the staff member’s own name', () => { + expect(toAuthorOption(JANE)).toEqual({ id: '1', name: 'Jane Doe', email: 'jane@example.com' }); }); it('falls back to the email when a staff member has no name', () => { - expect(authorSuggestions([NAMELESS], [], '')).toEqual([ - { id: '3', name: 'ghost@example.com', email: 'ghost@example.com' }, - ]); - }); - - it('answers with nothing before the staff browse has loaded', () => { - expect(authorSuggestions(undefined, [], '')).toEqual([]); + expect(toAuthorOption(NAMELESS)).toEqual({ + id: '3', + name: 'ghost@example.com', + email: 'ghost@example.com', + }); }); }); diff --git a/apps/admin/src/editor/settings/authors-options.ts b/apps/admin/src/editor/settings/authors-options.ts index 5dfe45ffb34..4c94754d72c 100644 --- a/apps/admin/src/editor/settings/authors-options.ts +++ b/apps/admin/src/editor/settings/authors-options.ts @@ -22,8 +22,13 @@ function fold(value: string): string { .toLowerCase(); } -function toOption(user: Pick): AuthorOption { - return { id: user.id, name: user.name || user.email, email: user.email }; +/** What a staff member reads as: their name, or their email when they have none. */ +export function authorName(person: Pick): string { + return person.name || person.email; +} + +export function toAuthorOption(user: Pick): AuthorOption { + return { id: user.id, name: authorName(user), email: user.email }; } /** @@ -35,19 +40,6 @@ export function matchesAuthor(user: Pick, term: return [user.name, user.slug, user.email].some((field) => fold(field ?? '').includes(needle)); } -/** The rows the list offers: everyone not already an author, narrowed by the term. */ -export function authorSuggestions( - users: User[] | undefined, - selected: ReadonlyArray, - term: string, -): AuthorOption[] { - const chosen = new Set(selected.map(({ id }) => id)); - const trimmed = term.trim(); - return (users ?? []) - .filter((user) => !chosen.has(user.id) && (!trimmed || matchesAuthor(user, trimmed))) - .map(toOption); -} - /** * The chips, in the post's own order. A saved post carries its authors' names, * so a chip is named whether or not the staff browse has run. @@ -65,7 +57,7 @@ export function selectedAuthors( const user = known.get(author.id); options.push( user - ? toOption(user) + ? toAuthorOption(user) : { id: author.id, name: author.name || author.email || author.id, diff --git a/apps/admin/src/editor/settings/authors-picker.tsx b/apps/admin/src/editor/settings/authors-picker.tsx index 8bde79d24f5..3c1022a17a6 100644 --- a/apps/admin/src/editor/settings/authors-picker.tsx +++ b/apps/admin/src/editor/settings/authors-picker.tsx @@ -1,14 +1,14 @@ -import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import type { KeyboardEvent } from 'react'; -import { Badge, Button, inputSurface } from '@tryghost/shade/components'; +import { useCallback, useRef } from 'react'; +import { Button } from '@tryghost/shade/components'; import { Stack, Text } from '@tryghost/shade/primitives'; -import { cn, LucideIcon } from '@tryghost/shade/utils'; +import type { User } from '@tryghost/admin-x-framework/api/users'; import { settingsAuthorChip, settingsAuthorsList, settingsAuthorsPicker, } from '@tryghost/test-data/selectors/editor'; -import type { AuthorOption } from './authors-options'; +import { ChipPicker } from '@/shared/pickers/chip-picker'; +import { authorName, matchesAuthor, toAuthorOption, type AuthorOption } from './authors-options'; export interface AuthorsPickerProps { inputId: string; @@ -16,16 +16,14 @@ export interface AuthorsPickerProps { invalid: boolean; /** The post's authors, in order. */ selected: AuthorOption[]; - /** Everyone else, already narrowed by the typed term. */ - suggestions: AuthorOption[]; + /** The site's staff, whoever the browse has answered with so far. */ + staff: User[]; loading: boolean; loadError: boolean; onRetry: () => void; onChange: (next: AuthorOption[]) => void; /** The first open; the staff browse starts here rather than on every editor entry. */ onOpen: () => void; - onSearch: (term: string) => void; - term: string; } /** @@ -38,246 +36,76 @@ export function AuthorsPicker({ describedBy, invalid, selected, - suggestions, + staff, loading, loadError, onRetry, onChange, onOpen, - onSearch, - term, }: AuthorsPickerProps) { - const listId = useId(); - const optionId = (index: number) => `${listId}-${index}`; - const [open, setOpen] = useState(false); - const [highlighted, setHighlighted] = useState(0); const inputRef = useRef(null); - const containerRef = useRef(null); - const listRef = useRef(null); const showLoadError = loadError && !loading; - // The term goes with the list when the writer leaves the field: left behind, - // it reads as an edit that nothing will ever commit. - const closeAndDiscard = useCallback(() => { - setOpen(false); - onSearch(''); - }, [onSearch]); - - // Dismissal on `pointerdown` without preventing the default, so the click - // that follows still reaches whatever the writer aimed at. - useEffect(() => { - if (!open) { - return; - } - - const handlePointerDown = (event: PointerEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - closeAndDiscard(); - } - }; - - document.addEventListener('pointerdown', handlePointerDown); - - return () => document.removeEventListener('pointerdown', handlePointerDown); - }, [closeAndDiscard, open]); - - // Back to the first row whenever the list narrows, so the highlight never - // points past the end of what is on screen. - useEffect(() => { - setHighlighted(0); - }, [term]); - - // A pick can shrink the list under the highlight, which leaves the stored - // index past the end until the next arrow key moves it. - const highlightedIndex = Math.min(highlighted, Math.max(suggestions.length - 1, 0)); - - useEffect(() => { - listRef.current - ?.querySelector('[data-highlighted="true"]') - ?.scrollIntoView({ block: 'nearest' }); - }, [highlightedIndex, open]); - - const reveal = () => { - setOpen(true); - onOpen(); - }; - - const choose = (option: AuthorOption) => { - onChange([...selected, option]); - // Cleared either way: leaving the term in the field means the next thing - // typed appends to a search already acted on. - onSearch(''); - }; - - const remove = (option: AuthorOption) => { - onChange(selected.filter((author) => author.id !== option.id)); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - // IME confirmation and candidate navigation belong to the input method. - // Safari can end composition before its confirmation keydown, reporting 229. - if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) { - return; - } - - if (event.key === 'Backspace' && !term && selected.length > 0) { - remove(selected[selected.length - 1]); - // The list comes back with the removed author in it (gh-token-input.js). - reveal(); - return; - } - - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - - if (!open) { - reveal(); - return; + // Stable: a new handler each render would re-register the shell's document + // listeners for as long as the list is open. + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) { + onOpen(); } - - if (suggestions.length > 0) { - const step = event.key === 'ArrowDown' ? 1 : -1; - - setHighlighted((highlightedIndex + step + suggestions.length) % suggestions.length); - } - - return; - } - - // Escape keeps the term: the writer closed the list, not the search. - if (event.key === 'Escape' && open) { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - return; - } - - const commits = event.key === 'Enter' || (event.key === 'Tab' && term.trim().length > 0); - if (commits && open && !showLoadError && suggestions[highlightedIndex]) { - event.preventDefault(); - choose(suggestions[highlightedIndex]); - } - }; + }, + [onOpen], + ); return ( -
{ - if (!event.currentTarget.contains(event.relatedTarget)) { - closeAndDiscard(); - } - }} - > -
{ - inputRef.current?.focus(); - reveal(); - }} - > - {selected.map((author) => ( - - {author.name} - - - ))} - { - onSearch(event.target.value); - reveal(); - }} - onKeyDown={handleKeyDown} - /> - -
- {open && ( -
- {showLoadError && ( - - - Couldn’t load authors. - - - - )} -
- {!showLoadError && suggestions.length === 0 && ( -
- {loading ? 'Loading authors...' : 'No authors found'} -
- )} - {!showLoadError && - suggestions.map((option, index) => { - const isHighlighted = index === highlightedIndex; - - return ( -
choose(option)} - // Keeps focus in the input, which clicking a plain div would - // otherwise drop, so the writer can keep typing after picking. - onMouseDown={(event) => event.preventDefault()} - onMouseEnter={() => setHighlighted(index)} - > - {option.name} - - {option.email} - -
- ); - })} -
-
+ Retry + + + ) : null + } + options={staff} + placeholder="Select authors..." + renderOption={(person) => ( + <> + {authorName(person)} + {person.email} + )} -
+ selected={selected} + testIds={{ + field: settingsAuthorsPicker, + list: settingsAuthorsList, + chip: settingsAuthorChip, + }} + hideSelected + reopenOnRemove + onAdd={(person) => onChange([...selected, toAuthorOption(person)])} + onOpenChange={handleOpenChange} + onRemove={(key) => onChange(selected.filter((author) => author.id !== key))} + /> ); } diff --git a/apps/admin/src/editor/settings/authors-section.tsx b/apps/admin/src/editor/settings/authors-section.tsx index 4d62539c645..b8d57615e98 100644 --- a/apps/admin/src/editor/settings/authors-section.tsx +++ b/apps/admin/src/editor/settings/authors-section.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react'; +import { useCallback, useId, useState } from 'react'; import { Label } from '@tryghost/shade/components'; import { Text } from '@tryghost/shade/primitives'; import { useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; @@ -9,12 +9,7 @@ import { AUTHORS_REQUIRED } from '@/editor/session/settings-fields'; import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { SettingsSection } from './settings-section'; import { AuthorsPicker } from './authors-picker'; -import { - AUTHORS_SEARCH_PARAMS, - authorSuggestions, - selectedAuthors, - type AuthorOption, -} from './authors-options'; +import { AUTHORS_SEARCH_PARAMS, selectedAuthors, type AuthorOption } from './authors-options'; export interface AuthorsSectionProps { session: EditorSessionHandle; @@ -30,7 +25,7 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { const inputId = useId(); const errorId = useId(); const [browsing, setBrowsing] = useState(false); - const [term, setTerm] = useState(''); + const startBrowsing = useCallback(() => setBrowsing(true), []); const { data, isFetching, isError, refetch } = useBrowseUsers({ defaultErrorHandler: false, @@ -60,12 +55,10 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { loadError={isError} loading={isFetching} selected={selected} - suggestions={authorSuggestions(data?.users, selected, term)} - term={term} + staff={data?.users ?? []} onChange={change} - onOpen={() => setBrowsing(true)} + onOpen={startBrowsing} onRetry={() => void refetch()} - onSearch={setTerm} /> {invalid ? ( + import('@uiw/react-codemirror').then(({ Prec, keymap }) => + Prec.lowest( + keymap.of([ + { + key: 'Escape', + run: (view) => { + view.setTabFocusMode(2_000); + return true; + }, + }, + ]), + ), + ); + +// Loaded on demand so CodeMirror stays out of the editor's main bundle. +const EDITOR_EXTENSIONS = [ + () => import('@codemirror/lang-html').then((module) => module.html()), + TAB_FOCUS_ESCAPE, +]; + +const EDITOR_HEIGHT = '240px'; + +function EditorLabel({ text, helper }: { text: string; helper: string }) { + return ( + <> + {text} {helper} + + ); +} + +export interface CodeInjectionSectionProps { + session: EditorSessionHandle; + postType: PostType; +} + +/** + * The header and footer code this post injects into the page it renders on, + * beside whatever the site already injects. + */ +export function CodeInjectionSection({ session, postType }: CodeInjectionSectionProps) { + const name = postType === 'page' ? 'Page' : 'Post'; + + return ( + } + id="code-injection" + label="Code injection" + title="Code injection" + wide + > + } + value={session.settings.codeinjection_head ?? ''} + onBlur={session.commitSettings} + // A field cleared back to empty is stored as no value, as the excerpt is. + onChange={(value) => session.stageSettings({ codeinjection_head: value || null })} + /> + } + value={session.settings.codeinjection_foot ?? ''} + onBlur={session.commitSettings} + onChange={(value) => session.stageSettings({ codeinjection_foot: value || null })} + /> + + ); +} diff --git a/apps/admin/src/editor/settings/facebook-card-section.tsx b/apps/admin/src/editor/settings/facebook-card-section.tsx new file mode 100644 index 00000000000..170f29eba0d --- /dev/null +++ b/apps/admin/src/editor/settings/facebook-card-section.tsx @@ -0,0 +1,230 @@ +import { useCallback, useId } from 'react'; +import { toast } from 'sonner'; +import { Input, Label, LoadingIndicator, Textarea } from '@tryghost/shade/components'; +import { + ImageUpload, + ImageUploadAction, + ImageUploadActions, + ImageUploadDropzone, + ImageUploadImage, + ImageUploadPreview, +} from '@tryghost/shade/patterns'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { getImageUrl, useUploadImage } from '@tryghost/admin-x-framework/api/images'; +import { + settingsFacebookDescriptionInput, + settingsFacebookPreview, + settingsFacebookPreviewImage, + settingsFacebookTitleInput, +} from '@tryghost/test-data/selectors/editor'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; +import { + ACCEPTED_IMAGE_TYPES, + UNSUPPORTED_IMAGE_MESSAGE, + uploadErrorMessage, +} from '@/shared/images/image-upload'; +import type { PostCardConfig } from '@/editor/card-config'; +import { + OG_DESCRIPTION_MAX, + OG_DESCRIPTION_TOO_LONG, + OG_TITLE_MAX, + OG_TITLE_TOO_LONG, + overLength, +} from '@/editor/session/settings-fields'; +import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import { FieldError } from './field-error'; +import { truncate } from './meta-data-fields'; +import { SettingsSubview } from './settings-subview'; +import { + SOCIAL_DESCRIPTION_PLACEHOLDER_LENGTH, + SOCIAL_PREVIEW_LENGTH, + SOCIAL_TITLE_PLACEHOLDER_LENGTH, + siteDomain, + socialDescription, + socialImage, + socialTitle, +} from './social-card-fields'; + +const IMAGE_SUBJECT = 'Facebook image'; +const ADD_IMAGE_LABEL = 'Add Facebook image'; +const REMOVE_IMAGE_LABEL = 'Remove Facebook image'; + +export interface FacebookCardSectionProps { + session: EditorSessionHandle; + /** The site's homepage URL, which the card previews the post under. */ + siteUrl: string; + /** The feature image the writer is looking at, which the card falls back to. */ + featureImage: string | null; + /** Carries the site's own description and images, which the card falls back to last. */ + cardConfig: PostCardConfig; +} + +/** + * The card Facebook shows for the post: an image, title and description that + * stand in for the post's own, and the result they produce. + */ +export function FacebookCardSection({ + session, + siteUrl, + featureImage, + cardConfig, +}: FacebookCardSectionProps) { + const titleId = useId(); + const titleErrorId = useId(); + const descriptionId = useId(); + const descriptionErrorId = useId(); + const { mutateAsync: uploadImage, isPending } = useUploadImage(); + + const ogImage = session.settings.og_image ?? ''; + const ogTitle = session.settings.og_title ?? ''; + const ogDescription = session.settings.og_description ?? ''; + const titleError = overLength(ogTitle, OG_TITLE_MAX) ? OG_TITLE_TOO_LONG : null; + const descriptionError = overLength(ogDescription, OG_DESCRIPTION_MAX) + ? OG_DESCRIPTION_TOO_LONG + : null; + + const previewTitle = socialTitle({ + own: ogTitle, + metaTitle: session.settings.meta_title ?? '', + title: session.bind.title, + }); + const previewDescription = socialDescription({ + own: ogDescription, + customExcerpt: session.settings.custom_excerpt ?? '', + metaDescription: session.settings.meta_description ?? '', + postExcerpt: session.loadedRecord?.excerpt ?? '', + siteDescription: cardConfig.siteDescription, + }); + const previewImage = socialImage({ + own: ogImage, + featureImage: featureImage ?? '', + siteSocialImage: cardConfig.siteOgImage ?? '', + siteCoverImage: cardConfig.siteCoverImage ?? '', + }); + + const handleUpload = useCallback( + async (file: File) => { + try { + session.editSettings({ og_image: getImageUrl(await uploadImage({ file })) }); + } catch (error) { + toast.error(uploadErrorMessage(error, IMAGE_SUBJECT)); + } + }, + [session, uploadImage], + ); + + return ( + } + id="facebook-card" + label="Facebook card" + title="Facebook card" + wide + > + {ogImage ? ( + + + + + session.editSettings({ og_image: null })} + > + + + + + + ) : ( + + files[0] && void handleUpload(files[0])} + onDropRejected={() => toast.error(UNSUPPORTED_IMAGE_MESSAGE)} + > + {isPending ? ( + + ) : ( + + + )} + + + )} + + + + session.stageSettings({ og_title: event.target.value || null })} + /> + {titleError ? : null} + + + + +