diff --git a/.changeset/koenig-style-export.md b/.changeset/koenig-style-export.md new file mode 100644 index 00000000000..5d3f57e0b04 --- /dev/null +++ b/.changeset/koenig-style-export.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": minor +--- + +Added a `./style.css` package export so ESM consumers can load the editor stylesheet, which only the UMD bundle injects on its own diff --git a/.changeset/weak-moose-brake.md b/.changeset/weak-moose-brake.md new file mode 100644 index 00000000000..cc733e77cb3 --- /dev/null +++ b/.changeset/weak-moose-brake.md @@ -0,0 +1,15 @@ +--- +"ghost-storage-base": none +"@tryghost/adapter-base-cache": none +"@tryghost/adapter-base-scheduling": none +"@tryghost/adapter-base-sso": none +"@tryghost/kg-card-factory": none +"@tryghost/kg-clean-basic-html": none +"@tryghost/kg-converters": none +"@tryghost/kg-default-cards": none +"@tryghost/kg-default-nodes": none +"@tryghost/kg-markdown-html-renderer": none +"@tryghost/kg-utils": none +--- + +Update dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00c9a93251..fba4ca3f909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,6 +241,7 @@ jobs: if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then echo 'affected_projects=[]' >> "$GITHUB_OUTPUT" echo 'affected_projects_str=' >> "$GITHUB_OUTPUT" + echo 'typecheck_projects_str=' >> "$GITHUB_OUTPUT" echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT" echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT" echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT" @@ -261,6 +262,9 @@ jobs: AFFECTED_PROJECTS_STR=$(pnpm nx show projects ${AFFECTED_ARG} --sep=, | tr -d '\n') echo "affected_projects_str=$AFFECTED_PROJECTS_STR" >> "$GITHUB_OUTPUT" + TYPECHECK_PROJECTS_STR=$(pnpm -s nx show projects ${AFFECTED_ARG} --withTarget test:types --sep=, | tr -d '\n') + echo "typecheck_projects_str=$TYPECHECK_PROJECTS_STR" >> "$GITHUB_OUTPUT" + UNIT_TEST_AFFECTED_ARG="$AFFECTED_ARG" if [[ "${{ steps.changed.outputs.unit-test-globals }}" == 'true' ]]; then UNIT_TEST_AFFECTED_ARG="" @@ -296,6 +300,7 @@ jobs: outputs: affected_projects: ${{ steps.affected.outputs.affected_projects }} affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }} + typecheck_projects_str: ${{ steps.affected.outputs.typecheck_projects_str }} unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }} affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }} publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }} @@ -748,6 +753,33 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + job_typecheck: + runs-on: ubuntu-latest + needs: [job_setup] + if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.typecheck_projects_str != '' + name: Typecheck + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1000 + - uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + trust-lockfile: 'true' + + - name: Typecheck projects + run: pnpm nx run-many -t test:types -p "${{ needs.job_setup.outputs.typecheck_projects_str }}" + env: + FORCE_COLOR: 0 + NX_SKIP_LOG_GROUPING: true + + - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + status: ${{ job.status }} + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + job_acceptance-tests: # Private copies of this repo get 2-core runners, where vitest's DB suite # falls to a single worker; the public repo's runner is already 4-core. @@ -2305,6 +2337,7 @@ jobs: job_docker, job_ghost-cli, job_admin-tests, + job_typecheck, job_unit-tests, job_acceptance-tests, job_legacy-tests, @@ -2328,7 +2361,7 @@ jobs: # Runs only on push-to-main — never on pull_request — so the `id-token: write` # permission is never exposed to PR-controlled code (ref: ONC-1677). publish_public_apps: - needs: [job_setup, job_lint, job_unit-tests, job_build_e2e_public_apps] + needs: [job_setup, job_lint, job_typecheck, job_unit-tests, job_build_e2e_public_apps] name: Publish ${{ matrix.package_name }} runs-on: ubuntu-latest # Serialize per-app publishes so two quick main merges can't both compute the @@ -2339,11 +2372,13 @@ jobs: group: publish-public-app-${{ matrix.package_name }} cancel-in-progress: false if: | - github.event_name != 'pull_request' + always() + && github.event_name != 'pull_request' && github.repository == 'TryGhost/Ghost' && needs.job_setup.outputs.is_main == 'true' && needs.job_setup.result == 'success' && needs.job_lint.result == 'success' + && (needs.job_typecheck.result == 'success' || needs.job_typecheck.result == 'skipped') && needs.job_unit-tests.result == 'success' && needs.job_build_e2e_public_apps.result == 'success' && needs.job_setup.outputs.publish_public_apps_matrix != '[]' diff --git a/apps/admin-x-framework/src/api/content-types.ts b/apps/admin-x-framework/src/api/content-types.ts index 64c206100aa..c405246393f 100644 --- a/apps/admin-x-framework/src/api/content-types.ts +++ b/apps/admin-x-framework/src/api/content-types.ts @@ -3,9 +3,11 @@ type Override = Omit & Changes; export type Email = { + id?: string; opened_count: number; email_count: number; - status?: string; + status?: 'pending' | 'submitting' | 'submitted' | 'failed'; + error?: string | null; track_opens?: boolean; track_clicks?: boolean; }; diff --git a/apps/admin-x-framework/src/api/email-previews.ts b/apps/admin-x-framework/src/api/email-previews.ts new file mode 100644 index 00000000000..1ef05f2e58c --- /dev/null +++ b/apps/admin-x-framework/src/api/email-previews.ts @@ -0,0 +1,67 @@ +import { createMutation, createQueryWithId } from '../utils/api/hooks'; + +export type EmailPreview = { + html: string; + plaintext: string; + subject: string; +}; + +export interface EmailPreviewResponseType { + email_previews: EmailPreview[]; +} + +export interface EmailPreviewParams { + memberStatus?: 'free' | 'paid'; + /** Tier slug - narrows the paid audience to a single tier */ + memberTier?: string; + /** Newsletter slug - the server falls back to the post's, then the default newsletter */ + newsletter?: string; +} + +const dataType = 'EmailPreviewResponseType'; + +const useEmailPreviewQuery = createQueryWithId({ + dataType, + path: (id) => `/email_previews/posts/${id}/`, +}); + +export const useEmailPreview = ( + id: string, + options: EmailPreviewParams & Parameters[1] = {}, +) => { + const { memberStatus, memberTier, newsletter, searchParams, ...query } = options; + + const params: Record = { ...searchParams }; + if (memberStatus) { + params.member_status = memberStatus; + } + if (memberTier) { + params.member_tier = memberTier; + } + if (newsletter) { + params.newsletter = newsletter; + } + + return useEmailPreviewQuery(id, { ...query, searchParams: params }); +}; + +export interface SendTestEmailPayload { + postId: string; + /** The server accepts exactly one recipient per request */ + emails: string[]; + memberStatus?: 'free' | 'paid'; + memberTier?: string; + newsletter?: string; +} + +/** Sends a test email for a post. Responds 204 with no body. */ +export const useSendTestEmail = createMutation({ + method: 'POST', + path: ({ postId }) => `/email_previews/posts/${postId}/`, + body: ({ emails, memberStatus, memberTier, newsletter }) => ({ + emails, + ...(newsletter && { newsletter }), + ...(memberStatus && { member_status: memberStatus }), + ...(memberTier && { member_tier: memberTier }), + }), +}); diff --git a/apps/admin-x-framework/src/api/emails.ts b/apps/admin-x-framework/src/api/emails.ts new file mode 100644 index 00000000000..3292b868a24 --- /dev/null +++ b/apps/admin-x-framework/src/api/emails.ts @@ -0,0 +1,21 @@ +import { createMutation } from '../utils/api/hooks'; +import { postsDataType } from './posts'; +import type { Email } from './content-types'; + +export interface EmailsResponseType { + emails: Email[]; +} + +/** + * Retry a failed email send. + * + * The framework has no email queries - the email consumers see is the copy + * embedded on the post (the editor read contract includes `email`), so a + * successful retry invalidates post queries to refresh that embedded copy. + */ +export const useRetryEmail = createMutation({ + method: 'PUT', + path: (id) => `/emails/${id}/retry/`, + body: () => ({}), + invalidateQueries: { dataType: postsDataType }, +}); diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index f0a00ba2799..3781b7781a1 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -11,6 +11,7 @@ import { apiUrl } from '../utils/api/fetch-api'; import type { FieldValue } from '@tryghost/custom-field-types'; import { useCurrentUser } from './current-user'; import { canManageMembers } from './users'; +import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter'; export type MemberLabel = { id: string; @@ -187,6 +188,132 @@ export function useMemberCount() { return data?.meta?.pagination.total; } +// ----------------------------------------------------------------------------- +// Filtered member counts (email recipients) +// ----------------------------------------------------------------------------- + +// The Ember members-count-cache's TTL; the framework default staleTime (5min) +// is too stale for publish-flow recipient counts. +const MEMBERS_COUNT_STALE_TIME = 60 * 1000; + +const useBrowseMembersCountQuery = createQuery({ + dataType, + path: membersPath, +}); + +export interface MembersCountResult { + /** `null` while loading and for roles that cannot browse members. */ + count: number | null; + isLoading: boolean; +} + +/** + * Number of members matching a filter, consolidated from the Ember + * `members-count-cache` service + `members-count-fetcher` resource: a browse + * request with `limit=1` reading `meta.pagination.total`, cached per-filter + * for 60 seconds. As in Ember, roles that cannot manage members get + * `count: null` without a request, a nullish filter counts as 0 without a + * request, and request errors resolve to 0 with no error toast. While the + * current user is still loading the result is `{count: null, isLoading: true}` + * so callers can tell it apart from a role that cannot browse members. + */ +export function useMembersCount(filter: string | null | undefined): MembersCountResult { + const { data: currentUser } = useCurrentUser(); + const canFetch = Boolean(currentUser && canManageMembers(currentUser)); + const enabled = canFetch && filter !== null && filter !== undefined; + + const result = useBrowseMembersCountQuery({ + // order/page pin the same cheap, stable request shape the Ember cache used + searchParams: { filter: filter ?? '', order: 'id', limit: '1', page: '1' }, + staleTime: MEMBERS_COUNT_STALE_TIME, + enabled, + defaultErrorHandler: false, + }); + + if (currentUser === undefined) { + return { count: null, isLoading: true }; + } + + if (!enabled || result.isError) { + return { count: canFetch ? 0 : null, isLoading: false }; + } + + return { + count: result.data?.meta?.pagination.total ?? null, + isLoading: result.isLoading, + }; +} + +// gh-pluralize combined toLocaleString with ember-inflector; every noun used +// below pluralizes regularly so a trailing "s" matches its output. +function pluralizedCount(count: number, noun: string): string { + return `${count.toLocaleString()} ${count === 1 ? noun : `${noun}s`}`; +} + +export interface MembersCountStringOptions { + /** + * The recipient count, usually from `useMembersCount`: a number renders + * numeric copy; `null` (role cannot browse members) and `undefined` (not + * fetched) render the descriptive fallback copy instead. + */ + count?: number | null; + /** With `hasMultipleNewsletters`, switches copy to "subscribers of ". */ + newsletter?: { name: string; recipientFilter: string }; + hasMultipleNewsletters?: boolean; +} + +/** + * Human-readable copy for a recipient count, ported from the Ember + * `members-count-cache#countString` (which fetched the count itself; this + * pure port takes it from `useMembersCount`, which matches its semantics). + */ +export function membersCountString( + filter: string = '', + { count, newsletter, hasMultipleNewsletters = false }: MembersCountStringOptions = {}, +): string { + const nounSingular = newsletter && hasMultipleNewsletters ? 'subscriber' : 'member'; + const nounPlural = `${nounSingular}s`; + const suffix = newsletter && hasMultipleNewsletters ? ` of ${newsletter.name}` : ''; + + // Strips the newsletter scope composed by getFullRecipientFilter to get back + // the user-selected segment; a plain comma split like the Ember original. + const basicFilter = newsletter + ? filter.replace(newsletter.recipientFilter, '').replace(/^\+\((.*)\)$/, '$1') + : filter; + const filterParts = basicFilter.split(','); + const isFree = filterParts.length === 1 && filterParts[0] === FREE_SEGMENT; + const isPaid = filterParts.length === 1 && filterParts[0] === PAID_SEGMENT; + const isAll = + !filter || (filterParts.includes(FREE_SEGMENT) && filterParts.includes(PAID_SEGMENT)); + + // Ember reserved this copy for plain Editors and fetched a count for every + // other role on the spot; a pure function can't fetch, so an absent count — + // whatever the role — gets the descriptive copy rather than a bogus "0". + if (count === undefined || count === null) { + if (isFree) { + return `all free ${nounPlural}${suffix}`; + } + if (isPaid) { + return `all paid ${nounPlural}${suffix}`; + } + if (isAll) { + return `all ${nounPlural}${suffix}`; + } + + return 'a custom members segment'; + } + + if (isFree) { + return pluralizedCount(count, `free ${nounSingular}`) + suffix; + } + + if (isPaid) { + return pluralizedCount(count, `paid ${nounSingular}`) + suffix; + } + + return pluralizedCount(count, nounSingular) + suffix; +} + export type NewMember = { email: string; name?: string | null; diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 86c7702b54d..65e8a345db7 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -53,6 +53,8 @@ export interface PostResponseType { const dataType = 'PostsResponseType'; +export const postsDataType = dataType; + export const useBrowsePosts = createQuery({ dataType, path: '/posts/', diff --git a/apps/admin-x-framework/src/index.ts b/apps/admin-x-framework/src/index.ts index f43720c41a1..1cca4d09469 100644 --- a/apps/admin-x-framework/src/index.ts +++ b/apps/admin-x-framework/src/index.ts @@ -53,6 +53,19 @@ export { } from './utils/post-helpers'; export { focusKoenigEditorOnBottomClick } from './utils/focus-koenig-editor-on-bottom-click'; +// Recipient filter utilities +export { + EVERYONE_RECIPIENT_FILTER, + FREE_SEGMENT, + PAID_SEGMENT, + buildRecipientFilter, + getFullRecipientFilter, + getNewsletterRecipientFilter, + getRecipientType, + parseRecipientFilter, +} from './utils/recipient-filter'; +export type { RecipientFilterSegments, RecipientType } from './utils/recipient-filter'; + // Source utilities export { getFaviconDomain, diff --git a/apps/admin-x-framework/src/utils/recipient-filter.ts b/apps/admin-x-framework/src/utils/recipient-filter.ts new file mode 100644 index 00000000000..93c3eea40bb --- /dev/null +++ b/apps/admin-x-framework/src/utils/recipient-filter.ts @@ -0,0 +1,158 @@ +// Email recipient-filter string handling, consolidated from the Ember admin's +// four copies of the same comma-split logic: `utils/publish-options.js`, +// `components/gh-members-recipient-select.js`, +// `components/editor/modals/publish-flow.js` and +// `services/members-count-cache.js`. Behavior (including quirks) is preserved +// so Ember and React screens classify and rebuild filters identically. + +export const FREE_SEGMENT = 'status:free'; +export const PAID_SEGMENT = 'status:-free'; + +/** + * The canonical "everyone" spelling. Selecting both the free and paid + * checkboxes produces this filter, and the server treats it as all members. + */ +export const EVERYONE_RECIPIENT_FILTER = `${FREE_SEGMENT},${PAID_SEGMENT}`; + +const BASE_SEGMENTS: string[] = [FREE_SEGMENT, PAID_SEGMENT]; + +export interface RecipientFilterSegments { + /** Exact `status:free` present among the base segments (the "free members" checkbox). */ + free: boolean; + /** Exact `status:-free` present among the base segments (the "paid members" checkbox). */ + paid: boolean; + /** + * Raw base segments in first-occurrence order. Kept verbatim (untrimmed) + * because a padded segment like `" status:free"` is classified as a base + * segment but does not check the free checkbox, and survives a rebuild as-is. + */ + base: string[]; + /** + * Raw non-blank segments that are not base segments — `label:`, + * `tier:` and any other custom NQL segments — in first-occurrence order. + */ + specific: string[]; +} + +/** + * Splits a recipient filter into base (free/paid) and specific (label/tier) + * segments by comma, the way `gh-members-recipient-select` does. This is a + * plain comma split, not an NQL parse: a specific segment whose value contains + * a comma is split apart the same way the Ember component splits it. + */ +export function parseRecipientFilter(filter: string | null | undefined): RecipientFilterSegments { + const items = (filter || '').split(','); + const base: string[] = []; + const specific: string[] = []; + + for (const item of items) { + if (BASE_SEGMENTS.includes(item.trim())) { + if (!base.includes(item)) { + base.push(item); + } + } else if (item.trim() !== '') { + if (!specific.includes(item)) { + specific.push(item); + } + } + } + + return { + free: base.includes(FREE_SEGMENT), + paid: base.includes(PAID_SEGMENT), + base, + specific, + }; +} + +/** + * Rebuilds a recipient filter string from parsed segments, mirroring + * `gh-members-recipient-select#updateFilter`: base segments first, then + * specific segments, deduplicated, joined by comma. Returns `null` for an + * empty selection (the "no recipients" state). + * + * `paidAvailable: false` drops the paid segment, matching the component's + * behavior when Stripe is not connected. + */ +export function buildRecipientFilter( + segments: Pick, + { paidAvailable = true }: { paidAvailable?: boolean } = {}, +): string | null { + const selected = new Set([...segments.base, ...segments.specific]); + + if (!paidAvailable) { + selected.delete(PAID_SEGMENT); + } + + return Array.from(selected).join(',') || null; +} + +export type RecipientType = 'none' | 'all' | 'free' | 'paid' | 'specific'; + +/** + * Classifies a recipient filter the way the publish flow does + * (`editor/modals/publish-flow.js#recipientType`). + * + * The "all" case is a substring check, not a segment check, so any filter + * containing both `status:free` and `status:-free` anywhere classifies as + * "all" even alongside other segments — e.g. `label:x,status:free,status:-free`. + */ +export function getRecipientType(filter: string | null | undefined): RecipientType { + if (!filter) { + return 'none'; + } + + if (filter === FREE_SEGMENT) { + return 'free'; + } + + if (filter === PAID_SEGMENT) { + return 'paid'; + } + + if (filter.includes(FREE_SEGMENT) && filter.includes(PAID_SEGMENT)) { + return 'all'; + } + + return 'specific'; +} + +/** + * Derives the filter that scopes members to a newsletter's audience + * (`models/newsletter.js#recipientFilter`): actively subscribed to the + * newsletter, email not disabled, and paid-only when the newsletter's + * visibility is `paid`. + */ +export function getNewsletterRecipientFilter({ + slug, + visibility, +}: { + slug: string; + visibility?: string; +}): string { + const filter = [`newsletters.slug:${slug}`, 'email_disabled:0']; + + if (visibility === 'paid') { + filter.push(PAID_SEGMENT); + } + + return filter.join('+'); +} + +/** + * Composes the full filter sent to the email service + * (`utils/publish-options.js#fullRecipientFilter`): the newsletter's audience + * filter, optionally AND-ed with the selected recipient filter. + */ +export function getFullRecipientFilter( + newsletterRecipientFilter: string, + recipientFilter: string | null | undefined, +): string { + let filter = newsletterRecipientFilter; + + if (recipientFilter) { + filter += `+(${recipientFilter})`; + } + + return filter; +} diff --git a/apps/admin-x-framework/test/unit/api/email-previews.test.tsx b/apps/admin-x-framework/test/unit/api/email-previews.test.tsx new file mode 100644 index 00000000000..81ce5b8a29a --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/email-previews.test.tsx @@ -0,0 +1,110 @@ +import { act, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { useEmailPreview, useSendTestEmail } from '../../../src/api/email-previews'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const previewCall = (mock: any) => + mock.calls.find(([input]: [unknown]) => String(input).includes('/email_previews/')); + +const requestBody = (mock: any) => JSON.parse(previewCall(mock)[1].body as string); + +describe('email previews api', () => { + it('reads an email preview with the full audience and newsletter params', async () => { + await withMockFetch( + { + json: { + email_previews: [{ html: '

Hi

', plaintext: 'Hi', subject: 'Hello' }], + // the permissions gate fetches the current user through the same mock + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEmailPreview('post-1', { + memberStatus: 'paid', + memberTier: 'gold', + newsletter: 'weekly', + }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(previewCall(mock)[0] as string); + expect(url.pathname).toBe('/ghost/api/admin/email_previews/posts/post-1/'); + expect(Object.fromEntries(url.searchParams.entries())).toEqual({ + member_status: 'paid', + member_tier: 'gold', + newsletter: 'weekly', + }); + expect(result.current.data?.email_previews[0].subject).toBe('Hello'); + }, + ); + }); + + it('omits audience params that are not provided', async () => { + await withMockFetch( + { + json: { + email_previews: [{ html: '

Hi

', plaintext: 'Hi', subject: 'Hello' }], + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => + useEmailPreview('post-1', { memberStatus: 'free' }), + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(previewCall(mock)[0] as string); + expect(Object.fromEntries(url.searchParams.entries())).toEqual({ + member_status: 'free', + }); + }, + ); + }); + + it('sends a test email with the audience and newsletter in the body', async () => { + await withMockFetch({ status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useSendTestEmail()); + + await act(async () => { + await result.current.mutateAsync({ + postId: 'post-1', + emails: ['test@example.com'], + memberStatus: 'paid', + memberTier: 'gold', + newsletter: 'weekly', + }); + }); + + const [url, options] = previewCall(mock); + expect(new URL(url as string).pathname).toBe('/ghost/api/admin/email_previews/posts/post-1/'); + expect(options.method).toBe('POST'); + expect(requestBody(mock)).toEqual({ + emails: ['test@example.com'], + newsletter: 'weekly', + member_status: 'paid', + member_tier: 'gold', + }); + }); + }); + + it('sends a test email with only the recipient when no audience is given', async () => { + await withMockFetch({ status: 204 }, async (mock) => { + const { result } = renderHookWithProviders(() => useSendTestEmail()); + + await act(async () => { + await result.current.mutateAsync({ + postId: 'post-1', + emails: ['test@example.com'], + }); + }); + + expect(requestBody(mock)).toEqual({ emails: ['test@example.com'] }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/emails.test.tsx b/apps/admin-x-framework/test/unit/api/emails.test.tsx new file mode 100644 index 00000000000..17be24205ad --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/emails.test.tsx @@ -0,0 +1,59 @@ +import { act } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; +import { useRetryEmail } from '../../../src/api/emails'; +import { postsDataType } from '../../../src/api/posts'; +import { withMockFetch } from '../../utils/mock-fetch'; + +describe('emails api', () => { + it('retries a failed email via the retry endpoint', async () => { + await withMockFetch( + { + json: { emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }] }, + headers: { 'content-type': 'application/json' }, + }, + async (mock) => { + const { result } = renderHookWithProviders(() => useRetryEmail()); + + let response; + await act(async () => { + response = await result.current.mutateAsync('email-1'); + }); + + const [url, options] = mock.calls[0]; + expect(new URL(url as string).pathname).toBe('/ghost/api/admin/emails/email-1/retry/'); + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body as string)).toEqual({}); + expect(response).toEqual({ + emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }], + }); + }, + ); + }); + + it('invalidates post queries so the embedded email refreshes', async () => { + const queryClient = createTestQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + const onInvalidate = vi.fn(); + + await withMockFetch( + { + json: { emails: [{ id: 'email-1', status: 'pending', email_count: 10, opened_count: 0 }] }, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => useRetryEmail(), { + queryClient, + frameworkProps: { onInvalidate }, + }); + + await act(async () => { + await result.current.mutateAsync('email-1'); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [postsDataType] }); + expect(onInvalidate).toHaveBeenCalledWith(postsDataType); + }, + ); + }); +}); diff --git a/apps/admin-x-framework/test/unit/api/members.test.tsx b/apps/admin-x-framework/test/unit/api/members.test.tsx index 944bf38158b..de5ebaf3a9b 100644 --- a/apps/admin-x-framework/test/unit/api/members.test.tsx +++ b/apps/admin-x-framework/test/unit/api/members.test.tsx @@ -16,7 +16,9 @@ import { useMemberActivityFeed, useMemberCount, useMemberLogout, + useMembersCount, useRemoveMemberEmailSuppression, + membersCountString, } from '../../../src/api/members'; import type { MemberActivityEvent, @@ -311,6 +313,179 @@ describe('members api', () => { }); }); + describe('useMembersCount', () => { + it('fetches the count for a filter via a limit=1 browse request', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch( + { + json: membersResponse(4289), + }, + async (mockFetch) => { + const { result } = renderHookWithProviders( + () => useMembersCount('status:free,label:vip'), + { queryClient }, + ); + + await waitFor(() => { + expect(result.current.count).toBe(4289); + }); + + expect(result.current.isLoading).toBe(false); + + const url = new URL(mockFetch.calls[0][0].toString()); + expect(url.pathname).toBe('/ghost/api/admin/members/'); + expect(url.searchParams.get('filter')).toBe('status:free,label:vip'); + expect(url.searchParams.get('order')).toBe('id'); + expect(url.searchParams.get('limit')).toBe('1'); + expect(url.searchParams.get('page')).toBe('1'); + }, + ); + }); + + it('reports loading while the current user is unresolved', () => { + const queryClient = createTestQueryClient(); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => new Promise(() => {})) as typeof globalThis.fetch; + + try { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + expect(result.current).toEqual({ count: null, isLoading: true }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns a null count without fetching for roles that cannot manage members', async () => { + const queryClient = createQueryClientWithCurrentUser([{ id: 'role-1', name: 'Editor' }]); + + await withMockFetch({}, async (mockFetch) => { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toEqual({ count: null, isLoading: false }); + expect(mockFetch.calls).toHaveLength(0); + }); + }); + + it('counts a nullish filter as 0 without fetching', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch({}, async (mockFetch) => { + const { result } = renderHookWithProviders(() => useMembersCount(null), { queryClient }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current).toEqual({ count: 0, isLoading: false }); + expect(mockFetch.calls).toHaveLength(0); + }); + }); + + it('resolves request errors to a count of 0', async () => { + const queryClient = createQueryClientWithCurrentUser([ + { id: 'role-1', name: 'Administrator' }, + ]); + + await withMockFetch( + { json: { errors: [{ message: 'Nope' }] }, status: 500, ok: false }, + async () => { + const { result } = renderHookWithProviders(() => useMembersCount('status:free'), { + queryClient, + }); + + await waitFor(() => { + expect(result.current).toEqual({ count: 0, isLoading: false }); + }); + }, + ); + }); + }); + + describe('membersCountString', () => { + it('pluralizes counts per recipient type', () => { + expect(membersCountString('status:free', { count: 1 })).toBe('1 free member'); + expect(membersCountString('status:free', { count: 0 })).toBe('0 free members'); + expect(membersCountString('status:-free', { count: 5 })).toBe('5 paid members'); + expect(membersCountString('status:free,status:-free', { count: 2 })).toBe('2 members'); + expect(membersCountString('label:vip', { count: 3 })).toBe('3 members'); + expect(membersCountString('status:free,label:vip', { count: 3 })).toBe('3 members'); + }); + + it('formats large counts with the locale separator', () => { + expect(membersCountString('status:free,status:-free', { count: 12345 })).toBe( + `${(12345).toLocaleString()} members`, + ); + }); + + it('falls back to descriptive copy when no count was fetched', () => { + expect(membersCountString('status:free')).toBe('all free members'); + expect(membersCountString('status:-free')).toBe('all paid members'); + expect(membersCountString('status:free,status:-free')).toBe('all members'); + expect(membersCountString('')).toBe('all members'); + expect(membersCountString('label:vip')).toBe('a custom members segment'); + }); + + it('falls back to descriptive copy for a null count from useMembersCount', () => { + // null is useMembersCount's "role cannot browse members" value; it must + // never render as a zero count. + expect(membersCountString('status:free', { count: null })).toBe('all free members'); + expect(membersCountString('label:vip', { count: null })).toBe('a custom members segment'); + }); + + it('switches to subscriber copy and strips the newsletter scope when there are multiple newsletters', () => { + const newsletter = { + name: 'Weekly', + recipientFilter: 'newsletters.slug:weekly+email_disabled:0', + }; + const fullFilter = `${newsletter.recipientFilter}+(status:free)`; + + expect( + membersCountString(fullFilter, { + newsletter, + hasMultipleNewsletters: true, + }), + ).toBe('all free subscribers of Weekly'); + + expect( + membersCountString(fullFilter, { + count: 9, + newsletter, + hasMultipleNewsletters: true, + }), + ).toBe('9 free subscribers of Weekly'); + }); + + it('keeps member copy for a single newsletter', () => { + const newsletter = { + name: 'Weekly', + recipientFilter: 'newsletters.slug:weekly+email_disabled:0', + }; + const fullFilter = `${newsletter.recipientFilter}+(status:free)`; + + expect( + membersCountString(fullFilter, { + count: 9, + newsletter, + hasMultipleNewsletters: false, + }), + ).toBe('9 free members'); + }); + }); + it('syncs the sidebar member count from an unfiltered members list query', async () => { const queryClient = createQueryClientWithCurrentUser(); const memberDetailKey = [ diff --git a/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts new file mode 100644 index 00000000000..9c43f62e8e3 --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/recipient-filter.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { + EVERYONE_RECIPIENT_FILTER, + FREE_SEGMENT, + PAID_SEGMENT, + buildRecipientFilter, + getFullRecipientFilter, + getNewsletterRecipientFilter, + getRecipientType, + parseRecipientFilter, +} from '../../../src/utils/recipient-filter'; + +describe('recipient-filter', () => { + describe('parseRecipientFilter', () => { + it('returns empty segments for null, undefined and empty filters', () => { + for (const filter of [null, undefined, '']) { + expect(parseRecipientFilter(filter)).toEqual({ + free: false, + paid: false, + base: [], + specific: [], + }); + } + }); + + it('parses the base segments into checkbox state', () => { + expect(parseRecipientFilter(FREE_SEGMENT)).toEqual({ + free: true, + paid: false, + base: [FREE_SEGMENT], + specific: [], + }); + + expect(parseRecipientFilter(PAID_SEGMENT)).toEqual({ + free: false, + paid: true, + base: [PAID_SEGMENT], + specific: [], + }); + + expect(parseRecipientFilter(EVERYONE_RECIPIENT_FILTER)).toEqual({ + free: true, + paid: true, + base: [FREE_SEGMENT, PAID_SEGMENT], + specific: [], + }); + }); + + it('splits base and specific segments preserving order', () => { + expect( + parseRecipientFilter('label:vip,status:free,tier:gold,status:-free,label:beta'), + ).toEqual({ + free: true, + paid: true, + base: [FREE_SEGMENT, PAID_SEGMENT], + specific: ['label:vip', 'tier:gold', 'label:beta'], + }); + }); + + it('drops blank segments and deduplicates', () => { + expect(parseRecipientFilter('label:vip,, ,status:free,label:vip,status:free')).toEqual({ + free: true, + paid: false, + base: [FREE_SEGMENT], + specific: ['label:vip'], + }); + }); + + it('classifies padded base segments as base without checking the checkbox, like Ember', () => { + // gh-members-recipient-select trims for base classification but tests + // checkbox state against the raw segment. + expect(parseRecipientFilter(' status:free,label:vip')).toEqual({ + free: false, + paid: false, + base: [' status:free'], + specific: ['label:vip'], + }); + }); + }); + + describe('buildRecipientFilter', () => { + it('round-trips every recipient type', () => { + const filters = [ + FREE_SEGMENT, + PAID_SEGMENT, + EVERYONE_RECIPIENT_FILTER, + 'label:vip', + 'tier:gold', + 'status:free,label:vip,tier:gold', + 'status:free,status:-free,label:vip,label:beta,tier:gold,tier:silver', + ' status:free,label:vip', + ]; + + for (const filter of filters) { + expect(buildRecipientFilter(parseRecipientFilter(filter))).toBe(filter); + } + }); + + it('rebuilds with base segments first, like the Ember select', () => { + expect(buildRecipientFilter(parseRecipientFilter('label:vip,status:free'))).toBe( + 'status:free,label:vip', + ); + }); + + it('returns null for an empty selection', () => { + expect(buildRecipientFilter({ base: [], specific: [] })).toBeNull(); + expect(buildRecipientFilter(parseRecipientFilter(null))).toBeNull(); + }); + + it('drops the paid segment when paid is unavailable', () => { + expect( + buildRecipientFilter(parseRecipientFilter(EVERYONE_RECIPIENT_FILTER), { + paidAvailable: false, + }), + ).toBe(FREE_SEGMENT); + + expect( + buildRecipientFilter(parseRecipientFilter(PAID_SEGMENT), { paidAvailable: false }), + ).toBeNull(); + }); + }); + + describe('getRecipientType', () => { + it('classifies each filter shape', () => { + expect(getRecipientType(null)).toBe('none'); + expect(getRecipientType(undefined)).toBe('none'); + expect(getRecipientType('')).toBe('none'); + expect(getRecipientType(FREE_SEGMENT)).toBe('free'); + expect(getRecipientType(PAID_SEGMENT)).toBe('paid'); + expect(getRecipientType(EVERYONE_RECIPIENT_FILTER)).toBe('all'); + expect(getRecipientType('label:vip')).toBe('specific'); + expect(getRecipientType('label:vip,tier:gold')).toBe('specific'); + expect(getRecipientType('status:free,label:vip')).toBe('specific'); + expect(getRecipientType('status:-free,tier:gold')).toBe('specific'); + }); + + it('classifies both base segments as all even alongside specific segments', () => { + // Substring semantics from the Ember publish flow. + expect(getRecipientType('status:free,status:-free,label:vip')).toBe('all'); + expect(getRecipientType('label:vip,status:free,status:-free')).toBe('all'); + }); + }); + + describe('getNewsletterRecipientFilter', () => { + it('scopes to newsletter subscribers with email enabled', () => { + expect(getNewsletterRecipientFilter({ slug: 'weekly' })).toBe( + 'newsletters.slug:weekly+email_disabled:0', + ); + }); + + it('adds the paid segment for paid-visibility newsletters', () => { + expect(getNewsletterRecipientFilter({ slug: 'weekly', visibility: 'paid' })).toBe( + 'newsletters.slug:weekly+email_disabled:0+status:-free', + ); + expect(getNewsletterRecipientFilter({ slug: 'weekly', visibility: 'members' })).toBe( + 'newsletters.slug:weekly+email_disabled:0', + ); + }); + }); + + describe('getFullRecipientFilter', () => { + const newsletterFilter = 'newsletters.slug:weekly+email_disabled:0'; + + it('returns the newsletter filter alone when there is no recipient filter', () => { + expect(getFullRecipientFilter(newsletterFilter, null)).toBe(newsletterFilter); + expect(getFullRecipientFilter(newsletterFilter, undefined)).toBe(newsletterFilter); + expect(getFullRecipientFilter(newsletterFilter, '')).toBe(newsletterFilter); + }); + + it('ANDs the recipient filter onto the newsletter filter', () => { + expect(getFullRecipientFilter(newsletterFilter, EVERYONE_RECIPIENT_FILTER)).toBe( + 'newsletters.slug:weekly+email_disabled:0+(status:free,status:-free)', + ); + expect(getFullRecipientFilter(newsletterFilter, 'label:vip,tier:gold')).toBe( + 'newsletters.slug:weekly+email_disabled:0+(label:vip,tier:gold)', + ); + }); + }); +}); diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts new file mode 100644 index 00000000000..03f6af18683 --- /dev/null +++ b/apps/admin/src/editor/card-config.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@tryghost/admin-x-framework/api/config'; +import type { Setting } from '@tryghost/admin-x-framework/api/settings'; +import type { SiteData } from '@tryghost/admin-x-framework/api/site'; +import { + type PostCardConfigPorts, + type PostCardConfigSources, + buildCardConfigPost, + buildPostCardConfig, + getCardVisibilitySettings, +} from './card-config'; + +const settingsFrom = (values: Record): Setting[] => + Object.entries(values).map(([key, value]) => ({ key, value })); + +const baseSettings = { + title: 'Test Site', + description: 'Thoughts, stories and ideas.', + unsplash: true, + transistor: false, + members_signup_access: 'all', + stripe_connect_publishable_key: null, + stripe_connect_secret_key: null, + stripe_secret_key: null, + stripe_publishable_key: null, +}; + +const config = { stripeDirect: false, klipy: { apiKey: null }, labs: {} } as Config; +const site = { url: 'https://example.com/blog', site_uuid: 'site-uuid' } as SiteData; +const owner = { roles: [{ name: 'Owner' }] } as const; +const contributor = { roles: [{ name: 'Contributor' }] } as const; +const unsplashHeaders = { Authorization: 'Client-ID test', 'X-Unsplash-Cache': true }; + +const ports: PostCardConfigPorts = { + fetchEmbed: vi.fn(), + fetchAutocompleteLinks: vi.fn(), + searchLinks: vi.fn(), + fetchLabels: vi.fn(), + createSnippet: vi.fn(), + deleteSnippet: vi.fn(), +}; + +const sources = (overrides: Partial = {}): PostCardConfigSources => ({ + settings: settingsFrom(baseSettings), + config, + site, + currentUser: owner, + unsplashHeaders, + pinturaConfig: null, + post: buildCardConfigPost({ displayName: 'post', visibility: 'members' }, 'public'), + snippets: [], + ...overrides, +}); + +describe('buildCardConfigPost', () => { + it('returns undefined without a post', () => { + expect(buildCardConfigPost(undefined, 'public')).toBeUndefined(); + }); + + it('narrows the post to the fields cards read', () => { + expect( + buildCardConfigPost( + { displayName: 'page', showTitleAndFeatureImage: false, visibility: 'paid' }, + 'public', + ), + ).toEqual({ + displayName: 'page', + isPage: true, + showTitleAndFeatureImage: false, + visibility: 'paid', + }); + }); + + it('falls back to the site default visibility for an unsaved post', () => { + expect(buildCardConfigPost({ displayName: 'post' }, 'members')).toEqual({ + displayName: 'post', + isPage: false, + showTitleAndFeatureImage: true, + visibility: 'members', + }); + }); +}); + +describe('getCardVisibilitySettings', () => { + it('restricts pages to web-only visibility', () => { + expect(getCardVisibilitySettings({ isPage: true, displayName: 'page' })).toBe('web only'); + expect(getCardVisibilitySettings({ isPage: false, displayName: 'post' })).toBe('web and email'); + expect(getCardVisibilitySettings(undefined)).toBe('web and email'); + }); +}); + +describe('buildPostCardConfig', () => { + it('assembles the Ember editor card config', () => { + const cardConfig = buildPostCardConfig(sources(), ports); + + expect(cardConfig).toMatchObject({ + unsplash: unsplashHeaders, + klipy: null, + pinturaConfig: null, + renderLabels: true, + feature: { transistor: false, paywallImprovements: false }, + deprecated: { headerV1: true }, + membersEnabled: true, + siteTitle: 'Test Site', + siteDescription: 'Thoughts, stories and ideas.', + siteUrl: 'https://example.com/blog/', + siteUuid: 'site-uuid', + stripeEnabled: false, + visibilitySettings: 'web and email', + post: { displayName: 'post', isPage: false, visibility: 'members' }, + snippets: [], + }); + expect(cardConfig.fetchEmbed).toBe(ports.fetchEmbed); + expect(cardConfig.fetchAutocompleteLinks).toBe(ports.fetchAutocompleteLinks); + expect(cardConfig.searchLinks).toBe(ports.searchLinks); + expect(cardConfig.fetchLabels).toBe(ports.fetchLabels); + expect(cardConfig.createSnippet).toBe(ports.createSnippet); + expect(cardConfig.deleteSnippet).toBe(ports.deleteSnippet); + }); + + it('drops Unsplash when the integration is off', () => { + const cardConfig = buildPostCardConfig( + sources({ settings: settingsFrom({ ...baseSettings, unsplash: false }) }), + ports, + ); + + expect(cardConfig.unsplash).toBeNull(); + }); + + it('passes Klipy through only when an API key is configured', () => { + const klipy = { apiKey: 'key', contentFilter: 'off' }; + const cardConfig = buildPostCardConfig(sources({ config: { ...config, klipy } }), ports); + + expect(cardConfig.klipy).toEqual(klipy); + }); + + it('hides labels from contributors', () => { + const cardConfig = buildPostCardConfig(sources({ currentUser: contributor }), ports); + + expect(cardConfig.renderLabels).toBe(false); + }); + + it('reads feature flags from settings and labs', () => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ ...baseSettings, transistor: true }), + config: { ...config, labs: { paywallImprovements: true } }, + }), + ports, + ); + + expect(cardConfig.feature).toEqual({ transistor: true, paywallImprovements: true }); + }); + + it('treats invite-only member signup as members disabled', () => { + const cardConfig = buildPostCardConfig( + sources({ settings: settingsFrom({ ...baseSettings, members_signup_access: 'invite' }) }), + ports, + ); + + expect(cardConfig.membersEnabled).toBe(false); + }); + + it('reports Stripe as enabled from connect keys', () => { + const cardConfig = buildPostCardConfig( + sources({ + settings: settingsFrom({ + ...baseSettings, + stripe_connect_publishable_key: 'pk', + stripe_connect_secret_key: 'sk', + }), + }), + ports, + ); + + expect(cardConfig.stripeEnabled).toBe(true); + }); + + it('limits pages to web-only card visibility', () => { + const cardConfig = buildPostCardConfig( + sources({ post: buildCardConfigPost({ displayName: 'page' }, 'public') }), + ports, + ); + + expect(cardConfig.visibilitySettings).toBe('web only'); + }); + + it('passes pintura and snippets through untouched', () => { + const pinturaConfig = { jsUrl: 'https://cdn/pintura.js', cssUrl: 'https://cdn/pintura.css' }; + const snippets = [{ id: '1', name: 'Sign-off', value: '{"root":{}}' }]; + const cardConfig = buildPostCardConfig(sources({ pinturaConfig, snippets }), ports); + + expect(cardConfig.pinturaConfig).toBe(pinturaConfig); + expect(cardConfig.snippets).toBe(snippets); + }); +}); diff --git a/apps/admin/src/editor/card-config.ts b/apps/admin/src/editor/card-config.ts new file mode 100644 index 00000000000..852f7850c3a --- /dev/null +++ b/apps/admin/src/editor/card-config.ts @@ -0,0 +1,136 @@ +import { type Config } from '@tryghost/admin-x-framework/api/config'; +import { + type Setting, + checkStripeEnabled, + getSettingValue, +} from '@tryghost/admin-x-framework/api/settings'; +import { type SiteData, getHomepageUrl } from '@tryghost/admin-x-framework/api/site'; +import { isContributorUser } from '@tryghost/admin-x-framework/api/users'; +import type { AutocompleteLink, LinkSearchGroup } from './link-suggestions'; + +export type PostType = 'post' | 'page'; + +export interface CardConfigPost { + displayName: PostType; + isPage: boolean; + showTitleAndFeatureImage: boolean; + visibility: string; +} + +export interface CardConfigPostSource { + displayName: PostType; + showTitleAndFeatureImage?: boolean; + visibility?: string | null; +} + +export interface CardConfigSnippet { + id: string; + name: string; + value: string; +} + +export interface CardConfigSnippetInput { + name: string; + value: string; +} + +export interface PostCardConfigSources { + settings: Setting[]; + config: Config; + site: SiteData; + currentUser: Parameters[0]; + unsplashHeaders: Record; + pinturaConfig: { jsUrl: string; cssUrl: string } | null; + post: CardConfigPost | undefined; + snippets: CardConfigSnippet[]; +} + +export interface PostCardConfigPorts { + fetchEmbed: (url: string, options: { type?: string }) => Promise; + fetchAutocompleteLinks: () => Promise; + searchLinks: (term?: string) => Promise; + fetchLabels: () => Promise; + createSnippet?: (snippet: CardConfigSnippetInput) => void; + deleteSnippet?: (snippet: { name: string }) => void; +} + +export type CardVisibilitySettings = 'web only' | 'web and email'; + +export interface PostCardConfig extends PostCardConfigPorts { + unsplash: Record | null; + klipy: NonNullable | null; + pinturaConfig: { jsUrl: string; cssUrl: string } | null; + renderLabels: boolean; + feature: { transistor: boolean; paywallImprovements: boolean }; + deprecated: { headerV1: boolean }; + membersEnabled: boolean; + siteTitle: string; + siteDescription: string; + siteUrl: string; + siteUuid: string; + stripeEnabled: boolean; + post: CardConfigPost | undefined; + snippets: CardConfigSnippet[]; + visibilitySettings: CardVisibilitySettings; +} + +// An unsaved post has no visibility until the first save applies the site +// default, so it is resolved here to keep `visibility` present for cards. +export function buildCardConfigPost( + post: CardConfigPostSource | undefined, + defaultContentVisibility: string, +): CardConfigPost | undefined { + if (!post) { + return undefined; + } + + return { + displayName: post.displayName, + isPage: post.displayName === 'page', + showTitleAndFeatureImage: post.showTitleAndFeatureImage ?? true, + visibility: post.visibility || defaultContentVisibility, + }; +} + +export function getCardVisibilitySettings( + post: Pick | undefined, +): CardVisibilitySettings { + const isPage = post?.isPage || post?.displayName === 'page'; + return isPage ? 'web only' : 'web and email'; +} + +export function buildPostCardConfig( + sources: PostCardConfigSources, + ports: PostCardConfigPorts, +): PostCardConfig { + const { settings, config, site, currentUser } = sources; + + return { + unsplash: getSettingValue(settings, 'unsplash') ? sources.unsplashHeaders : null, + klipy: config.klipy?.apiKey ? config.klipy : null, + pinturaConfig: sources.pinturaConfig, + fetchAutocompleteLinks: ports.fetchAutocompleteLinks, + fetchEmbed: ports.fetchEmbed, + fetchLabels: ports.fetchLabels, + renderLabels: !isContributorUser(currentUser), + feature: { + transistor: getSettingValue(settings, 'transistor') === true, + paywallImprovements: config.labs?.paywallImprovements === true, + }, + deprecated: { + headerV1: true, + }, + membersEnabled: getSettingValue(settings, 'members_signup_access') === 'all', + searchLinks: ports.searchLinks, + siteTitle: getSettingValue(settings, 'title') ?? '', + siteDescription: getSettingValue(settings, 'description') ?? '', + siteUrl: getHomepageUrl(site), + siteUuid: site.site_uuid, + stripeEnabled: checkStripeEnabled(settings, config), + post: sources.post, + snippets: sources.snippets, + createSnippet: ports.createSnippet, + deleteSnippet: ports.deleteSnippet, + visibilitySettings: getCardVisibilitySettings(sources.post), + }; +} diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 34571c94ef6..cf1f5436ef3 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -1,33 +1,263 @@ +import { useCallback, useEffect, useState } from 'react'; import { AdminLink } from '@/shared/admin-link'; -import { useParams } from '@tryghost/admin-x-framework'; -import { Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; - -/** - * Placeholder for the React editor, served behind the `editorReact` Labs - * flag. It only proves the EditorGate cutover seam end to end; the editor - * itself still lives in Ember while the flag is off. - */ -export default function EditorScreen() { - const editorPath = useParams()['*']; - const isPage = editorPath?.split('/')[0] === 'page'; - const listPath = isPage ? '/pages' : '/posts'; - const listLabel = isPage ? 'Back to pages' : 'Back to posts'; +import { NotFound } from '@/shared/not-found'; +import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework'; +import { Button, LoadingIndicator } from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; +import { APIError } from '@tryghost/admin-x-framework/errors'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { + type PageEditorRecord, + useEditPage, + useEditorPage, +} from '@tryghost/admin-x-framework/api/pages'; +import { + type PostEditorRecord, + useEditPost, + useEditorPost, +} from '@tryghost/admin-x-framework/api/posts'; +import { + type User, + isAdminUser, + isAuthorOrContributor, + isContributorUser, + isEditorUser, + isOwnerUser, +} from '@tryghost/admin-x-framework/api/users'; +import type { CardConfigPostSource, PostType } from './card-config'; +import { PostEditor } from './post-editor'; +import { usePostCardConfig } from './use-post-card-config'; +import { usePostSnippets } from './use-post-snippets'; + +type EditorRecord = PostEditorRecord | PageEditorRecord; + +function EditorLoading() { + return ( + + + + ); +} + +function EditorLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + {message} + + + ); +} + +function EditorHeader({ postType }: { postType: PostType }) { + const listLabel = postType === 'page' ? 'Pages' : 'Posts'; return ( - - - The React editor is under construction. Turn off the “React editor” flag in Labs to use the - editor. - - + + ); +} + +function EditorSurface({ postType, record }: { postType: PostType; record?: EditorRecord }) { + const { data: currentUser } = useCurrentUser(); + const showExcerpt = useFeatureFlag('editorExcerpt'); + const [title, setTitle] = useState(() => + record?.title === '(Untitled)' ? '' : (record?.title ?? ''), + ); + const [excerpt, setExcerpt] = useState(() => record?.custom_excerpt ?? ''); + + const canManageSnippets = + !!currentUser && + (isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser)); + const { snippets, createSnippet, deleteSnippet, snippetDialog } = usePostSnippets({ + canManage: canManageSnippets, + }); + + const [cardConfigPost] = useState(() => ({ + displayName: postType, + showTitleAndFeatureImage: + record && 'show_title_and_feature_image' in record + ? record.show_title_and_feature_image + : undefined, + visibility: record?.visibility, + })); + const cardConfig = usePostCardConfig({ + post: cardConfigPost, + snippets, + createSnippet, + deleteSnippet, + }); + + if (!cardConfig) { + return ; + } + + return ( + + +
+ +
+ {snippetDialog}
); } + +// The API returns posts the user cannot edit, so authorship is checked here +function shouldReturnToList(user: User, record: EditorRecord): boolean { + const isAuthored = record.authors?.some((author) => author.id === user.id) ?? false; + + if (isAuthorOrContributor(user) && !isAuthored) { + return true; + } + + return isContributorUser(user) && record.status !== 'draft'; +} + +interface ConversionState { + id: string; + record?: EditorRecord; + error?: unknown; +} + +// Mobiledoc content is converted server-side before the editor opens it +function useLexicalConversion(postType: PostType) { + const { mutateAsync: editPost } = useEditPost(); + const { mutateAsync: editPage } = useEditPage(); + const [state, setState] = useState(null); + + const convert = useCallback( + async (source: EditorRecord) => { + const payload = { id: source.id, updated_at: source.updated_at }; + const options = { convertToLexical: true }; + setState({ id: source.id }); + + try { + const record: EditorRecord | undefined = + postType === 'page' + ? (await editPage({ page: payload, options })).pages[0] + : (await editPost({ post: payload, options })).posts[0]; + setState(record ? { id: source.id, record } : { id: source.id, error: true }); + } catch (error) { + setState({ id: source.id, error }); + } + }, + [editPage, editPost, postType], + ); + + return { state, convert }; +} + +function ExistingPostEditor({ postType, id }: { postType: PostType; id: string }) { + const navigate = useNavigate(); + const { data: currentUser } = useCurrentUser(); + const postQuery = useEditorPost(id, { + enabled: postType === 'post', + defaultErrorHandler: false, + }); + const pageQuery = useEditorPage(id, { + enabled: postType === 'page', + defaultErrorHandler: false, + }); + const query = postType === 'page' ? pageQuery : postQuery; + const loaded: EditorRecord | undefined = + postType === 'page' ? pageQuery.data?.pages[0] : postQuery.data?.posts[0]; + const { state: conversion, convert } = useLexicalConversion(postType); + const listPath = postType === 'page' ? '/pages' : '/posts'; + + const returnToList = !!currentUser && !!loaded && shouldReturnToList(currentUser, loaded); + useEffect(() => { + if (returnToList) { + navigate(listPath, { replace: true }); + } + }, [returnToList, navigate, listPath]); + + const needsConversion = !!currentUser && !!loaded?.mobiledoc && !loaded.lexical && !returnToList; + useEffect(() => { + if (needsConversion && loaded && conversion?.id !== loaded.id) { + void convert(loaded); + } + }, [needsConversion, loaded, conversion?.id, convert]); + + const notFound = query.error instanceof APIError && query.error.response?.status === 404; + if (notFound) { + return ; + } + + if (query.error) { + return ( + void query.refetch()} + /> + ); + } + + if (query.isPending || !currentUser || returnToList) { + return ; + } + + if (!loaded) { + return ; + } + + let record = loaded; + if (needsConversion) { + const converted = conversion?.id === loaded.id ? conversion : undefined; + + if (converted?.error) { + return ( + void convert(loaded)} + /> + ); + } + + if (!converted?.record) { + return ; + } + + record = converted.record; + } + + return ; +} + +export default function EditorScreen() { + const editorPath = useParams()['*'] ?? ''; + const [typeSegment, id, ...rest] = editorPath.split('/').filter(Boolean); + + if (!typeSegment) { + return ; + } + + if ((typeSegment !== 'post' && typeSegment !== 'page') || rest.length > 0) { + return ; + } + + if (id) { + return ; + } + + return ; +} diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx index b2c6cefa0a3..04b392fc7ea 100644 --- a/apps/admin/src/editor/editor.acceptance.test.tsx +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -1,58 +1,68 @@ import { describe, expect, it } from 'vitest'; -import { page } from 'vitest/browser'; -import { renderAdminApp } from '@test-utils/acceptance'; +import { + fakeAdminEndpoint, + fakePosts, + fakeSnippets, + post, + renderAdminApp, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; const FLAG_ON = { labs: { editorReact: true } }; const FLAG_OFF = { labs: { editorReact: false } }; /** * Proves the `editorReact` flag swap end-to-end in the real admin app: the - * React placeholder appears only when the flag is on, and the Ember side of - * the URL is delegated to otherwise. + * React editor appears only when the flag is on, and the Ember side of the + * URL is delegated to otherwise. * * There is no Ember app in this harness, so "Ember serves it" shows up as the - * React placeholder being absent rather than as an Ember editor being present - * — the Ember half of the handshake (the lexical-editor route aborting its + * React editor being absent rather than as an Ember editor being present — + * the Ember half of the handshake (the lexical-editor route aborting its * transition) is covered in * apps/ember-admin/tests/acceptance/editor-react-flag-test.js. */ describe('Editor flag', () => { - const placeholder = () => page.getByTestId('editor-react-placeholder'); + function fakeEditorWorld() { + fakeSnippets([]); + fakePosts([]); + fakeAdminEndpoint('GET', /^\/posts\/abc123\/\?/, { posts: [post({ id: 'abc123' })] }); + fakeAdminEndpoint('GET', /^\/pages\/abc123\/\?/, { pages: [post({ id: 'abc123' })] }); + } - it('renders the React placeholder when the flag is on', async () => { + it('renders the React editor when the flag is on', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/post/abc123', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); - await expect - .element(page.getByRole('link', { name: 'Back to posts' })) - .toHaveAttribute('href', '#/posts'); + await expect.element(editorScreen.root()).toBeVisible(); + await expect.element(editorScreen.backLink('post')).toHaveAttribute('href', '#/posts'); }); it('serves a new-post URL too', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/post', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); + await expect.element(editorScreen.root()).toBeVisible(); }); it('returns page editors to the pages list', async () => { + fakeEditorWorld(); await renderAdminApp('/editor/page/abc123', FLAG_ON); - await expect.element(placeholder()).toBeVisible(); - await expect - .element(page.getByRole('link', { name: 'Back to pages' })) - .toHaveAttribute('href', '#/pages'); + await expect.element(editorScreen.root()).toBeVisible(); + await expect.element(editorScreen.backLink('page')).toHaveAttribute('href', '#/pages'); }); it('defers to Ember when the flag is off', async () => { await renderAdminApp('/editor/post/abc123', FLAG_OFF); - await expect(placeholder()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); it('defers to Ember when the flag is absent entirely', async () => { await renderAdminApp('/editor/post/abc123'); - await expect(placeholder()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); }); diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts new file mode 100644 index 00000000000..28c0201c3cf --- /dev/null +++ b/apps/admin/src/editor/editor.screen.ts @@ -0,0 +1,36 @@ +import { page } from 'vitest/browser'; +import { + editorBody, + editorExcerptInput, + editorLoadError, + editorSecondaryInstance, + editorTitleInput, + editorWordCount, + pagesBackLink, + postEditor, + postsBackLink, + tkIndicator, +} from '@tryghost/test-data/selectors/editor'; + +/** Editor screen locators and gestures for acceptance specs; no assertions. */ +export const editorScreen = { + root: () => page.getByTestId(postEditor), + titleInput: () => page.getByTestId(editorTitleInput), + excerptInput: () => page.getByTestId(editorExcerptInput), + /** The primary Koenig content editable. */ + body: () => page.getByTestId(editorBody).getByRole('textbox'), + secondaryInstance: () => page.getByTestId(editorSecondaryInstance), + wordCount: () => page.getByTestId(editorWordCount), + loadError: () => page.getByTestId(editorLoadError), + notFound: () => page.getByRole('heading', { name: 'Page not found' }), + titleTkIndicator: () => page.getByTestId(tkIndicator), + backLink: (postType: 'post' | 'page') => + page.getByRole('link', { + name: postType === 'page' ? pagesBackLink : postsBackLink, + exact: true, + }), + /** Whether keyboard focus is inside the primary Koenig body. */ + bodyHasFocus: (): boolean => + document.querySelector(`[data-testid="${editorBody}"]`)?.contains(document.activeElement) ?? + false, +}; diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx new file mode 100644 index 00000000000..da8c0438d83 --- /dev/null +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -0,0 +1,122 @@ +import * as Sentry from '@sentry/react'; +import { Suspense, useCallback, useMemo } from 'react'; +import { LoadingIndicator } from '@tryghost/shade/components'; +import { koenigFileUploadTypes, useKoenigFileUpload } from '@tryghost/admin-x-framework/hooks'; +import ErrorBoundary from '@/settings/components/error-boundary'; +import { + type EditorResource, + type KoenigInstance, + loadKoenig, +} from '@/settings/components/koenig-loader'; +import type { PostCardConfig } from './card-config'; + +const fileUploader = { + useFileUpload: useKoenigFileUpload, + fileTypes: koenigFileUploadTypes, +}; + +const NOOP = () => {}; + +export interface KoenigPostEditorProps { + initialLexical: string | null; + placeholder: string; + cardConfig: PostCardConfig; + darkMode: boolean; + cursorDidExitAtTop?: () => void; + onChange?: (lexical: unknown) => void; + onSecondaryChange?: (lexical: unknown) => void; + registerAPI: (api: KoenigInstance | null) => void; + registerSecondaryAPI: (api: KoenigInstance | null) => void; + onWordCountChange: (count: number) => void; + onTkCountChange: (count: number) => void; +} + +interface KoenigInstanceMountProps extends KoenigPostEditorProps { + editor: EditorResource; + isSecondary: boolean; + onError: (error: unknown) => void; +} + +// The hidden secondary instance loads the same initial state: Koenig normalises +// documents on load, so its output is the baseline change detection compares against +function KoenigInstanceMount({ + editor, + isSecondary, + onError, + initialLexical, + placeholder, + cardConfig, + darkMode, + cursorDidExitAtTop, + onChange, + onSecondaryChange, + registerAPI, + registerSecondaryAPI, + onWordCountChange, + onTkCountChange, +}: KoenigInstanceMountProps) { + const { KoenigComposer, KoenigEditor, WordCountPlugin, TKCountPlugin } = editor.read(); + + return ( + + ); +} + +export function KoenigPostEditor(props: KoenigPostEditorProps) { + const editor = useMemo(() => loadKoenig(), []); + + const onError = useCallback((error: unknown) => { + // eslint-disable-next-line no-console + console.error(error); + + Sentry.captureException(error, { + tags: { lexical: true }, + contexts: { + koenig: { + version: window['@tryghost/koenig-lexical']?.version, + }, + }, + }); + // not rethrown: Lexical attempts to recover without losing user data + }, []); + + return ( +
+ + + +
+ } + > + + + + + + ); +} diff --git a/apps/admin/src/editor/link-suggestions.test.ts b/apps/admin/src/editor/link-suggestions.test.ts new file mode 100644 index 00000000000..6da0512bc5a --- /dev/null +++ b/apps/admin/src/editor/link-suggestions.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAutocompleteLinks, + buildLatestPostsGroup, + buildOfferLinks, + decoratePostSearchResult, + filterLinkSearchResults, + formatPublishedDate, + searchIndexEntitiesGroup, + searchIndexPostsGroup, +} from './link-suggestions'; + +const decoration = { timezone: 'Etc/UTC', membersEnabled: true }; + +describe('buildAutocompleteLinks', () => { + const settings = { + postType: 'post' as const, + homepageUrl: 'https://example.com/', + paidMembersEnabled: true, + donationsEnabled: true, + recommendationsEnabled: true, + }; + + it('lists every portal link in the Ember order', () => { + const offerLinks = buildOfferLinks( + [{ name: 'Spring sale', code: 'spring' }], + settings.homepageUrl, + ); + + expect(buildAutocompleteLinks(settings, offerLinks)).toEqual([ + { label: 'Homepage', value: 'https://example.com/' }, + { label: 'Free signup', value: '#/portal/signup/free' }, + { label: 'Paid signup', value: '#/portal/signup' }, + { label: 'Upgrade or change plan', value: '#/portal/account/plans' }, + { label: 'Tips and donations', value: '#/portal/support' }, + { label: 'Gift subscriptions', value: '#/portal/gift' }, + { label: 'Share post', value: '#/share' }, + { label: 'Recommendations', value: '#/portal/recommendations' }, + { label: 'Offer — Spring sale', value: 'https://example.com/spring' }, + ]); + }); + + it('drops paid, donation and recommendation links when those features are off', () => { + const links = buildAutocompleteLinks( + { + ...settings, + postType: 'page', + paidMembersEnabled: false, + donationsEnabled: false, + recommendationsEnabled: false, + }, + [], + ); + + expect(links).toEqual([ + { label: 'Homepage', value: 'https://example.com/' }, + { label: 'Free signup', value: '#/portal/signup/free' }, + { label: 'Share page', value: '#/share' }, + ]); + }); + + it('resolves offer codes against a subdirectory homepage', () => { + expect(buildOfferLinks([{ name: 'Sale', code: '/sale' }], 'https://example.com/blog/')).toEqual( + [{ label: 'Offer — Sale', value: 'https://example.com/blog/sale' }], + ); + }); +}); + +describe('formatPublishedDate', () => { + it('formats in the site timezone as day, short month, year', () => { + expect(formatPublishedDate('2026-03-01T23:30:00.000Z', 'Etc/UTC')).toBe('1 Mar 2026'); + expect(formatPublishedDate('2026-03-01T23:30:00.000Z', 'Pacific/Auckland')).toBe('2 Mar 2026'); + }); +}); + +describe('decoratePostSearchResult', () => { + it('adds the published date and a members-only marker', () => { + const item = decoratePostSearchResult( + { + id: 'post.1', + title: 'Hello', + url: '/hello/', + visibility: 'members', + publishedAt: '2026-01-05T10:00:00.000Z', + }, + decoration, + ); + + expect(item.metaText).toBe('5 Jan 2026'); + expect(item.metaIconTitle).toBe('Members only'); + expect(item.MetaIcon).toBeDefined(); + }); + + it.each([ + ['paid', 'Paid-members only'], + ['tiers', 'Specific tiers only'], + ])('marks %s content', (visibility, title) => { + const item = decoratePostSearchResult({ id: 'post.1', title: 'Hello', visibility }, decoration); + + expect(item.metaIconTitle).toBe(title); + }); + + it('adds no marker when members are disabled or content is public', () => { + expect( + decoratePostSearchResult( + { id: 'post.1', title: 'Hello', visibility: 'paid' }, + { ...decoration, membersEnabled: false }, + ).MetaIcon, + ).toBeUndefined(); + expect( + decoratePostSearchResult({ id: 'post.1', title: 'Hello', visibility: 'public' }, decoration) + .MetaIcon, + ).toBeUndefined(); + }); +}); + +describe('filterLinkSearchResults', () => { + it('keeps only linkable published content and decorates posts and pages', () => { + const groups = filterLinkSearchResults( + [ + { + groupName: 'Staff', + options: [ + { id: 'user.1', title: 'Ann', url: 'https://example.com/author/ann/' }, + { id: 'user.2', title: 'Bob', url: 'https://example.com/404/' }, + ], + }, + { groupName: 'Tags', options: [{ id: 'tag.1', title: 'News', url: null }] }, + { + groupName: 'Posts', + options: [ + { id: 'post.1', title: 'Draft', url: 'https://example.com/p/1/', status: 'draft' }, + { + id: 'post.2', + title: 'Live', + url: 'https://example.com/live/', + status: 'published', + visibility: 'paid', + publishedAt: '2026-02-02T00:00:00.000Z', + }, + ], + }, + { + groupName: 'Pages', + options: [ + { + id: 'page.1', + title: 'About', + url: 'https://example.com/about/', + status: 'published', + }, + ], + }, + ], + decoration, + ); + + expect(groups.map((group) => group.label)).toEqual(['Staff', 'Posts', 'Pages']); + expect(groups[0].items.map((item) => item.title)).toEqual(['Ann']); + expect(groups[1].items).toHaveLength(1); + expect(groups[1].items[0]).toMatchObject({ + title: 'Live', + metaText: '2 Feb 2026', + metaIconTitle: 'Paid-members only', + }); + expect(groups[2].items[0].title).toBe('About'); + }); +}); + +describe('buildLatestPostsGroup', () => { + it('wraps the latest posts in a decorated group', () => { + const groups = buildLatestPostsGroup( + [ + { + id: '1', + title: 'Latest', + url: 'https://example.com/latest/', + visibility: 'public', + published_at: '2026-04-10T00:00:00.000Z', + }, + ], + decoration, + ); + + expect(groups).toEqual([ + { + label: 'Latest posts', + items: [expect.objectContaining({ id: '1', title: 'Latest', metaText: '10 Apr 2026' })], + }, + ]); + }); +}); + +describe('search index groups', () => { + const posts = [ + { id: '1', title: 'Getting started', url: '/start/', status: 'published' }, + { id: '2', title: 'Other', url: '/other/', status: 'published' }, + ]; + + it('matches post titles case-insensitively', () => { + expect(searchIndexPostsGroup('Posts', posts, 'STARTED').options).toEqual([ + expect.objectContaining({ id: 'post.1', title: 'Getting started', url: '/start/' }), + ]); + }); + + it('matches staff and tag names', () => { + expect( + searchIndexEntitiesGroup('Staff', [{ id: 'u1', name: 'Ann Author', url: '/ann/' }], 'ann') + .options, + ).toEqual([{ id: 'user.u1', title: 'Ann Author', url: '/ann/' }]); + expect( + searchIndexEntitiesGroup('Tags', [{ id: 't1', name: 'News', url: '/tag/news/' }], 'ne') + .options, + ).toEqual([{ id: 'tag.t1', title: 'News', url: '/tag/news/' }]); + }); + + it('matches nothing for a blank term', () => { + expect(searchIndexPostsGroup('Pages', posts, ' ').options).toEqual([]); + }); +}); diff --git a/apps/admin/src/editor/link-suggestions.ts b/apps/admin/src/editor/link-suggestions.ts new file mode 100644 index 00000000000..a2ff477597a --- /dev/null +++ b/apps/admin/src/editor/link-suggestions.ts @@ -0,0 +1,259 @@ +import type { ComponentType } from 'react'; +import { LucideIcon } from '@tryghost/shade/utils'; +import type { PostType } from './card-config'; + +export interface AutocompleteLink { + label: string; + value: string; +} + +export interface AutocompleteLinkSettings { + postType: PostType; + homepageUrl: string; + paidMembersEnabled: boolean; + donationsEnabled: boolean; + recommendationsEnabled: boolean; +} + +export interface OfferLinkSource { + name: string; + code: string; +} + +export interface LinkSearchItem { + id: string; + title: string; + url?: string | null; + status?: string; + visibility?: string; + publishedAt?: string | null; + metaText?: string; + MetaIcon?: ComponentType<{ className?: string }>; + metaIconTitle?: string; +} + +export interface LinkSearchResultGroup { + groupName: string; + options: LinkSearchItem[]; +} + +export interface LinkSearchGroup { + label: string; + items: LinkSearchItem[]; +} + +export interface LinkDecorationSettings { + timezone: string; + membersEnabled: boolean; +} + +export interface LatestPostSource { + id: string; + title: string; + url?: string | null; + visibility?: string; + published_at?: string | null; +} + +export interface SearchIndexPost { + id: string; + title: string; + url?: string | null; + status?: string; + visibility?: string; + published_at?: string | null; +} + +export interface SearchIndexEntity { + id: string; + name: string; + url?: string | null; +} + +export function buildOfferLinks( + offers: OfferLinkSource[], + homepageUrl: string, +): AutocompleteLink[] { + return offers.map((offer) => ({ + label: `Offer — ${offer.name}`, + value: `${homepageUrl}${offer.code.replace(/^\//, '')}`, + })); +} + +export function buildAutocompleteLinks( + settings: AutocompleteLinkSettings, + offerLinks: AutocompleteLink[], +): AutocompleteLink[] { + const defaults = [ + { label: 'Homepage', value: settings.homepageUrl }, + { label: 'Free signup', value: '#/portal/signup/free' }, + ]; + + const shareLink = [{ label: `Share ${settings.postType}`, value: '#/share' }]; + + const memberLinks = settings.paidMembersEnabled + ? [ + { label: 'Paid signup', value: '#/portal/signup' }, + { label: 'Upgrade or change plan', value: '#/portal/account/plans' }, + ] + : []; + + const donationLink = settings.donationsEnabled + ? [{ label: 'Tips and donations', value: '#/portal/support' }] + : []; + + const recommendationLink = settings.recommendationsEnabled + ? [{ label: 'Recommendations', value: '#/portal/recommendations' }] + : []; + + const giftLink = settings.paidMembersEnabled + ? [{ label: 'Gift subscriptions', value: '#/portal/gift' }] + : []; + + return [ + ...defaults, + ...memberLinks, + ...donationLink, + ...giftLink, + ...shareLink, + ...recommendationLink, + ...offerLinks, + ]; +} + +// Ember renders `D MMM YYYY` in the site timezone +export function formatPublishedDate(publishedAt: string, timezone: string): string { + const parts = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: timezone, + }).formatToParts(new Date(publishedAt)); + const part = (type: Intl.DateTimeFormatPartTypes) => + parts.find((candidate) => candidate.type === type)?.value ?? ''; + + return `${part('day')} ${part('month')} ${part('year')}`; +} + +export function decoratePostSearchResult( + item: LinkSearchItem, + settings: LinkDecorationSettings, +): LinkSearchItem { + const decorated: LinkSearchItem = { ...item }; + + if (item.publishedAt) { + decorated.metaText = formatPublishedDate(item.publishedAt, settings.timezone); + } + + if (settings.membersEnabled && item.visibility) { + if (item.visibility === 'members') { + decorated.MetaIcon = LucideIcon.Lock; + decorated.metaIconTitle = 'Members only'; + } else if (item.visibility === 'paid') { + decorated.MetaIcon = LucideIcon.DollarSign; + decorated.metaIconTitle = 'Paid-members only'; + } else if (item.visibility === 'tiers') { + decorated.MetaIcon = LucideIcon.DollarSign; + decorated.metaIconTitle = 'Specific tiers only'; + } + } + + return decorated; +} + +export function filterLinkSearchResults( + results: LinkSearchResultGroup[], + settings: LinkDecorationSettings, +): LinkSearchGroup[] { + const filteredResults: LinkSearchGroup[] = []; + + results.forEach((group) => { + // only content with a public URL is linkable + let items = group.options.filter((item) => item.url); + + if (group.groupName === 'Posts' || group.groupName === 'Pages') { + items = items.filter((item) => item.status === 'published'); + } + + if (group.groupName === 'Staff') { + items = items.filter((item) => !/\/404\//.test(item.url ?? '')); + } + + if (items.length === 0) { + return; + } + + if (group.groupName === 'Posts' || group.groupName === 'Pages') { + items = items.map((item) => decoratePostSearchResult(item, settings)); + } + + filteredResults.push({ + label: group.groupName, + items, + }); + }); + + return filteredResults; +} + +export function buildLatestPostsGroup( + posts: LatestPostSource[], + settings: LinkDecorationSettings, +): LinkSearchGroup[] { + const items = posts.map((post) => + decoratePostSearchResult( + { + id: post.id, + title: post.title, + url: post.url, + visibility: post.visibility, + publishedAt: post.published_at, + }, + settings, + ), + ); + + return [{ label: 'Latest posts', items }]; +} + +export function searchIndexPostsGroup( + groupName: 'Posts' | 'Pages', + entries: SearchIndexPost[], + term: string, +): LinkSearchResultGroup { + return { + groupName, + options: matchTerm(entries, term, (entry) => entry.title).map((entry) => ({ + id: `${groupName === 'Posts' ? 'post' : 'page'}.${entry.id}`, + title: entry.title, + url: entry.url, + status: entry.status, + visibility: entry.visibility, + publishedAt: entry.published_at, + })), + }; +} + +export function searchIndexEntitiesGroup( + groupName: 'Staff' | 'Tags', + entries: SearchIndexEntity[], + term: string, +): LinkSearchResultGroup { + return { + groupName, + options: matchTerm(entries, term, (entry) => entry.name).map((entry) => ({ + id: `${groupName === 'Staff' ? 'user' : 'tag'}.${entry.id}`, + title: entry.name, + url: entry.url, + })), + }; +} + +function matchTerm(entries: Entry[], term: string, text: (entry: Entry) => string) { + const needle = term.trim().toLowerCase(); + if (!needle) { + return []; + } + + return entries.filter((entry) => text(entry).toLowerCase().includes(needle)); +} diff --git a/apps/admin/src/editor/post-editor.acceptance.test.tsx b/apps/admin/src/editor/post-editor.acceptance.test.tsx new file mode 100644 index 00000000000..89079e9ccf2 --- /dev/null +++ b/apps/admin/src/editor/post-editor.acceptance.test.tsx @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + currentRoute, + currentUserResponse, + fakeAdminEndpoint, + fakePosts, + fakeSnippets, + post, + renderAdminApp, + staffRole, + type RenderAdminAppOptions, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; + +const POST_ID = 'abc123'; +const FLAG_ON = { labs: { editorReact: true } }; +const CURRENT_USER_ID = '1'; + +const MOBILEDOC = + '{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Legacy"]]]]}'; + +// Every editor mount browses snippets for the card menu and, through Koenig's +// link toolbar preload, the five latest published posts. +function fakeEditorChrome() { + fakeSnippets([]); + return fakePosts([]); +} + +function fakeEditorPost(overrides: Partial> = {}) { + fakeEditorChrome(); + return fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [ + post({ + id: POST_ID, + title: 'Hello from React', + custom_excerpt: 'A short summary', + lexical: buildLexicalParagraph('Hello from React'), + ...overrides, + }), + ], + }); +} + +function bootAs(role: 'Author' | 'Contributor'): RenderAdminAppOptions { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: role })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +function pasteText(content: string) { + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', content); + document.activeElement?.dispatchEvent( + new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }), + ); +} + +/** + * The React post editor behind the `editorReact` flag: loads the post, + * mounts Koenig with its hidden secondary instance and keeps edits in memory. + * Nothing is saved yet — any write request other than the mobiledoc + * conversion would 418 and fail the test. + */ +describe('Post editor', () => { + it('loads the post into the title and body', async () => { + const postsApi = fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await expect.element(editorScreen.wordCount()).toHaveTextContent('3 words'); + expect(postsApi.lastRequest?.url).toContain('formats=mobiledoc%2Clexical'); + expect(postsApi.lastRequest?.url).toContain('include=tags%2Cauthors'); + }); + + it('mounts the hidden secondary Koenig instance without doubling its requests', async () => { + const latestPostsApi = fakeEditorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { + posts: [post({ id: POST_ID, lexical: buildLexicalParagraph('Hello') })], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toBeVisible(); + await expect + .element(editorScreen.secondaryInstance()) + .toHaveAttribute('data-secondary-instance', 'true'); + await expect.element(editorScreen.secondaryInstance()).not.toBeVisible(); + await expect.poll(() => latestPostsApi.requests.length).toBe(1); + expect(latestPostsApi.lastRequest?.limit).toBe(5); + }); + + it('updates the word count as the body is edited', async () => { + fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const body = editorScreen.body(); + await expect.element(editorScreen.wordCount()).toHaveTextContent('3 words'); + await body.click(); + await userEvent.keyboard('{End} and more'); + + await expect.element(body).toHaveTextContent('Hello from React and more'); + await expect.element(editorScreen.wordCount()).toHaveTextContent('5 words'); + }); + + it('keeps title edits in memory', async () => { + fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const title = editorScreen.titleInput(); + await expect.element(title).toHaveValue('Hello from React'); + await title.fill('Changed title TK'); + + await expect.element(title).toHaveValue('Changed title TK'); + await expect.element(editorScreen.titleTkIndicator()).toBeVisible(); + }); + + it('moves from the title into the body on Enter and cleans pasted titles', async () => { + fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const title = editorScreen.titleInput(); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); + await title.click(); + await userEvent.keyboard('{Enter}'); + + await expect.poll(() => editorScreen.bodyHasFocus()).toBe(true); + await expect.element(title).toHaveValue('Hello from React'); + + await title.fill(''); + await title.click(); + pasteText(' Line one\nLine two\r\n\nLine three '); + + await expect.element(title).toHaveValue('Line one Line two Line three'); + }); + + it('shows the excerpt only behind the editorExcerpt flag', async () => { + fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, { + labs: { editorReact: true, editorExcerpt: true }, + }); + + await expect.element(editorScreen.excerptInput()).toHaveValue('A short summary'); + }); + + it('hides the excerpt without the flag', async () => { + fakeEditorPost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.titleInput()).toBeVisible(); + await expect(editorScreen.excerptInput()).toHaveCount(0); + }); + + it.each<['post' | 'page']>([['post'], ['page']])( + 'converts a mobiledoc %s to lexical before opening it', + async (type) => { + const resource = `${type}s`; + const legacy = post({ + id: POST_ID, + title: `Legacy ${type}`, + mobiledoc: MOBILEDOC, + lexical: null, + updated_at: '2024-05-06T07:08:09.000Z', + }); + fakeEditorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/${resource}/${POST_ID}/\\?`), { + [resource]: [legacy], + }); + const convertApi = fakeAdminEndpoint('PUT', new RegExp(`^/${resource}/${POST_ID}/\\?`), { + [resource]: [ + post({ + ...legacy, + mobiledoc: null, + lexical: buildLexicalParagraph('Converted from mobiledoc'), + }), + ], + }); + await renderAdminApp(`/editor/${type}/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.body()).toHaveTextContent('Converted from mobiledoc'); + expect(convertApi.requests).toHaveLength(1); + expect(convertApi.lastRequest?.url).toContain('convert_to_lexical=true'); + expect(convertApi.lastRequest?.url).toContain('formats=mobiledoc%2Clexical'); + const body = convertApi.lastRequest?.body as Record; + expect(body[resource]).toEqual([{ id: POST_ID, updated_at: legacy.updated_at }]); + }, + ); + + it('shows an error when the conversion response carries no post', async () => { + fakeEditorPost({ title: 'Legacy post', mobiledoc: MOBILEDOC, lexical: null }); + fakeAdminEndpoint('PUT', new RegExp(`^/posts/${POST_ID}/\\?`), { posts: [] }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect + .element(editorScreen.loadError()) + .toHaveTextContent('Couldn’t convert this post for editing.'); + await expect(editorScreen.body()).toHaveCount(0); + }); + + it('shows an error instead of an empty body when the conversion fails', async () => { + fakeEditorPost({ title: 'Legacy post', mobiledoc: MOBILEDOC, lexical: null }); + fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ message: 'Invalid mobiledoc structure.' }] }, + { status: 422 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect + .element(editorScreen.loadError()) + .toHaveTextContent('Couldn’t convert this post for editing.'); + await expect(editorScreen.body()).toHaveCount(0); + }); + + it('shows a 404 for a post that does not exist', async () => { + fakeEditorChrome(); + fakeAdminEndpoint( + 'GET', + new RegExp(`^/posts/${POST_ID}/\\?`), + { errors: [{ message: 'Post not found.' }] }, + { status: 404 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.notFound()).toBeVisible(); + }); + + it('shows a 404 for an unknown editor type', async () => { + await renderAdminApp('/editor/article/abc123', FLAG_ON); + + await expect.element(editorScreen.notFound()).toBeVisible(); + }); + + type Role = 'Author' | 'Contributor'; + type Status = ReturnType['status']; + + it.each<[string, Role, 'post' | 'page', Status, string, string]>([ + ['author', 'Author', 'post', 'published', 'other-user', '/posts'], + ['contributor', 'Contributor', 'post', 'draft', 'other-user', '/posts'], + ['contributor', 'Contributor', 'page', 'published', CURRENT_USER_ID, '/pages'], + ])( + 'returns a %s to the list for a %s %s they cannot edit', + async (_role, role, type, status, authorId, listPath) => { + fakeEditorChrome(); + fakeAdminEndpoint('GET', new RegExp(`^/${type}s/${POST_ID}/\\?`), { + [`${type}s`]: [ + post({ + id: POST_ID, + status, + authors: [{ id: authorId }], + mobiledoc: MOBILEDOC, + lexical: null, + }), + ], + }); + await renderAdminApp(`/editor/${type}/${POST_ID}`, bootAs(role)); + + // a mobiledoc record must not be converted for a user who is redirected; + // the PUT has no fake, so it would 418 and fail the test + await expect.poll(currentRoute).toBe(listPath); + await expect(editorScreen.root()).toHaveCount(0); + }, + ); + + it.each<[string, Role, Status]>([ + ['author', 'Author', 'published'], + ['contributor', 'Contributor', 'draft'], + ])('lets a %s edit their own %s post', async (_role, role, status) => { + fakeEditorPost({ status, authors: [{ id: CURRENT_USER_ID }] }); + await renderAdminApp(`/editor/post/${POST_ID}`, bootAs(role)); + + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + expect(currentRoute()).toBe(`/editor/post/${POST_ID}`); + }); + + it('opens an empty editor for a new post', async () => { + fakeEditorChrome(); + await renderAdminApp('/editor/post', FLAG_ON); + + await expect.element(editorScreen.titleInput()).toHaveValue(''); + await expect.element(editorScreen.titleInput()).toHaveAttribute('placeholder', 'Post title'); + await expect.element(editorScreen.body()).toBeVisible(); + await expect.element(editorScreen.wordCount()).toHaveTextContent('0 words'); + }); + + it('labels a new page as a page', async () => { + fakeEditorChrome(); + await renderAdminApp('/editor/page', FLAG_ON); + + await expect.element(editorScreen.titleInput()).toHaveAttribute('placeholder', 'Page title'); + await expect.element(editorScreen.backLink('page')).toHaveAttribute('href', '#/pages'); + }); + + it('sends the bare editor URL to a new post', async () => { + fakeEditorChrome(); + await renderAdminApp('/editor', FLAG_ON); + + await expect.poll(currentRoute).toBe('/editor/post'); + await expect.element(editorScreen.titleInput()).toHaveAttribute('placeholder', 'Post title'); + }); +}); diff --git a/apps/admin/src/editor/post-editor.tsx b/apps/admin/src/editor/post-editor.tsx new file mode 100644 index 00000000000..5f26b5f286d --- /dev/null +++ b/apps/admin/src/editor/post-editor.tsx @@ -0,0 +1,321 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, cn, formatNumber } from '@tryghost/shade/utils'; +import { useFocusContext } from '@tryghost/shade/app'; +import { focusKoenigEditorOnBottomClick } from '@tryghost/admin-x-framework'; +import type { KoenigInstance } from '@/settings/components/koenig-loader'; +import type { PostCardConfig, PostType } from './card-config'; +import { KoenigPostEditor } from './koenig-post-editor'; +import { textHasTk } from './tk'; + +export interface PostEditorProps { + postType: PostType; + title: string; + excerpt: string; + /** Initial body; the editor owns its own state after mount. */ + initialLexical: string | null; + cardConfig: PostCardConfig; + showExcerpt: boolean; + autofocusTitle?: boolean; + onTitleChange: (title: string) => void; + onExcerptChange: (excerpt: string) => void; + onLexicalChange?: (lexical: unknown) => void; + onSecondaryChange?: (lexical: unknown) => void; + registerEditorApi?: (api: KoenigInstance | null) => void; + registerSecondaryApi?: (api: KoenigInstance | null) => void; + onTkCountChange?: (count: number) => void; +} + +const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +const fieldClassName = + 'block w-full resize-none overflow-hidden border-0 bg-transparent p-0 outline-none'; + +function useAutosize(ref: React.RefObject, value: string) { + const measure = useCallback(() => { + const element = ref.current; + if (!element) { + return; + } + element.style.height = 'auto'; + element.style.height = `${element.scrollHeight}px`; + }, [ref]); + + useLayoutEffect(measure, [measure, value]); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + // measuring inside the observer callback would resize the observed element mid-loop + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(measure); + }); + observer.observe(element); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [ref, measure]); +} + +function TkIndicator({ onClick, testId }: { onClick: () => void; testId: string }) { + return ( + + ); +} + +export function PostEditor({ + postType, + title, + excerpt, + initialLexical, + cardConfig, + showExcerpt, + autofocusTitle = false, + onTitleChange, + onExcerptChange, + onLexicalChange, + onSecondaryChange, + registerEditorApi, + registerSecondaryApi, + onTkCountChange, +}: PostEditorProps) { + const { darkMode } = useFocusContext(); + const titleRef = useRef(null); + const excerptRef = useRef(null); + const editorApiRef = useRef(null); + const skipFocusEditorRef = useRef(false); + const [wordCount, setWordCount] = useState(0); + const [bodyTkCount, setBodyTkCount] = useState(0); + + useAutosize(titleRef, title); + useAutosize(excerptRef, excerpt); + + const titleHasTk = textHasTk(title); + const excerptHasTk = showExcerpt && textHasTk(excerpt); + + useEffect(() => { + onTkCountChange?.((titleHasTk ? 1 : 0) + (excerptHasTk ? 1 : 0) + bodyTkCount); + }, [onTkCountChange, titleHasTk, excerptHasTk, bodyTkCount]); + + const focusTitle = useCallback(() => { + titleRef.current?.focus(); + }, []); + + const focusExcerpt = useCallback(() => { + excerptRef.current?.focus(); + // runs after the keyboard event so the caret lands at the end + setTimeout(() => excerptRef.current?.setSelectionRange(-1, -1), 0); + }, []); + + const registerApi = useCallback( + (api: KoenigInstance | null) => { + editorApiRef.current = api; + registerEditorApi?.(api); + }, + [registerEditorApi], + ); + + const registerSecondary = useCallback( + (api: KoenigInstance | null) => { + registerSecondaryApi?.(api); + }, + [registerSecondaryApi], + ); + + const moveIntoEditor = (key: string) => { + const editorApi = editorApiRef.current; + if (!editorApi) { + return; + } + if (key === 'Enter' && !editorApi.editorIsEmpty()) { + editorApi.insertParagraphAtTop({ focus: true }); + } else { + editorApi.focusEditor({ position: 'top' }); + } + }; + + const onTitleKeyDown = (event: React.KeyboardEvent) => { + const { key } = event; + const { value, selectionStart } = event.currentTarget; + const couldLeaveTitle = !value || selectionStart === value.length; + + if (showExcerpt) { + // Tab is handled by the browser + if (key === 'Enter') { + event.preventDefault(); + focusExcerpt(); + } + if ((key === 'ArrowDown' || key === 'ArrowRight') && !event.shiftKey && couldLeaveTitle) { + event.preventDefault(); + focusExcerpt(); + } + return; + } + + if (!editorApiRef.current || event.nativeEvent.isComposing) { + return; + } + + const arrowLeavingTitle = (key === 'ArrowDown' || key === 'ArrowRight') && couldLeaveTitle; + if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) { + event.preventDefault(); + moveIntoEditor(key); + } + }; + + const onExcerptKeyDown = (event: React.KeyboardEvent) => { + const { key } = event; + const { value, selectionStart } = event.currentTarget; + + if ((key === 'ArrowUp' || key === 'ArrowLeft') && !event.shiftKey) { + if (!value || selectionStart === 0) { + event.preventDefault(); + focusTitle(); + return; + } + } + + const couldLeaveExcerpt = !value || selectionStart === value.length; + const arrowLeavingExcerpt = (key === 'ArrowRight' || key === 'ArrowDown') && couldLeaveExcerpt; + if (key === 'Enter' || (key === 'Tab' && !event.shiftKey) || arrowLeavingExcerpt) { + event.preventDefault(); + moveIntoEditor(key); + } + }; + + const cleanPastedTitle = (event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData('text'); + if (!pastedText) { + return; + } + event.preventDefault(); + // execCommand keeps the paste on the browser's undo stack + document.execCommand('insertText', false, pastedText.replace(/(\n|\r)+/g, ' ').trim()); + }; + + // A mousedown on a card can deselect another card, so the mouseup can land + // outside the clicked card; refocusing then would change the selection + const trackMouseDown = (event: React.MouseEvent) => { + skipFocusEditorRef.current = event.nativeEvent + .composedPath() + .some( + (element) => + element instanceof Element && + element.matches('[data-lexical-decorator], [data-kg-slash-menu]'), + ); + }; + + const focusEditorOnPaneClick = (event: React.MouseEvent) => { + if ( + !skipFocusEditorRef.current && + event.target === event.currentTarget && + editorApiRef.current + ) { + focusKoenigEditorOnBottomClick(editorApiRef.current, event); + } + skipFocusEditorRef.current = false; + }; + + const onPaneDrop = (event: React.DragEvent) => { + if (event.dataTransfer.files.length > 0) { + event.preventDefault(); + editorApiRef.current?.insertFiles(Array.from(event.dataTransfer.files)); + } + }; + + return ( +
+
+ event.preventDefault()} + onDrop={onPaneDrop} + onMouseDown={trackMouseDown} + onMouseUp={focusEditorOnPaneClick} + > +
+ {titleHasTk && } +