Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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'}
Expand All @@ -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."');
});
});
13 changes: 8 additions & 5 deletions apps/admin/src/settings/app/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -27,11 +28,13 @@ export function App({upgradeStatus}: AppProps) {
return (
<SettingsAppProvider upgradeStatus={upgradeStatus}>
<div className='admin-x-base admin-x-settings [--color-focus-ring:var(--color-green-500)] [--focus-ring:var(--color-green-500)]'>
<NiceModal.Provider>
<SettingsLocationSync />
<MainContent />
<Outlet />
</NiceModal.Provider>
<ConfirmationProvider>
<NiceModal.Provider>
<SettingsLocationSync />
<MainContent />
<Outlet />
</NiceModal.Provider>
</ConfirmationProvider>
</div>
</SettingsAppProvider>
);
Expand Down
25 changes: 13 additions & 12 deletions apps/admin/src/settings/app/components/confirmation-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NiceModal.Provider />);

act(() => {
void NiceModal.show(ConfirmationModal, props);
});
const Trigger = () => {
const {confirm} = useConfirmation();
useEffect(() => {
confirm(props);

}, [confirm]);
return null;
};
render(<ConfirmationProvider><Trigger /></ConfirmationProvider>);
};

it('renders the supplied content and confirms without closing implicitly', async () => {
Expand Down
19 changes: 11 additions & 8 deletions apps/admin/src/settings/app/components/confirmation-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import {
AlertDialog,
Expand Down Expand Up @@ -31,7 +30,14 @@ export interface ConfirmationModalProps {
testId?: string;
}

export const ConfirmationModalContent: React.FC<ConfirmationModalProps> = ({
export type ConfirmationHostProps = {
visible?: boolean;
onRemove: () => void;
};

export const ConfirmationModalContent: React.FC<ConfirmationModalProps & ConfirmationHostProps> = ({
visible = true,
onRemove,
title = 'Are you sure?',
prompt,
cancelLabel = 'Cancel',
Expand All @@ -45,7 +51,6 @@ export const ConfirmationModalContent: React.FC<ConfirmationModalProps> = ({
stickyFooter = false,
testId = 'confirmation-modal'
}) => {
const modal = useModal();
const [taskState, setTaskState] = useState<'running' | ''>('');
const isRunning = taskState === 'running';
const runningLabel = okRunningLabel || okLabel;
Expand All @@ -58,15 +63,15 @@ export const ConfirmationModalContent: React.FC<ConfirmationModalProps> = ({
if (onCancel) {
onCancel();
} else {
modal.remove();
onRemove();
}
};

const handleConfirm = async () => {
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);
Expand Down Expand Up @@ -94,7 +99,7 @@ export const ConfirmationModalContent: React.FC<ConfirmationModalProps> = ({
const footer = customFooter === undefined ? defaultFooter : customFooter;

return (
<AlertDialog open={modal.visible} onOpenChange={open => !open && handleCancel()}>
<AlertDialog open={visible} onOpenChange={open => !open && handleCancel()}>
<AlertDialogContent
className={cn(
'z-[1100] max-h-[calc(100dvh-4rem)] w-[calc(100%-2rem)] overflow-y-auto bg-background'
Expand All @@ -121,5 +126,3 @@ export const ConfirmationModalContent: React.FC<ConfirmationModalProps> = ({
</AlertDialog>
);
};

export default NiceModal.create(ConfirmationModalContent);
25 changes: 13 additions & 12 deletions apps/admin/src/settings/app/components/limit-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NiceModal.Provider />);

act(() => {
void NiceModal.show(LimitModal, props);
});
const Trigger = () => {
const {showLimit} = useConfirmation();
useEffect(() => {
showLimit(props);

}, [showLimit]);
return null;
};
render(<ConfirmationProvider><Trigger /></ConfirmationProvider>);
};

it('preserves the upgrade defaults and renders HTML prompts', async () => {
Expand Down
11 changes: 6 additions & 5 deletions apps/admin/src/settings/app/components/limit-modal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +12,9 @@ export interface LimitModalProps {
}) => void | Promise<void>;
}

export const LimitModalContent: React.FC<LimitModalProps> = ({
export const LimitModalContent: React.FC<LimitModalProps & ConfirmationHostProps> = ({
visible = true,
onRemove,
title = 'Upgrade your plan',
prompt,
okLabel = 'Upgrade',
Expand All @@ -31,9 +32,9 @@ export const LimitModalContent: React.FC<LimitModalProps> = ({
prompt={<div className='w-full'>{promptContent}</div>}
testId='limit-modal'
title={title}
visible={visible}
onOk={onOk}
onRemove={onRemove}
/>
);
};

export default NiceModal.create(LimitModalContent);
Original file line number Diff line number Diff line change
@@ -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<ConfirmationContextType | null>(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<ConfirmationRequest[]>([]);
const nextId = useRef(0);

const show = useCallback((request: Omit<ConfirmationRequest, 'id'>): 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 (
<ConfirmationContext.Provider value={contextValue}>
{children}
{requests.map((request) => {
const remove = () => setRequests(current => current.filter(r => r.id !== request.id));
return request.kind === 'confirm' ?
<ConfirmationModalContent key={request.id} {...request.props} onRemove={remove} /> :
<LimitModalContent key={request.id} {...request.props} onRemove={remove} />;
})}
</ConfirmationContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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);

Expand All @@ -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',
Expand All @@ -53,7 +53,7 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
};

const handleResetAuth = () => {
NiceModal.show(ConfirmationModal, {
confirm({
title: 'Reset all authentication?',
prompt: (
<>
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 (
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,20 +14,21 @@ 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})
});
updateRoute('integrations');
}
});
}
}, [limiter, updateRoute]);
}, [limiter, showLimit, updateRoute]);

return <SettingsModal
okLabel='Add'
Expand Down
Loading
Loading