From bd615485186704177a96be582c68a67d75440031 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Mon, 10 Aug 2026 17:22:42 -0500 Subject: [PATCH 1/7] Changed settings confirmations to a context provider (#29818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref First slice of the settings NiceModal burn-down (follows the router swap #29780 and un-portal #29789). `ConfirmationModal` and `LimitModal` were NiceModal-shown from **54 call sites across 27 files** — the bulk of settings' remaining NiceModal usage. They now go through a settings-local `ConfirmationProvider`: --- apps/admin/src/settings/app/app.tsx | 13 +++-- .../components/confirmation-modal.test.tsx | 25 +++++---- .../app/components/confirmation-modal.tsx | 19 ++++--- .../app/components/limit-modal.test.tsx | 25 +++++---- .../settings/app/components/limit-modal.tsx | 11 ++-- .../providers/confirmation-provider.tsx | 56 +++++++++++++++++++ .../settings/advanced/danger-zone.tsx | 10 ++-- .../settings/advanced/integrations.tsx | 6 +- .../integrations/add-integration-modal.tsx | 8 +-- .../integrations/custom-integration-modal.tsx | 6 +- .../integrations/transistor-modal.tsx | 6 +- .../integrations/webhooks-table.test.tsx | 5 +- .../advanced/integrations/webhooks-table.tsx | 5 +- .../advanced/integrations/zapier-modal.tsx | 6 +- .../universal-import-modal.tsx | 5 +- .../app/components/settings/email/emails.tsx | 9 +-- .../components/settings/email/newsletters.tsx | 23 ++++---- .../newsletters/add-newsletter-modal.tsx | 8 +-- .../newsletters/newsletter-detail-modal.tsx | 11 ++-- .../newsletters/newsletters-tab-content.tsx | 23 ++++---- .../settings/general/user-detail-modal.tsx | 13 ++--- .../settings/general/users/staff-token.tsx | 6 +- .../growth/offers/edit-offer-modal.tsx | 8 +-- .../edit-recommendation-modal.tsx | 6 +- .../custom-fields/custom-field-modal.tsx | 9 +-- .../settings/membership/member-emails.tsx | 9 +-- .../membership/portal/portal-modal.tsx | 12 ++-- .../stripe/stripe-connect-modal.tsx | 12 ++-- .../components/settings/membership/tiers.tsx | 6 +- .../membership/tiers/tier-detail-modal.tsx | 6 +- .../components/settings/site/change-theme.tsx | 8 +-- .../settings/site/design-and-theme-modal.tsx | 8 +-- .../components/settings/site/theme-modal.tsx | 32 ++++++----- .../site/theme/advanced-theme-settings.tsx | 8 +-- .../site/theme/invalid-theme-modal.tsx | 6 +- .../site/theme/theme-installed-modal.tsx | 6 +- 36 files changed, 258 insertions(+), 177 deletions(-) create mode 100644 apps/admin/src/settings/app/components/providers/confirmation-provider.tsx diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/app/app.tsx index c560ce3737c..5db40d992aa 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/app/app.tsx @@ -1,6 +1,7 @@ import MainContent from './main-content'; import NiceModal from '@ebay/nice-modal-react'; import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider'; +import {ConfirmationProvider} from './components/providers/confirmation-provider'; import {Outlet, useLocation} from '@tryghost/admin-x-framework'; import {useEffect} from 'react'; import {useScrollSectionContext} from './hooks/use-scroll-section'; @@ -27,11 +28,13 @@ export function App({upgradeStatus}: AppProps) { return ( - - - - - + + + + + + + ); diff --git a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx b/apps/admin/src/settings/app/components/confirmation-modal.test.tsx index 0dcb8d2f5cd..39472b55f48 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx +++ b/apps/admin/src/settings/app/components/confirmation-modal.test.tsx @@ -1,18 +1,19 @@ -import ConfirmationModal, {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; +import {useEffect} from 'react'; +import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('ConfirmationModal', () => { - afterEach(() => { - void NiceModal.remove(ConfirmationModal); - }); - const showModal = (props: ConfirmationModalProps) => { - render(); - - act(() => { - void NiceModal.show(ConfirmationModal, props); - }); + const Trigger = () => { + const {confirm} = useConfirmation(); + useEffect(() => { + confirm(props); + + }, [confirm]); + return null; + }; + render(); }; it('renders the supplied content and confirms without closing implicitly', async () => { diff --git a/apps/admin/src/settings/app/components/confirmation-modal.tsx b/apps/admin/src/settings/app/components/confirmation-modal.tsx index 3e408c86b08..892ea1b62dc 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.tsx +++ b/apps/admin/src/settings/app/components/confirmation-modal.tsx @@ -1,4 +1,3 @@ -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import { AlertDialog, @@ -31,7 +30,14 @@ export interface ConfirmationModalProps { testId?: string; } -export const ConfirmationModalContent: React.FC = ({ +export type ConfirmationHostProps = { + visible?: boolean; + onRemove: () => void; +}; + +export const ConfirmationModalContent: React.FC = ({ + visible = true, + onRemove, title = 'Are you sure?', prompt, cancelLabel = 'Cancel', @@ -45,7 +51,6 @@ export const ConfirmationModalContent: React.FC = ({ stickyFooter = false, testId = 'confirmation-modal' }) => { - const modal = useModal(); const [taskState, setTaskState] = useState<'running' | ''>(''); const isRunning = taskState === 'running'; const runningLabel = okRunningLabel || okLabel; @@ -58,7 +63,7 @@ export const ConfirmationModalContent: React.FC = ({ if (onCancel) { onCancel(); } else { - modal.remove(); + onRemove(); } }; @@ -66,7 +71,7 @@ export const ConfirmationModalContent: React.FC = ({ setTaskState('running'); try { - await onOk?.(modal); + await onOk?.({remove: onRemove}); } catch (error) { // eslint-disable-next-line no-console console.error('Unhandled Promise Rejection. Make sure you catch errors in your onOk handler.', error); @@ -94,7 +99,7 @@ export const ConfirmationModalContent: React.FC = ({ const footer = customFooter === undefined ? defaultFooter : customFooter; return ( - !open && handleCancel()}> + !open && handleCancel()}> = ({ ); }; - -export default NiceModal.create(ConfirmationModalContent); diff --git a/apps/admin/src/settings/app/components/limit-modal.test.tsx b/apps/admin/src/settings/app/components/limit-modal.test.tsx index 9a18d0ca831..fc3e6058be1 100644 --- a/apps/admin/src/settings/app/components/limit-modal.test.tsx +++ b/apps/admin/src/settings/app/components/limit-modal.test.tsx @@ -1,18 +1,19 @@ -import LimitModal, {type LimitModalProps} from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {type LimitModalProps} from '@/settings/app/components/limit-modal'; +import {useEffect} from 'react'; +import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('LimitModal', () => { - afterEach(() => { - void NiceModal.remove(LimitModal); - }); - const showModal = (props: LimitModalProps) => { - render(); - - act(() => { - void NiceModal.show(LimitModal, props); - }); + const Trigger = () => { + const {showLimit} = useConfirmation(); + useEffect(() => { + showLimit(props); + + }, [showLimit]); + return null; + }; + render(); }; it('preserves the upgrade defaults and renders HTML prompts', async () => { diff --git a/apps/admin/src/settings/app/components/limit-modal.tsx b/apps/admin/src/settings/app/components/limit-modal.tsx index 20f5b4bd033..2f9a0b91c05 100644 --- a/apps/admin/src/settings/app/components/limit-modal.tsx +++ b/apps/admin/src/settings/app/components/limit-modal.tsx @@ -1,7 +1,6 @@ -import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; -import {ConfirmationModalContent} from './confirmation-modal'; +import {type ConfirmationHostProps, ConfirmationModalContent} from './confirmation-modal'; export interface LimitModalProps { title?: string; @@ -13,7 +12,9 @@ export interface LimitModalProps { }) => void | Promise; } -export const LimitModalContent: React.FC = ({ +export const LimitModalContent: React.FC = ({ + visible = true, + onRemove, title = 'Upgrade your plan', prompt, okLabel = 'Upgrade', @@ -31,9 +32,9 @@ export const LimitModalContent: React.FC = ({ prompt={{promptContent}} testId='limit-modal' title={title} + visible={visible} onOk={onOk} + onRemove={onRemove} /> ); }; - -export default NiceModal.create(LimitModalContent); diff --git a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx new file mode 100644 index 00000000000..b22fb12d597 --- /dev/null +++ b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx @@ -0,0 +1,56 @@ +import React, {createContext, useCallback, useContext, useRef, useState} from 'react'; +import {type ConfirmationModalProps, ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; +import {type LimitModalProps, LimitModalContent} from '@/settings/app/components/limit-modal'; + +export type ConfirmationHandle = {remove: () => void}; + +type ConfirmationRequest = + | {id: number; kind: 'confirm'; props: ConfirmationModalProps} + | {id: number; kind: 'limit'; props: LimitModalProps}; + +type ConfirmationContextType = { + confirm: (props: ConfirmationModalProps) => ConfirmationHandle; + showLimit: (props: LimitModalProps) => ConfirmationHandle; +}; + +const ConfirmationContext = createContext(null); + +export function useConfirmation(): ConfirmationContextType { + const context = useContext(ConfirmationContext); + if (!context) { + throw new Error('useConfirmation must be used inside ConfirmationProvider'); + } + return context; +} + +export const ConfirmationProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [requests, setRequests] = useState([]); + const nextId = useRef(0); + + const show = useCallback((request: Omit): ConfirmationHandle => { + nextId.current += 1; + const id = nextId.current; + // One request per kind, matching NiceModal's per-component keying: a + // second show replaces the first instead of stacking (StrictMode + // double-effects depend on this). + setRequests(current => [...current.filter(r => r.kind !== request.kind), {...request, id} as ConfirmationRequest]); + return {remove: () => setRequests(current => current.filter(r => r.id !== id))}; + }, []); + + const confirm = useCallback((props: ConfirmationModalProps) => show({kind: 'confirm', props}), [show]); + const showLimit = useCallback((props: LimitModalProps) => show({kind: 'limit', props}), [show]); + + const contextValue = React.useMemo(() => ({confirm, showLimit}), [confirm, showLimit]); + + return ( + + {children} + {requests.map((request) => { + const remove = () => setRequests(current => current.filter(r => r.id !== request.id)); + return request.kind === 'confirm' ? + : + ; + })} + + ); +}; diff --git a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx index d8c79689ccb..29311eb7a79 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx @@ -1,5 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import trackEvent from '@/settings/app/utils/analytics'; @@ -8,6 +6,7 @@ import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -24,6 +23,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const handleError = useHandleError(); const {config} = useGlobalData(); const {totalUsers} = useStaffUsers(); + const {confirm} = useConfirmation(); const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth); @@ -34,7 +34,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { : 'All staff users, including you, will be signed out and must reset their password before signing back in.'; const handleDeleteAllContent = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Would you really like to delete all content from your blog?', prompt: 'This is permanent! No backups, no restores, no magic undo button. We warned you, k?', okVariant: 'destructive', @@ -53,7 +53,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }; const handleResetAuth = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reset all authentication?', prompt: ( <> @@ -85,7 +85,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }; const handleRemoveAllGiftLinks = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reset all gift links?', prompt: 'This immediately invalidates every active gift link across your site. Anyone holding one will lose access. New gift links can still be created afterwards.', okLabel: 'Reset all gift links', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx index b1553e17bb4..2ffba14db5c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx @@ -1,7 +1,5 @@ import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationsSettingsImg from '@/settings/app/assets/images/integrations-settings.png'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; @@ -11,6 +9,7 @@ import {LucideIcon} from '@tryghost/shade/utils'; import {Plug} from 'lucide-react'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -201,6 +200,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio const {updateRoute} = useSettingsNavigation(); const {mutateAsync: deleteIntegration} = useDeleteIntegration(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); if (integrations.length) { return ( @@ -220,7 +220,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio title={integration.name} custom onDelete={() => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure?', prompt: 'Deleting this integration will remove all webhooks and api keys associated with it.', okVariant: 'destructive', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx index c7b382c58da..2c717137192 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx @@ -1,8 +1,7 @@ -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import {useEffect, useState} from 'react'; import {Field, FieldError, FieldGroup, FieldLabel, Input} from '@tryghost/shade/components'; import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useCreateIntegration} from '@tryghost/admin-x-framework/api/integrations'; @@ -15,12 +14,13 @@ function AddIntegrationModal() { const {mutateAsync: createIntegration} = useCreateIntegration(); const limiter = useLimiter(); const handleError = useHandleError(); + const {showLimit} = useConfirmation(); useEffect(() => { if (limiter?.isLimited('customIntegrations')) { limiter.errorIfWouldGoOverLimit('customIntegrations').catch((error) => { if (error instanceof HostLimitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: error.message || `Your current plan doesn't support more custom integrations.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); @@ -28,7 +28,7 @@ function AddIntegrationModal() { } }); } - }, [limiter, updateRoute]); + }, [limiter, showLimit, updateRoute]); return = ({in const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const {mutateAsync: uploadImage} = useUploadImage(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {formState, updateForm, handleSave, saveState, errors, clearError, okProps} = useForm({ initialState: integration, @@ -64,7 +64,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in const name = apiKey.type === 'content' ? 'Content' : 'Admin'; - NiceModal.show(ConfirmationModal, { + confirm({ title: `Regenerate ${name} API Key`, prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`, okLabel: `Regenerate ${name} API Key`, diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx index a9bfc9c2fdc..aa77cacf6bd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx @@ -1,14 +1,13 @@ import APIKeys from './api-keys'; import BookmarkThumb from '@/settings/app/assets/images/integrations/ghost-transistor.png'; import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationHeader from './integration-header'; -import NiceModal from '@ebay/nice-modal-react'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -23,6 +22,7 @@ function TransistorModal() { const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [regenerated, setRegenerated] = useState(false); const builtInApiIntegrationsDisabled = config.hostSettings?.limits?.customIntegrations?.disabled; @@ -50,7 +50,7 @@ function TransistorModal() { setRegenerated(false); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Regenerate Admin API Key', prompt: 'You will need to update the API key in your Transistor account settings after regenerating.', okLabel: 'Regenerate Admin API Key', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx index 1f3e4ad29af..977bb1a35e8 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx @@ -1,4 +1,5 @@ import WebhooksTable from '@/settings/app/components/settings/advanced/integrations/webhooks-table'; +import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {render, screen} from '@testing-library/react'; @@ -17,7 +18,7 @@ describe('WebhooksTable', () => { webhooks: [] } as unknown as Integration; - const {container} = render(); + const {container} = render(); expect(screen.getByRole('heading', {name: 'No webhooks'})).toBeInTheDocument(); expect(screen.getByText('Add a webhook to send Ghost events to another service.')).toBeInTheDocument(); @@ -40,7 +41,7 @@ describe('WebhooksTable', () => { }] } as unknown as Integration; - const {container} = render(); + const {container} = render(); const table = screen.getByRole('table'); const addButton = screen.getByRole('button', {name: 'Add webhook'}); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx index 21253318ae4..a3d8717303a 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx @@ -1,4 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import NiceModal from '@ebay/nice-modal-react'; import WebhookModal from './webhook-modal'; import {Button, EmptyIndicator, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; @@ -7,12 +6,14 @@ import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { const {mutateAsync: deleteWebhook} = useDeleteWebhook(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const webhooks = integration.webhooks || []; const showAddWebhookModal = () => { @@ -22,7 +23,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { }; const handleDelete = (id: string) => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure?', prompt: 'Deleting this webhook may prevent the integration from functioning.', okVariant: 'destructive', diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx index 7da7fbe9c10..6e32adfaf4a 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx @@ -1,14 +1,13 @@ import APIKeys from './api-keys'; import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import IntegrationHeader from './integration-header'; -import NiceModal from '@ebay/nice-modal-react'; import ZapierLogo from '@/settings/app/assets/images/zapier-logo.svg'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -31,6 +30,7 @@ function ZapierModal() { const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [regenerated, setRegenerated] = useState(false); const zapierDisabled = config.hostSettings?.limits?.customIntegrations?.disabled; @@ -50,7 +50,7 @@ function ZapierModal() { setRegenerated(false); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Regenerate Admin API Key', prompt: 'You will need to locate the Ghost App within your Zapier account and click on "Reconnect" to enter the new Admin API Key.', okLabel: 'Regenerate Admin API Key', diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx index 6798ef96292..63aec562128 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,10 +1,10 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import {Button, Dropzone} from '@tryghost/shade/components'; import {ExternalLink} from 'lucide-react'; import {Inline} from '@tryghost/shade/primitives'; import {SettingsModal} from '@tryghost/shade/patterns'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; @@ -13,6 +13,7 @@ const UniversalImportModal: React.FC = () => { const {mutateAsync: importContent} = useImportContent(); const [uploading, setUploading] = useState(false); const handleError = useHandleError(); + const {confirm} = useConfirmation(); return ( { try { await importContent(file); modal.remove(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Import in progress', prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.`, cancelLabel: '', diff --git a/apps/admin/src/settings/app/components/settings/email/emails.tsx b/apps/admin/src/settings/app/components/settings/email/emails.tsx index 9e994664229..673676d185e 100644 --- a/apps/admin/src/settings/app/components/settings/email/emails.tsx +++ b/apps/admin/src/settings/app/components/settings/email/emails.tsx @@ -1,4 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import DefaultRecipients from './default-recipients'; import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; @@ -13,6 +12,7 @@ import {APIError} from '@tryghost/admin-x-framework/errors'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -68,6 +68,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> const verifyEmailToken = useQueryParams().getParam('verifyEmail'); const {mutateAsync: verifySenderUpdate} = useVerifyAutomatedEmailSender(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const submittedTokenRef = useRef(null); const [selectedTab, setSelectedTab] = useState<'newsletters' | 'transactional'>(newslettersEnabled ? 'newsletters' : 'transactional'); const [newslettersFilter, setNewslettersFilter] = useState('active'); @@ -102,7 +103,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> } updateRoute('emails'); - NiceModal.show(ConfirmationModal, { + confirm({ title, prompt, okLabel: 'Close', @@ -117,7 +118,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> } updateRoute('emails'); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Error verifying email address', prompt, okLabel: 'Close', @@ -129,7 +130,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> }; verify(); - }, [handleError, updateRoute, verifyEmailToken, verifySenderUpdate]); + }, [confirm, handleError, updateRoute, verifyEmailToken, verifySenderUpdate]); const openNewNewsletter = () => { updateRoute('newsletters/new'); diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx b/apps/admin/src/settings/app/components/settings/email/newsletters.tsx index a72e51c1b1f..400e157bf9f 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx +++ b/apps/admin/src/settings/app/components/settings/email/newsletters.tsx @@ -1,6 +1,4 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import NewslettersList from './newsletters/newsletters-list'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode, useEffect, useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import useQueryParams from '@/settings/app/hooks/use-query-params'; @@ -10,17 +8,17 @@ import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; -const NavigateToNewsletter = ({id, children}: {id: string; children: ReactNode}) => { - const modal = useModal(); +const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); return { updateRoute(`newsletters/${id}`); - modal.remove(); + onNavigate(); }}>{children}; }; @@ -37,6 +35,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { const verifyEmailToken = useQueryParams().getParam('verifyEmail'); const {mutateAsync: verifyEmail} = useVerifyNewsletterEmail(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [newsletters, setNewsletters] = useState(apiNewsletters || []); @@ -52,21 +51,23 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { const verify = async () => { try { const {newsletters: [updatedNewsletter], meta: {email_verified: emailVerified} = {}} = await verifyEmail({token: verifyEmailToken}); + const handleRef: {current: ConfirmationHandle | null} = {current: null}; + const closeConfirmation = () => handleRef.current?.remove(); let title; let prompt; if (emailVerified && emailVerified === 'sender_email') { title = 'Newsletter email verified'; - prompt = <>Newsletter {updatedNewsletter.name} will now be sent from {updatedNewsletter.sender_email}.>; + prompt = <>Newsletter {updatedNewsletter.name} will now be sent from {updatedNewsletter.sender_email}.>; } else if (emailVerified && emailVerified === 'sender_reply_to') { title = 'Reply-to address verified'; - prompt = <>Newsletter {updatedNewsletter.name} will now use {updatedNewsletter.sender_reply_to} as the reply-to address.>; + prompt = <>Newsletter {updatedNewsletter.name} will now use {updatedNewsletter.sender_reply_to} as the reply-to address.>; } else { title = 'Email address verified'; - prompt = <>Email address for newsletter {updatedNewsletter.name} has been changed.>; + prompt = <>Email address for newsletter {updatedNewsletter.name} has been changed.>; } - NiceModal.show(ConfirmationModal, { + handleRef.current = confirm({ title, prompt, okLabel: 'Close', @@ -79,7 +80,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { if (e instanceof APIError && e.message === 'Token expired') { prompt = 'Verification link has expired.'; } - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Error verifying email address', prompt: prompt, okLabel: 'Close', @@ -90,7 +91,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { } }; verify(); - }, [verifyEmailToken, handleError, verifyEmail]); + }, [verifyEmailToken, handleError, verifyEmail, confirm]); const buttons = ( { diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx b/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx index 75ac7b3ed43..d7ed745a3e8 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx @@ -1,9 +1,8 @@ -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Switch, Textarea} from '@tryghost/shade/components'; import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {formatNumber} from '@tryghost/shade/utils'; @@ -15,6 +14,7 @@ const AddNewsletterModal: React.FC = () => { const {updateRoute} = useSettingsNavigation(); const returnRoute = useFeatureFlag('automations') ? 'emails' : 'newsletters'; const handleError = useHandleError(); + const {showLimit} = useConfirmation(); const [isCheckingLimit, setIsCheckingLimit] = useState(true); const [limitError, setLimitError] = useState(null); @@ -75,13 +75,13 @@ const AddNewsletterModal: React.FC = () => { useEffect(() => { if (limitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: limitError.message || `Your current plan doesn't support more newsletters.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); updateRoute(returnRoute); } - }, [limitError, returnRoute, updateRoute]); + }, [limitError, returnRoute, updateRoute, showLimit]); if (isCheckingLimit || limitError) { return null; diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx b/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx index 3360feda276..62b67678fea 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx @@ -1,10 +1,7 @@ import ColorPickerField from '@/settings/app/components/color-picker-field'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import HeaderImageField from '@/settings/app/components/settings/email-design/header-image-field'; import HtmlField from '@/settings/app/components/html-field'; -import LimitModal from '@/settings/app/components/limit-modal'; import NewsletterPreview from './newsletter-preview'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useCallback, useEffect, useState} from 'react'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import useSettingGroup from '@/settings/app/hooks/use-setting-group'; @@ -16,6 +13,7 @@ import {Inline, Stack} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useParams} from '@tryghost/admin-x-framework'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {getSettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -107,6 +105,7 @@ const Sidebar: React.FC<{ const {localSettings} = useSettingGroup(); const [siteTitle] = getSettingValues(localSettings, ['title']) as string[]; const handleError = useHandleError(); + const {confirm, showLimit} = useConfirmation(); const {data: {newsletters: apiNewsletters} = {}} = useBrowseNewsletters(); const commentsEnabled = ['all', 'paid'].includes(getSettingValue(settings, 'comments_enabled') || ''); @@ -153,7 +152,7 @@ const Sidebar: React.FC<{ const confirmStatusChange = async () => { if (newsletter.status === 'active') { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Archive newsletter', prompt: <> Your newsletter {newsletter.name} will no longer be visible to members or available as an option when publishing new posts. @@ -176,7 +175,7 @@ const Sidebar: React.FC<{ await limiter?.errorIfWouldGoOverLimit('newsletters'); } catch (error) { if (error instanceof HostLimitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: error.message || `Your current plan doesn't support more newsletters.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); @@ -186,7 +185,7 @@ const Sidebar: React.FC<{ } } - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reactivate newsletter', prompt: <> Reactivating {newsletter.name} will immediately make it visible to members and re-enable it as an option when publishing new posts. diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx b/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx index 513162c8d8d..27434c66435 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx +++ b/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx @@ -1,6 +1,4 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import NewslettersList from './newsletters-list'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode, useEffect, useState} from 'react'; import useQueryParams from '@/settings/app/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; @@ -9,17 +7,17 @@ import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; -const NavigateToNewsletter = ({id, children}: {id: string; children: ReactNode}) => { - const modal = useModal(); +const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); return { updateRoute(`newsletters/${id}`); - modal.remove(); + onNavigate(); }}>{children}; }; @@ -44,6 +42,7 @@ const NewslettersTabContent: React.FC = ({filter}) = const verifyEmailToken = useQueryParams().getParam('verifyEmail'); const {mutateAsync: verifyEmail} = useVerifyNewsletterEmail(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [newsletters, setNewsletters] = useState(apiNewsletters || []); @@ -59,21 +58,23 @@ const NewslettersTabContent: React.FC = ({filter}) = const verify = async () => { try { const {newsletters: [updatedNewsletter], meta: {email_verified: emailVerified} = {}} = await verifyEmail({token: verifyEmailToken}); + const handleRef: {current: ConfirmationHandle | null} = {current: null}; + const closeConfirmation = () => handleRef.current?.remove(); let title; let prompt; if (emailVerified && emailVerified === 'sender_email') { title = 'Newsletter email verified'; - prompt = <>Newsletter {updatedNewsletter.name} will now be sent from {updatedNewsletter.sender_email}.>; + prompt = <>Newsletter {updatedNewsletter.name} will now be sent from {updatedNewsletter.sender_email}.>; } else if (emailVerified && emailVerified === 'sender_reply_to') { title = 'Reply-to address verified'; - prompt = <>Newsletter {updatedNewsletter.name} will now use {updatedNewsletter.sender_reply_to} as the reply-to address.>; + prompt = <>Newsletter {updatedNewsletter.name} will now use {updatedNewsletter.sender_reply_to} as the reply-to address.>; } else { title = 'Email address verified'; - prompt = <>Email address for newsletter {updatedNewsletter.name} has been changed.>; + prompt = <>Email address for newsletter {updatedNewsletter.name} has been changed.>; } - NiceModal.show(ConfirmationModal, { + handleRef.current = confirm({ title, prompt, okLabel: 'Close', @@ -86,7 +87,7 @@ const NewslettersTabContent: React.FC = ({filter}) = if (e instanceof APIError && e.message === 'Token expired') { prompt = 'Verification link has expired.'; } - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Error verifying email address', prompt: prompt, okLabel: 'Close', @@ -97,7 +98,7 @@ const NewslettersTabContent: React.FC = ({filter}) = } }; verify(); - }, [verifyEmailToken, handleError, verifyEmail]); + }, [verifyEmailToken, handleError, verifyEmail, confirm]); const sortedActiveNewsletters = newsletters.filter(n => n.status === 'active').sort((a, b) => a.sort_order - b.sort_order) || []; const archivedNewsletters = newsletters.filter(newsletter => newsletter.status !== 'active'); diff --git a/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx b/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx index b01d8b565d3..ee71145a161 100644 --- a/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx @@ -1,7 +1,4 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import EmailNotificationsTab from './users/email-notifications-tab'; -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import ProfileTab from './users/profile-tab'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import SocialLinksTab from './users/social-links-tab'; @@ -24,6 +21,7 @@ import {Text} from '@tryghost/shade/primitives'; import {type User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useGetUserBySlug, useMakeOwner} from '@tryghost/admin-x-framework/api/users'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; const validators: Record) => string> = { @@ -91,6 +89,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel const {ownerUser} = useStaffUsers(); const {currentUser} = useGlobalData(); const handleError = useHandleError(); + const {confirm, showLimit} = useConfirmation(); const {formState, setFormState, saveState, handleSave, updateForm, errors, setErrors, clearError, okProps} = useForm({ initialState: user, savingDelay: 500, @@ -172,7 +171,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel await limiter?.errorIfWouldGoOverLimit('staff'); } catch (error) { if (error instanceof HostLimitError) { - NiceModal.show(LimitModal, { + showLimit({ formSheet: true, prompt: error.message || `Your current plan doesn't support more users.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) @@ -188,7 +187,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel if (_user.status === 'inactive') { warningText = 'This user will be able to log in again and will have the same permissions they had previously.'; } - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure you want to suspend this user?', prompt: ( <> @@ -216,7 +215,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel }; const confirmDelete = (_user: User, {owner}: {owner: User}) => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure you want to delete this user?', prompt: ( <> @@ -243,7 +242,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel }; const confirmMakeOwner = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Transfer Ownership', prompt: 'Are you sure you want to transfer the ownership of this blog? You will not be able to undo this action.', okLabel: 'Yep — I\'m sure', diff --git a/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx b/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx index b922aaebc43..1bba7534a1b 100644 --- a/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx +++ b/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx @@ -1,8 +1,7 @@ import APIKeys from '@/settings/app/components/settings/advanced/integrations/api-keys'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import {Text} from '@tryghost/shade/primitives'; import {genStaffToken, getStaffToken} from '@tryghost/admin-x-framework/api/staff-token'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -11,6 +10,7 @@ const StaffToken: React.FC = () => { enabled: false }); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const [token, setToken] = useState(''); const {mutateAsync: newApiKey} = genStaffToken(); @@ -25,7 +25,7 @@ const StaffToken: React.FC = () => { } , [apiKey]); const genConfirmation = () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Regenerate your Staff Access Token', prompt: 'You can regenerate your Staff Access Token any time, but any scripts or applications using it will need to be updated.', okLabel: 'Regenerate your Staff Access Token', diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx index 05bba1ff0f5..d71bc1bbd8a 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx @@ -1,5 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame'; import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Textarea} from '@tryghost/shade/components'; @@ -12,6 +10,7 @@ import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useEffect, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -34,6 +33,7 @@ const Sidebar: React.FC<{ const {siteData} = useGlobalData(); const [isCopied, setIsCopied] = useState(false); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {mutateAsync: editOffer} = useEditOffer(); const [nameLength, setNameLength] = useState(offer?.name.length || 0); @@ -57,7 +57,7 @@ const Sidebar: React.FC<{ const confirmStatusChange = async () => { if (offer?.status === 'active') { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Archive offer', prompt: <> New members will no longer be able to subscribe using this offer. @@ -77,7 +77,7 @@ const Sidebar: React.FC<{ } }); } else { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reactivate offer', prompt: <> Reactivating {offer?.name} will allow new members to subscribe using this offer. Existing members will remain unchanged. diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx index 0246fb0969a..44b50f2c728 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx @@ -1,11 +1,10 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form'; import {Button} from '@tryghost/shade/components'; import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface EditRecommendationModalProps { @@ -17,6 +16,7 @@ const EditRecommendationModal: React.FC = ({recomm const {mutateAsync: editRecommendation} = useEditRecommendation(); const {mutateAsync: deleteRecommendation} = useDeleteRecommendation(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {formState, updateForm, handleSave, errors, clearError, setErrors, okProps} = useForm({ initialState: { @@ -37,7 +37,7 @@ const EditRecommendationModal: React.FC = ({recomm const leftButton = ( { onClose(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Delete recommendation', prompt: <> Your recommendation {recommendation.title} will no longer be visible to your audience. diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx index 27c9f523924..ef9fc2ba5ee 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx @@ -1,4 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import CustomFieldIcon from './custom-field-icon'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; @@ -8,6 +7,7 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {ValidationError, getErrorMessage} from '@tryghost/admin-x-framework/errors'; import {memberCustomFieldUserTypes, useCreateMemberCustomField, useDeleteMemberCustomField, useEditMemberCustomField, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; @@ -31,6 +31,7 @@ const renderTypeOption = (option: {label: string; value: string}) => ( const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) => { const modal = useModal(); + const {confirm} = useConfirmation(); const {mutateAsync: createField} = useCreateMemberCustomField(); const {mutateAsync: editField} = useEditMemberCustomField(); const {mutateAsync: deleteField} = useDeleteMemberCustomField(); @@ -85,7 +86,7 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) const archiveButton = ( { modal.remove(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Archive custom field', prompt: <> Your custom field {field!.name} will no longer show up on your members, collect new information, or appear in filters. @@ -113,7 +114,7 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) const reactivateButton = ( { modal.remove(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Reactivate custom field', prompt: <> Reactivating {field!.name} will immediately make it available again on your members, for collecting, and in filters. @@ -145,7 +146,7 @@ const CustomFieldModal = NiceModal.create<{field?: MemberCustomField}>(({field}) // would put irreversible data loss on equal footing with Save. const confirmDeleteField = () => { modal.remove(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Delete custom field', prompt: <>{field!.name} and every value collected from your members will be permanently deleted from the database. This can’t be undone.>, okLabel: 'Delete', diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx b/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx index 458198ce579..5bfcf1ed7ec 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx @@ -1,4 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useRef} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; @@ -13,6 +12,7 @@ import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailRecord import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail, useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; @@ -145,6 +145,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: editAutomatedEmail, isPending: isEditingAutomatedEmail} = useEditAutomatedEmail(); const {mutateAsync: verifySenderUpdate} = useVerifyAutomatedEmailSender(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const automatedEmails = automatedEmailsData?.automated_emails || []; const isMutating = isAddingAutomatedEmail || isEditingAutomatedEmail; @@ -196,7 +197,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { prompt = <>Welcome email reply-to address has been verified and updated.>; } - NiceModal.show(ConfirmationModal, { + confirm({ title, prompt, okLabel: 'Close', @@ -212,7 +213,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { clearVerifyEmailFromRoute(); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Error verifying email address', prompt, okLabel: 'Close', @@ -224,7 +225,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { }; verify(); - }, [handleError, verifyEmailToken, verifySenderUpdate]); + }, [confirm, handleError, verifyEmailToken, verifySenderUpdate]); const handleToggle = async (emailType: 'free' | 'paid') => { const existing = automatedEmails.find(email => email.slug === WELCOME_EMAIL_SLUGS[emailType]); diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx index 9d0f554093a..0c2f988aa0a 100644 --- a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx @@ -1,7 +1,5 @@ import AccountPage from './account-page'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import LookAndFeel from './look-and-feel'; -import NiceModal from '@ebay/nice-modal-react'; import PortalPreview from './portal-preview'; import React, {useEffect, useState} from 'react'; import SignupOptions from './signup-options'; @@ -12,6 +10,7 @@ import {type Setting, type SettingValue, getSettingValues, useEditSettings} from import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type Tier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; import {fullEmailAddress} from '@tryghost/admin-x-framework/api/site'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useFocusContext} from '@tryghost/shade/app'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -71,6 +70,7 @@ const PortalModal: React.FC = () => { const [selectedSidebarTab, setSelectedSidebarTab] = useState('signupOptions'); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {settings, siteData, config} = useGlobalData(); const {mutateAsync: editSettings} = useEditSettings(); const {data: {tiers: allTiers} = {}} = useBrowseTiers(); @@ -88,7 +88,7 @@ const PortalModal: React.FC = () => { try { const {settings: verifiedSettings} = await verifyToken({token}); const [supportEmail] = getSettingValues(verifiedSettings, ['members_support_address']); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Support address verified', prompt: <>Your support email address has been changed to {supportEmail}.>, okLabel: 'Close', @@ -102,7 +102,7 @@ const PortalModal: React.FC = () => { if (e?.message === 'Token expired') { prompt = 'Verification link has expired.'; } - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Error verifying support address', prompt: prompt, okLabel: 'Close', @@ -115,7 +115,7 @@ const PortalModal: React.FC = () => { if (verifyEmail) { checkToken({token: verifyEmail}); } - }, [handleError, verifyEmail, verifyToken]); + }, [confirm, handleError, verifyEmail, verifyToken]); const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm({ initialState: { @@ -143,7 +143,7 @@ const PortalModal: React.FC = () => { const currentEmail = currentSettings.find(setting => setting.key === 'support_email_address')?.value || fullEmailAddress(currentSettings.find(setting => setting.key === 'members_support_address')?.value?.toString() || 'noreply', siteData, config); - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Confirm email address', prompt: <> We've sent a confirmation email to {newEmail}. diff --git a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx index 8dafce99460..64d0d50f149 100644 --- a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx @@ -1,9 +1,6 @@ import BookmarkThumb from '@/settings/app/assets/images/stripe-thumb.jpg'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import GhostLogo from '@/settings/app/assets/images/orb-squircle.png'; import GhostLogoPink from '@/settings/app/assets/images/orb-pink.png'; -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import StripeButton from '@/settings/app/components/stripe-button'; import StripeLogo from '@/settings/app/assets/images/stripe-emblem.svg'; @@ -20,6 +17,7 @@ import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; import {useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -161,6 +159,7 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => { const {mutateAsync: deleteStripeSettings} = useDeleteStripeSettings(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const openDisconnectStripeModal = async () => { const {data} = await fetchMembers(); @@ -168,7 +167,7 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => { // const hasActiveStripeSubscriptions = false; //... // this.ghostPaths.url.api('/members/') + '?filter=status:paid&limit=0'; - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Disconnect Stripe', prompt: (hasActiveStripeSubscriptions ? 'Cannot disconnect while there are members with active Stripe subscriptions.' : <>You‘re about to disconnect your Stripe account {stripeConnectAccountName} from this site. This will automatically turn off paid memberships on this site.>), okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect', @@ -260,6 +259,7 @@ const StripeConnectModal: React.FC = () => { const {updateRoute} = useSettingsNavigation(); const [step, setStep] = useState<'start' | 'connect'>('start'); const limiter = useLimiter(); + const {showLimit} = useConfirmation(); // Extract specific values needed for checkStripeEnabled, so not to // cause unnecessary re-renders by passing the whole settings object @@ -276,7 +276,7 @@ const StripeConnectModal: React.FC = () => { } catch (error) { if (error instanceof HostLimitError) { updateRoute('tiers'); - NiceModal.show(LimitModal, { + showLimit({ prompt: error.message || `Your current plan doesn't support Stripe Connect.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); @@ -286,7 +286,7 @@ const StripeConnectModal: React.FC = () => { }; checkLimit(); - }, [limiter, updateRoute, stripeEnabled, hasStripeConnectLimit]); + }, [limiter, updateRoute, stripeEnabled, hasStripeConnectLimit, showLimit]); const startFlow = () => { setStep('connect'); diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx b/apps/admin/src/settings/app/components/settings/membership/tiers.tsx index 32143999ff7..d20901f5ff2 100644 --- a/apps/admin/src/settings/app/components/settings/membership/tiers.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/tiers.tsx @@ -1,5 +1,3 @@ -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import StripeButton from '@/settings/app/components/stripe-button'; import TiersList from './tiers/tiers-list'; @@ -10,6 +8,7 @@ import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; import {type Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; import {formatNumber} from '@tryghost/shade/utils'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; @@ -35,6 +34,7 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { const archivedTiers = getArchivedTiers(tiers || []); const {updateRoute} = useSettingsNavigation(); const limiter = useLimiter(); + const {showLimit} = useConfirmation(); const openConnectModal = async () => { // Allow Stripe despite the limit when it's already connected, so it's @@ -44,7 +44,7 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { await limiter.errorIfWouldGoOverLimit('limitStripeConnect'); } catch (error) { if (error instanceof HostLimitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: error.message || `Your current plan doesn't support Stripe Connect.`, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); diff --git a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx b/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx index 1e35c187ebc..0008ec9df37 100644 --- a/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/membership/tiers/tier-detail-modal.tsx @@ -1,5 +1,3 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useRef} from 'react'; import TierDetailPreview from './tier-detail-preview'; import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; @@ -17,6 +15,7 @@ import {type Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admi import {currencies, currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; export type TierFormState = Partial> & { trial_days: string; @@ -32,6 +31,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => { const {mutateAsync: editSettings} = useEditSettings(); const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {localSettings, siteData} = useSettingGroup(); const [portalPlansJson] = getSettingValues(localSettings, ['portal_plans']) as string[]; const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[]; @@ -181,7 +181,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => { Existing members will remain unchanged. >; const okLabel = tier.active ? 'Archive' : 'Reactivate'; - NiceModal.show(ConfirmationModal, { + confirm({ title: promptTitle, prompt: prompt, okLabel: okLabel, diff --git a/apps/admin/src/settings/app/components/settings/site/change-theme.tsx b/apps/admin/src/settings/app/components/settings/site/change-theme.tsx index cc201660fbc..85bfc8bb2f5 100644 --- a/apps/admin/src/settings/app/components/settings/site/change-theme.tsx +++ b/apps/admin/src/settings/app/components/settings/site/change-theme.tsx @@ -1,5 +1,3 @@ -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import TopLevelGroup from '@/settings/app/components/top-level-group'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; @@ -9,6 +7,7 @@ import {Text} from '@tryghost/shade/primitives'; import {type Theme, useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; @@ -17,6 +16,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { const [isCheckingLimit, setIsCheckingLimit] = useState(false); const {checkThemeLimitError} = useCheckThemeLimitError(); const {route, updateRoute} = useSettingsNavigation(); + const {showLimit} = useConfirmation(); const {data: themesData} = useBrowseThemes(); const activeTheme = themesData?.themes.find((theme: Theme) => theme.active); @@ -38,7 +38,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { } if (themeLimitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: themeLimitError, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); @@ -55,7 +55,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({keywords}) => { const limitError = await checkThemeLimitError('.'); if (limitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: limitError, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); diff --git a/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx b/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx index a8f8f5da5bd..2197cd5c789 100644 --- a/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/design-and-theme-modal.tsx @@ -1,9 +1,8 @@ import ChangeThemeModal from './theme-modal'; import DesignModal from './design-modal'; -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal from '@ebay/nice-modal-react'; import React, {useCallback, useEffect, useState} from 'react'; import ThemeCodeEditorModal from './theme/theme-code-editor-modal'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; import {parseEditingThemeRoute} from './theme/theme-editor-utils'; import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; @@ -20,13 +19,14 @@ const DesignAndThemeModal: React.FC = () => { const [installationAllowed, setInstallationAllowed] = useState(null); const [hasCheckedInstallation, setHasCheckedInstallation] = useState(false); const {themeName: editingThemeName, isInvalid: hasInvalidEditingThemeRoute} = parseEditingThemeRoute(currentPath); + const {showLimit} = useConfirmation(); const showThemeLimitModal = useCallback((error: string) => { - NiceModal.show(LimitModal, { + showLimit({ prompt: error, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); - }, [updateRoute]); + }, [showLimit, updateRoute]); useEffect(() => { const checkIfThemeChangeAllowed = async () => { diff --git a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx index 8febf0c9828..9c533f2a154 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx @@ -1,8 +1,6 @@ import AdvancedThemeSettings from './theme/advanced-theme-settings'; -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import InvalidThemeModal, {type FatalErrors} from './theme/invalid-theme-modal'; -import LimitModal from '@/settings/app/components/limit-modal'; -import NiceModal, {useModal} from '@ebay/nice-modal-react'; +import NiceModal from '@ebay/nice-modal-react'; import OfficialThemes from './theme/official-themes'; import React, {useEffect, useState} from 'react'; import ThemeInstalledModal from './theme/theme-installed-modal'; @@ -14,6 +12,7 @@ import {type OfficialTheme} from '@/settings/app/components/providers/settings-a import {PageHeader, SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -34,14 +33,11 @@ interface ThemeModalContentProps { } const UploadModalContent: React.FC<{onUpload: (file: File) => void}> = ({onUpload}) => { - const modal = useModal(); - return ( { - modal.remove(); onUpload(file); }} > @@ -59,6 +55,7 @@ const ThemeToolbar: React.FC = ({ const {mutateAsync: uploadTheme} = useUploadTheme(); const {checkThemeLimitError, isThemeLimited} = useCheckThemeLimitError(); const handleError = useHandleError(); + const {confirm, showLimit} = useConfirmation(); const [uploadConfig, setUploadConfig] = useState<{enabled: boolean; error?: string} | undefined>(); const [isUploading, setUploading] = useState(false); @@ -86,7 +83,7 @@ const ThemeToolbar: React.FC = ({ const themeFileName = file?.name.replace(/\.zip$/, ''); const existingThemeNames = themes.map(t => t.name); if (isDefaultOrLegacyTheme({name: themeFileName})) { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Upload failed', cancelLabel: 'Cancel', okLabel: '', @@ -101,7 +98,7 @@ const ThemeToolbar: React.FC = ({ } }); } else if (existingThemeNames.includes(themeFileName)) { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Overwrite theme', prompt: ( <> @@ -228,14 +225,18 @@ const ThemeToolbar: React.FC = ({ } if (uploadConfig.enabled) { - NiceModal.show(ConfirmationModal, { + const handleRef: {current: ConfirmationHandle | null} = {current: null}; + handleRef.current = confirm({ title: 'Upload theme', - prompt: , + prompt: { + handleRef.current?.remove(); + onThemeUpload(file); + }} />, okLabel: '', formSheet: false }); } else { - NiceModal.show(LimitModal, { + showLimit({ title: 'Upgrade to enable custom themes', prompt: uploadConfig.error || <>Your current plan only supports official themes. You can install them from the Ghost theme marketplace.>, onOk: () => updateRoute({route: '/pro', isExternal: true}) @@ -308,6 +309,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = const {mutateAsync: activateTheme} = useActivateTheme(); const {checkThemeLimitError} = useCheckThemeLimitError(); const handleError = useHandleError(); + const {confirm, showLimit} = useConfirmation(); const onSelectTheme = (theme: OfficialTheme|null) => { setSelectedTheme(theme); @@ -353,7 +355,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = > } >; - NiceModal.show(ConfirmationModal, { + confirm({ title: titleText, prompt, okLabel: 'Install', @@ -388,7 +390,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = }; handleUrlInstallation(); - }, [themeRef, source, installTheme, handleError, activateTheme, updateRoute, themes, installedFromMarketplace, checkThemeLimitError, isMounted]); + }, [themeRef, source, installTheme, handleError, activateTheme, updateRoute, themes, installedFromMarketplace, checkThemeLimitError, confirm, isMounted]); if (!themes) { return; @@ -402,7 +404,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = // Check theme limit FIRST, before any confirmation modals const limitError = await checkThemeLimitError(selectedTheme.name); if (limitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: limitError, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); @@ -412,7 +414,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = // Handle the overwrite confirmation if needed if (installedTheme && !isDefaultOrLegacyTheme(selectedTheme)) { return new Promise((resolve) => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Overwrite theme', prompt: ( <> diff --git a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx index 805ca7e8979..f0e1c8a4b8e 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx @@ -1,6 +1,4 @@ -import ConfirmationModal from '@/settings/app/components/confirmation-modal'; import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; -import LimitModal from '@/settings/app/components/limit-modal'; import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; @@ -12,6 +10,7 @@ import {type Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyThe import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; @@ -60,6 +59,7 @@ const ThemeActions: React.FC = ({ const handleError = useHandleError(); const {route, updateRoute} = useSettingsNavigation(); const {checkThemeLimitError} = useCheckThemeLimitError(); + const {confirm, showLimit} = useConfirmation(); const handleActivate = async () => { try { @@ -96,7 +96,7 @@ const ThemeActions: React.FC = ({ }; const handleDelete = async () => { - NiceModal.show(ConfirmationModal, { + confirm({ title: 'Are you sure you want to delete this?', prompt: ( <> @@ -131,7 +131,7 @@ const ThemeActions: React.FC = ({ const limitError = await checkThemeLimitError('.'); if (limitError) { - NiceModal.show(LimitModal, { + showLimit({ prompt: limitError, onOk: () => updateRoute({route: '/pro', isExternal: true}) }); diff --git a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx index 7838b262849..dfa07682af9 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx @@ -1,4 +1,4 @@ -import NiceModal from '@ebay/nice-modal-react'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; @@ -21,6 +21,8 @@ const InvalidThemeModal: React.FC<{ const blockingIssueCount = blockingProblems.length + stringErrors.length; const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.>; + const modal = useModal(); + return } stickyFooter={true} title={title} + visible={modal.visible} onOk={onRetry} + onRemove={() => modal.remove()} />; }; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx index 7ab99b48e7a..353af778f05 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx @@ -1,4 +1,4 @@ -import NiceModal from '@ebay/nice-modal-react'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {type ReactNode} from 'react'; import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; @@ -46,6 +46,8 @@ const ThemeInstalledModal: React.FC<{ > ); + const modal = useModal(); + return } stickyFooter={true} title={modalTitle} + visible={modal.visible} onOk={async (activateModal) => { if (!installedTheme.active) { try { @@ -88,6 +91,7 @@ const ThemeInstalledModal: React.FC<{ onActivate?.(); activateModal?.remove(); }} + onRemove={() => modal.remove()} />; }; From 9a2e092189f49a4631b5431a7e8121ff307b265e Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Mon, 10 Aug 2026 17:02:23 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20missing=20email=20no?= =?UTF-8?q?tification=20when=20a=20member=20import=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/BER-3816 A member import too large to run inside the request is accepted straight away and reported by email once the background job finishes, and when that job failed no email was ever sent. The publisher was told to expect one and nothing ever came, leaving a failed import indistinguishable from one still running. Nobody else found out either, since a queued import runs in-process where a thrown error never reaches the error tracker. The import now runs in three phases and each says what it may do. Gathering what the rows are written against is the only phase allowed to throw, so a throw means nothing was written; writing reports rather than throws, since once a row has committed an import that failed halfway is not one that never ran; and settling up afterwards guards each step on its own, so a failed lookup cannot skip archiving a Stripe price that nothing else will ever clean up. The job then always tells the publisher something, sending a distinct email for an import that could not be completed at all. Whether a row failed for a reason the publisher can act on is not asked. A row that failed is a row that failed, and it goes into the report they receive with the reason beside it. Nothing inspects those errors, which is also why a row's values -- which a database driver inlines into the message of the statement that failed -- cannot reach a log or an error tracker from here. What does reach them is the run itself failing, a cleanup that did not finish, or a notification that could not be sent, none of which carry a row. --- .../import-export/import/completion-email.ts | 70 ++- .../{email-template.js => email-template.ts} | 55 +- .../members/import-export/import/importer.ts | 185 ++++-- .../members/import-export/import/spool.ts | 7 +- .../services/members/import-export/index.ts | 29 +- .../import/completion-email.test.ts | 38 +- .../import/error-handling.test.ts | 546 ++++++++++++++++++ 7 files changed, 811 insertions(+), 119 deletions(-) rename ghost/core/core/server/services/members/import-export/import/{email-template.js => email-template.ts} (80%) create mode 100644 ghost/core/test/unit/server/services/members/import-export/import/error-handling.test.ts diff --git a/ghost/core/core/server/services/members/import-export/import/completion-email.ts b/ghost/core/core/server/services/members/import-export/import/completion-email.ts index 0051a448190..7aa8bac6f18 100644 --- a/ghost/core/core/server/services/members/import-export/import/completion-email.ts +++ b/ghost/core/core/server/services/members/import-export/import/completion-email.ts @@ -1,22 +1,28 @@ import {serialize} from '../csv'; +import renderImportEmail, {headingFor, type ImportEmailSummary} from './email-template'; import {isCustomFieldColumn} from '@tryghost/custom-field-types/csv'; import type {MemberImportRow, ImportErrorRow, ImportLabel, Label} from './row'; -const emailTemplate = require('./email-template'); - // The finished import as the email reads it: how many imported and which rows // failed. Structural, so the importer's richer result satisfies it directly. interface ImportSummary { imported: number; errors: ImportErrorRow[]; + importLabel?: ImportLabel; +} + +export interface EmailLinks { + siteUrl(): URL; + membersUrl(labelSlug?: string): URL; } -interface CompletionEmailInput { - result: ImportSummary; +// A null result is an import that never produced one. It stopped before writing anything, +// which by then can only be our doing: the file was parsed and mapped inside the request. +interface ImportEmailInput { + result: ImportSummary | null; recipient: string; labelName: string; - importLabel: ImportLabel | null; - urlFor: (type: string, data: unknown, absolute: boolean) => string; + links: EmailLinks; } interface EmailPayload { @@ -102,15 +108,12 @@ function toErrorReportRow(row: ImportErrorRow): ErrorReportRow { }; } -// The error report attached to the completion email: the failed rows as CSV. It shares -// the serialiser with the export but not the shaping -- the export writes db members, -// this echoes submitted rows. Member columns come from the shaper's keys (so the type +// The error report attached to the completion email: the failed rows as CSV, called only +// when there are rows to list. It shares the serialiser with the export but not the +// shaping -- the export writes db members, this echoes submitted rows. Member columns come from the shaper's keys (so the type // stays the single source); the custom_fields.* columns across the rows are threaded in // before the last error column, and each row's custom cells merged on. function buildErrorReport(errors: ImportErrorRow[]): string { - if (errors.length === 0) { - return serialize([]); - } const memberColumns = Object.keys(toErrorReportRow(errors[0])).filter(column => column !== 'error'); const customColumns = [...new Set(errors.flatMap(row => Object.keys(customFieldCells(row))))]; const columns = [...memberColumns, ...customColumns, 'error']; @@ -118,29 +121,32 @@ function buildErrorReport(errors: ImportErrorRow[]): string { return serialize(rows, {columns}); } -// Compose the completion email for a finished import: the summary and its links, -// plus the attached error report. Owns how the outcome is presented, so the -// importer yields only the result and never touches email or CSV formatting. -export default function buildCompletionEmail({result, recipient, labelName, importLabel, urlFor}: CompletionEmailInput): EmailPayload { - const siteUrl = new URL(urlFor('home', null, true)); - const membersUrl = new URL('members', urlFor('admin', null, true)); - if (importLabel) { - membersUrl.searchParams.set('label', importLabel.slug); - } - - const html = emailTemplate({result, siteUrl, membersUrl, emailRecipient: recipient, importLabel}); - const subject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful'; +// The one email an import sends, whatever became of it. What the publisher is told and +// what is attached both follow from the result: no result means nothing was written, so +// there is nothing in their file to fix and an attached CSV would say there was, and no +// failed rows means there is nothing for a report to list. +export default function buildImportEmail({result, recipient, labelName, links}: ImportEmailInput): EmailPayload { + const summary: ImportEmailSummary = !result ? 'did-not-run' : (result.imported > 0 ? 'added' : 'all-failed'); return { to: recipient, - subject, - html, + subject: headingFor[summary], + html: renderImportEmail({ + summary, + imported: result?.imported ?? 0, + errorCount: result?.errors.length ?? 0, + siteUrl: links.siteUrl(), + membersUrl: links.membersUrl(result?.importLabel?.slug), + emailRecipient: recipient + }), forceTextContent: true, - attachments: [{ - filename: `${labelName} - Errors.csv`, - content: buildErrorReport(result.errors), - contentType: 'text/csv', - contentDisposition: 'attachment' - }] + attachments: result?.errors.length + ? [{ + filename: `${labelName} - Errors.csv`, + content: buildErrorReport(result.errors), + contentType: 'text/csv', + contentDisposition: 'attachment' + }] + : [] }; } diff --git a/ghost/core/core/server/services/members/import-export/import/email-template.js b/ghost/core/core/server/services/members/import-export/import/email-template.ts similarity index 80% rename from ghost/core/core/server/services/members/import-export/import/email-template.js rename to ghost/core/core/server/services/members/import-export/import/email-template.ts index 27a2e6f4ba4..6e4c6a5b97a 100644 --- a/ghost/core/core/server/services/members/import-export/import/email-template.js +++ b/ghost/core/core/server/services/members/import-export/import/email-template.ts @@ -1,15 +1,40 @@ -function formatNumber(number) { - return number.toLocaleString(); +function formatNumber(value: number): string { + return value.toLocaleString(); } -const iff = (cond, yes, no) => (cond ? yes : no); -module.exports = ({result, siteUrl, membersUrl, emailRecipient}) => ` +const iff = (cond: boolean, yes: string, no: string): string => (cond ? yes : no); + +// The outcomes this email knows how to render. Exhaustive: a state added here without a +// paragraph below sends a heading and nothing else, to a publisher, from a background job. +export type ImportEmailSummary = 'did-not-run' | 'all-failed' | 'added'; + +// The heading is a pure function of the outcome, and the subject is the same sentence, so +// both are read from here rather than derived twice and left to disagree. +export const headingFor: Record = { + 'did-not-run': 'Your member import could not be completed', + 'all-failed': 'Your member import was unsuccessful', + added: 'Your member import is complete' +}; + +interface ImportEmailProps { + summary: ImportEmailSummary; + imported: number; + errorCount: number; + siteUrl: URL; + membersUrl: URL; + emailRecipient: string; +} + +// The states are exclusive, so each paragraph below tests for exactly one. +export default function renderImportEmail({summary, imported, errorCount, siteUrl, membersUrl, emailRecipient}: ImportEmailProps): string { + const heading = headingFor[summary]; + return ` - Your member import is complete + ${heading} '; + const t = tag({name: 'News', slug: 'news', codeinjection_head: head, codeinjection_foot: foot}); + const saveApi = fakeTagWorld(t); + await renderAdminApp(`/tags/${t.slug}`, FLAGS); + + await page.getByRole('button', {name: /Code injection/}).click(); + const headerEditor = page.getByRole('textbox', {name: /^Tag header/}); + const footerEditor = page.getByRole('textbox', {name: /^Tag footer/}); + await expect.element(headerEditor).toBeVisible(); + await expect.element(footerEditor).toBeVisible(); + await expect.poll(() => (headerEditor.element() as HTMLElement).innerText).toBe(head); + await expect.poll(() => (footerEditor.element() as HTMLElement).innerText).toBe(foot); + + await headerEditor.fill(''); + await footerEditor.fill(''); + await page.getByRole('button', {name: 'Save'}).click(); + + await expect.element(page.getByRole('button', {name: 'Saved'})).toBeVisible(); + const saved = (saveApi.lastRequest?.body as {tags: Array>}).tags[0]; + expect(saved.codeinjection_head).toBe(''); + expect(saved.codeinjection_foot).toBe(''); + }); + + it('keeps CodeMirror autocomplete outside the clipped editor surface', async () => { + const t = tag({name: 'News', slug: 'news'}); + fakeTagWorld(t); + await renderAdminApp(`/tags/${t.slug}`, FLAGS); + + await page.getByRole('button', {name: /Code injection/}).click(); + await new Promise((resolve) => { + window.setTimeout(resolve, 250); + }); + const headerEditor = page.getByRole('textbox', {name: /^Tag header/}); + await headerEditor.fill('<'); + + await new Promise((resolve) => { + window.setTimeout(resolve, 75); + }); + + await expect.poll(() => { + const tooltip = document.querySelector('.cm-tooltip-autocomplete'); + const tooltipParent = tooltip?.closest('.cm-tooltip-parent'); + const container = tooltipParent?.firstElementChild as HTMLElement | null; + const editor = headerEditor.element().closest('[data-testid="codeinjection-head"]'); + + if (!tooltip || !tooltipParent || !container || !editor) { + return null; + } + + const tooltipRect = tooltip.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + + return { + containerBackground: getComputedStyle(container).backgroundColor, + containerHeight: container.getBoundingClientRect().height, + escapesEditor: tooltipRect.bottom > editorRect.bottom || tooltipRect.top < editorRect.top, + hostParent: tooltipParent.parentElement?.tagName, + tooltipOnscreen: tooltipRect.bottom > 0 + && tooltipRect.right > 0 + && tooltipRect.top < window.innerHeight + && tooltipRect.left < window.innerWidth, + tooltipPosition: getComputedStyle(tooltip).position + }; + }).toEqual({ + containerBackground: 'rgba(0, 0, 0, 0)', + containerHeight: 0, + escapesEditor: true, + hostParent: 'BODY', + tooltipOnscreen: true, + tooltipPosition: 'fixed' + }); + }); + it('redirects to billing during a force upgrade', async () => { const config = configResponse(FLAGS); config.config.hostSettings = {forceUpgrade: true}; diff --git a/apps/shade/package.json b/apps/shade/package.json index 758e45f3d59..98aa4f16b86 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -103,10 +103,11 @@ "vitest": "catalog:" }, "dependencies": { - "@ebay/nice-modal-react": "catalog:", + "@codemirror/state": "catalog:", "@dnd-kit/core": "catalog:", "@dnd-kit/sortable": "catalog:", "@dnd-kit/utilities": "catalog:", + "@ebay/nice-modal-react": "catalog:", "@hookform/resolvers": "5.4.0", "@number-flow/react": "0.6.2", "@radix-ui/react-accordion": "1.2.15", @@ -132,6 +133,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/validator": "catalog:", + "@uiw/react-codemirror": "catalog:", "class-variance-authority": "0.7.1", "clsx": "catalog:", "cmdk": "1.1.1", diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts index 5adf05147e9..8a13658bcff 100644 --- a/apps/shade/src/components.ts +++ b/apps/shade/src/components.ts @@ -12,6 +12,7 @@ export * from './components/ui/calendar'; export * from './components/ui/card'; export * from './components/ui/chart'; export * from './components/ui/checkbox'; +export * from './components/ui/code-editor'; export * from './components/ui/command'; export * from './components/ui/combobox'; export * from './components/ui/context-menu'; diff --git a/apps/shade/src/components/ui/code-editor-view.tsx b/apps/shade/src/components/ui/code-editor-view.tsx new file mode 100644 index 00000000000..ed0e02d8390 --- /dev/null +++ b/apps/shade/src/components/ui/code-editor-view.tsx @@ -0,0 +1,206 @@ +import CodeMirror, {EditorView, tooltips, type BasicSetupOptions, type ReactCodeMirrorProps, type ReactCodeMirrorRef} from '@uiw/react-codemirror'; +import React, {type FocusEventHandler, forwardRef, useEffect, useId, useMemo, useRef, useState} from 'react'; + +import {FieldDescription, FieldLabel} from '@/components/ui/field'; +import {inputSurface} from '@/components/ui/input-surface'; +import {Stack} from '@/components/primitives/stack'; +import {cn} from '@/lib/utils'; +import {useFocusContext} from '@/providers/shade-provider'; +import type {Extension} from '@codemirror/state'; + +export interface CodeEditorProps extends Omit { + title?: React.ReactNode; + value?: string; + height?: string; + error?: boolean; + hint?: React.ReactNode; + clearBg?: boolean; + extensions: Array | (() => Extension | Promise)>; + ariaLabel?: string; + onChange?: (value: string) => void; +} + +const codeMirrorClasses = [ + '[&_.cm-editor]:bg-transparent', + '[&_.cm-editor]:border-transparent', + '[&_.cm-scroller]:font-mono', + '[&_.cm-scroller]:border-transparent', + '[&_.cm-activeLine]:bg-transparent', + '[&_.cm-activeLineGutter]:bg-transparent', + '[&_.cm-gutters]:bg-muted', + '[&_.cm-gutters]:text-muted-foreground', + '[&_.cm-gutters]:border-border', + '[&_.cm-cursor]:border-foreground' +].join(' '); + +// Imported asynchronously by CodeEditor so CodeMirror stays out of the main bundle. +const CodeEditorView = forwardRef(function CodeEditorView({ + title, + value, + height = '200px', + error, + hint, + clearBg = true, + extensions, + ariaLabel, + editable = true, + onChange, + onFocus, + onBlur, + className, + ...props +}, ref) { + const id = useId(); + const labelId = `${id}-label`; + const hintId = `${id}-description`; + const hasTitle = Boolean(title); + const sizeRef = useRef(null); + const {darkMode, setFocusState} = useFocusContext(); + const focusedRef = useRef(false); + const setFocusStateRef = useRef(setFocusState); + const [width, setWidth] = useState(100); + const [resolvedExtensions, setResolvedExtensions] = useState([]); + + setFocusStateRef.current = setFocusState; + + // Keep autocomplete outside the editor and accordion overflow boundaries. + // CodeMirror can switch fixed tooltips to absolute after its first layout + // measurement. A viewport-sized body host gives both positioning modes the + // same coordinate space, preventing the visible first-frame jump. + const [tooltipParent] = useState(() => document.createElement('div')); + + useEffect(() => { + tooltipParent.className = 'shade cm-tooltip-parent pointer-events-none fixed inset-0 z-[60]'; + document.body.appendChild(tooltipParent); + + return () => { + tooltipParent.remove(); + }; + }, [tooltipParent]); + + const basicSetup = useMemo(() => ({ + crosshairCursor: false, + searchKeymap: false + }), []); + + const editorExtensions = useMemo(() => { + const contentAttributes: Record = {id}; + + if (hasTitle) { + contentAttributes['aria-labelledby'] = labelId; + } else if (ariaLabel) { + contentAttributes['aria-label'] = ariaLabel; + } + if (error) { + contentAttributes['aria-invalid'] = 'true'; + } + if (hint) { + contentAttributes['aria-describedby'] = hintId; + } + if (!editable) { + contentAttributes['aria-disabled'] = 'true'; + } + + return [ + ...resolvedExtensions, + tooltips({position: 'fixed', parent: tooltipParent}), + EditorView.contentAttributes.of(contentAttributes) + ]; + }, [ariaLabel, editable, error, hasTitle, hint, hintId, id, labelId, resolvedExtensions, tooltipParent]); + + const handleFocus: FocusEventHandler = (event) => { + onFocus?.(event); + focusedRef.current = true; + setFocusState(true); + }; + + const handleBlur: FocusEventHandler = (event) => { + onBlur?.(event); + focusedRef.current = false; + setFocusState(false); + }; + + const handleLabelClick = () => { + if (editable) { + document.getElementById(id)?.focus(); + } + }; + + useEffect(() => { + return () => { + if (focusedRef.current) { + setFocusStateRef.current(false); + } + }; + }, []); + + useEffect(() => { + let cancelled = false; + + const pendingExtensions = extensions.map(extension => ( + typeof extension === 'function' ? extension() : extension + )); + + Promise.all(pendingExtensions).then((nextExtensions) => { + if (!cancelled) { + setResolvedExtensions(nextExtensions); + } + }, () => { + if (!cancelled) { + setResolvedExtensions([]); + } + }); + + return () => { + cancelled = true; + }; + }, [extensions]); + + useEffect(() => { + const resizeObserver = new ResizeObserver(([entry]) => { + setWidth(entry.contentRect.width); + }); + + resizeObserver.observe(sizeRef.current!); + + return () => resizeObserver.disconnect(); + }, []); + + const styles = cn( + inputSurface('within'), + 'peer order-2 w-full max-w-full overflow-hidden', + clearBg && 'bg-transparent', + height === 'full' && 'h-full', + !editable && 'cursor-not-allowed opacity-50', + codeMirrorClasses, + className + ); + + return ( + + + + {title && {title}} + + {hint && {hint}} + + + ); +}); + +CodeEditorView.displayName = 'CodeEditorView'; + +export default CodeEditorView; diff --git a/apps/shade/src/components/ui/code-editor.stories.tsx b/apps/shade/src/components/ui/code-editor.stories.tsx new file mode 100644 index 00000000000..49ce0fcdd0e --- /dev/null +++ b/apps/shade/src/components/ui/code-editor.stories.tsx @@ -0,0 +1,91 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; + +import {CodeEditor} from '@/components/ui/code-editor'; + +const meta = { + title: 'Components / CodeEditor', + component: CodeEditor, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'CodeMirror-backed input for editing code with consumer-provided language support.' + } + } + }, + decorators: [ + Story => ( + + + + ) + ] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + extensions: [], + hint: 'Injected into {{ghost_head}}', + title: 'Code injection', + value: '' + }, + parameters: { + docs: { + description: { + story: 'Use for editable code with a visible label and supporting hint.' + } + } + } +}; + +export const Focused: Story = { + args: { + autoFocus: true, + extensions: [], + title: 'Focused editor', + value: 'body { color: currentColor; }' + }, + parameters: { + docs: { + description: { + story: 'Shows the focus-visible treatment used while editing code.' + } + } + } +}; + +export const WithError: Story = { + args: { + error: true, + extensions: [], + hint: 'Something is wrong with this code.', + title: 'Code injection', + value: '' + }, + parameters: { + docs: { + description: { + story: 'Use the error state to associate invalid editor chrome with a corrective hint.' + } + } + } +}; + +export const Disabled: Story = { + args: { + editable: false, + extensions: [], + title: 'Code injection', + value: '' + }, + parameters: { + docs: { + description: { + story: 'Use when code should remain visible but cannot be changed.' + } + } + } +}; diff --git a/apps/shade/src/components/ui/code-editor.tsx b/apps/shade/src/components/ui/code-editor.tsx new file mode 100644 index 00000000000..71978e05a97 --- /dev/null +++ b/apps/shade/src/components/ui/code-editor.tsx @@ -0,0 +1,20 @@ +import React, {Suspense, forwardRef} from 'react'; + +import type {CodeEditorProps} from '@/components/ui/code-editor-view'; +import type {ReactCodeMirrorRef} from '@uiw/react-codemirror'; + +export type {CodeEditorProps}; + +const CodeEditorView = React.lazy(() => import('@/components/ui/code-editor-view')); + +const CodeEditor = forwardRef(function CodeEditor(props, ref) { + return ( + + + + ); +}); + +CodeEditor.displayName = 'CodeEditor'; + +export {CodeEditor}; diff --git a/apps/shade/styles.css b/apps/shade/styles.css index 28ca4b3a95f..c5b382e909f 100644 --- a/apps/shade/styles.css +++ b/apps/shade/styles.css @@ -107,6 +107,23 @@ html.theme-switching *::after { scrollbar-width: none; /* Firefox */ } +/* CodeEditor autocomplete is portalled to a viewport-sized body child so it + can escape form-control and accordion overflow boundaries. CodeMirror adds + an intermediate container carrying the editor theme classes; collapse and + clear that container so those classes cannot paint a full-screen surface. */ +.cm-tooltip-parent > * { + height: 0 !important; + background: none !important; +} + +.cm-tooltip-parent .cm-tooltip { + pointer-events: auto; +} + +.cm-tooltip-parent .cm-tooltip-autocomplete.cm-tooltip ul li:not([aria-selected]) { + background: var(--background); +} + /* Prose classes are for formatting arbitrary HTML that comes from the API */ .gh-prose-links a { color: #30cf43; diff --git a/apps/shade/test/unit/components/ui/code-editor.test.tsx b/apps/shade/test/unit/components/ui/code-editor.test.tsx new file mode 100644 index 00000000000..0d0befcab34 --- /dev/null +++ b/apps/shade/test/unit/components/ui/code-editor.test.tsx @@ -0,0 +1,83 @@ +import assert from 'assert/strict'; +import {beforeEach, describe, it} from 'vitest'; +import {fireEvent, screen} from '@testing-library/react'; + +import ShadeProvider, {useFocusContext} from '../../../../src/providers/shade-provider'; +import {CodeEditor} from '../../../../src/components/ui/code-editor'; +import {render} from '../../utils/test-utils'; + +class ResizeObserverMock implements ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +const FocusState = () => { + const {isAnyTextFieldFocused} = useFocusContext(); + return {String(isAnyTextFieldFocused)}; +}; + +const Harness = ({showEditor = true}: {showEditor?: boolean}) => ( + + {showEditor && ( + + )} + + +); + +describe('CodeEditor', () => { + beforeEach(() => { + global.ResizeObserver = ResizeObserverMock; + }); + + it('associates hint text with the editable content', async () => { + render(); + + const editor = await screen.findByRole('textbox', {name: 'Code editor'}); + const descriptionId = editor.getAttribute('aria-describedby'); + + assert.ok(descriptionId); + assert.equal(document.getElementById(descriptionId)?.textContent, 'Add valid HTML.'); + }); + + it('associates the visible title and renders as one layout item', async () => { + render( + + + + + + ); + + const editor = await screen.findByRole('textbox', {name: 'HTML editor'}); + const labelId = editor.getAttribute('aria-labelledby'); + + assert.ok(labelId); + assert.equal(document.getElementById(labelId)?.textContent, 'HTML editor'); + assert.equal(screen.getByTestId('editor-layout').childElementCount, 1); + + fireEvent.click(document.getElementById(labelId)!); + assert.equal(document.activeElement, editor); + }); + + it('clears the shared focus state when a focused editor unmounts', async () => { + const {rerender} = render(); + const editor = await screen.findByRole('textbox', {name: 'Code editor'}); + + fireEvent.focus(editor); + assert.equal(screen.getByTestId('focus-state').textContent, 'true'); + + rerender(); + assert.equal(screen.getByTestId('focus-state').textContent, 'false'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c407e16296..5055db5ce9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1629,6 +1629,9 @@ importers: apps/shade: dependencies: + '@codemirror/state': + specifier: 'catalog:' + version: 6.7.1 '@dnd-kit/core': specifier: 'catalog:' version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1716,6 +1719,9 @@ importers: '@types/validator': specifier: 'catalog:' version: 13.15.10 + '@uiw/react-codemirror': + specifier: 'catalog:' + version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.6)(codemirror@6.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: 0.7.1 version: 0.7.1 From ae6ed4b47acf2493371378468792d2cf2e65d643 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Mon, 10 Aug 2026 16:32:44 +0100 Subject: [PATCH 4/7] Pinned that the members API neither reads nor writes custom fields ref BER-3863 An investigation into whether a member could reach custom fields through the members API found that they cannot, and never could. Every member-facing surface serialises from a field whitelist that predates the feature, and the member update path drops unknown keys twice before it reaches any custom-field code. Nothing asserted that, so these two tests pin it: a member's own response carries no custom fields, and custom fields a member sends are dropped while the rest of the update still applies, the same way email already behaves on that endpoint. --- .../e2e-api/members/custom-fields.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 ghost/core/test/e2e-api/members/custom-fields.test.ts diff --git a/ghost/core/test/e2e-api/members/custom-fields.test.ts b/ghost/core/test/e2e-api/members/custom-fields.test.ts new file mode 100644 index 00000000000..011cd3f342a --- /dev/null +++ b/ghost/core/test/e2e-api/members/custom-fields.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; + +const {agentProvider, fixtureManager, mockManager} = require('../../utils/e2e-framework'); +const models = require('../../../core/server/models'); + +// Custom fields are staff-only. Nothing in the members API is written to expose or +// accept them — the member response is built from a field whitelist that predates the +// feature, and the update path drops unknown keys — but nothing asserted it. These pin +// the two endpoints that hand a member their own payload. Other member-facing surfaces +// (newsletter preferences, theme member data, the comments author shape) narrow through +// their own whitelists and are not covered here. +describe('Member Custom Fields Members API', function () { + let adminAgent: { + get: (_url: string) => any; + put: (_url: string) => any; + post: (_url: string) => any; + loginAsOwner: () => Promise; + }; + let membersAgent: { + get: (_url: string) => any; + put: (_url: string) => any; + loginAs: (_email: string) => Promise; + }; + let memberId: string; + let fieldKey: string; + let fieldCounter = 0; + + async function readValuesAsStaff() { + const {body} = await adminAgent + .get(`members/${memberId}/`) + .expectStatus(200); + return body.members[0].custom_fields; + } + + beforeAll(async function () { + ({adminAgent, membersAgent} = await agentProvider.getAgentsForMembers()); + await fixtureManager.init('newsletters', 'members:newsletters'); + await adminAgent.loginAsOwner(); + await membersAgent.loginAs('member@example.com'); + + const member = await models.Member.findOne({email: 'member@example.com'}, {require: true}); + memberId = member.id; + }); + + beforeEach(async function () { + mockManager.mockLabsEnabled('membersCustomFields'); + + fieldCounter += 1; + const {body} = await adminAgent + .post('members/custom_fields/') + .body({members_custom_fields: [{name: `Shoe size ${fieldCounter}`, type: 'short_text'}]}) + .expectStatus(201); + fieldKey = body.members_custom_fields[0].key; + + await adminAgent + .put(`members/${memberId}/`) + .body({members: [{custom_fields: {[fieldKey]: '9'}}]}) + .expectStatus(200); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('does not return custom fields to the member who holds them', async function () { + const {body} = await membersAgent + .get('/api/member/') + .expectStatus(200); + + assert.equal(Object.hasOwn(body, 'custom_fields'), false); + }); + + it('does not write custom fields a member sends', async function () { + const {body} = await membersAgent + .put('/api/member/') + .body({name: 'Renamed', custom_fields: {[fieldKey]: '12'}}) + .expectStatus(200); + + // The rest of the body still applies, so the value is dropped rather than + // the request being rejected — the same way `email` behaves here. + assert.equal(body.name, 'Renamed'); + assert.equal(Object.hasOwn(body, 'custom_fields'), false); + assert.equal((await readValuesAsStaff())[fieldKey], '9'); + }); +}); From da98f00bb38aa6d09b741c82d02a1bc1a5d6b62f Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Tue, 11 Aug 2026 00:17:23 +0100 Subject: [PATCH 5/7] Changed custom field keys to be minted with underscores ref https://linear.app/ghost/issue/BER-3862 A key is typed by hand into member filters, CSV columns, email replacement strings and, before long, themes and editor cards. The hyphen a slug separates with is the one character those readers disagree about: NQL will not parse a hyphen in a property path, and a replacement string matches word characters only, so a hyphenated field reaches the reader as literal text with nothing to say it was never substituted. The convention contradicted itself as well, since the parts of a composite field were already underscored and a name typed with underscores kept them, leaving a single export column carrying both. Underneath that the format had no definition of its own. It was whatever slugify produced, so a rule a growing number of surfaces depend on lived in a helper that answers to URLs and could widen on a version bump. The characters a key may contain are now stated where its readers are known, as an allowlist rather than a list of things to strip out, and the libraries keep only the transliteration and invisible-character stripping they own. Trimming the ends falls out of that rule and makes __proto__ unmintable under every spelling, so it no longer needs reserving alongside constructor. A key is minted once and never changes, so a definition created earlier keeps its old key for good, and rewriting one in place would leave its stored values and every reference to it pointing somewhere else. Those definitions are discarded with their values instead: custom fields sit behind a private flag and have never been released, so only a site that deliberately opted in can hold one, and re-creating the field re-mints the key. The migration measures a key against the shape this release mints rather than looking for a hyphen, because the previous minting passed underscores through untouched and the definitions endpoint shipped four days before the reserved-key guard did. --- .../import-members/upload.test.ts | 12 +-- ...-field-definitions-for-underscored-keys.js | 58 +++++++++++ .../definitions-service.ts | 41 ++++---- .../services/members-custom-fields/key.ts | 35 +++++++ ghost/core/package.json | 3 +- .../admin/member-custom-fields.test.ts | 99 ++++++++++++------- .../members-custom-fields/key.test.ts | 61 ++++++++++++ .../values-service.test.ts | 2 +- .../services/members-bread-service.test.js | 2 +- pnpm-lock.yaml | 6 ++ pnpm-workspace.yaml | 1 + 11 files changed, 257 insertions(+), 63 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-15-24-09-reset-custom-field-definitions-for-underscored-keys.js create mode 100644 ghost/core/core/server/services/members-custom-fields/key.ts create mode 100644 ghost/core/test/unit/server/services/members-custom-fields/key.test.ts diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/upload.test.ts b/apps/admin/src/members/components/bulk-action-modals/import-members/upload.test.ts index 5647b2149f3..2ca2e3fcd3b 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/upload.test.ts +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/upload.test.ts @@ -114,7 +114,7 @@ describe('buildImportResponse', () => { it('keeps a reason whole however it is punctuated', () => { // A reason may quote a cell the publisher wrote, and a CSV cell legally holds // both a comma and a newline. - const punctuated = 'custom_fields.home-address.country: Enter a 2-letter country code, like US.'; + const punctuated = 'custom_fields.home_address.country: Enter a 2-letter country code, like US.'; const multiline = '"Gold\nPlan" is not a valid tier.'; const result = buildImportResponse({ meta: { @@ -197,9 +197,9 @@ describe('buildImportResponse', () => { imported: 0, invalid: [{ email: 'a@test.com', - 'custom_fields.home-address.country': 'IRL', - errors: ['Missing email address', 'custom_fields.home-address.country: Enter a 2-letter country code, like US.'], - error: 'Missing email address\ncustom_fields.home-address.country: Enter a 2-letter country code, like US.' + 'custom_fields.home_address.country': 'IRL', + errors: ['Missing email address', 'custom_fields.home_address.country: Enter a 2-letter country code, like US.'], + error: 'Missing email address\ncustom_fields.home_address.country: Enter a 2-letter country code, like US.' }] }, import_label: {name: 'Test', slug: 'test'} @@ -210,7 +210,7 @@ describe('buildImportResponse', () => { // CRLF between rows; a reason may itself contain a bare newline. const [header] = csv.split('\r\n'); - expect(header).toBe('"email","custom_fields.home-address.country","error"'); - expect(csv).toContain('"Missing email address\ncustom_fields.home-address.country: Enter a 2-letter country code, like US."'); + expect(header).toBe('"email","custom_fields.home_address.country","error"'); + expect(csv).toContain('"Missing email address\ncustom_fields.home_address.country: Enter a 2-letter country code, like US."'); }); }); diff --git a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-15-24-09-reset-custom-field-definitions-for-underscored-keys.js b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-15-24-09-reset-custom-field-definitions-for-underscored-keys.js new file mode 100644 index 00000000000..53ee130025b --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-15-24-09-reset-custom-field-definitions-for-underscored-keys.js @@ -0,0 +1,58 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); + +const FIELDS_TABLE = 'members_custom_fields'; +const VALUES_TABLE = 'members_custom_field_values'; + +// The shape this release mints: alphanumeric runs joined by single underscores, with no +// separator at either end. Written out here rather than imported from the service, so a +// later change to how keys are minted cannot change what this migration did. +const MINTED_KEY_SHAPE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/; + +// A key naming an inherited property reads back as that property wherever a member's +// values are indexed, so it goes too, whatever its shape. Minting has refused these +// since 6.53, but the definitions endpoint shipped four days before that guard did. +const INHERITED_PROPERTY_NAMES = Object.getOwnPropertyNames(Object.prototype); + +function isUnmintable(key) { + return !MINTED_KEY_SHAPE.test(key) || INHERITED_PROPERTY_NAMES.includes(key); +} + +// A key is minted once and never changes, so a definition created before this release +// keeps whatever shape it was given. Those definitions are discarded rather than +// rewritten: custom fields sit behind a private flag and have never been released, so +// only a site deliberately opted in can hold one, and re-creating a field re-mints it. +// +// Only the keys this release could not produce go. Hyphens are what changed, but they +// are not the whole set: the previous minting passed underscores through untouched, so +// a leading, trailing or doubled one survived it too. +// +// Every definition is read rather than filtered in SQL. The table is small, the rule is +// a regular expression neither engine agrees on, and `_` is a wildcard in LIKE, so the +// pattern that looks obvious is not the one that runs. +// +// Values are deleted explicitly rather than left to the foreign key: it cascades on +// MySQL, but SQLite only enforces one when `foreign_keys` is on, which knex-migrator +// does not guarantee. +module.exports = createTransactionalMigration( + async function up(knex) { + const definitions = await knex(FIELDS_TABLE).select('id', 'key'); + const discarded = definitions.filter(definition => isUnmintable(definition.key)); + + if (discarded.length === 0) { + logging.info('No custom field definitions to discard: every key is one this release can mint'); + return; + } + + const ids = discarded.map(definition => definition.id); + const discardedValues = await knex(VALUES_TABLE).whereIn('custom_field_id', ids).del(); + const discardedFields = await knex(FIELDS_TABLE).whereIn('id', ids).del(); + + logging.info(`Discarded ${discardedFields} custom field definition(s) this release cannot mint, and ${discardedValues} value(s): ${discarded.map(definition => definition.key).join(', ')}`); + }, + async function down() { + // Nothing to undo: an underscored key is a key the previous release can read and + // address, it is only one it would not have minted. + logging.info('Leaving underscored custom field keys in place: the previous release reads them unchanged'); + } +); diff --git a/ghost/core/core/server/services/members-custom-fields/definitions-service.ts b/ghost/core/core/server/services/members-custom-fields/definitions-service.ts index 83bf760ca3e..6235504e634 100644 --- a/ghost/core/core/server/services/members-custom-fields/definitions-service.ts +++ b/ghost/core/core/server/services/members-custom-fields/definitions-service.ts @@ -7,11 +7,9 @@ import {FieldTypeSchema} from '@tryghost/custom-field-types'; import {customFieldCodec} from './codec'; import {FIELD_STATUS, FieldStatusSchema} from './schema'; import {activeFields} from './queries'; +import {mintableKey} from './key'; import {type RecordCustomFieldAction, type RequestContext} from './actions'; -// @tryghost/string ships no types; slugify is the same helper tags/labels use. -const {slugify} = require('@tryghost/string') as {slugify(input: string): string}; - // The same NQL -> knex bridge Bookshelf's filter plugin uses, applied directly to // our raw-knex query: nql parses the `filter` string to a Mongo query, mongo-knex // turns that into parametrised WHERE clauses. Neither needs a Bookshelf model. @@ -28,22 +26,20 @@ const TABLE = 'members_custom_fields'; const columns = require('../../data/schema').tables[TABLE]; const MAX_NAME_LENGTH: number = columns.name.maxlength; const MAX_KEY_LENGTH: number = columns.key.maxlength; -const MAX_SLUG_ITERATIONS = 1000; -// Reserve room for a `-` suffix (n up to MAX_SLUG_ITERATIONS). -const MAX_KEY_BASE_LENGTH = MAX_KEY_LENGTH - (String(MAX_SLUG_ITERATIONS).length + 1); +const MAX_KEY_ITERATIONS = 1000; +// Reserve room for a `_` suffix (n up to MAX_KEY_ITERATIONS). +const MAX_KEY_BASE_LENGTH = MAX_KEY_LENGTH - (String(MAX_KEY_ITERATIONS).length + 1); // A key becomes a property name on the plain objects that carry a member's values — // on both sides of the wire, since `custom_fields` is JSON and a client gets a plain // object from JSON.parse. A key naming a member of Object.prototype reads back as -// inherited rather than absent wherever one of those objects is indexed, and -// `__proto__` holds no value at all: the values schema drops it during parse, and -// assigning it sets a prototype rather than a property. +// inherited rather than absent wherever one of those objects is indexed. // // Derived rather than listed, because the set is a consequence of how keys are -// minted: slugifying lowercases, so only an already-lowercase prototype name can -// survive to become a key. Currently `constructor` and `__proto__`. +// minted: minting lowercases and trims leading underscores, so a prototype name +// survives only if it has neither. `constructor` is the only one. const RESERVED_KEYS = Object.getOwnPropertyNames(Object.prototype) - .filter(name => slugify(name) === name); + .filter(name => mintableKey(name) === name); const FieldName = z.string().trim().min(1, {message: 'Custom field name is required.'}).max(MAX_NAME_LENGTH, {message: 'Custom field name is too long.'}); @@ -124,8 +120,8 @@ export class CustomFieldDefinitionsService { * * Running inside the transaction also makes a batch self-consistent for free — * `assertNameAvailable` and `mintKey` see the rows inserted earlier in the same - * batch, so two items sharing a name are caught and two items sharing a slug - * get distinct keys, exactly as if they had arrived as separate requests. + * batch, so two items sharing a name are caught and two items deriving the same + * key get distinct ones, exactly as if they had arrived as separate requests. */ async add(context: RequestContext, input: unknown): Promise { const requestedCount = Array.isArray(input) ? input.length : 0; @@ -141,10 +137,10 @@ export class CustomFieldDefinitionsService { } const fields = parsed.data; - // Slugify before opening the transaction: it needs no database access, and + // Mint before opening the transaction: it needs no database access, and // an unusable name is a payload problem worth reporting on its own terms. const bases = fields.map((field, index) => { - const base = slugify(field.name); + const base = mintableKey(field.name); if (!base) { throw new errors.ValidationError({ message: 'Custom field name must contain at least one usable character.', @@ -240,12 +236,15 @@ export class CustomFieldDefinitionsService { } /** - * Pick a free key from the name's slug: `base`, then `base-2`, `base-3`, ... + * Pick a free key from the name's base: `base`, then `base_2`, `base_3`, ... * Reads the keys already taken by that base — including archived fields, so a - * slug is never reused once minted. Mirrors how tags/labels generate slugs. + * key is never reused once minted. */ private async mintKey(db: Knex, base: string): Promise { - const safeBase = base.slice(0, MAX_KEY_BASE_LENGTH); + // Trimmed again after cutting, because the cut can land mid-separator and + // a key that ends in one is not a shape minting is allowed to produce. The + // base starts with an alphanumeric, so something always survives. + const safeBase = base.slice(0, MAX_KEY_BASE_LENGTH).replace(/_+$/, ''); const taken = new Set([ ...RESERVED_KEYS, ...await db(TABLE).where('key', 'like', `${safeBase}%`).pluck('key') @@ -253,8 +252,8 @@ export class CustomFieldDefinitionsService { if (!taken.has(safeBase)) { return safeBase; } - for (let suffix = 2; suffix <= MAX_SLUG_ITERATIONS; suffix += 1) { - const candidate = `${safeBase}-${suffix}`; + for (let suffix = 2; suffix <= MAX_KEY_ITERATIONS; suffix += 1) { + const candidate = `${safeBase}_${suffix}`; if (!taken.has(candidate)) { return candidate; } diff --git a/ghost/core/core/server/services/members-custom-fields/key.ts b/ghost/core/core/server/services/members-custom-fields/key.ts new file mode 100644 index 00000000000..4aed59637c3 --- /dev/null +++ b/ghost/core/core/server/services/members-custom-fields/key.ts @@ -0,0 +1,35 @@ +// Neither package ships types. +const {stripInvisibleChars} = require('@tryghost/string') as {stripInvisibleChars(input: string): string}; +const unidecode = require('unidecode') as (input: string) => string; + +/** Every character a key may contain. Nothing else survives minting. */ +export const KEY_CHARACTERS = /^[a-z0-9_]+$/; + +/** + * Mint the key a field is addressed by, from the name a publisher chose. + * + * A key is typed by hand into member filters, CSV columns, email replacement strings + * and, later, themes. Those agree on letters, digits and the underscore, and disagree + * about everything else, so what a key may contain is stated here as an allowlist + * rather than inherited from slugify, whose separator and reserved characters answer + * to URLs. The two formats happen to be near-identical today; they are not the same + * rule, and this one belongs where its consumers are known. + * + * Transliteration and invisible-character stripping stay with the libraries that own + * them. Only the policy is local. + * + * Runs collapse and the ends are trimmed, so no key can lead or trail with a + * separator. `__proto__` is unmintable as a result, which is worth more than + * reserving it would be. + * + * Returns an empty string for a name with nothing usable in it; the caller decides + * what to do about that. + */ +export function mintableKey(name: string): string { + return unidecode(stripInvisibleChars(name)) + .toLowerCase() + // Dropped rather than separated, so a possessive reads as one word. + .replace(/'/g, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} diff --git a/ghost/core/package.json b/ghost/core/package.json index ed885e49510..cfe4e517fa1 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.57.2-rc.0", + "version": "6.58.0-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -238,6 +238,7 @@ "tldts": "catalog:", "type-fest": "catalog:", "ua-parser-js": "1.0.41", + "unidecode": "catalog:", "xml": "1.0.1", "zod": "catalog:" }, diff --git a/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts b/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts index 942a3ba6c48..eb006ac307c 100644 --- a/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts +++ b/ghost/core/test/e2e-api/admin/member-custom-fields.test.ts @@ -81,9 +81,9 @@ describe('Member Custom Fields Admin API', function () { assert.deepEqual(body.members_custom_fields, []); }); - it('creates a field, minting a slug key from the name', async function () { + it('creates a field, minting an underscored key from the name', async function () { const created = await createField({name: 'Favourite topic'}); - assert.equal(created.key, 'favourite-topic'); + assert.equal(created.key, 'favourite_topic'); assert.equal(created.name, 'Favourite topic'); assert.equal(created.type, 'short_text'); // A new field is active; status travels with the definition so the UI @@ -96,16 +96,24 @@ describe('Member Custom Fields Admin API', function () { assert.equal(list.members_custom_fields.length, 1); const read = (await agent.get(`members/custom_fields/${created.key}/`).expectStatus(200)).body; - assert.equal(read.members_custom_fields[0].key, 'favourite-topic'); + assert.equal(read.members_custom_fields[0].key, 'favourite_topic'); }); - it('mints a suffixed key when the derived slug collides', async function () { - // Two distinct names can derive the same slug ("!" is stripped), so + // A key is referenced from filters, CSV columns, replacement strings and + // themes, and the hyphen is the character those disagree about. Nothing a + // publisher can type may put one in a key, including typing one themselves. + it('mints underscores for a name that already contains hyphens', async function () { + const created = await createField({name: 'T-Shirt size'}); + assert.equal(created.key, 't_shirt_size'); + }); + + it('mints a suffixed key when the derived base collides', async function () { + // Two distinct names can derive the same base ("!" is stripped), so // the key is suffixed even though the names stay unique. const first = await createField({name: 'Favourite topic'}); const second = await createField({name: 'Favourite topic!'}); - assert.equal(first.key, 'favourite-topic'); - assert.equal(second.key, 'favourite-topic-2'); + assert.equal(first.key, 'favourite_topic'); + assert.equal(second.key, 'favourite_topic_2'); }); it('rejects a duplicate name, case-insensitively', async function () { @@ -118,7 +126,7 @@ describe('Member Custom Fields Admin API', function () { .expectStatus(422); }); - it('rejects a name with no sluggable characters', async function () { + it('rejects a name with no usable characters', async function () { await agent .post('members/custom_fields/') .body({members_custom_fields: [{name: '!!!', type: 'short_text'}]}) @@ -127,16 +135,12 @@ describe('Member Custom Fields Admin API', function () { // A key names a property on the plain objects carrying a member's values, so // one naming a member of Object.prototype reads back as inherited rather than - // absent wherever it is indexed. Those keys are already taken, so the - // publisher keeps the name and the key takes a suffix. The match is on the - // slug rather than the name, which is what catches every spelling that - // collapses onto it. + // absent wherever it is indexed. That key is already taken, so the publisher + // keeps the name and the key takes a suffix. The match is on the key rather + // than the name, which is what catches every spelling that collapses onto it. const reservedSpellings = [ - {name: 'Constructor', key: 'constructor-2'}, - {name: 'constructor', key: 'constructor-2'}, - {name: '__proto__', key: '__proto__-2'}, - {name: '__PROTO__', key: '__proto__-2'}, - {name: '__proto__', key: '__proto__-2'} + {name: 'Constructor', key: 'constructor_2'}, + {name: 'constructor', key: 'constructor_2'} ]; for (const {name, key} of reservedSpellings) { it(`mints ${key} for the name ${name}, and the value round-trips`, async function () { @@ -150,20 +154,35 @@ describe('Member Custom Fields Admin API', function () { }); } + // The other prototype name a publisher could reach for needs no reserving: + // minting trims leading and trailing separators, so no spelling of it can + // produce the key itself. + for (const name of ['__proto__', '__PROTO__', '__proto__']) { + it(`cannot mint __proto__ from the name ${name}`, async function () { + const field = await createField({name}); + assert.equal(field.key, 'proto'); + + const memberId = await createMember(); + await setValues(memberId, {proto: 'Bex'}); + + assert.deepEqual(await readValues(memberId), {proto: 'Bex'}); + }); + } + // A reserved key is claimed by whichever field takes the suffix first, so the // next one along has to keep counting rather than collide with it. it('keeps counting past a reserved key already claimed by another field', async function () { const first = await createField({name: 'Constructor'}); const second = await createField({name: 'Constructor!'}); - assert.equal(first.key, 'constructor-2'); - assert.equal(second.key, 'constructor-3'); + assert.equal(first.key, 'constructor_2'); + assert.equal(second.key, 'constructor_3'); }); // The batch runs in one transaction, so mintKey sees the rows minted earlier // in the same request — reserving a key must hold within a batch too, not // just across separate requests. - it('mints distinct keys when two definitions in one batch both slug onto a reserved key', async function () { + it('mints distinct keys when two definitions in one batch both derive a reserved key', async function () { const {body} = await agent .post('members/custom_fields/') .body({members_custom_fields: [ @@ -174,19 +193,33 @@ describe('Member Custom Fields Admin API', function () { assert.deepEqual( body.members_custom_fields.map((f: {key: string}) => f.key), - ['constructor-2', 'constructor-3'] + ['constructor_2', 'constructor_3'] ); }); - // A reserved slug must not take a whole prefix with it — only the exact key. + // A reserved key must not take a whole prefix with it, only the exact key. it('leaves a name that merely starts with a reserved word unsuffixed', async function () { const field = await createField({name: 'Constructor role'}); - assert.equal(field.key, 'constructor-role'); + assert.equal(field.key, 'constructor_role'); const memberId = await createMember(); await setValues(memberId, {[field.key]: 'Foreman'}); - assert.deepEqual(await readValues(memberId), {'constructor-role': 'Foreman'}); + assert.deepEqual(await readValues(memberId), {constructor_role: 'Foreman'}); + }); + + // The key column is shorter than the name column, so a long name is cut to + // fit. The cut can land mid-separator, and a key ending in one is a shape + // minting is not allowed to produce. + it('mints a key that fits the column without ending in a separator', async function () { + const created = await createField({name: `${'a'.repeat(185)} bcdef`}); + + assert.ok(created.key.length <= 191, `key was ${created.key.length} characters`); + assert.doesNotMatch(created.key, /_$/); + + // And the suffix a collision adds lands against that trimmed base. + const second = await createField({name: `${'a'.repeat(185)} bcdeg`}); + assert.doesNotMatch(second.key, /__/); }); it('rejects a name that exceeds the maximum length', async function () { @@ -301,7 +334,7 @@ describe('Member Custom Fields Admin API', function () { await agent.get('members/custom_fields/?filter=' + encodeURIComponent('status:')).expectStatus(400); }); - it('archives a field, keeping its name and slug reserved', async function () { + it('archives a field, keeping its name and key reserved', async function () { const first = await createField({name: 'Favourite topic'}); const archived = await setStatus(first.key, 'archived'); @@ -318,10 +351,10 @@ describe('Member Custom Fields Admin API', function () { .body({members_custom_fields: [{name: 'Favourite topic', type: 'short_text'}]}) .expectStatus(422); - // ...and so does its slug: a different name deriving the same slug is + // ...and so does its key: a different name deriving the same base is // suffixed rather than reusing (and resurrecting) the old key. const second = await createField({name: 'Favourite topic!'}); - assert.equal(second.key, 'favourite-topic-2'); + assert.equal(second.key, 'favourite_topic_2'); }); it('restores an archived field', async function () { @@ -362,7 +395,7 @@ describe('Member Custom Fields Admin API', function () { assert.equal(list.length, 1); }); - it('permanently deletes an archived field, freeing its name and slug', async function () { + it('permanently deletes an archived field, freeing its name and key', async function () { const original = await createField({name: 'Favourite topic'}); await setStatus(original.key, 'archived'); @@ -373,10 +406,10 @@ describe('Member Custom Fields Admin API', function () { assert.deepEqual(list, []); await agent.get(`members/custom_fields/${original.key}/`).expectStatus(404); - // The row is gone, so the name and its base slug are free again: a + // The row is gone, so the name and its base key are free again: a // fresh field with the same name reclaims the original (unsuffixed) key. const fresh = await createField({name: 'Favourite topic'}); - assert.equal(fresh.key, 'favourite-topic'); + assert.equal(fresh.key, 'favourite_topic'); }); }); @@ -401,8 +434,8 @@ describe('Member Custom Fields Admin API', function () { assert.equal(list.members_custom_fields.length, 3); }); - it('mints distinct keys when two definitions in the batch derive the same slug', async function () { - // Within a batch each insert is visible to the next, so slug collision + it('mints distinct keys when two definitions in the batch derive the same base', async function () { + // Within a batch each insert is visible to the next, so a collision // resolves exactly as it would across two separate requests. const {body} = await agent .post('members/custom_fields/') @@ -414,7 +447,7 @@ describe('Member Custom Fields Admin API', function () { assert.deepEqual( body.members_custom_fields.map((field: {key: string}) => field.key), - ['favourite-topic', 'favourite-topic-2'] + ['favourite_topic', 'favourite_topic_2'] ); }); diff --git a/ghost/core/test/unit/server/services/members-custom-fields/key.test.ts b/ghost/core/test/unit/server/services/members-custom-fields/key.test.ts new file mode 100644 index 00000000000..579f44b680a --- /dev/null +++ b/ghost/core/test/unit/server/services/members-custom-fields/key.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import {KEY_CHARACTERS, mintableKey} from '../../../../../core/server/services/members-custom-fields/key'; + +describe('Custom field key minting', function () { + it('separates words with underscores', function () { + assert.equal(mintableKey('Shipping address'), 'shipping_address'); + }); + + it('replaces a hyphen the publisher typed', function () { + assert.equal(mintableKey('T-Shirt size'), 't_shirt_size'); + }); + + it('transliterates to ASCII', function () { + assert.equal(mintableKey('Ünïcødé Field'), 'unicode_field'); + assert.equal(mintableKey('Привет мир'), 'privet_mir'); + }); + + it('drops apostrophes rather than separating on them', function () { + assert.equal(mintableKey("Sam's Field"), 'sams_field'); + assert.equal(mintableKey('Sam’s Field'), 'sams_field'); + }); + + it('collapses runs and trims the ends', function () { + assert.equal(mintableKey(' lots of space '), 'lots_of_space'); + assert.equal(mintableKey('---leading---trailing---'), 'leading_trailing'); + assert.equal(mintableKey('a__b'), 'a_b'); + }); + + // Leading and trailing separators are trimmed, so the one prototype name that + // would otherwise survive minting cannot be spelled at all. + it('cannot mint __proto__ under any spelling', function () { + for (const spelling of ['__proto__', '__PROTO__', '__proto__']) { + assert.equal(mintableKey(spelling), 'proto'); + } + }); + + it('returns empty for a name with nothing usable in it', function () { + assert.equal(mintableKey('!!!'), ''); + assert.equal(mintableKey('🎉'), ''); + assert.equal(mintableKey(''), ''); + }); + + // The charset is the contract every consumer reads a key through: NQL will not + // parse a hyphen in a property path, an email replacement string matches word + // characters only, and a dot would be indistinguishable from the separator in a + // `custom_fields..` CSV column. A transliteration library widening + // what it emits has to fail here rather than downstream. + it('mints only characters every consumer accepts', function () { + const names = [ + 'Shipping address', 'T-Shirt size', "Sam's Field", 'Ünïcødé Field', + '½ measure', 'Привет мир', '日本語のフィールド', 'emoji 🎉 field', + 'café_naïve', '100% Sure', '£5 tier', 'A/B test', + 'a@b:c/d?e#f[g]h!i$j&k(l)m*n+o,p;q=r', 'Ω omega', 'naïve—dash', 'xy' + ]; + + for (const name of names) { + const key = mintableKey(name); + assert.match(key, KEY_CHARACTERS, `minted ${JSON.stringify(key)} from ${JSON.stringify(name)}`); + } + }); +}); diff --git a/ghost/core/test/unit/server/services/members-custom-fields/values-service.test.ts b/ghost/core/test/unit/server/services/members-custom-fields/values-service.test.ts index d4d44f2612f..f08bcf7971c 100644 --- a/ghost/core/test/unit/server/services/members-custom-fields/values-service.test.ts +++ b/ghost/core/test/unit/server/services/members-custom-fields/values-service.test.ts @@ -19,7 +19,7 @@ describe('CustomFieldValuesService', function () { }); it('answers yes for a body that names a value', function () { - assert.equal(service().namesValues({'favourite-topic': 'Ghosts'}), true); + assert.equal(service().namesValues({favourite_topic: 'Ghosts'}), true); }); it('rejects a body that is present but is not a values object', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js index f7d6ad93048..860327d51a2 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/members-bread-service.test.js @@ -97,7 +97,7 @@ describe('MemberBreadService', function () { const {service, createStub} = createService({}, customFieldValues); await assert.rejects( - () => service.add({email: 'test@example.com', custom_fields: {'favourite-topic': 'Ghosts'}}, {}), + () => service.add({email: 'test@example.com', custom_fields: {favourite_topic: 'Ghosts'}}, {}), (error) => { assert.equal(error.errorType, 'ValidationError'); assert.equal(error.property, 'custom_fields'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5055db5ce9e..302c364fbac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ catalogs: typescript-eslint: specifier: 8.62.1 version: 8.62.1 + unidecode: + specifier: 1.1.0 + version: 1.1.0 validator: specifier: 13.12.0 version: 13.12.0 @@ -2597,6 +2600,9 @@ importers: ua-parser-js: specifier: 1.0.41 version: 1.0.41 + unidecode: + specifier: 'catalog:' + version: 1.1.0 xml: specifier: 1.0.1 version: 1.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1c6dd9878b4..915de3a3a27 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -183,6 +183,7 @@ catalog: '@dnd-kit/sortable': 7.0.2 '@dnd-kit/utilities': 3.2.2 shell-quote: 1.10.0 + unidecode: 1.1.0 catalogs: react17: From a21010a816e102b57ce825ce043802980afd2a17 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Mon, 10 Aug 2026 21:06:10 -0500 Subject: [PATCH 6/7] Fixed flaky tag code injection acceptance test (#29875) no ref - Clear populated CodeMirror fields through their own `ControlOrMeta+A` and Backspace key handling. - Wait for the empty document before filling each replacement, then verify the rendered value before saving. - Reuse the expected replacement values in the exact request-body assertions. --- .../detail/tag-detail.acceptance.test.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx index 7b1d6c0c339..83235781b83 100644 --- a/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx +++ b/apps/admin/src/tags/detail/tag-detail.acceptance.test.tsx @@ -52,14 +52,31 @@ describe('Tag detail (tagDetailsReact on)', () => { await expect.poll(() => (headerEditor.element() as HTMLElement).innerText).toBe(head); await expect.poll(() => (footerEditor.element() as HTMLElement).innerText).toBe(foot); - await headerEditor.fill(''); - await footerEditor.fill(''); + const updatedHead = ''; + const updatedFoot = ''; + + // Playwright manipulates contenteditable DOM directly when clearing, + // which can race CodeMirror's document reconciliation. Clear through + // CodeMirror's keyboard handling before filling the empty editor. + await headerEditor.click(); + await userEvent.keyboard('{ControlOrMeta>}a{/ControlOrMeta}'); + await userEvent.keyboard('{Backspace}'); + await expect.poll(() => headerEditor.element().textContent).toBe(''); + await headerEditor.fill(updatedHead); + await expect.poll(() => (headerEditor.element() as HTMLElement).innerText).toBe(updatedHead); + + await footerEditor.click(); + await userEvent.keyboard('{ControlOrMeta>}a{/ControlOrMeta}'); + await userEvent.keyboard('{Backspace}'); + await expect.poll(() => footerEditor.element().textContent).toBe(''); + await footerEditor.fill(updatedFoot); + await expect.poll(() => (footerEditor.element() as HTMLElement).innerText).toBe(updatedFoot); await page.getByRole('button', {name: 'Save'}).click(); await expect.element(page.getByRole('button', {name: 'Saved'})).toBeVisible(); const saved = (saveApi.lastRequest?.body as {tags: Array>}).tags[0]; - expect(saved.codeinjection_head).toBe(''); - expect(saved.codeinjection_foot).toBe(''); + expect(saved.codeinjection_head).toBe(updatedHead); + expect(saved.codeinjection_foot).toBe(updatedFoot); }); it('keeps CodeMirror autocomplete outside the clipped editor surface', async () => { From de65389dbea6f0819c8664217e28b7b3e1972685 Mon Sep 17 00:00:00 2001 From: "tryghost-renovate[bot]" <269725441+tryghost-renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:57:17 +0000 Subject: [PATCH 7/7] Update dependency @tanstack/react-query to v5.101.4 (#29587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@tanstack/react-query](https://tanstack.com/query) ([source](https://redirect.github.com/TanStack/query/tree/HEAD/packages/react-query)) | [`5.101.2` → `5.101.4`](https://renovatebot.com/diffs/npm/@tanstack%2freact-query/5.101.2/5.101.4) |  |  | --- ### Release Notes TanStack/query (@tanstack/react-query) ### [`v5.101.4`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51014) [Compare Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.101.3...@tanstack/react-query@5.101.4) ##### Patch Changes - Updated dependencies \[]: - [@tanstack/query-core](https://redirect.github.com/tanstack/query-core)@5.101.4 ### [`v5.101.3`](https://redirect.github.com/TanStack/query/blob/HEAD/packages/react-query/CHANGELOG.md#51013) [Compare Source](https://redirect.github.com/TanStack/query/compare/@tanstack/react-query@5.101.2...@tanstack/react-query@5.101.3) ##### Patch Changes - Updated dependencies \[[`7e3c822`](https://redirect.github.com/TanStack/query/commit/7e3c822a10896f41a8f1031c16b85096277af677)]: - [@tanstack/query-core](https://redirect.github.com/tanstack/query-core)@5.101.3 --- ### Configuration 📅 **Schedule**: (in timezone Etc/UTC) - Branch creation - Only on Sunday and Saturday (`* * * * 0,6`) - Between 11:00 PM and 11:59 PM, Monday through Friday (`* 23 * * 1-5`) - Between 12:00 AM and 04:59 AM, Monday through Saturday (`* 0-4 * * 1-6`) - Automerge - Only on Sunday and Saturday (`* * * * 0,6`) - Between 11:00 PM and 11:59 PM, Monday through Friday (`* 23 * * 1-5`) - Between 12:00 AM and 04:59 AM, Monday through Saturday (`* 0-4 * * 1-6`) 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://redirect.github.com/renovatebot/renovate). Co-authored-by: tryghost-renovate[bot] <269725441+tryghost-renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 24 ++++++++++++------------ pnpm-workspace.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 302c364fbac..a8a8d32b447 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,8 +118,8 @@ catalogs: specifier: 4.2.2 version: 4.2.2 '@tanstack/react-query': - specifier: 5.101.2 - version: 5.101.2 + specifier: 5.101.4 + version: 5.101.4 '@tanstack/react-virtual': specifier: 3.14.6 version: 3.14.6 @@ -549,7 +549,7 @@ importers: version: 0.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: 'catalog:' - version: 5.101.2(react@18.3.1) + version: 5.101.4(react@18.3.1) '@tryghost/admin-x-framework': specifier: workspace:* version: link:../admin-x-framework @@ -673,7 +673,7 @@ importers: version: 2.0.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.101.2(react@18.3.1) + version: 5.101.4(react@18.3.1) '@tanstack/react-virtual': specifier: 'catalog:' version: 3.14.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -912,7 +912,7 @@ importers: version: 7.120.4(react@18.3.1) '@tanstack/react-query': specifier: 'catalog:' - version: 5.101.2(react@18.3.1) + version: 5.101.4(react@18.3.1) '@tinybirdco/charts': specifier: 0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -8737,11 +8737,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/query-core@5.101.2': - resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - '@tanstack/react-query@5.101.2': - resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} peerDependencies: react: ^18 || ^19 @@ -27987,11 +27987,11 @@ snapshots: tailwindcss: 4.2.2 vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0) - '@tanstack/query-core@5.101.2': {} + '@tanstack/query-core@5.101.4': {} - '@tanstack/react-query@5.101.2(react@18.3.1)': + '@tanstack/react-query@5.101.4(react@18.3.1)': dependencies: - '@tanstack/query-core': 5.101.2 + '@tanstack/query-core': 5.101.4 react: 18.3.1 '@tanstack/react-virtual@3.14.5(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 915de3a3a27..8b1ffcef706 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -71,7 +71,7 @@ catalog: '@storybook/react-vite': 10.4.6 '@tailwindcss/postcss': 4.2.2 '@tailwindcss/vite': 4.2.2 - '@tanstack/react-query': 5.101.2 + '@tanstack/react-query': 5.101.4 '@tanstack/react-virtual': 3.14.6 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 14.3.1
New members will no longer be able to subscribe using this offer.
Reactivating {offer?.name} will allow new members to subscribe using this offer. Existing members will remain unchanged.
Your recommendation {recommendation.title} will no longer be visible to your audience.