diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 6070d985c43..7ae7ed2ee5b 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -20,7 +20,14 @@ Path: `ghost/core/core/server/services/gift-links/CONTEXT.md` Gift Links covers shareable access to individual protected posts and pages without creating a membership. +### Newsletter Email Sending + +Path: `ghost/core/core/server/services/email-service/CONTEXT.md` + +Newsletter Email Sending covers the preparation and submission of newsletter emails to the configured email provider. + ## Relationships - **Portal ↔ Gift Subscriptions**: Portal presents the purchase and redemption journeys for gift subscriptions. - **Gift Subscriptions ↔ Gift Links**: A gift-subscription redemption link claims fixed-duration membership access; a Gift Link grants access to one protected post or page. +- **Newsletter Email Sending ↔ Gift Subscriptions**: Newsletter Email Sending describes the status of a whole newsletter send; Gift Subscriptions' email delivery status describes one recipient's gift email. diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 2193314555b..123e18dfa77 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -80,8 +80,8 @@ "dependencies": { "@sentry/react": "catalog:", "@tanstack/react-query": "catalog:", - "@tryghost/custom-field-types": "workspace:*", "@tryghost/limit-service": "catalog:", + "@tryghost/metafield-types": "workspace:*", "@tryghost/nql-string": "workspace:*", "@tryghost/shade": "workspace:*", "@tryghost/string": "catalog:", @@ -89,7 +89,8 @@ "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", - "sonner": "catalog:" + "sonner": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@internal/cfg-eslint-react": "workspace:*", diff --git a/apps/admin-x-framework/src/api/email-previews.ts b/apps/admin-x-framework/src/api/email-previews.ts index 1ef05f2e58c..cc0f45ed646 100644 --- a/apps/admin-x-framework/src/api/email-previews.ts +++ b/apps/admin-x-framework/src/api/email-previews.ts @@ -1,14 +1,18 @@ import { createMutation, createQueryWithId } from '../utils/api/hooks'; +import { z } from 'zod'; -export type EmailPreview = { - html: string; - plaintext: string; - subject: string; -}; +export const EmailPreviewSchema = z.object({ + html: z.string(), + plaintext: z.string(), + subject: z.string(), +}); -export interface EmailPreviewResponseType { - email_previews: EmailPreview[]; -} +export const EmailPreviewResponseSchema = z.object({ + email_previews: z.array(EmailPreviewSchema).min(1), +}); + +export type EmailPreview = z.infer; +export type EmailPreviewResponseType = z.infer; export interface EmailPreviewParams { memberStatus?: 'free' | 'paid'; @@ -23,6 +27,7 @@ const dataType = 'EmailPreviewResponseType'; const useEmailPreviewQuery = createQueryWithId({ dataType, path: (id) => `/email_previews/posts/${id}/`, + parseResponse: (data) => EmailPreviewResponseSchema.parse(data), }); export const useEmailPreview = ( @@ -57,6 +62,7 @@ export interface SendTestEmailPayload { /** Sends a test email for a post. Responds 204 with no body. */ export const useSendTestEmail = createMutation({ method: 'POST', + sessionExpiryRedirect: false, path: ({ postId }) => `/email_previews/posts/${postId}/`, body: ({ emails, memberStatus, memberTier, newsletter }) => ({ emails, diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index 9c9ee118247..bc3954c9b91 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -7,22 +7,22 @@ import { type FieldType, type PartType, type PartsOf, -} from '@tryghost/custom-field-types'; -import { csvColumnsForField } from '@tryghost/custom-field-types/csv'; +} from '@tryghost/metafield-types'; +import { csvColumnsForField } from '@tryghost/metafield-types/csv'; import { Meta, createMutation, createQuery } from '../utils/api/hooks'; // Re-exported so the import mapping can recognize a custom_fields.* column (same reason // as the re-exports below). -export { isCustomFieldColumn } from '@tryghost/custom-field-types/csv'; -export type { FieldIdentity, FieldIdentityString } from '@tryghost/custom-field-types/identity'; +export { isMetafieldColumn } from '@tryghost/metafield-types/csv'; +export type { FieldIdentity, FieldIdentityString } from '@tryghost/metafield-types/identity'; // Re-exported so admin apps can type address values and validate against the // same schemas the server enforces, without a direct dependency on the shared // catalog package — the framework is their surface for everything custom-fields. -export type { Address as MemberCustomFieldAddress } from '@tryghost/custom-field-types'; -export { FIELD_TYPES as MEMBER_CUSTOM_FIELD_TYPES } from '@tryghost/custom-field-types'; -export { FIELD_KINDS as MEMBER_CUSTOM_FIELD_KINDS } from '@tryghost/custom-field-types'; -export type { FieldKind as MemberCustomFieldKind } from '@tryghost/custom-field-types'; +export type { Address as MemberCustomFieldAddress } from '@tryghost/metafield-types'; +export { FIELD_TYPES as MEMBER_CUSTOM_FIELD_TYPES } from '@tryghost/metafield-types'; +export { FIELD_KINDS as MEMBER_CUSTOM_FIELD_KINDS } from '@tryghost/metafield-types'; +export type { FieldKind as MemberCustomFieldKind } from '@tryghost/metafield-types'; export type MemberCustomField = { namespace: string; @@ -44,7 +44,7 @@ export type MemberCustomField = { /** * The user-type catalog: the presentation layer over the shared field types. * - * The shared catalog (@tryghost/custom-field-types) owns what a field type *is* + * The shared catalog (@tryghost/metafield-types) owns what a field type *is* * - its storage and validation. This catalog owns what a publisher is told it is: * its name, and which control collects a value. Admin surfaces (settings * list/modal, member detail) render from here so every surface presents fields @@ -169,7 +169,7 @@ export const memberCustomFieldCsvColumns = ( }); }; -export type { PartType as MemberCustomFieldPartType } from '@tryghost/custom-field-types'; +export type { PartType as MemberCustomFieldPartType } from '@tryghost/metafield-types'; /** One part of a composite field type: the key the value schema declares, its label, and its declared type. */ export type MemberCustomFieldPart = { diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index 3781b7781a1..b0123aa0c0b 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -8,7 +8,7 @@ import { createQueryWithId, } from '../utils/api/hooks'; import { apiUrl } from '../utils/api/fetch-api'; -import type { FieldValue } from '@tryghost/custom-field-types'; +import type { FieldValue } from '@tryghost/metafield-types'; import { useCurrentUser } from './current-user'; import { canManageMembers } from './users'; import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter'; diff --git a/apps/admin-x-framework/src/api/newsletters.ts b/apps/admin-x-framework/src/api/newsletters.ts index b8d51aa6ba1..e6c300f6985 100644 --- a/apps/admin-x-framework/src/api/newsletters.ts +++ b/apps/admin-x-framework/src/api/newsletters.ts @@ -1,71 +1,96 @@ import { InfiniteData } from '@tanstack/react-query'; import { Meta, createInfiniteQuery, createMutation } from '../utils/api/hooks'; import { insertToQueryCache, updateQueryCache } from '../utils/api/update-queries'; +import { z } from 'zod'; -export type Newsletter = { - id: string; - uuid: string; - name: string; - description: string | null; - feedback_enabled: boolean; - slug: string; - sender_name: string | null; - sender_email: string | null; - sender_reply_to: string; - status: string; - visibility: string; - subscribe_on_signup: boolean; - sort_order: number; - header_image: string | null; - show_header_icon: boolean; - show_header_title: boolean; - title_font_category: string; - title_font_weight: string; - title_alignment: string; - show_excerpt: boolean; - show_feature_image: boolean; - body_font_category: string; - footer_content: string | null; - show_badge: boolean; - show_header_name: boolean; - show_post_title_section: boolean; - show_comment_cta: boolean; - show_share_button: boolean; - show_subscription_details: boolean; - show_latest_posts: boolean; - background_color: string; - header_background_color: string; - button_color: string | null; - link_color: string | null; - post_title_color: string | null; - section_title_color: string | null; - divider_color: string | null; - button_corners: string | null; - button_style: string | null; - image_corners: string | null; - link_style: string | null; - divider_style: string | null; - created_at: string; - updated_at: string; - count?: { - posts?: number; - active_members?: number; - }; -}; +export const NewsletterSchema = z.object({ + id: z.string(), + uuid: z.string(), + name: z.string(), + description: z.string().nullable(), + feedback_enabled: z.boolean(), + slug: z.string(), + sender_name: z.string().nullable(), + sender_email: z.string().nullable(), + sender_reply_to: z.string(), + status: z.string(), + visibility: z.string(), + subscribe_on_signup: z.boolean(), + sort_order: z.number(), + header_image: z.string().nullable(), + show_header_icon: z.boolean(), + show_header_title: z.boolean(), + title_font_category: z.string(), + title_font_weight: z.string(), + title_alignment: z.string(), + show_excerpt: z.boolean(), + show_feature_image: z.boolean(), + body_font_category: z.string(), + footer_content: z.string().nullable(), + show_badge: z.boolean(), + show_header_name: z.boolean(), + show_post_title_section: z.boolean(), + show_comment_cta: z.boolean(), + show_share_button: z.boolean(), + show_subscription_details: z.boolean(), + show_latest_posts: z.boolean(), + background_color: z.string(), + header_background_color: z.string(), + button_color: z.string().nullable(), + link_color: z.string().nullable(), + post_title_color: z.string().nullable(), + section_title_color: z.string().nullable(), + divider_color: z.string().nullable(), + button_corners: z.string().nullable(), + button_style: z.string().nullable(), + image_corners: z.string().nullable(), + link_style: z.string().nullable(), + // Older and current Core versions may omit this design setting. + divider_style: z.string().nullish(), + created_at: z.string(), + updated_at: z.string(), + count: z + .object({ + posts: z.number().optional(), + active_members: z.number().optional(), + }) + .optional(), +}); -export interface NewslettersResponseType { - meta?: Meta; - newsletters: Newsletter[]; -} +const NewslettersMetaSchema = z.object({ + capabilities: z + .object({ + dislikes: z.boolean().optional(), + }) + .optional(), + pagination: z.object({ + page: z.number(), + limit: z.union([z.number(), z.literal('all')]), + pages: z.number(), + total: z.number(), + next: z.number().nullable(), + prev: z.number().nullable(), + }), +}); + +export const NewslettersResponseSchema = z.object({ + meta: NewslettersMetaSchema.optional(), + newsletters: z.array(NewsletterSchema), +}); + +export type Newsletter = z.infer; +export type NewslettersResponseType = z.infer; const dataType = 'NewslettersResponseType'; export const newslettersDataType = dataType; export const useBrowseNewsletters = createInfiniteQuery< - NewslettersResponseType & { isEnd: boolean } + NewslettersResponseType & { isEnd: boolean }, + NewslettersResponseType >({ dataType, path: '/newsletters/', + parseResponse: (data) => NewslettersResponseSchema.parse(data), defaultSearchParams: { include: 'count.active_members,count.posts', limit: '50' }, defaultNextPageParams: (lastPage, otherParams) => ({ ...otherParams, diff --git a/apps/admin-x-framework/src/hooks/use-feature-flag.ts b/apps/admin-x-framework/src/hooks/use-feature-flag.ts index b5509df0e38..e1a2d7b1426 100644 --- a/apps/admin-x-framework/src/hooks/use-feature-flag.ts +++ b/apps/admin-x-framework/src/hooks/use-feature-flag.ts @@ -1,11 +1,14 @@ import { useBrowseConfig } from '../api/config'; +import { useFeatureFlagOverrides } from '../providers/feature-flag-overrides-context'; /** - * Returns whether a Labs flag is explicitly enabled. Only boolean `true` - * counts — `false` while config is loading, missing, or failed. - * Avoids refetching stale config when a feature-gated component mounts. + * Returns whether a Labs flag is explicitly enabled by config or the current + * session's URL overrides. Only boolean `true` config values count. Avoids + * refetching stale config when a feature-gated component mounts. */ export const useFeatureFlag = (flag: string): boolean => { const { data: config } = useBrowseConfig({ refetchOnMount: false }); - return config?.config.labs?.[flag] === true; + const { enabledFlags } = useFeatureFlagOverrides(); + + return config?.config.labs?.[flag] === true || enabledFlags.includes(flag); }; diff --git a/apps/admin-x-framework/src/providers/feature-flag-overrides-context.ts b/apps/admin-x-framework/src/providers/feature-flag-overrides-context.ts new file mode 100644 index 00000000000..43efe348758 --- /dev/null +++ b/apps/admin-x-framework/src/providers/feature-flag-overrides-context.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; + +interface FeatureFlagOverridesContextValue { + enabledFlags: string[]; +} + +export const FeatureFlagOverridesContext = createContext({ + enabledFlags: [], +}); + +export const useFeatureFlagOverrides = () => useContext(FeatureFlagOverridesContext); diff --git a/apps/admin-x-framework/src/providers/framework-provider.tsx b/apps/admin-x-framework/src/providers/framework-provider.tsx index d266b8da996..c5d4788cd91 100644 --- a/apps/admin-x-framework/src/providers/framework-provider.tsx +++ b/apps/admin-x-framework/src/providers/framework-provider.tsx @@ -44,6 +44,8 @@ export interface FrameworkProviderProps { onUpdate: (dataType: string, response: unknown) => void; onInvalidate: (dataType: string) => void; onDelete: (dataType: string, id: string) => void; + // Called after URL overrides are synced to sessionStorage. May return cleanup work. + onFeatureFlagOverridesChange?: () => void | (() => void); // Optional QueryClient override. Defaults to the shared window-level // singleton; test harnesses pass a fresh client per render for isolation. diff --git a/apps/admin-x-framework/src/providers/router-provider.tsx b/apps/admin-x-framework/src/providers/router-provider.tsx index 0aa13ff3fc5..b36b501f661 100644 --- a/apps/admin-x-framework/src/providers/router-provider.tsx +++ b/apps/admin-x-framework/src/providers/router-provider.tsx @@ -12,6 +12,8 @@ import { import { useFramework } from './framework-provider'; import { NavigationStackProvider } from './navigation-stack-provider'; import { ErrorPage } from '@tryghost/shade/primitives'; +import { syncFeatureFlagOverrides } from '../utils/feature-flag-overrides'; +import { FeatureFlagOverridesContext } from './feature-flag-overrides-context'; /** * This provider uses React Router to provide a router context to React apps @@ -33,6 +35,23 @@ export interface RouterProviderProps { children?: React.ReactNode; } +function FeatureFlagOverridesRouteProvider({ children }: { children: React.ReactNode }) { + const { search } = useLocation(); + const { onFeatureFlagOverridesChange } = useFramework(); + const enabledFlags = useMemo(() => syncFeatureFlagOverrides(search), [search]); + const value = useMemo(() => ({ enabledFlags }), [enabledFlags]); + + useEffect(() => { + return onFeatureFlagOverridesChange?.(); + }, [enabledFlags, onFeatureFlagOverridesChange]); + + return ( + + {children} + + ); +} + // Store scroll positions globally const scrollPositions = new Map(); @@ -100,7 +119,11 @@ export function RouterProvider({ routes, prefix, errorElement, children }: Route // Create a root route that wraps all routes with NavigationStackProvider // and any additional children (providers) so they have access to routing const rootRoute: RouteObject = { - element: {children}, + element: ( + + {children} + + ), hydrateFallbackElement: <>, children: routes.map((route) => ({ ...route, diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index fe4ec9f4ef7..17e95e4a6ab 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -38,6 +38,7 @@ interface QueryOptions { headers?: Record; defaultSearchParams?: Record; permissions?: UserRoleType[]; + parseResponse?: (data: unknown) => ResponseData; returnData?: (originalData: unknown) => ResponseData; } @@ -66,7 +67,16 @@ export const createQuery = ...query, enabled: hasPermission && (query.enabled ?? true), queryKey: [options.dataType, url], - queryFn: () => fetchApi(url, { ...options, ...requestOptions }), + queryFn: async () => { + if (options.parseResponse) { + const data = await fetchApi(url, { + headers: options.headers, + ...requestOptions, + }); + return options.parseResponse(data); + } + return fetchApi(url, { headers: options.headers, ...requestOptions }); + }, }); const data = useMemo( @@ -86,21 +96,24 @@ export const createQuery = }; }; -type InfiniteQueryOptions = Omit, 'returnData'> & { - returnData: NonNullable['returnData']>; +type InfiniteQueryOptions = Omit< + QueryOptions, + 'returnData' +> & { + returnData: (originalData: unknown) => ResponseData; defaultNextPageParams?: ( - data: ResponseData, + data: PageData, params: Record, ) => Record | undefined; }; type InfiniteQueryPageParam = Record | undefined; -type InfiniteQueryHookOptions = Omit< +type InfiniteQueryHookOptions = Omit< UseInfiniteQueryOptions< - ResponseData, + PageData, Error, - InfiniteData, + InfiniteData, QueryKey, InfiniteQueryPageParam >, @@ -108,15 +121,22 @@ type InfiniteQueryHookOptions = Omit< > & { searchParams?: Record; defaultErrorHandler?: boolean; + /** Whether this query leaves an expired session for its caller to handle in place. */ + requestOptions?: Pick; getNextPageParams?: ( - data: ResponseData, + data: PageData, params: Record, ) => Record | undefined; }; export const createInfiniteQuery = - (options: InfiniteQueryOptions) => - ({ searchParams, getNextPageParams, ...query }: InfiniteQueryHookOptions = {}) => { + (options: InfiniteQueryOptions) => + ({ + searchParams, + requestOptions, + getNextPageParams, + ...query + }: InfiniteQueryHookOptions = {}) => { const fetchApi = useFetchApi(); const handleError = useHandleError(); const hasPermission = usePermission(options.permissions); @@ -124,9 +144,9 @@ export const createInfiniteQuery = const nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({})); const result = useInfiniteQuery< - ResponseData, + PageData, Error, - InfiniteData, + InfiniteData, QueryKey, InfiniteQueryPageParam >({ @@ -136,10 +156,17 @@ export const createInfiniteQuery = options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams), ], - queryFn: ({ pageParam }) => - fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams), { - ...options, - }), + queryFn: async ({ pageParam }) => { + const url = apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams); + if (options.parseResponse) { + const data = await fetchApi(url, { + headers: options.headers, + ...requestOptions, + }); + return options.parseResponse(data); + } + return fetchApi(url, { headers: options.headers, ...requestOptions }); + }, initialPageParam: undefined, getNextPageParam: (data) => nextPageParams(data, searchParams || options.defaultSearchParams || {}), diff --git a/apps/admin-x-framework/src/utils/feature-flag-overrides.ts b/apps/admin-x-framework/src/utils/feature-flag-overrides.ts new file mode 100644 index 00000000000..8a2897fcfdb --- /dev/null +++ b/apps/admin-x-framework/src/utils/feature-flag-overrides.ts @@ -0,0 +1,46 @@ +const LABS_QUERY_PARAM = 'labs'; +const LABS_STORAGE_KEY = 'ghost-admin:labs-overrides'; + +const getUrlFeatureFlags = (searchParams: URLSearchParams): string[] => { + return searchParams + .getAll(LABS_QUERY_PARAM) + .flatMap((value) => value.split(',')) + .filter(Boolean); +}; + +export const getStoredFeatureFlagOverrides = (): string[] => { + try { + const storedFlags: unknown = JSON.parse(sessionStorage.getItem(LABS_STORAGE_KEY) ?? '[]'); + + if (!Array.isArray(storedFlags)) { + return []; + } + + return storedFlags.filter((flag): flag is string => typeof flag === 'string'); + } catch { + return []; + } +}; + +export const syncFeatureFlagOverrides = (search: string): string[] => { + const searchParams = new URLSearchParams(search); + + if (!searchParams.has(LABS_QUERY_PARAM)) { + return getStoredFeatureFlagOverrides(); + } + + const flags = getUrlFeatureFlags(searchParams); + + try { + if (flags.length > 0) { + sessionStorage.setItem(LABS_STORAGE_KEY, JSON.stringify(flags)); + } else { + sessionStorage.removeItem(LABS_STORAGE_KEY); + } + } catch { + // Storage can be unavailable in restricted browser environments. The URL + // override still applies to the current React render in that case. + } + + return flags; +}; 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 index 81ce5b8a29a..54d6281ee14 100644 --- a/apps/admin-x-framework/test/unit/api/email-previews.test.tsx +++ b/apps/admin-x-framework/test/unit/api/email-previews.test.tsx @@ -67,6 +67,24 @@ describe('email previews api', () => { ); }); + it('rejects an invalid email preview response', async () => { + await withMockFetch( + { + json: { + email_previews: null, + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => useEmailPreview('post-1')); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }, + ); + }); + it('sends a test email with the audience and newsletter in the body', async () => { await withMockFetch({ status: 204 }, async (mock) => { const { result } = renderHookWithProviders(() => useSendTestEmail()); diff --git a/apps/admin-x-framework/test/unit/api/newsletters.test.tsx b/apps/admin-x-framework/test/unit/api/newsletters.test.tsx new file mode 100644 index 00000000000..10a73c61ea4 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/newsletters.test.tsx @@ -0,0 +1,79 @@ +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NewslettersResponseSchema, useBrowseNewsletters } from '../../../src/api/newsletters'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { withMockFetch } from '../../utils/mock-fetch'; + +describe('newsletters api', () => { + it('accepts the current Core newsletter response shape', () => { + const result = NewslettersResponseSchema.parse({ + newsletters: [ + { + id: 'newsletter-1', + uuid: '0b71d5a2-bb5f-4d8d-8911-b4539d60e0f0', + name: 'Weekly digest', + description: null, + feedback_enabled: false, + slug: 'weekly-digest', + sender_name: null, + sender_email: null, + sender_reply_to: 'newsletter', + status: 'active', + visibility: 'members', + subscribe_on_signup: true, + sort_order: 0, + header_image: null, + show_header_icon: true, + show_header_title: true, + title_font_category: 'sans_serif', + title_font_weight: 'bold', + title_alignment: 'center', + show_excerpt: false, + show_feature_image: true, + body_font_category: 'sans_serif', + footer_content: null, + show_badge: true, + show_header_name: true, + show_post_title_section: true, + show_comment_cta: true, + show_share_button: false, + show_subscription_details: false, + show_latest_posts: false, + background_color: 'light', + header_background_color: 'transparent', + button_color: 'accent', + link_color: 'accent', + post_title_color: null, + section_title_color: null, + divider_color: null, + button_corners: 'rounded', + button_style: 'fill', + image_corners: 'square', + link_style: 'underline', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + ], + }); + + expect(result.newsletters[0].slug).toBe('weekly-digest'); + }); + + it('rejects an invalid newsletters response', async () => { + await withMockFetch( + { + json: { + newsletters: null, + users: [{ id: 'user-1', roles: [] }], + }, + headers: { 'content-type': 'application/json' }, + }, + async () => { + const { result } = renderHookWithProviders(() => useBrowseNewsletters()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }, + ); + }); +}); diff --git a/apps/admin-x-framework/test/unit/hooks/use-feature-flag.test.ts b/apps/admin-x-framework/test/unit/hooks/use-feature-flag.test.ts index 815da64a43c..a926c9e1389 100644 --- a/apps/admin-x-framework/test/unit/hooks/use-feature-flag.test.ts +++ b/apps/admin-x-framework/test/unit/hooks/use-feature-flag.test.ts @@ -4,10 +4,15 @@ import { useFeatureFlag } from '../../../src/hooks/use-feature-flag'; vi.mock('../../../src/api/config', () => ({ useBrowseConfig: vi.fn(), })); +vi.mock('../../../src/providers/feature-flag-overrides-context', () => ({ + useFeatureFlagOverrides: vi.fn(), +})); import { useBrowseConfig } from '../../../src/api/config'; +import { useFeatureFlagOverrides } from '../../../src/providers/feature-flag-overrides-context'; const mockUseBrowseConfig = useBrowseConfig as any; +const mockUseFeatureFlagOverrides = vi.mocked(useFeatureFlagOverrides); const withLabs = (labs: Record) => ({ data: { config: { labs } }, @@ -16,6 +21,7 @@ const withLabs = (labs: Record) => ({ describe('useFeatureFlag', () => { beforeEach(() => { vi.clearAllMocks(); + mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: [] }); }); it('returns true when the flag is explicitly true', () => { @@ -65,4 +71,22 @@ describe('useFeatureFlag', () => { expect(result.current).toBe(false); }); + + it('enables a flag when the session override enables it', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ myFlag: false })); + mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: ['myFlag'] }); + + const { result } = renderHook(() => useFeatureFlag('myFlag')); + + expect(result.current).toBe(true); + }); + + it('returns false when the session override does not enable the flag', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({ myFlag: false })); + mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: ['otherFlag'] }); + + const { result } = renderHook(() => useFeatureFlag('myFlag')); + + expect(result.current).toBe(false); + }); }); diff --git a/apps/admin-x-framework/test/unit/providers/router-provider.test.tsx b/apps/admin-x-framework/test/unit/providers/router-provider.test.tsx index 0a6fe24d183..ef9e5386995 100644 --- a/apps/admin-x-framework/test/unit/providers/router-provider.test.tsx +++ b/apps/admin-x-framework/test/unit/providers/router-provider.test.tsx @@ -1,8 +1,35 @@ import { StrictMode } from 'react'; import { render, waitFor } from '@testing-library/react'; -import { Navigate } from '../../../src/providers/router-provider'; +import { Navigate, RouterProvider } from '../../../src/providers/router-provider'; import { TestWrapper } from '../../../src/test/test-utils'; +describe('feature flag overrides', () => { + beforeEach(() => { + sessionStorage.clear(); + window.location.hash = ''; + }); + + afterEach(() => { + sessionStorage.clear(); + window.location.hash = ''; + }); + + it('notifies the host after storing URL overrides', async () => { + window.location.hash = '#/?labs=testFlag'; + const onFeatureFlagOverridesChange = vi.fn(() => { + expect(sessionStorage.getItem('ghost-admin:labs-overrides')).toBe('["testFlag"]'); + }); + + render( + + Home }]} /> + , + ); + + await waitFor(() => expect(onFeatureFlagOverridesChange).toHaveBeenCalled()); + }); +}); + describe('Navigate', () => { it('performs cross-app navigation once after mounting in Strict Mode', async () => { const externalNavigate = vi.fn(); diff --git a/apps/admin-x-framework/test/unit/utils/feature-flag-overrides.test.ts b/apps/admin-x-framework/test/unit/utils/feature-flag-overrides.test.ts new file mode 100644 index 00000000000..92f84af23ce --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/feature-flag-overrides.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + getStoredFeatureFlagOverrides, + syncFeatureFlagOverrides, +} from '../../../src/utils/feature-flag-overrides'; + +describe('feature flag overrides', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('stores flags listed in the labs query parameter', () => { + expect(syncFeatureFlagOverrides('?labs=testFlag,secondFlag')).toEqual([ + 'testFlag', + 'secondFlag', + ]); + expect(getStoredFeatureFlagOverrides()).toEqual(['testFlag', 'secondFlag']); + }); + + it('supports repeated labs query parameters', () => { + expect(syncFeatureFlagOverrides('?labs=testFlag&labs=secondFlag')).toEqual([ + 'testFlag', + 'secondFlag', + ]); + }); + + it('uses stored overrides when the URL has no labs parameter', () => { + syncFeatureFlagOverrides('?labs=testFlag'); + + expect(syncFeatureFlagOverrides('?page=2')).toEqual(['testFlag']); + }); + + it('replaces stored overrides when the URL specifies new flags', () => { + syncFeatureFlagOverrides('?labs=testFlag'); + + expect(syncFeatureFlagOverrides('?labs=secondFlag')).toEqual(['secondFlag']); + expect(getStoredFeatureFlagOverrides()).toEqual(['secondFlag']); + }); + + it('clears stored overrides for an empty labs parameter', () => { + syncFeatureFlagOverrides('?labs=testFlag'); + + expect(syncFeatureFlagOverrides('?labs=')).toEqual([]); + expect(getStoredFeatureFlagOverrides()).toEqual([]); + }); + + it('ignores malformed stored overrides', () => { + sessionStorage.setItem('ghost-admin:labs-overrides', '{invalid'); + + expect(getStoredFeatureFlagOverrides()).toEqual([]); + }); +}); diff --git a/apps/admin/package.json b/apps/admin/package.json index ec92bda026d..13dab44eb53 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -33,12 +33,12 @@ "@tryghost/admin-x-framework": "workspace:*", "@tryghost/checkout": "workspace:*", "@tryghost/color-utils": "catalog:", - "@tryghost/custom-field-types": "workspace:*", "@tryghost/custom-fonts": "catalog:", "@tryghost/i18n": "workspace:*", "@tryghost/kg-clean-basic-html": "workspace:*", "@tryghost/kg-unsplash-selector": "workspace:*", "@tryghost/koenig-lexical": "workspace:*", + "@tryghost/metafield-types": "workspace:*", "@tryghost/nql": "catalog:", "@tryghost/nql-lang": "catalog:", "@tryghost/nql-string": "workspace:*", @@ -169,7 +169,7 @@ "projects": [ "@tryghost/admin-x-framework", "@tryghost/checkout", - "@tryghost/custom-field-types", + "@tryghost/metafield-types", "@tryghost/shade" ], "target": "build" diff --git a/apps/admin/src/editor/post-editor.tsx b/apps/admin/src/editor/post-editor.tsx index f43c9643bc1..8ed57dd38f6 100644 --- a/apps/admin/src/editor/post-editor.tsx +++ b/apps/admin/src/editor/post-editor.tsx @@ -280,7 +280,7 @@ export function PostEditor({ autoFocus={autofocusTitle} className={cn( fieldClassName, - 'mb-4 text-4xl leading-tight font-bold tracking-tight text-foreground placeholder:font-bold placeholder:text-muted-foreground', + 'admin7-heading-features mb-4 text-4xl leading-tight font-bold tracking-tight text-foreground placeholder:font-bold placeholder:text-muted-foreground', )} data-testid="editor-title-input" placeholder={`${capitalize(postType)} title`} diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md new file mode 100644 index 00000000000..dfa877a2a39 --- /dev/null +++ b/apps/admin/src/editor/preview/README.md @@ -0,0 +1,39 @@ +# Post preview + +`` shows a post as its readers will get it: rendered by the site (Web) or rendered as the newsletter it would be sent as (Email). It is self-contained — the caller supplies the post's identity and preview URL, and the modal reads everything else (settings, tiers, newsletters, the current user, the email preview) from the Admin API. + +| Prop | Meaning | +| ---------------- | -------------------------------------------------------------------------------- | +| `open` | Whether the modal is shown; `onOpenChange` reports closing | +| `postId` | Identifies the post for the email preview and test-send endpoints | +| `previewUrl` | The post's public preview URL; empty until the post has a uuid | +| `isPost` | Pages have no email preview | +| `newsletterSlug` | The post's own newsletter, preselected in the email preview | +| `onBeforeOpen` | Awaited before the preview renders, so the caller can save the draft it previews | + +The modal never writes to the post. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller. + +## Audience + +One audience drives both formats, held as a segment plus an optional tier slug and translated by `preview-url.ts`: + +| Segment | Web query | Email params | +| ----------- | --------------------------------------- | --------------------------------------- | +| `anonymous` | `member_status=anonymous` | not offered — email has no visitor | +| `free` | `member_status=free` | `member_status=free` | +| `paid` | `member_status=paid` | `member_status=paid` | +| `tier` | `member_status=paid&member_tier=` | `member_status=paid&member_tier=` | + +The paid audiences appear only when paid members are enabled, and the tier audience only when the site has paid tiers. The default is a free member. + +## Email + +The Email tab is offered for posts only, when members are on, newsletters are not disabled in the editor settings, and the user is not a contributor. + +The rendered email arrives as a complete HTML document and is shown in a `srcdoc` iframe sandboxed without `allow-scripts` and without `allow-same-origin`, so it can neither run its own scripts nor reach the admin page. Scrollbar styling is concatenated into that document because the admin stylesheet does not apply inside it. + +Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected. + +## Not here yet + +Known gaps, listed so they are not mistaken for decisions: the email subject is read-only (editing it would write to the post), there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override. diff --git a/apps/admin/src/editor/preview/browser-preview.tsx b/apps/admin/src/editor/preview/browser-preview.tsx new file mode 100644 index 00000000000..9a4355753a5 --- /dev/null +++ b/apps/admin/src/editor/preview/browser-preview.tsx @@ -0,0 +1,37 @@ +import { EmptyIndicator, PreviewChrome } from '@tryghost/shade/components'; +import { LucideIcon } from '@tryghost/shade/utils'; + +import { browserPreviewUrl, type PreviewAudience, type PreviewDevice } from './preview-url'; + +interface BrowserPreviewProps { + /** The post's public preview URL, before the audience params are applied. */ + previewUrl: string; + audience: PreviewAudience; + device: PreviewDevice; +} + +export function BrowserPreview({ previewUrl, audience, device }: BrowserPreviewProps) { + if (!previewUrl) { + return ( + + + + ); + } + + return ( + +