From 1a93b0d7056c0d523eb7fed8ecc9bbb1023ac0d3 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 11:21:22 -0500 Subject: [PATCH 01/21] Removed NiceModal from settings and Shade (#30017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Every settings dialog now renders as a controlled component, either from its route or from the component that opens it, so nothing is created or shown through NiceModal any more. This removes: - the `NiceModal.Provider` from the settings app - the NiceModal fallback paths from Shade's `SettingsModal` and the settings `PreviewModalContent` — `onClose` is now required on both (every consumer already passed it) - the `@ebay/nice-modal-react` dependency from admin, Shade and admin-x-framework (which had it listed but never imported it), plus its catalog entry and lockfile entries --- apps/admin-x-framework/package.json | 1 - apps/admin/package.json | 1 - apps/admin/src/settings/app/app.tsx | 9 ++-- .../providers/confirmation-provider.tsx | 5 +- .../app/components/settings/preview-modal.tsx | 30 +++-------- apps/shade/package.json | 1 - .../patterns/settings-modal.stories.tsx | 18 +++---- .../components/patterns/settings-modal.tsx | 32 +++--------- .../patterns/settings-modal.test.tsx | 48 ++++------------- .../components/ui/overlay-escape.test.tsx | 52 +++++-------------- pnpm-lock.yaml | 23 -------- pnpm-workspace.yaml | 1 - 12 files changed, 47 insertions(+), 174 deletions(-) diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 4c74d3c9c28..9e937596725 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -95,7 +95,6 @@ "vitest": "catalog:" }, "dependencies": { - "@ebay/nice-modal-react": "catalog:", "@sentry/react": "catalog:", "@tanstack/react-query": "catalog:", "@tinybirdco/charts": "0.3.0", diff --git a/apps/admin/package.json b/apps/admin/package.json index e778f87b099..532e69fc8f8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,7 +25,6 @@ "@codemirror/state": "catalog:", "@codemirror/theme-one-dark": "catalog:", "@dnd-kit/sortable": "catalog:", - "@ebay/nice-modal-react": "catalog:", "@sentry/react": "catalog:", "@svg-maps/world": "2.0.0", "@tanstack/react-query": "catalog:", diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/app/app.tsx index 992ac686b99..3db6609d3d5 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/app/app.tsx @@ -1,5 +1,4 @@ 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 {DialogPortalProvider} from './components/providers/dialog-portal'; @@ -31,11 +30,9 @@ export function App({upgradeStatus}: AppProps) {
- - - - - + + +
diff --git a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx index b22fb12d597..bb19070ffe8 100644 --- a/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx +++ b/apps/admin/src/settings/app/components/providers/confirmation-provider.tsx @@ -30,9 +30,8 @@ export const ConfirmationProvider: React.FC<{children: React.ReactNode}> = ({chi 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). + // One request per kind: 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))}; }, []); diff --git a/apps/admin/src/settings/app/components/settings/preview-modal.tsx b/apps/admin/src/settings/app/components/settings/preview-modal.tsx index 5b6e7e1a4b5..347825af7f5 100644 --- a/apps/admin/src/settings/app/components/settings/preview-modal.tsx +++ b/apps/admin/src/settings/app/components/settings/preview-modal.tsx @@ -1,6 +1,5 @@ import React, {useEffect} from 'react'; import {ExternalLink} from 'lucide-react'; -import {useModal} from '@ebay/nice-modal-react'; import {Box, Inline, Text, type TextElement, type TextLeading, type TextSize} from '@tryghost/shade/primitives'; import {Button, type ButtonProps, Separator} from '@tryghost/shade/components'; @@ -37,8 +36,8 @@ const headingLeading: Record = { }; /** - * Compatibility shell for settings preview modals while the legacy NiceModal - * flows are migrated to consumer-controlled Shade compositions. + * Consumer-controlled shell for the settings preview dialogs (design, portal, + * newsletters, offers). New modal flows should use Shade compositions directly. */ export interface PreviewModalProps { testId?: string; @@ -72,15 +71,12 @@ export interface PreviewModalProps { onCancel?: () => void; onOk?: () => void; - /** Supersedes the NiceModal close path; without it the modal must be mounted through NiceModal. Keep its presence stable across renders — toggling defined/undefined remounts the modal subtree. */ - onClose?: () => void; + onClose: () => void; afterClose?: () => void; } -type PreviewModalContentBaseProps = Omit & {requestClose: () => void}; - -const PreviewModalContentBase: React.FC = ({ - requestClose, +export const PreviewModalContent: React.FC = ({ + onClose, testId, title, titleHeadingLevel = 4, @@ -188,7 +184,7 @@ const PreviewModalContentBase: React.FC = ({ const handleCancel = onCancel || (() => { confirm(dirty, () => { - requestClose(); + onClose(); afterClose?.(); }); }); @@ -207,7 +203,7 @@ const PreviewModalContentBase: React.FC = ({ title='' width={width} hideXOnMobile - onClose={requestClose} + onClose={onClose} > = ({ ); }; - -const NicePreviewModalContent: React.FC> = (props) => { - const modal = useModal(); - return modal.remove()} />; -}; - -export const PreviewModalContent: React.FC = ({onClose, ...props}) => { - if (onClose) { - return ; - } - return ; -}; diff --git a/apps/shade/package.json b/apps/shade/package.json index 98aa4f16b86..b2cf27bb1b8 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -107,7 +107,6 @@ "@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", diff --git a/apps/shade/src/components/patterns/settings-modal.stories.tsx b/apps/shade/src/components/patterns/settings-modal.stories.tsx index 0c322108e01..81542e10575 100644 --- a/apps/shade/src/components/patterns/settings-modal.stories.tsx +++ b/apps/shade/src/components/patterns/settings-modal.stories.tsx @@ -1,16 +1,17 @@ -import NiceModal from '@ebay/nice-modal-react'; +import {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react-vite'; import {Button} from '@/components/ui/button'; import {Box} from '@/components/primitives/box'; import {SettingsModal, type SettingsModalProps} from '@/components/patterns/settings-modal'; -const SettingsModalStory = (props: SettingsModalProps) => { - const StoryModal = NiceModal.create(() => ); +const SettingsModalStory = (props: Omit) => { + const [open, setOpen] = useState(false); return ( - + + {open && setOpen(false)} />} ); }; @@ -22,15 +23,10 @@ const meta = { parameters: { docs: { description: { - component: 'Transitional compatibility shell for the existing settings NiceModal flows. New modal flows should use Shade Dialog primitives directly.' + component: 'Consumer-controlled shell for the legacy full-page settings dialogs. New modal flows should use Shade Dialog primitives directly.' } } - }, - decorators: [Story => ( - - - - )] + } } satisfies Meta; export default meta; diff --git a/apps/shade/src/components/patterns/settings-modal.tsx b/apps/shade/src/components/patterns/settings-modal.tsx index 74d78553fb0..a8c802ff6f8 100644 --- a/apps/shade/src/components/patterns/settings-modal.tsx +++ b/apps/shade/src/components/patterns/settings-modal.tsx @@ -1,4 +1,3 @@ -import {useModal} from '@ebay/nice-modal-react'; import {cva} from 'class-variance-authority'; import {X} from 'lucide-react'; import React, {forwardRef, useEffect, useState} from 'react'; @@ -14,8 +13,8 @@ import useGlobalDirtyState from '@/hooks/use-global-dirty-state'; import {cn} from '@/lib/utils'; /** - * Compatibility shell for settings modals while the legacy NiceModal flows are - * migrated to Shade's consumer-controlled Dialog primitives. + * Consumer-controlled settings modal shell. Legacy full-page settings dialogs + * still render through it; new modal flows should use Shade Dialog primitives. */ export type SettingsModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'bleed'; @@ -44,8 +43,7 @@ export interface SettingsModalProps { onCancel?: () => void; topRightContent?: 'close' | React.ReactNode; hideXOnMobile?: boolean; - /** Supersedes the NiceModal close path; without it the modal must be mounted through NiceModal. Keep its presence stable across renders — toggling defined/undefined remounts the modal subtree. */ - onClose?: () => void; + onClose: () => void; afterClose?: () => void; children?: React.ReactNode; backDrop?: boolean; @@ -124,9 +122,7 @@ const headerOffsets: Record = { bleed: '-inset-x-10' }; -type SettingsModalContentProps = Omit & {requestClose: () => void}; - -const SettingsModalContent = forwardRef(({ +const SettingsModal = forwardRef(({ 'aria-label': ariaLabel, className, size = 'md', @@ -150,7 +146,7 @@ const SettingsModalContent = forwardRef( onCancel, topRightContent, hideXOnMobile = false, - requestClose, + onClose, afterClose, children, backDrop = true, @@ -173,7 +169,7 @@ const SettingsModalContent = forwardRef( const removeModal = () => { confirm(dirty, () => { - requestClose(); + onClose(); afterClose?.(); }); }; @@ -359,22 +355,6 @@ const SettingsModalContent = forwardRef( ); }); -SettingsModalContent.displayName = 'SettingsModalContent'; - -const NiceSettingsModal = forwardRef>((props, ref) => { - const modal = useModal(); - return modal.remove()} />; -}); - -NiceSettingsModal.displayName = 'NiceSettingsModal'; - -const SettingsModal = forwardRef(({onClose, ...props}, ref) => { - if (onClose) { - return ; - } - return ; -}); - SettingsModal.displayName = 'SettingsModal'; export {SettingsModal, settingsModalVariants}; diff --git a/apps/shade/test/unit/components/patterns/settings-modal.test.tsx b/apps/shade/test/unit/components/patterns/settings-modal.test.tsx index 42cfbc834ce..2bd7a34dc37 100644 --- a/apps/shade/test/unit/components/patterns/settings-modal.test.tsx +++ b/apps/shade/test/unit/components/patterns/settings-modal.test.tsx @@ -1,15 +1,8 @@ -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import {describe, expect, it, vi} from 'vitest'; import {SettingsModal, settingsModalVariants, type SettingsModalSize} from '@/components/patterns/settings-modal'; -const TestSettingsModal = NiceModal.create(() => ( - undefined}> - Modal content - -)); - describe('SettingsModal', () => { it.each(['sm', 'md', 'lg', 'xl', 'full'])('uses the standard dialog radius for the %s size', (size) => { expect(settingsModalVariants({size})).toContain('rounded-lg'); @@ -21,27 +14,23 @@ describe('SettingsModal', () => { expect(settingsModalVariants({size})).not.toContain('rounded-lg'); }); - it('uses content-sized outline and primary actions by default', async () => { - render(); - - act(() => { - void NiceModal.show(TestSettingsModal); - }); + it('uses content-sized outline and primary actions by default', () => { + render( + undefined} onOk={() => undefined}> + Modal content + + ); - const cancelButton = await screen.findByRole('button', {name: 'Cancel'}); + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); const okButton = screen.getByRole('button', {name: 'OK'}); expect(cancelButton.className).toContain('border-control-border'); expect(cancelButton.className).toContain('bg-transparent'); expect(cancelButton.className).not.toContain('hover:bg-accent'); expect(okButton.className).not.toContain('min-w-20'); - - act(() => { - void NiceModal.remove(TestSettingsModal); - }); }); - it('renders without a NiceModal context and closes through onClose', () => { + it('closes through onClose', () => { const onClose = vi.fn(); render( @@ -71,23 +60,4 @@ describe('SettingsModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); - - it('still closes through NiceModal when no onClose is passed', async () => { - const BridgeModal = NiceModal.create(() => ( - - Modal content - - )); - - render(); - act(() => { - void NiceModal.show(BridgeModal); - }); - - fireEvent.click(await screen.findByTestId('close-modal')); - - await waitFor(() => { - expect(screen.queryByTestId('close-modal')).toBeNull(); - }); - }); }); diff --git a/apps/shade/test/unit/components/ui/overlay-escape.test.tsx b/apps/shade/test/unit/components/ui/overlay-escape.test.tsx index ad3c3d04776..71cc091f921 100644 --- a/apps/shade/test/unit/components/ui/overlay-escape.test.tsx +++ b/apps/shade/test/unit/components/ui/overlay-escape.test.tsx @@ -1,5 +1,4 @@ -import NiceModal from '@ebay/nice-modal-react'; -import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import {useState} from 'react'; import {beforeAll, describe, expect, it, vi} from 'vitest'; @@ -55,17 +54,11 @@ describe('nested overlay Escape behavior', () => { it.each(overlayCases)('closes an uncontrolled %s before its parent SettingsModal', async (_name, Overlay) => { const onCancel = vi.fn(); const onOpenChange = vi.fn(); - const TestModal = NiceModal.create(() => ( - + render( + - )); - - render(); - - act(() => { - void NiceModal.show(TestModal); - }); + ); await screen.findByText(/Menu item|Option one|Popover content|Combobox content/); fireEvent.keyDown(document, {key: 'Escape'}); @@ -80,10 +73,6 @@ describe('nested overlay Escape behavior', () => { await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce(); }); - - act(() => { - void NiceModal.remove(TestModal); - }); }); it('preserves controlled overlay state behavior', async () => { @@ -102,16 +91,11 @@ describe('nested overlay Escape behavior', () => { ); }; - const TestModal = NiceModal.create(() => ( - + render( + - )); - - render(); - act(() => { - void NiceModal.show(TestModal); - }); + ); await screen.findByText('Controlled content'); fireEvent.keyDown(document, {key: 'Escape'}); @@ -126,8 +110,8 @@ describe('nested overlay Escape behavior', () => { const onCancel = vi.fn(); const onOuterOpenChange = vi.fn(); const onInnerOpenChange = vi.fn(); - const TestModal = NiceModal.create(() => ( - + render( + Open outer @@ -141,12 +125,7 @@ describe('nested overlay Escape behavior', () => { - )); - - render(); - act(() => { - void NiceModal.show(TestModal); - }); + ); await screen.findByText('Inner item'); fireEvent.keyDown(document, {key: 'Escape'}); @@ -179,16 +158,11 @@ describe('nested overlay Escape behavior', () => { it('keeps the modal open when a later document listener handles Escape', async () => { const onCancel = vi.fn(); - const TestModal = NiceModal.create(() => ( - + render( + Modal content - )); - - render(); - act(() => { - void NiceModal.show(TestModal); - }); + ); await screen.findByText('Modal content'); const handleNestedEscape = (event: KeyboardEvent) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a1fd2f281d..27751ee72b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,9 +57,6 @@ catalogs: '@dnd-kit/utilities': specifier: 3.2.2 version: 3.2.2 - '@ebay/nice-modal-react': - specifier: 1.2.13 - version: 1.2.13 '@eslint/compat': specifier: 2.1.0 version: 2.1.0 @@ -748,9 +745,6 @@ importers: '@dnd-kit/sortable': specifier: 'catalog:' version: 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@ebay/nice-modal-react': - specifier: 'catalog:' - version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/react': specifier: 'catalog:' version: 7.120.4(react@18.3.1) @@ -990,9 +984,6 @@ importers: apps/admin-x-framework: dependencies: - '@ebay/nice-modal-react': - specifier: 'catalog:' - version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/react': specifier: 'catalog:' version: 7.120.4(react@18.3.1) @@ -1724,9 +1715,6 @@ importers: '@dnd-kit/utilities': specifier: 'catalog:' version: 3.2.2(react@18.3.1) - '@ebay/nice-modal-react': - specifier: 'catalog:' - version: 1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@hookform/resolvers': specifier: 5.4.0 version: 5.4.0(react-hook-form@7.80.0(react@18.3.1)) @@ -5282,12 +5270,6 @@ packages: react: '>=17.0.2' react-dom: '>=17.0.2' - '@ebay/nice-modal-react@1.2.13': - resolution: {integrity: sha512-jx8xIWe/Up4tpNuM02M+rbnLoxdngTGk3Y8LjJsLGXXcSoKd/+eZStZcAlIO/jwxyz/bhPZnpqPJZWAmhOofuA==} - peerDependencies: - react: '>16.8.0' - react-dom: '>16.8.0' - '@elastic/elasticsearch@8.19.2': resolution: {integrity: sha512-LMJCju/+AZkDlJArd/MYABWTDrHi4U7j3qGTKi1hYC7+67SaiYmYFItiueACQVrj2j3ECPMcguZ+7WrZeB+Z5g==} engines: {node: '>=20'} @@ -24237,11 +24219,6 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - '@ebay/nice-modal-react@1.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@elastic/elasticsearch@8.19.2(supports-color@10.2.2)': dependencies: '@elastic/transport': 8.10.1(supports-color@10.2.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d2de342fea6..8b714738fe2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,7 +54,6 @@ catalog: '@codemirror/state': 6.7.1 '@codemirror/theme-one-dark': 6.1.3 '@codemirror/view': 6.43.6 - '@ebay/nice-modal-react': 1.2.13 '@eslint/compat': 2.1.0 '@eslint/js': 9.39.5 '@faker-js/faker': 10.5.0 From 7854a6c78e7929c3dde0ccfba070524b92856296 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 12:12:16 -0500 Subject: [PATCH 02/21] Changed settings save callbacks to accept async handlers (#30076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref `SettingsModal.onOk` (Shade), `TopLevelGroup.onSave` and the Pintura editor's `handleSave` are all passed async handlers throughout settings, but were typed as returning `void`. Under the type-checked lint rules that apply outside the settings quarantine, every one of those call sites is a `no-misused-promises` error — 30-odd sites across the areas about to move out of `settings/app/`. The callers fire the handler and do not await it, so the honest type is `void | Promise<…>`, with the callers discarding the result explicitly (`void onOk()`). This lets the settings code move out of the quarantine without wrapping dozens of handlers in `() => void handle()` arrows. --- .../admin/src/settings/app/components/top-level-group.tsx | 7 ++++--- apps/admin/src/settings/app/hooks/use-pintura-editor.ts | 8 +++++--- apps/shade/src/components/patterns/settings-modal.tsx | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/admin/src/settings/app/components/top-level-group.tsx b/apps/admin/src/settings/app/components/top-level-group.tsx index 3138f013820..ecd05f8b1b4 100644 --- a/apps/admin/src/settings/app/components/top-level-group.tsx +++ b/apps/admin/src/settings/app/components/top-level-group.tsx @@ -31,7 +31,8 @@ interface TopLevelGroupProps { highlightOnModalClose?: boolean; enableCMDS?: boolean; onEditingChange?: (isEditing: boolean) => void; - onSave?: () => void; + /** May be async; the group fires it without awaiting, so the handler owns its own error handling. */ + onSave?: () => void | Promise; onCancel?: () => void; } @@ -81,7 +82,7 @@ const TopLevelGroup: React.FC = ({ const handleSaveShortcut = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); - onSave?.(); + void onSave?.(); } }; @@ -100,7 +101,7 @@ const TopLevelGroup: React.FC = ({ {(saveState === 'unsaved' || alwaysShowSaveButton) && ( - )} diff --git a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts b/apps/admin/src/settings/app/hooks/use-pintura-editor.ts index a1acdf160c4..81e49a9d402 100644 --- a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts +++ b/apps/admin/src/settings/app/hooks/use-pintura-editor.ts @@ -4,10 +4,11 @@ import {type Setting, getSettingValues} from '@tryghost/admin-x-framework/api/se import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; interface OpenEditorParams { image: string; - handleSave: (dest: File) => void; + handleSave: (dest: File) => void | Promise; } type FrameOptionType = 'solidSharp' | 'solidRound' | 'lineSingle' | 'hook' | 'polaroid' | undefined; @@ -47,6 +48,7 @@ declare global { export default function usePinturaEditor() { const {config: globalConfig, settings} = useGlobalData() as { config: Config, settings: Setting[] }; + const handleError = useHandleError(); const [pintura] = getSettingValues(settings, ['pintura']); const [scriptLoaded, setScriptLoaded] = useState(false); const [cssLoaded, setCssLoaded] = useState(false); @@ -219,13 +221,13 @@ export default function usePinturaEditor() { }); editor.on('process', (result) => { - handleSave(result.dest); + Promise.resolve(handleSave(result.dest)).catch(handleError); }); setIsOpen(true); } }, - [isEnabled] + [handleError, isEnabled] ); // Only allow closing the modal if the close button was clicked diff --git a/apps/shade/src/components/patterns/settings-modal.tsx b/apps/shade/src/components/patterns/settings-modal.tsx index a8c802ff6f8..6f213229a5f 100644 --- a/apps/shade/src/components/patterns/settings-modal.tsx +++ b/apps/shade/src/components/patterns/settings-modal.tsx @@ -39,7 +39,8 @@ export interface SettingsModalProps { footerClassName?: string; header?: boolean; padding?: boolean; - onOk?: () => void; + /** May be async; the modal fires it without awaiting, so the handler owns its own error handling. */ + onOk?: () => void | Promise; onCancel?: () => void; topRightContent?: 'close' | React.ReactNode; hideXOnMobile?: boolean; @@ -222,7 +223,7 @@ const SettingsModal = forwardRef(({ const handleCMDS = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); - onOk(); + void onOk(); } }; @@ -291,7 +292,7 @@ const SettingsModal = forwardRef(({ )} {okLabel && ( - From 85478e3c03aa83af5f0109f23cf59068289b0df9 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 18 Aug 2026 12:18:59 -0500 Subject: [PATCH 03/21] Expanded email analytics service test coverage (#30073) no ref This is a test-only change that covers more code paths. --- .../email-analytics-service.test.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts index 5014e4a525c..ec750735c9d 100644 --- a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts @@ -386,6 +386,21 @@ describe('EmailAnalyticsService', function () { sinon.assert.notCalled(eventProcessor.aggregate); }); + it('returns 0 and clears metadata when a pending fetch is canceled', async function () { + await service.schedule({ + begin: new Date(2023, 0, 1), + end: new Date(2023, 0, 2) + }); + service.getStatus().scheduled.canceled = true; + setJobMetadataStub.resetHistory(); + + const result = await service.fetchScheduled(); + + assert.equal(result.eventCount, 0); + sinon.assert.calledOnceWithExactly(setJobMetadataStub, JOB_NAMES.scheduled, null); + sinon.assert.notCalled(eventProcessor.processBatch); + }); + it('fetches events with correct parameters', async function () { await service.schedule({ begin: new Date(2023, 0, 1), @@ -400,6 +415,63 @@ describe('EmailAnalyticsService', function () { assert.deepEqual(eventProcessor.processBatch.getCall(0).args[0], [1,2,3,4,5,6,7,8,9,10]); }); + it('resumes from the last processed event timestamp', async function () { + const begin = new Date(2023, 0, 1); + const lastEventTimestamp = new Date(2023, 0, 1, 12); + const end = new Date(2023, 0, 2); + const fetchEvents = sinon.stub().resolves(); + service = createService({ + queries: { + getJobData: sinon.stub().resolves({ + finished_at: lastEventTimestamp, + started_at: null, + metadata: { + begin: begin.toISOString(), + end: end.toISOString() + } + }), + setJobTimestamp: setJobTimestampStub, + setJobStatus: setJobStatusStub, + setJobMetadata: setJobMetadataStub + }, + fetchEvents + }); + await service.restoreScheduled(); + + await service.fetchScheduled(); + + sinon.assert.calledOnceWithMatch(fetchEvents, { + begin: lastEventTimestamp, + end + }); + }); + + it('cancels a fetch while it is processing', async function () { + service = createService({ + queries: { + setJobTimestamp: setJobTimestampStub, + setJobStatus: setJobStatusStub, + setJobMetadata: setJobMetadataStub + }, + fetchEvents: async ({batchHandler}: {batchHandler: BatchHandler}) => { + service.cancelScheduled(); + await batchHandler([1]); + }, + createEventProcessor: () => eventProcessor + }); + await service.schedule({ + begin: new Date(2023, 0, 1), + end: new Date(2023, 0, 2) + }); + setJobMetadataStub.resetHistory(); + + const result = await service.fetchScheduled(); + + assert.equal(result.eventCount, 1); + sinon.assert.calledWith(setJobMetadataStub, JOB_NAMES.scheduled, null); + assert.equal(service.getStatus().scheduled.canceled, undefined); + }); + it('bails when end date is before begin date', async function () { await service.schedule({ begin: new Date(2023, 0, 2), @@ -466,6 +538,19 @@ describe('EmailAnalyticsService', function () { }); }); + it('rejects scheduling while a fetch is running', async function () { + await service.schedule({ + begin: new Date(2023, 0, 1), + end: new Date(2023, 0, 2) + }); + service.getStatus().scheduled.running = true; + + await assert.rejects(service.schedule({ + begin: new Date(2023, 0, 3), + end: new Date(2023, 0, 4) + }), /Already fetching scheduled events/); + }); + it('clears metadata when canceling a non-running schedule', async function () { await service.schedule({ begin: new Date(2023, 0, 1), @@ -563,6 +648,96 @@ describe('EmailAnalyticsService', function () { await assert.rejects(service.fetchLatestOpenedEvents(), /final aggregation failed/); }); + + it('supports processors without aggregation', async function () { + const eventProcessor = createStubEventProcessor(); + eventProcessor.aggregate = undefined as never; + const service = createServiceWithEventProcessor(eventProcessor); + + const result = await service.fetchLatestOpenedEvents(); + + assert.equal(result.eventCount, 1); + assert.equal(result.aggregationTimeMs, 0); + }); + + it('supports aggregation without timing details', async function () { + const eventProcessor = createStubEventProcessor(); + eventProcessor.aggregate.resolves(undefined); + const service = createServiceWithEventProcessor(eventProcessor); + + const result = await service.fetchLatestOpenedEvents(); + + assert.equal(result.eventCount, 1); + assert.equal(result.aggregationTimeMs, 0); + }); + + it('preserves new email and member IDs in the cumulative result', async function () { + const eventProcessor = createStubEventProcessor(); + eventProcessor.processBatch.callsFake(async (_events, result) => { + result.emailIds.push('email-id'); + result.memberIds.push('member-id'); + }); + const service = createServiceWithEventProcessor(eventProcessor); + + const result = await service.fetchLatestOpenedEvents(); + + assert.deepEqual(result.result.emailIds, ['email-id']); + assert.deepEqual(result.result.memberIds, ['member-id']); + }); + + it('rejects when fetching events fails', async function () { + const error = new Error('fetch failed'); + const service = createService({ + queries: { + getLastEventTimestamp: sinon.stub().resolves(), + setJobTimestamp: sinon.stub().resolves(), + setJobStatus: sinon.stub().resolves() + }, + fetchEvents: sinon.stub().rejects(error) + }); + + await assert.rejects(service.fetchLatestOpenedEvents(), error); + }); + + it('rejects when fetching events throws a non-Error value', async function () { + const service = createService({ + queries: { + getLastEventTimestamp: sinon.stub().resolves(), + setJobTimestamp: sinon.stub().resolves(), + setJobStatus: sinon.stub().resolves() + }, + fetchEvents: async () => { + throw 'fetch failed'; + } + }); + + await assert.rejects(service.fetchLatestOpenedEvents(), error => error === 'fetch failed'); + }); + + it('persists and advances the last processed event timestamp', async function () { + const lastEventTimestamp = new Date(Date.now() - 10_000); + const setJobTimestamp = sinon.stub().resolves(); + const eventProcessor = createStubEventProcessor(); + eventProcessor.processBatch.callsFake(async (_events, _result, fetchData) => { + fetchData.lastEventTimestamp = lastEventTimestamp; + }); + const service = createService({ + queries: { + getLastEventTimestamp: sinon.stub().resolves(), + setJobTimestamp, + setJobStatus: sinon.stub().resolves() + }, + fetchEvents: async ({batchHandler}: {batchHandler: BatchHandler}) => { + await batchHandler([1]); + }, + createEventProcessor: () => eventProcessor + }); + + await service.fetchLatestOpenedEvents({maxEvents: 2}); + + sinon.assert.calledWithExactly(setJobTimestamp, JOB_NAMES.latestOpened, 'finished', lastEventTimestamp); + assert.deepEqual(service.getStatus().latestOpened.lastEventTimestamp, new Date(lastEventTimestamp.getTime() + 1000)); + }); }); describe('restoreScheduled', function () { @@ -611,6 +786,18 @@ describe('EmailAnalyticsService', function () { assert.equal(status.scheduled.schedule, undefined); }); + it('handles errors while restoring persisted data', async function () { + const service = createService({ + queries: { + getJobData: sinon.stub().rejects(new Error('read failed')) + } + }); + + await service.restoreScheduled(); + + assert.equal(service.getStatus().scheduled.schedule, undefined); + }); + it('does nothing when metadata is null', async function () { const service = createService({ queries: { @@ -694,6 +881,21 @@ describe('EmailAnalyticsService', function () { await service.fetchMissing(); sinon.assert.calledOnce(fetchLatestSpy); }); + + it('quits if the end is before the begin', async function () { + const fetchEvents = sinon.spy(); + const service = createService({ + queries: { + getLastJobRunTimestamp: sinon.stub().resolves(new Date(Date.now() + 1000)) + }, + fetchEvents + }); + + const result = await service.fetchMissing(); + + assert.equal(result.eventCount, 0); + sinon.assert.notCalled(fetchEvents); + }); }); }); }); From 2d7d3a2c70c0a22c90525e7e758c786d65cd3282 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 12:27:07 -0500 Subject: [PATCH 04/21] Moved general settings out of the settings quarantine (#30080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref First area to leave `settings/app/`: the general settings components now live beside their acceptance tests in `settings/general`. Files outside `settings/app` run the type-aware lint rules the rest of admin uses, so the move also clears that debt for these files: - async handlers passed to void callbacks are wrapped (`void …`) or extracted into named handlers - `searchKeywords` moves to `general/search-keywords.ts` so `general-settings.tsx` only exports components - `GHOST_BUILD_VERSION` is declared on `ImportMetaEnv` instead of being `any` - the invite error payload is narrowed instead of `as any` `withErrorBoundary` is registered as an `extraHOCs` entry for `react-refresh/only-export-components` — every settings section exports through it and the rule can't recognise it as a component otherwise (this was ~80 of the ~340 quarantine errors across all areas). Nothing changes for the remaining areas; the ESLint override for `src/settings/app/**` shrinks by attrition and deletes with the last chunk. --- apps/admin/eslint.config.js | 2 +- .../src/settings/app/components/settings.tsx | 2 +- .../settings/search-keywords.test.ts | 2 +- .../src/settings/app/components/sidebar.tsx | 2 +- apps/admin/src/settings/app/main-content.tsx | 2 +- .../settings => }/general/about.tsx | 0 .../general/general-settings.tsx | 11 +-- .../general/invite-user-modal.tsx | 3 +- .../general/publication-language.tsx | 0 .../src/settings/general/search-keywords.ts | 9 +++ .../settings => }/general/seo-meta.tsx | 4 +- .../settings => }/general/social-accounts.tsx | 2 +- .../settings => }/general/time-zone.tsx | 0 .../general/title-and-description.tsx | 0 .../general/user-detail-modal.tsx | 6 +- .../settings => }/general/users.tsx | 79 ++++++++++--------- .../general/users/change-password-form.tsx | 46 +++++------ .../general/users/email-notifications-tab.tsx | 0 .../general/users/profile-tab.tsx | 2 +- .../general/users/role-selector.tsx | 0 .../general/users/social-links-tab.tsx | 2 +- .../general/users/staff-token.tsx | 4 +- apps/admin/src/settings/routes.tsx | 12 +-- apps/admin/src/vite-env.d.ts | 4 + 24 files changed, 103 insertions(+), 91 deletions(-) rename apps/admin/src/settings/{app/components/settings => }/general/about.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/general/general-settings.tsx (58%) rename apps/admin/src/settings/{app/components/settings => }/general/invite-user-modal.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/general/publication-language.tsx (100%) create mode 100644 apps/admin/src/settings/general/search-keywords.ts rename apps/admin/src/settings/{app/components/settings => }/general/seo-meta.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/general/social-accounts.tsx (99%) rename apps/admin/src/settings/{app/components/settings => }/general/time-zone.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/general/title-and-description.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/general/user-detail-modal.tsx (98%) rename apps/admin/src/settings/{app/components/settings => }/general/users.tsx (89%) rename apps/admin/src/settings/{app/components/settings => }/general/users/change-password-form.tsx (88%) rename apps/admin/src/settings/{app/components/settings => }/general/users/email-notifications-tab.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/general/users/profile-tab.tsx (97%) rename apps/admin/src/settings/{app/components/settings => }/general/users/role-selector.tsx (100%) rename apps/admin/src/settings/{app/components/settings => }/general/users/social-links-tab.tsx (97%) rename apps/admin/src/settings/{app/components/settings => }/general/users/staff-token.tsx (97%) diff --git a/apps/admin/eslint.config.js b/apps/admin/eslint.config.js index 9c283c42db3..c9b27a46dbf 100644 --- a/apps/admin/eslint.config.js +++ b/apps/admin/eslint.config.js @@ -48,7 +48,7 @@ export default tseslint.config( extraSrcRules: { // The factory disables this (legacy violations elsewhere); this // workspace is clean, so keep enforcing it. - 'react-refresh/only-export-components': ['error', {allowConstantExport: true}] + 'react-refresh/only-export-components': ['error', {allowConstantExport: true, extraHOCs: ['withErrorBoundary']}] } }), // The factory is type-unaware; layer the type-checked rule set on top. diff --git a/apps/admin/src/settings/app/components/settings.tsx b/apps/admin/src/settings/app/components/settings.tsx index 5214cf93d43..a37b636dea8 100644 --- a/apps/admin/src/settings/app/components/settings.tsx +++ b/apps/admin/src/settings/app/components/settings.tsx @@ -3,7 +3,7 @@ import React from 'react'; import AdvancedSettings from './settings/advanced/advanced-settings'; import EmailSettings from './settings/email/email-settings'; import Emails from './settings/email/emails'; -import GeneralSettings from './settings/general/general-settings'; +import GeneralSettings from '@/settings/general/general-settings'; import GrowthSettings from './settings/growth/growth-settings'; import MembershipSettings from './settings/membership/membership-settings'; import SiteSettings from './settings/site/site-settings'; diff --git a/apps/admin/src/settings/app/components/settings/search-keywords.test.ts b/apps/admin/src/settings/app/components/settings/search-keywords.test.ts index 0dc1ee994ec..93b46f79d07 100644 --- a/apps/admin/src/settings/app/components/settings/search-keywords.test.ts +++ b/apps/admin/src/settings/app/components/settings/search-keywords.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert/strict'; import {searchKeywords as emailSearchKeywords} from '@/settings/app/components/settings/email/email-settings'; import {searchKeywords as emailsSearchKeywords} from '@/settings/app/components/settings/email/emails'; -import {searchKeywords as generalSearchKeywords} from '@/settings/app/components/settings/general/general-settings'; +import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as membershipSearchKeywords} from '@/settings/app/components/settings/membership/membership-settings'; const includes = (keywords: string[], term: string) => keywords.some(keyword => keyword.toLowerCase().includes(term.toLowerCase())); diff --git a/apps/admin/src/settings/app/components/sidebar.tsx b/apps/admin/src/settings/app/components/sidebar.tsx index 56d339c9ae1..4fec1c74a19 100644 --- a/apps/admin/src/settings/app/components/sidebar.tsx +++ b/apps/admin/src/settings/app/components/sidebar.tsx @@ -12,7 +12,7 @@ import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/ import {searchKeywords as advancedSearchKeywords} from './settings/advanced/advanced-settings'; import {searchKeywords as emailSearchKeywords} from './settings/email/email-settings'; import {searchKeywords as emailsSearchKeywords} from './settings/email/emails'; -import {searchKeywords as generalSearchKeywords} from './settings/general/general-settings'; +import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as growthSearchKeywords} from './settings/growth/growth-settings'; import {searchKeywords as membershipSearchKeywords} from './settings/membership/membership-settings'; import {searchKeywords as siteSearchKeywords} from './settings/site/site-settings'; diff --git a/apps/admin/src/settings/app/main-content.tsx b/apps/admin/src/settings/app/main-content.tsx index 8a2f0bc4e0a..1d1f482cf22 100644 --- a/apps/admin/src/settings/app/main-content.tsx +++ b/apps/admin/src/settings/app/main-content.tsx @@ -1,7 +1,7 @@ import ExitSettingsButton from './components/exit-settings-button'; import Settings from './components/settings'; import Sidebar from './components/sidebar'; -import Users from './components/settings/general/users'; +import Users from '@/settings/general/users'; import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns'; import {type ReactNode, useEffect} from 'react'; import {Text} from '@tryghost/shade/primitives'; diff --git a/apps/admin/src/settings/app/components/settings/general/about.tsx b/apps/admin/src/settings/general/about.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/about.tsx rename to apps/admin/src/settings/general/about.tsx diff --git a/apps/admin/src/settings/app/components/settings/general/general-settings.tsx b/apps/admin/src/settings/general/general-settings.tsx similarity index 58% rename from apps/admin/src/settings/app/components/settings/general/general-settings.tsx rename to apps/admin/src/settings/general/general-settings.tsx index ef59445bb59..e854294186b 100644 --- a/apps/admin/src/settings/app/components/settings/general/general-settings.tsx +++ b/apps/admin/src/settings/general/general-settings.tsx @@ -8,16 +8,7 @@ import SocialAccounts from './social-accounts'; import TimeZone from './time-zone'; import TitleAndDescription from './title-and-description'; import Users from './users'; - -export const searchKeywords = { - titleAndDescription: ['general', 'title and description', 'site title', 'site description', 'title & description'], - timeZone: ['general', 'time', 'date', 'site timezone', 'time zone'], - publicationLanguage: ['general', 'publication language', 'locale'], - users: ['general', 'users and permissions', 'roles', 'staff', 'invite people', 'contributors', 'editors', 'authors', 'administrators'], - metadata: ['general', 'metadata', 'title', 'description', 'search', 'engine', 'google', 'meta data', 'twitter card', 'structured data', 'rich cards', 'x card', 'social', 'facebook card', 'llms', 'ai', 'ai search engines', 'llm'], - socialAccounts: ['general', 'social accounts', 'facebook', 'twitter', 'threads', 'bluesky', 'mastodon', 'tiktok', 'youtube', 'instagram', 'linkedin', 'structured data', 'rich cards'], - analytics: ['general', 'analytics', 'tracking', 'privacy', 'membership'] -}; +import {searchKeywords} from './search-keywords'; const GeneralSettings: React.FC = () => { // This section is going to be updated with merging Twitter and Facebook to the Metadata group, diff --git a/apps/admin/src/settings/app/components/settings/general/invite-user-modal.tsx b/apps/admin/src/settings/general/invite-user-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/general/invite-user-modal.tsx rename to apps/admin/src/settings/general/invite-user-modal.tsx index ee7244d5766..f8959d7e0cf 100644 --- a/apps/admin/src/settings/app/components/settings/general/invite-user-modal.tsx +++ b/apps/admin/src/settings/general/invite-user-modal.tsx @@ -139,8 +139,7 @@ function InviteUserModal() { const title = 'Failed to send invitation'; let message = (If the problem persists, contact support..); if (e instanceof APIError) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = e.data as any; // we have unknown data types in the APIError/error classes + const data = e.data as {errors?: Array<{type?: string}>} | undefined; if (data?.errors?.[0]?.type === 'EmailError') { message = (Check your Mailgun configuration.); } diff --git a/apps/admin/src/settings/app/components/settings/general/publication-language.tsx b/apps/admin/src/settings/general/publication-language.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/publication-language.tsx rename to apps/admin/src/settings/general/publication-language.tsx diff --git a/apps/admin/src/settings/general/search-keywords.ts b/apps/admin/src/settings/general/search-keywords.ts new file mode 100644 index 00000000000..2460b053527 --- /dev/null +++ b/apps/admin/src/settings/general/search-keywords.ts @@ -0,0 +1,9 @@ +export const searchKeywords = { + titleAndDescription: ['general', 'title and description', 'site title', 'site description', 'title & description'], + timeZone: ['general', 'time', 'date', 'site timezone', 'time zone'], + publicationLanguage: ['general', 'publication language', 'locale'], + users: ['general', 'users and permissions', 'roles', 'staff', 'invite people', 'contributors', 'editors', 'authors', 'administrators'], + metadata: ['general', 'metadata', 'title', 'description', 'search', 'engine', 'google', 'meta data', 'twitter card', 'structured data', 'rich cards', 'x card', 'social', 'facebook card', 'llms', 'ai', 'ai search engines', 'llm'], + socialAccounts: ['general', 'social accounts', 'facebook', 'twitter', 'threads', 'bluesky', 'mastodon', 'tiktok', 'youtube', 'instagram', 'linkedin', 'structured data', 'rich cards'], + analytics: ['general', 'analytics', 'tracking', 'privacy', 'membership'] +}; diff --git a/apps/admin/src/settings/app/components/settings/general/seo-meta.tsx b/apps/admin/src/settings/general/seo-meta.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/general/seo-meta.tsx rename to apps/admin/src/settings/general/seo-meta.tsx index a5cddc798a2..874b092c456 100644 --- a/apps/admin/src/settings/app/components/settings/general/seo-meta.tsx +++ b/apps/admin/src/settings/general/seo-meta.tsx @@ -216,7 +216,7 @@ const SEOMeta: React.FC<{ keywords: string[] }> = ({keywords}) => { ) : ( - handleFacebookImageUpload(files[0])}> + void handleFacebookImageUpload(files[0])}> Upload Facebook image )} @@ -259,7 +259,7 @@ const SEOMeta: React.FC<{ keywords: string[] }> = ({keywords}) => { ) : ( - handleTwitterImageUpload(files[0])}> + void handleTwitterImageUpload(files[0])}> Upload X image )} diff --git a/apps/admin/src/settings/app/components/settings/general/social-accounts.tsx b/apps/admin/src/settings/general/social-accounts.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/general/social-accounts.tsx rename to apps/admin/src/settings/general/social-accounts.tsx index 1d3ca96cb18..81f08830912 100644 --- a/apps/admin/src/settings/app/components/settings/general/social-accounts.tsx +++ b/apps/admin/src/settings/general/social-accounts.tsx @@ -119,7 +119,7 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => { setErrors(formErrors); if (Object.keys(formErrors).length === 0) { - handleSave(); + void handleSave(); } }; diff --git a/apps/admin/src/settings/app/components/settings/general/time-zone.tsx b/apps/admin/src/settings/general/time-zone.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/time-zone.tsx rename to apps/admin/src/settings/general/time-zone.tsx diff --git a/apps/admin/src/settings/app/components/settings/general/title-and-description.tsx b/apps/admin/src/settings/general/title-and-description.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/title-and-description.tsx rename to apps/admin/src/settings/general/title-and-description.tsx diff --git a/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx b/apps/admin/src/settings/general/user-detail-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx rename to apps/admin/src/settings/general/user-detail-modal.tsx index a22c04a94cf..ae099d66a7b 100644 --- a/apps/admin/src/settings/app/components/settings/general/user-detail-modal.tsx +++ b/apps/admin/src/settings/general/user-detail-modal.tsx @@ -372,7 +372,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel ) : ( - handleImageUpload('profile_image', files[0])}> + void handleImageUpload('profile_image', files[0])}> )} @@ -386,7 +386,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel handleSave: async (file: File) => handleImageUpload('cover_image', file) })}>Edit cover image} - : handleImageUpload('cover_image', files[0])}>Upload cover image} + : void handleImageUpload('cover_image', files[0])}>Upload cover image} {showMenu &&
@@ -418,7 +418,7 @@ const UserDetailModalContent: React.FC<{user: User; onDeletingUserChange: (isDel {canSuspendUser && ( <> { - confirmSuspend(formState); + void confirmSuspend(formState); }}> {suspendUserLabel} diff --git a/apps/admin/src/settings/app/components/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/general/users.tsx rename to apps/admin/src/settings/general/users.tsx index ba8debf0e4e..3e367dae8f3 100644 --- a/apps/admin/src/settings/app/components/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -134,6 +134,35 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => { if (resendState === 'progress') { resendActionLabel = 'Resending...'; } + + const revokeInvite = async () => { + try { + setRevokeState('progress'); + await deleteInvite(invite.id); + toast.success(`Invitation revoked`, {description: invite.email}); + } catch (e) { + handleError(e); + } finally { + setRevokeState(''); + } + }; + + const resendInvite = async () => { + try { + setResendState('progress'); + await deleteInvite(invite.id); + await addInvite({ + email: invite.email, + roleId: invite.role_id + }); + toast.success(`Invitation resent`, {description: invite.email}); + } catch (e) { + handleError(e); + } finally { + setResendState(''); + } + }; + return (
); @@ -255,6 +260,17 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, }; const require2fa = getSettingValue(settings, 'require_email_mfa') || false; + + const updateRequire2fa = async (newValue: boolean) => { + try { + await editSettings([{ + key: 'require_email_mfa', + value: newValue + }]); + } catch (error) { + handleError(error); + } + }; const {mutateAsync: editSettings} = useEditSettings(); const handleError = useHandleError(); @@ -307,16 +323,7 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, { - try { - await editSettings([{ - key: 'require_email_mfa', - value: newValue - }]); - } catch (error) { - handleError(error); - } - }} + onCheckedChange={newValue => void updateRequire2fa(newValue)} />
diff --git a/apps/admin/src/settings/app/components/settings/general/users/change-password-form.tsx b/apps/admin/src/settings/general/users/change-password-form.tsx similarity index 88% rename from apps/admin/src/settings/app/components/settings/general/users/change-password-form.tsx rename to apps/admin/src/settings/general/users/change-password-form.tsx index 5c83216b83a..ee3d6441485 100644 --- a/apps/admin/src/settings/app/components/settings/general/users/change-password-form.tsx +++ b/apps/admin/src/settings/general/users/change-password-form.tsx @@ -158,6 +158,29 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => { setEditPassword(true); }; + const savePassword = async () => { + setSaveState('saving'); + const validationErrors = validate({password: newPassword, confirmPassword: confirmNewPassword}); + setErrors(validationErrors); + if (Object.keys(validationErrors).length > 0) { + setSaveState(''); + return; + } + try { + await updatePassword({ + newPassword, + confirmNewPassword, + oldPassword, + userId: user?.id + }); + setSaveState('saved'); + } catch (e) { + setSaveState(''); + toast.error(e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`); + handleError(e, {withToast: false}); + } + }; + let buttonLabel = 'Save password'; if (saveState === 'saving') { buttonLabel = 'Saving...'; @@ -197,28 +220,7 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => { diff --git a/apps/admin/src/settings/app/components/settings/general/users/email-notifications-tab.tsx b/apps/admin/src/settings/general/users/email-notifications-tab.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/users/email-notifications-tab.tsx rename to apps/admin/src/settings/general/users/email-notifications-tab.tsx diff --git a/apps/admin/src/settings/app/components/settings/general/users/profile-tab.tsx b/apps/admin/src/settings/general/users/profile-tab.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/general/users/profile-tab.tsx rename to apps/admin/src/settings/general/users/profile-tab.tsx index 1413763b501..2368ba9b947 100644 --- a/apps/admin/src/settings/app/components/settings/general/users/profile-tab.tsx +++ b/apps/admin/src/settings/general/users/profile-tab.tsx @@ -3,7 +3,7 @@ import RoleSelector from './role-selector'; import StaffToken from './staff-token'; import {Field, FieldDescription, FieldError, FieldLabel, Input, Textarea} from '@tryghost/shade/components'; import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; -import {type UserDetailProps} from '@/settings/app/components/settings/general/user-detail-modal'; +import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; diff --git a/apps/admin/src/settings/app/components/settings/general/users/role-selector.tsx b/apps/admin/src/settings/general/users/role-selector.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/general/users/role-selector.tsx rename to apps/admin/src/settings/general/users/role-selector.tsx diff --git a/apps/admin/src/settings/app/components/settings/general/users/social-links-tab.tsx b/apps/admin/src/settings/general/users/social-links-tab.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/general/users/social-links-tab.tsx rename to apps/admin/src/settings/general/users/social-links-tab.tsx index 4b1555d6174..92cc66dc009 100644 --- a/apps/admin/src/settings/app/components/settings/general/users/social-links-tab.tsx +++ b/apps/admin/src/settings/general/users/social-links-tab.tsx @@ -1,7 +1,7 @@ import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {SOCIAL_PLATFORM_CONFIGS, normalizeSocialInput} from '@/settings/app/utils/social-urls/index'; import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; -import {type UserDetailProps} from '@/settings/app/components/settings/general/user-detail-modal'; +import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {useState} from 'react'; import type {SocialPlatformKey} from '@/settings/app/utils/social-urls/index'; diff --git a/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx b/apps/admin/src/settings/general/users/staff-token.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx rename to apps/admin/src/settings/general/users/staff-token.tsx index 1bba7534a1b..05d53d52eb9 100644 --- a/apps/admin/src/settings/app/components/settings/general/users/staff-token.tsx +++ b/apps/admin/src/settings/general/users/staff-token.tsx @@ -21,8 +21,8 @@ const StaffToken: React.FC = () => { setToken(`${newAPI.data.apiKey.id}:${newAPI.data.apiKey.secret}`); } }; - getApiKey(); - } , [apiKey]); + void getApiKey(); + }, [apiKey]); const genConfirmation = () => { confirm({ diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx index 1c9742de852..643d67386d1 100644 --- a/apps/admin/src/settings/routes.tsx +++ b/apps/admin/src/settings/routes.tsx @@ -18,11 +18,11 @@ export const settingsRouteChildren: RouteObject[] = [ {path: 'theme/edit/*', element: }, {path: 'navigation/edit', lazy: lazyComponent(() => import('./app/components/settings/site/navigation-modal'))}, {path: 'announcement-bar/edit', lazy: lazyComponent(() => import('./app/components/settings/site/announcement-bar-modal'))}, - {path: 'staff/invite', lazy: lazyComponent(() => import('./app/components/settings/general/invite-user-modal'))}, - {path: 'staff/:slug', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))}, - {path: 'staff/:slug/edit', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))}, - {path: 'staff/:slug/social-links', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))}, - {path: 'staff/:slug/email-notifications', lazy: lazyComponent(() => import('./app/components/settings/general/user-detail-modal'))}, + {path: 'staff/invite', lazy: lazyComponent(() => import('./general/invite-user-modal'))}, + {path: 'staff/:slug', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, + {path: 'staff/:slug/edit', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, + {path: 'staff/:slug/social-links', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, + {path: 'staff/:slug/email-notifications', lazy: lazyComponent(() => import('./general/user-detail-modal'))}, {path: 'portal/edit', lazy: lazyComponent(() => import('./app/components/settings/membership/portal/portal-modal'))}, {path: 'tiers/add', lazy: lazyComponent(() => import('./app/components/settings/membership/tiers/tier-detail-modal'))}, {path: 'tiers/:tierId', lazy: lazyComponent(() => import('./app/components/settings/membership/tiers/tier-detail-modal'))}, @@ -52,7 +52,7 @@ export const settingsRouteChildren: RouteObject[] = [ {path: 'offers/edit/retention/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, {path: 'offers/success/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, {path: 'explore/testimonial', lazy: lazyComponent(() => import('./app/components/settings/growth/explore/testimonials-modal'))}, - {path: 'about', lazy: lazyComponent(() => import('./app/components/settings/general/about'))}, + {path: 'about', lazy: lazyComponent(() => import('./general/about'))}, // The lock-site setting was merged into the Access section. {path: 'locksite', element: }, // Section anchors (/settings/) and unknown paths render the shell diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts index c4b1a0c5da7..650bbe760f5 100644 --- a/apps/admin/src/vite-env.d.ts +++ b/apps/admin/src/vite-env.d.ts @@ -1,5 +1,9 @@ /// +interface ImportMetaEnv { + readonly GHOST_BUILD_VERSION?: string; +} + declare module '@tryghost/limit-service' declare module '@tryghost/nql' declare module '@tryghost/string' { From 62b84e4071f3ccabbec3a7f384c7e46396a58d55 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 12:27:45 -0500 Subject: [PATCH 05/21] Added Oxfmt format scripts (#30079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/PLA-137 Groundwork for the full-repo Oxfmt cutover: `pnpm format` / `pnpm format:check` root scripts, so there is one spelling for running the formatter. No enforcement yet — the CI check and lint-staged wiring land with the cutover so there is no half-state where CI reports thousands of unformatted files. --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 0ec025e3584..32a97968976 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,8 @@ "lint:boundaries": "depcruise ghost/core/core apps --config .dependency-cruiser.cjs", "lint:packages": "node scripts/check-internal-packages.js", "lint:docs": "pnpm lint:agent-skills && pnpm lint:agent-guidance && pnpm lint:markdown && pnpm lint:doc-links", + "format": "oxfmt", + "format:check": "oxfmt --check", "check": "pnpm lint && pnpm test", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", "test:unit": "pnpm nx run-many -t test:unit", From d1aa1cbf6d4357e02c7a2d1e6728b46d9195fcd6 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 12:37:04 -0500 Subject: [PATCH 06/21] Changed ESLint config and inline directives to survive the Oxfmt cutover (#30078) ref https://linear.app/ghost/issue/PLA-137 Groundwork so the upcoming full-repo Oxfmt run can be a pure formatting commit. Formatting everything and then linting surfaced two classes of breakage; both are fixed here without any formatting churn. --- .changeset/easy-wasps-appear.md | 13 +++++++++++++ apps/admin-x-framework/src/utils/api/fetch-api.ts | 3 ++- .../email/newsletters/newsletter-detail-modal.tsx | 3 +-- .../src/settings/app/utils/iframe-buffering.tsx | 6 ++++-- apps/signup-form/test/e2e/attribution.test.ts | 3 ++- e2e/eslint.config.js | 5 +++++ .../lib/helpers/input_password.js | 3 ++- ghost/core/core/frontend/helpers/body_class.js | 3 ++- ghost/core/core/frontend/helpers/cancel_link.js | 3 ++- ghost/core/core/frontend/helpers/color_to_rgba.js | 3 ++- .../core/core/frontend/helpers/content_api_key.js | 3 ++- .../core/core/frontend/helpers/content_api_url.js | 3 ++- .../core/frontend/helpers/contrast_text_color.js | 3 ++- ghost/core/core/frontend/helpers/facebook_url.js | 3 ++- ghost/core/core/frontend/helpers/ghost_foot.js | 3 ++- ghost/core/core/frontend/helpers/ghost_head.js | 3 ++- ghost/core/core/frontend/helpers/has.js | 6 ++++-- ghost/core/core/frontend/helpers/link_class.js | 3 ++- .../core/frontend/helpers/meta_description.js | 3 ++- ghost/core/core/frontend/helpers/meta_title.js | 3 ++- ghost/core/core/frontend/helpers/page_url.js | 3 ++- ghost/core/core/frontend/helpers/post_class.js | 3 ++- ghost/core/core/frontend/helpers/reading_time.js | 3 ++- .../core/core/frontend/helpers/social_accounts.js | 3 ++- ghost/core/core/frontend/helpers/social_url.js | 3 ++- ghost/core/core/frontend/helpers/total_members.js | 3 ++- .../core/frontend/helpers/total_paid_members.js | 3 ++- ghost/core/core/frontend/helpers/twitter_url.js | 3 ++- .../server/api/endpoints/utils/permissions.js | 3 ++- .../core/server/data/importer/handlers/json.js | 3 ++- ...22-09-02-20-52-backfill-new-product-columns.js | 3 ++- ...-04-50-member-subscription-created-batch-id.js | 6 ++++-- ...22-10-31-12-03-backfill-new-product-columns.js | 3 ++- .../importers/members-created-events-importer.js | 15 ++++++++------- .../core/server/services/email-analytics/index.ts | 4 +++- .../email-suppression-list.js | 6 ++++-- koenig/kg-card-factory/eslint.config.mjs | 5 +++++ koenig/kg-clean-basic-html/eslint.config.mjs | 5 +++++ koenig/kg-converters/eslint.config.mjs | 5 +++++ koenig/kg-default-cards/eslint.config.mjs | 5 +++++ koenig/kg-default-transforms/eslint.config.mjs | 5 +++++ koenig/kg-html-to-lexical/eslint.config.mjs | 5 +++++ .../kg-markdown-html-renderer/eslint.config.mjs | 5 +++++ koenig/kg-unsplash-selector/eslint.config.js | 5 +++++ koenig/kg-utils/eslint.config.mjs | 5 +++++ packages/i18n/test/i18n.lint.js | 15 ++++++++++----- 46 files changed, 155 insertions(+), 50 deletions(-) create mode 100644 .changeset/easy-wasps-appear.md diff --git a/.changeset/easy-wasps-appear.md b/.changeset/easy-wasps-appear.md new file mode 100644 index 00000000000..57ee67be712 --- /dev/null +++ b/.changeset/easy-wasps-appear.md @@ -0,0 +1,13 @@ +--- +"@tryghost/kg-utils": none +"@tryghost/kg-unsplash-selector": none +"@tryghost/kg-markdown-html-renderer": none +"@tryghost/kg-html-to-lexical": none +"@tryghost/kg-default-transforms": none +"@tryghost/kg-default-cards": none +"@tryghost/kg-converters": none +"@tryghost/kg-clean-basic-html": none +"@tryghost/kg-card-factory": none +--- + +Switched lint config to formatter-neutral rules; no runtime change diff --git a/apps/admin-x-framework/src/utils/api/fetch-api.ts b/apps/admin-x-framework/src/utils/api/fetch-api.ts index 00aa5fac788..91fa5bc779a 100644 --- a/apps/admin-x-framework/src/utils/api/fetch-api.ts +++ b/apps/admin-x-framework/src/utils/api/fetch-api.ts @@ -154,7 +154,7 @@ export const useFetchApi = () => { const {ghostVersion, sentryDSN} = useFramework(); // Memoized so hooks that depend on fetchApi can cache - // eslint-disable-next-line @typescript-eslint/no-explicit-any + /* eslint-disable @typescript-eslint/no-explicit-any */ return useCallback(async ( endpoint: string | URL, { @@ -167,6 +167,7 @@ export const useFetchApi = () => { onUploadProgress }: RequestOptions = {} ): Promise => { + /* eslint-enable @typescript-eslint/no-explicit-any */ const controller = new AbortController(); const requestInit: InternalRequestInit = { 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 978771f0eec..a7e3bdc9d36 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 @@ -695,8 +695,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b initialState: newsletter, savingDelay: 500, onSave: async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - const {meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); ``; + const {meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); let toastMessage; if (emailToVerify && emailToVerify === 'sender_email') { diff --git a/apps/admin/src/settings/app/utils/iframe-buffering.tsx b/apps/admin/src/settings/app/utils/iframe-buffering.tsx index f60f7275f2d..257b83023f4 100644 --- a/apps/admin/src/settings/app/utils/iframe-buffering.tsx +++ b/apps/admin/src/settings/app/utils/iframe-buffering.tsx @@ -13,10 +13,12 @@ type IframeBufferingProps = { addDelay?: boolean; }; -function debounce(func: any, wait: number) { // eslint-disable-line +// eslint-disable-next-line +function debounce(func: any, wait: number) { let timeout: NodeJS.Timeout; - return function executedFunction(...args: any) { // eslint-disable-line + // eslint-disable-next-line + return function executedFunction(...args: any) { const later = () => { clearTimeout(timeout); func(...args); diff --git a/apps/signup-form/test/e2e/attribution.test.ts b/apps/signup-form/test/e2e/attribution.test.ts index cd30d46dc11..32144525726 100644 --- a/apps/signup-form/test/e2e/attribution.test.ts +++ b/apps/signup-form/test/e2e/attribution.test.ts @@ -2,7 +2,7 @@ import {expect} from '@playwright/test'; import {initialize} from '../utils/e2e'; import {test} from '@playwright/test'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any +/* eslint-disable @typescript-eslint/no-explicit-any */ async function testHistory({page, embeddedOnUrl, path, urlHistory, sessionStorageHistory}: {page: any, embeddedOnUrl?: string, path: string, urlHistory: any[], sessionStorageHistory?: any[]}) { const {frame, lastApiRequest} = await initialize({page, title: 'Sign up', embeddedOnUrl, path}); @@ -30,6 +30,7 @@ async function testHistory({page, embeddedOnUrl, path, urlHistory, sessionStorag expect(lastApiRequest.body).toHaveProperty('email', 'jamie@example.com'); expect(lastApiRequest.body).toHaveProperty('urlHistory', urlHistory); } +/* eslint-enable @typescript-eslint/no-explicit-any */ test.describe('Attribution', async () => { test('Sends the current path', async ({page}) => { diff --git a/e2e/eslint.config.js b/e2e/eslint.config.js index 56caeb96a78..4509bae4e6a 100644 --- a/e2e/eslint.config.js +++ b/e2e/eslint.config.js @@ -148,6 +148,11 @@ export default tseslint.config([ // Manually include rules from plugin:ghost/ts and plugin:ghost/ts-test // These would normally come from the extends, but flat config requires explicit inclusion ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], // Sort multiple import lines into alphabetical groups 'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', { diff --git a/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js b/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js index 5720b0109fb..c2532d8a4cc 100644 --- a/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js +++ b/ghost/core/core/frontend/apps/private-blogging/lib/helpers/input_password.js @@ -7,7 +7,8 @@ const {SafeString, templates} = require('../../../../services/handlebars'); // We use the name input_password to match the helper for consistency: -module.exports = function input_password(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function input_password(options) { options = options || {}; options.hash = options.hash || {}; diff --git a/ghost/core/core/frontend/helpers/body_class.js b/ghost/core/core/frontend/helpers/body_class.js index a734a49b62f..38d75b26de9 100644 --- a/ghost/core/core/frontend/helpers/body_class.js +++ b/ghost/core/core/frontend/helpers/body_class.js @@ -11,7 +11,8 @@ const {SafeString} = require('../services/handlebars'); */ // We use the name body_class to match the helper for consistency -module.exports = function body_class(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function body_class(options) { let classes = []; const context = options.data.root.context || []; const obj = this.post || this.page; diff --git a/ghost/core/core/frontend/helpers/cancel_link.js b/ghost/core/core/frontend/helpers/cancel_link.js index b405005a219..a04612f1975 100644 --- a/ghost/core/core/frontend/helpers/cancel_link.js +++ b/ghost/core/core/frontend/helpers/cancel_link.js @@ -15,7 +15,8 @@ const messages = { invalidData: 'The {{cancel_link}} helper was used outside of a subscription context. See https://ghost.org/docs/themes/members/#cancel-links.' }; -function cancel_link(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +function cancel_link(options) { let truncateOptions = (options || {}).hash || {}; if (this.id === undefined || this.cancel_at_period_end === undefined) { diff --git a/ghost/core/core/frontend/helpers/color_to_rgba.js b/ghost/core/core/frontend/helpers/color_to_rgba.js index f901be1d617..ea2331eb613 100644 --- a/ghost/core/core/frontend/helpers/color_to_rgba.js +++ b/ghost/core/core/frontend/helpers/color_to_rgba.js @@ -1,6 +1,7 @@ const {Color} = require('@tryghost/color-utils'); -module.exports = function color_to_rgba(color, alpha) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function color_to_rgba(color, alpha) { const backgroundColor = (typeof color === 'string' && color.trim()) ? color.trim() : '#15171A'; const opacity = Number.isFinite(alpha) ? alpha : Number.parseFloat(alpha); const normalizedOpacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 0.25; diff --git a/ghost/core/core/frontend/helpers/content_api_key.js b/ghost/core/core/frontend/helpers/content_api_key.js index 5101fa043f0..b4aa6e9e302 100644 --- a/ghost/core/core/frontend/helpers/content_api_key.js +++ b/ghost/core/core/frontend/helpers/content_api_key.js @@ -2,7 +2,8 @@ const {SafeString} = require('../services/handlebars'); const logging = require('@tryghost/logging'); const {getFrontendKey} = require('../services/proxy'); -module.exports = async function content_api_key() { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = async function content_api_key() { try { const frontendKey = await getFrontendKey(); diff --git a/ghost/core/core/frontend/helpers/content_api_url.js b/ghost/core/core/frontend/helpers/content_api_url.js index 8cef0ab0da9..29c21e8aa8a 100644 --- a/ghost/core/core/frontend/helpers/content_api_url.js +++ b/ghost/core/core/frontend/helpers/content_api_url.js @@ -2,7 +2,8 @@ const {SafeString} = require('../services/handlebars'); const logging = require('@tryghost/logging'); const {urlUtils} = require('../services/proxy'); -module.exports = function content_api_url(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function content_api_url(options) { let result; const absoluteUrlRequested = getAbsoluteOption(options); diff --git a/ghost/core/core/frontend/helpers/contrast_text_color.js b/ghost/core/core/frontend/helpers/contrast_text_color.js index 1d1f5d5c93f..68b2013231d 100644 --- a/ghost/core/core/frontend/helpers/contrast_text_color.js +++ b/ghost/core/core/frontend/helpers/contrast_text_color.js @@ -1,6 +1,7 @@ const {textColorForBackgroundColor} = require('@tryghost/color-utils'); -module.exports = function contrast_text_color(color) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function contrast_text_color(color) { const backgroundColor = (typeof color === 'string' && color.trim()) ? color.trim() : '#15171A'; try { diff --git a/ghost/core/core/frontend/helpers/facebook_url.js b/ghost/core/core/frontend/helpers/facebook_url.js index cc8b0bd8cf2..15910d08f73 100644 --- a/ghost/core/core/frontend/helpers/facebook_url.js +++ b/ghost/core/core/frontend/helpers/facebook_url.js @@ -9,7 +9,8 @@ const {localUtils} = require('../services/handlebars'); /** * @deprecated Use {{social_url type="facebook"}} instead. */ -module.exports = function facebook_url(username, options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function facebook_url(username, options) { if (!options) { options = username; username = localUtils.findKey('facebook', this, options.data.site); diff --git a/ghost/core/core/frontend/helpers/ghost_foot.js b/ghost/core/core/frontend/helpers/ghost_foot.js index a439574faf0..71aa9add617 100644 --- a/ghost/core/core/frontend/helpers/ghost_foot.js +++ b/ghost/core/core/frontend/helpers/ghost_foot.js @@ -9,7 +9,8 @@ const _ = require('lodash'); const createFrame = hbs.handlebars.createFrame; // We use the name ghost_foot to match the helper for consistency: -module.exports = function ghost_foot(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function ghost_foot(options) { const foot = []; const globalCodeinjection = settingsCache.get('codeinjection_foot'); diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js index 87053512384..41570ded9e3 100644 --- a/ghost/core/core/frontend/helpers/ghost_head.js +++ b/ghost/core/core/frontend/helpers/ghost_head.js @@ -320,7 +320,8 @@ function getTinybirdTrackerScript(dataRoot) { * Also see how the root object gets created, https://github.com/wycats/handlebars.js/blob/v4.0.6/lib/handlebars/runtime.js#L259 */ // We use the name ghost_head to match the helper for consistency: -module.exports = async function ghost_head(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = async function ghost_head(options) { debug('begin'); // if server error page do nothing if (options.data.root.statusCode >= 500) { diff --git a/ghost/core/core/frontend/helpers/has.js b/ghost/core/core/frontend/helpers/has.js index 454b8425d05..39c5ed858e2 100644 --- a/ghost/core/core/frontend/helpers/has.js +++ b/ghost/core/core/frontend/helpers/has.js @@ -107,9 +107,11 @@ function evaluateStringMatch(expr, str, ci) { * @param {Object} data - global params */ function evaluateList(type, expr, obj, data) { - return expr.split(',').map(function (prop) { + const props = expr.split(',').map(function (prop) { return prop.trim().toLocaleLowerCase(); - })[type](function (prop) { + }); + + return props[type](function (prop) { if (prop.match(/^@/)) { return _.has(data, prop.replace(/@/, '')) && !_.isEmpty(_.get(data, prop.replace(/@/, ''))); } else { diff --git a/ghost/core/core/frontend/helpers/link_class.js b/ghost/core/core/frontend/helpers/link_class.js index 5ac27cf1d9a..6a54d5884b3 100644 --- a/ghost/core/core/frontend/helpers/link_class.js +++ b/ghost/core/core/frontend/helpers/link_class.js @@ -12,7 +12,8 @@ const messages = { forIsRequired: 'The {{link_class}} helper requires a for="" attribute.' }; -module.exports = function link_class(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function link_class(options) { options = options || {}; options.hash = options.hash || {}; options.data = options.data || {}; diff --git a/ghost/core/core/frontend/helpers/meta_description.js b/ghost/core/core/frontend/helpers/meta_description.js index 0ab68ba4e26..175b7d3f936 100644 --- a/ghost/core/core/frontend/helpers/meta_description.js +++ b/ghost/core/core/frontend/helpers/meta_description.js @@ -6,7 +6,8 @@ const metaData = require('../meta'); const {getMetaDataDescription} = metaData; // We use the name meta_description to match the helper for consistency: -module.exports = function meta_description(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function meta_description(options) { options = options || {}; return getMetaDataDescription(this, options.data.root) || ''; diff --git a/ghost/core/core/frontend/helpers/meta_title.js b/ghost/core/core/frontend/helpers/meta_title.js index 65a75f368c5..61fd88f2de1 100644 --- a/ghost/core/core/frontend/helpers/meta_title.js +++ b/ghost/core/core/frontend/helpers/meta_title.js @@ -6,6 +6,7 @@ const metaData = require('../meta'); const {getMetaDataTitle} = metaData; // We use the name meta_title to match the helper for consistency: -module.exports = function meta_title(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function meta_title(options) { return getMetaDataTitle(this, options.data.root, options); }; diff --git a/ghost/core/core/frontend/helpers/page_url.js b/ghost/core/core/frontend/helpers/page_url.js index d46126b5f07..38e2e2d4dc3 100644 --- a/ghost/core/core/frontend/helpers/page_url.js +++ b/ghost/core/core/frontend/helpers/page_url.js @@ -8,7 +8,8 @@ const metaData = require('../meta'); const getPaginatedUrl = metaData.getPaginatedUrl; // We use the name page_url to match the helper for consistency: -module.exports = function page_url(page, options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function page_url(page, options) { if (!options) { options = page; page = 1; diff --git a/ghost/core/core/frontend/helpers/post_class.js b/ghost/core/core/frontend/helpers/post_class.js index dd2779c4ec2..ba0263b3070 100644 --- a/ghost/core/core/frontend/helpers/post_class.js +++ b/ghost/core/core/frontend/helpers/post_class.js @@ -5,7 +5,8 @@ const {SafeString} = require('../services/handlebars'); // We use the name post_class to match the helper for consistency: -module.exports = function post_class() { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function post_class() { let classes = ['post']; const tags = this.post && this.post.tags ? this.post.tags : this.tags || []; diff --git a/ghost/core/core/frontend/helpers/reading_time.js b/ghost/core/core/frontend/helpers/reading_time.js index 6622161c64e..00c1216e92c 100644 --- a/ghost/core/core/frontend/helpers/reading_time.js +++ b/ghost/core/core/frontend/helpers/reading_time.js @@ -15,7 +15,8 @@ const {SafeString} = require('../services/handlebars'); const {readingTime: calculateAndFormatReadingTime} = require('@tryghost/helpers'); -module.exports = function reading_time(options) {// eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function reading_time(options) { options = options || {}; options.hash = options.hash || {}; const possiblyPost = this; diff --git a/ghost/core/core/frontend/helpers/social_accounts.js b/ghost/core/core/frontend/helpers/social_accounts.js index 722dd8e5602..4d947a1e2d3 100644 --- a/ghost/core/core/frontend/helpers/social_accounts.js +++ b/ghost/core/core/frontend/helpers/social_accounts.js @@ -45,7 +45,8 @@ const SOCIAL_PLATFORMS = [ {type: 'instagram', name: 'Instagram'} ]; -module.exports = function social_accounts(source, options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function social_accounts(source, options) { // {{#social_accounts}} with no positional arg: handlebars passes only options. if (arguments.length < 2) { throw new errors.IncorrectUsageError({ diff --git a/ghost/core/core/frontend/helpers/social_url.js b/ghost/core/core/frontend/helpers/social_url.js index 5f19b97bf9b..6311189c9b3 100644 --- a/ghost/core/core/frontend/helpers/social_url.js +++ b/ghost/core/core/frontend/helpers/social_url.js @@ -6,7 +6,8 @@ const {socialUrls} = require('../services/proxy'); const {localUtils} = require('../services/handlebars'); // We use the name social_url to match the helper for consistency: -module.exports = function social_url(options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function social_url(options) { // Check for required hash option 'type' if (!options || !options.hash || !options.hash.type) { return null; diff --git a/ghost/core/core/frontend/helpers/total_members.js b/ghost/core/core/frontend/helpers/total_members.js index f8d1865f41d..5f461e4e28c 100644 --- a/ghost/core/core/frontend/helpers/total_members.js +++ b/ghost/core/core/frontend/helpers/total_members.js @@ -4,7 +4,8 @@ const {SafeString} = require('../services/handlebars'); const {memberCountRounding, getMemberStats} = require('../utils/member-count'); -module.exports = async function total_members () { //eslint-disable-line +// eslint-disable-next-line +module.exports = async function total_members () { if (this.total) { return new SafeString(memberCountRounding(this.total)); } else { diff --git a/ghost/core/core/frontend/helpers/total_paid_members.js b/ghost/core/core/frontend/helpers/total_paid_members.js index acfbf44aa61..d665da86269 100644 --- a/ghost/core/core/frontend/helpers/total_paid_members.js +++ b/ghost/core/core/frontend/helpers/total_paid_members.js @@ -3,7 +3,8 @@ const {SafeString} = require('../services/handlebars'); const {memberCountRounding, getMemberStats} = require('../utils/member-count'); -module.exports = async function total_paid_members () { //eslint-disable-line +// eslint-disable-next-line +module.exports = async function total_paid_members () { if (this.paid) { return new SafeString(memberCountRounding(this.paid)); } else { diff --git a/ghost/core/core/frontend/helpers/twitter_url.js b/ghost/core/core/frontend/helpers/twitter_url.js index 62622067529..6ee39958be1 100644 --- a/ghost/core/core/frontend/helpers/twitter_url.js +++ b/ghost/core/core/frontend/helpers/twitter_url.js @@ -9,7 +9,8 @@ const {localUtils} = require('../services/handlebars'); /** * @deprecated Use {{social_url type="twitter"}} instead. */ -module.exports = function twitter_url(username, options) { // eslint-disable-line camelcase +// eslint-disable-next-line camelcase +module.exports = function twitter_url(username, options) { if (!options) { options = username; username = localUtils.findKey('twitter', this, options.data.site); diff --git a/ghost/core/core/server/api/endpoints/utils/permissions.js b/ghost/core/core/server/api/endpoints/utils/permissions.js index 12e7223b299..8a75c975eae 100644 --- a/ghost/core/core/server/api/endpoints/utils/permissions.js +++ b/ghost/core/core/server/api/endpoints/utils/permissions.js @@ -41,7 +41,8 @@ const nonePublicAuth = async (apiConfig, frame) => { } try { - const result = await permissions.canThis(frame.options.context)[apiConfig.method][singular](permissionIdentifier, unsafeAttrObject); + const permissionCheck = permissions.canThis(frame.options.context)[apiConfig.method][singular]; + const result = await permissionCheck(permissionIdentifier, unsafeAttrObject); /* * Allow the permissions function to return a list of excluded attributes. diff --git a/ghost/core/core/server/data/importer/handlers/json.js b/ghost/core/core/server/data/importer/handlers/json.js index a2abbc964fc..181721198b4 100644 --- a/ghost/core/core/server/data/importer/handlers/json.js +++ b/ghost/core/core/server/data/importer/handlers/json.js @@ -17,7 +17,8 @@ JSONHandler = { contentTypes: ['application/octet-stream', 'application/json'], directories: [], - loadFile: async function (files, startDir) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + loadFile: async function (files, startDir) { debug('loadFile', files); // @TODO: Handle multiple JSON files const filePath = files[0].path; diff --git a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js index 565294ce4b7..cabd035779a 100644 --- a/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js +++ b/ghost/core/core/server/data/migrations/versions/5.19/2022-09-02-20-52-backfill-new-product-columns.js @@ -22,7 +22,8 @@ module.exports = createTransactionalMigration( logging.info(`Updating ${rows.length} Tiers with price and currency information`); } - for (const row of rows) { // eslint-disable-line no-restricted-syntax + // eslint-disable-next-line no-restricted-syntax + for (const row of rows) { await knex('products').update(row).where('id', row.id); } }, diff --git a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js index a6a2c07eeec..193a675504e 100644 --- a/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js +++ b/ghost/core/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js @@ -26,14 +26,16 @@ module.exports = createTransactionalMigration( } // Attach a unique id to each row - for (const row of rows) { // eslint-disable-line no-restricted-syntax + // eslint-disable-next-line no-restricted-syntax + for (const row of rows) { row.batch_id = ObjectId().toHexString(); } // Create batches (insertBatch doesn't support the onConflict option) const batches = _.chunk(rows, 1000); - for (const batch of batches) { // eslint-disable-line no-restricted-syntax + // eslint-disable-next-line no-restricted-syntax + for (const batch of batches) { // Update the members_created_events table using INSERT ON DUPLICATE KEY UPDATE trick const response1 = await knex('members_created_events').insert(batch.map((r) => { return { diff --git a/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js b/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js index a498d3150c5..1f6a5ff5c29 100644 --- a/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js +++ b/ghost/core/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js @@ -25,7 +25,8 @@ module.exports = createTransactionalMigration( logging.info(`Updating ${rows.length} Tiers with price and currency information`); } - for (const row of rows) { // eslint-disable-line no-restricted-syntax + // eslint-disable-next-line no-restricted-syntax + for (const row of rows) { await knex('products').update(row).where('id', row.id); } }, diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 807eaa0e06e..650a5b4f022 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -20,15 +20,16 @@ class MembersCreatedEventsImporter extends TableImporter { } generateSource() { - let source = 'member'; if (luck(10)) { - source = 'admin'; - } else if (luck(5)) { - source = 'api'; - } else if (luck(5)) { // eslint-disable-line no-dupe-else-if - source = 'import'; + return 'admin'; } - return source; + if (luck(5)) { + return 'api'; + } + if (luck(5)) { + return 'import'; + } + return 'member'; } generate() { diff --git a/ghost/core/core/server/services/email-analytics/index.ts b/ghost/core/core/server/services/email-analytics/index.ts index da1d98d530d..93633ca9975 100644 --- a/ghost/core/core/server/services/email-analytics/index.ts +++ b/ghost/core/core/server/services/email-analytics/index.ts @@ -6,7 +6,9 @@ import type {GhostMetrics} from '@tryghost/metrics'; import type SettingsCache from '../../../shared/settings-cache'; import {EmailAnalyticsServiceWrapper} from './email-analytics-service-wrapper'; // @ts-expect-error This module lacks type definitions. -import {AGGREGATE_MEMBER_STATS_METRIC_NAME, NewsletterEmailAnalyticsBatchProcessor} from './newsletter-email-analytics-batch-processor'; +import {AGGREGATE_MEMBER_STATS_METRIC_NAME} from './newsletter-email-analytics-batch-processor'; +// @ts-expect-error This module lacks type definitions. +import {NewsletterEmailAnalyticsBatchProcessor} from './newsletter-email-analytics-batch-processor'; // @ts-expect-error This module lacks type definitions. import NewsletterEmailEventStorage from '../email-service/newsletter-email-event-storage'; // @ts-expect-error This module lacks type definitions. diff --git a/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js b/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js index d66e369935b..3fdd65c3457 100644 --- a/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js +++ b/ghost/core/core/server/services/email-suppression-list/email-suppression-list.js @@ -63,7 +63,8 @@ class AbstractEmailSuppressionList { * @param {string} email * @returns {Promise} */ - async removeEmail(email) { // eslint-disable-line + // eslint-disable-next-line + async removeEmail(email) { return Promise.reject(); } @@ -71,7 +72,8 @@ class AbstractEmailSuppressionList { * @param {string} email * @returns {Promise} */ - async getSuppressionData(email) { // eslint-disable-line + // eslint-disable-next-line + async getSuppressionData(email) { return Promise.reject(); } diff --git a/koenig/kg-card-factory/eslint.config.mjs b/koenig/kg-card-factory/eslint.config.mjs index 3b85fa8bbbe..2ec2e0bdf0f 100644 --- a/koenig/kg-card-factory/eslint.config.mjs +++ b/koenig/kg-card-factory/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/koenig/kg-clean-basic-html/eslint.config.mjs b/koenig/kg-clean-basic-html/eslint.config.mjs index 25fe9a16bf5..4bd9bc12baa 100644 --- a/koenig/kg-clean-basic-html/eslint.config.mjs +++ b/koenig/kg-clean-basic-html/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: { ghost: ghostPlugin }, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error', }, }, diff --git a/koenig/kg-converters/eslint.config.mjs b/koenig/kg-converters/eslint.config.mjs index 25fe9a16bf5..4bd9bc12baa 100644 --- a/koenig/kg-converters/eslint.config.mjs +++ b/koenig/kg-converters/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: { ghost: ghostPlugin }, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error', }, }, diff --git a/koenig/kg-default-cards/eslint.config.mjs b/koenig/kg-default-cards/eslint.config.mjs index d75842e22c2..89f6111b128 100644 --- a/koenig/kg-default-cards/eslint.config.mjs +++ b/koenig/kg-default-cards/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/koenig/kg-default-transforms/eslint.config.mjs b/koenig/kg-default-transforms/eslint.config.mjs index d75842e22c2..89f6111b128 100644 --- a/koenig/kg-default-transforms/eslint.config.mjs +++ b/koenig/kg-default-transforms/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/koenig/kg-html-to-lexical/eslint.config.mjs b/koenig/kg-html-to-lexical/eslint.config.mjs index 3b85fa8bbbe..2ec2e0bdf0f 100644 --- a/koenig/kg-html-to-lexical/eslint.config.mjs +++ b/koenig/kg-html-to-lexical/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/koenig/kg-markdown-html-renderer/eslint.config.mjs b/koenig/kg-markdown-html-renderer/eslint.config.mjs index 3b85fa8bbbe..2ec2e0bdf0f 100644 --- a/koenig/kg-markdown-html-renderer/eslint.config.mjs +++ b/koenig/kg-markdown-html-renderer/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/koenig/kg-unsplash-selector/eslint.config.js b/koenig/kg-unsplash-selector/eslint.config.js index d27ac2ab7a0..c3b4c657225 100644 --- a/koenig/kg-unsplash-selector/eslint.config.js +++ b/koenig/kg-unsplash-selector/eslint.config.js @@ -38,6 +38,11 @@ export default defineConfig([ }, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], // sort multiple import lines into alphabetical groups 'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', { diff --git a/koenig/kg-utils/eslint.config.mjs b/koenig/kg-utils/eslint.config.mjs index 3b85fa8bbbe..2ec2e0bdf0f 100644 --- a/koenig/kg-utils/eslint.config.mjs +++ b/koenig/kg-utils/eslint.config.mjs @@ -17,6 +17,11 @@ export default defineConfig([ plugins: {ghost: ghostPlugin}, rules: { ...ghostPlugin.configs.ts.rules, + // Formatting is owned by Oxfmt; keep the non-formatting rules ts-no-style also switches off + ...ghostPlugin.configs['ts-no-style'].rules, + camelcase: ghostPlugin.configs.ts.rules.camelcase, + curly: ghostPlugin.configs.ts.rules.curly, + 'dot-notation': ghostPlugin.configs.ts.rules['dot-notation'], '@typescript-eslint/no-explicit-any': 'error' } }, diff --git a/packages/i18n/test/i18n.lint.js b/packages/i18n/test/i18n.lint.js index 7497668f3ae..25e37d630de 100644 --- a/packages/i18n/test/i18n.lint.js +++ b/packages/i18n/test/i18n.lint.js @@ -272,7 +272,8 @@ class LinterContext { resultsToFilter.add(resultIndex); } - console.log(`Applied ${this._fixes.length} fixes`); // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.log(`Applied ${this._fixes.length} fixes`); for (const resultIndex of resultsToFilter) { this.summary.results[resultIndex].messages = this.summary.results[resultIndex].messages.filter(Boolean); @@ -310,17 +311,20 @@ async function exitWithSummary(summary) { const formattedResults = await formatter.format(summary.results, {cwd: '', rulesMeta: {}}); if (formattedResults) { - console.log(formattedResults); // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.log(formattedResults); } if (summary.ignoreAllFlagProvidedWithoutFixFlag) { - console.warn( // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.warn( '--unsafe-ignore-all was provided without --fix; errors were not ignored' ); } if (summary.errorCount > 0) { - console.log( // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.log( `\nNote: Since JSON doesn't support comments, use test/i18n-ignore.json to waive messages.\n\n` ); process.exit(1); @@ -479,7 +483,8 @@ async function analyze() { if (require.main === module) { analyze().then(results => exitWithSummary(results)).catch((error) => { - console.error(error); // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.error(error); process.exit(1); }); } From 3b1a39fbd634cb93854a466b4d7cd1c4fc04ab89 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 18 Aug 2026 12:46:10 -0500 Subject: [PATCH 07/21] Fixed flaky comments pagination acceptance test (#30083) no ref The comments pagination acceptance test now waits for the second thread request to be captured and for the final reply to render before checking the aggregate row count and completed pagination state. --- apps/admin/src/comments/comments.acceptance.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/comments/comments.acceptance.test.tsx b/apps/admin/src/comments/comments.acceptance.test.tsx index e0ac2a587ae..fecbfa40b1c 100644 --- a/apps/admin/src/comments/comments.acceptance.test.tsx +++ b/apps/admin/src/comments/comments.acceptance.test.tsx @@ -110,8 +110,9 @@ describe("Comments thread sidebar", () => { await commentsScreen.loadMoreRepliesButton().click(); + await expect.poll(() => threadApi.requests.length).toBe(2); + await expect.element(commentsScreen.threadRow(replies.at(-1)!.id)).toBeVisible(); await expect(commentsScreen.threadRows()).toHaveCount(6); // root + all 5 replies await expect.element(commentsScreen.loadMoreRepliesButton()).not.toBeInTheDocument(); - await expect.poll(() => threadApi.requests.length).toBe(2); }); }); From fc70961cf58a1fdda8d3f6ad8c31c4cf2b0ccfdd Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Tue, 18 Aug 2026 14:16:48 -0400 Subject: [PATCH 08/21] Improved automation canvas recentering after deletion (#30075) closes https://linear.app/ghost/issue/NY-1532/animate-automation-canvas-recentering-when-deleting-the-final-step A corrective viewport clamp after deleting the final step should make the workflow movement clear instead of snapping. Ignore floating-point zoom noise from the programmatic pan so it cannot restart its own transition. Video shows the nodes from the end of the list getting deleted and the canvas recentering smoothly. note it also shows that deleting a node from the middle of the workflow will _not_ animate smoothly, it just immediately goes away like it did before. The code for implementing that will be a little more involved and handled in a separate PR. https://github.com/user-attachments/assets/5bc13334-ec5d-42e6-a939-b308edf42956 --- .../components/canvas/use-canvas-viewport.ts | 7 +++++-- apps/admin/src/automations/editor.test.tsx | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts b/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts index 3ea955f56cc..0527678c272 100644 --- a/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts +++ b/apps/admin/src/automations/components/canvas/use-canvas-viewport.ts @@ -80,7 +80,7 @@ export const useCanvasViewport = ((_, viewport) => { - setZoom(viewport.zoom); + setZoom(currentZoom => Math.abs(currentZoom - viewport.zoom) < 1e-6 ? currentZoom : viewport.zoom); }, []); const contentBottom = contentBounds?.bottom; @@ -103,7 +103,10 @@ export const useCanvasViewport = ; type: string}; type EdgeRenderProps = {id: string; data: Record; sourceX: number; sourceY: number; targetX: number; targetY: number; sourcePosition: string; targetPosition: string}; +let mockOnMove: StubReactFlowProps['onMove']; vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); return { ...actual, - ReactFlow: ({nodes, edges, children, className, nodeTypes, edgeTypes, onInit, onNodeClick, onNodeDoubleClick, onPaneClick, zoomOnDoubleClick}: StubReactFlowProps) => { + ReactFlow: ({nodes, edges, children, className, nodeTypes, edgeTypes, onInit, onMove, onNodeClick, onNodeDoubleClick, onPaneClick, zoomOnDoubleClick}: StubReactFlowProps) => { React.useEffect(() => { onInit?.(mockReactFlow); }, [onInit]); + React.useEffect(() => { + mockOnMove = onMove; + }, [onMove]); return (
@@ -941,7 +945,14 @@ describe('AutomationEditor', () => { fireEvent.click(screen.getByRole('button', {name: 'Wait: 1 day'})); fireEvent.click(within(screen.getByRole('complementary', {name: 'Step details'})).getByRole('button', {name: 'Delete step'})); - await waitFor(() => expect(mockReactFlow.setViewport).toHaveBeenCalledWith({x: 372, y: -8, zoom: 1})); + await waitFor(() => expect(mockReactFlow.setViewport).toHaveBeenCalledWith( + {x: 372, y: -8, zoom: 1}, + {duration: 250, interpolate: 'linear'} + )); + + mockReactFlow.setViewport.mockClear(); + act(() => mockOnMove?.(null, {x: 372, y: -187, zoom: 1 + Number.EPSILON})); + expect(mockReactFlow.setViewport).not.toHaveBeenCalled(); } finally { clientWidthSpy.mockRestore(); clientHeightSpy.mockRestore(); From 0fd6dee7382aca68666fde9f298e108adee47bec Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 18 Aug 2026 14:17:04 -0400 Subject: [PATCH 09/21] Disabled GCHR cache push on PRs (#30081) no ref - the pr cache was never used (cache-from was always main) so the prior config meant a heavy push for no gain. Removing the cache-to on PRs will hopefully reduce the rate limit exceeded errors we get from Github --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29141787ce3..aacb2d3d026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1464,7 +1464,11 @@ jobs: tags: ${{ steps.meta-core.outputs.tags }} labels: ${{ steps.meta-core.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # PRs read cache-main but never write cache: the old cache-pr-N was + # write-only (cache-from is always cache-main) and mode=max cache export + # is the heaviest GHCR push in the run — skipping it on PRs cuts push + # volume with no rebuild cost. Only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-core-name) || '' }} # Uploaded here, before the full image is even built: on the artifact path # consumers (Ghost-Moya CD) need the core image — server only, no admin — @@ -1535,7 +1539,8 @@ jobs: tags: ${{ steps.meta-full.outputs.tags }} labels: ${{ steps.meta-full.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-full-name }}:cache-main - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-full-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # See core image above: PRs skip cache-to; only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-full-name) || '' }} # The production image artifact is saved before the e2e steps below: # Ghost-Moya CD discovers `docker-image-production` by name in this run @@ -1711,7 +1716,8 @@ jobs: tags: ${{ steps.meta-e2e.outputs.tags }} labels: ${{ steps.meta-e2e.outputs.labels }} cache-from: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-main', steps.strategy.outputs.image-e2e-name) || '' }} - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-e2e-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # See core image above: PRs skip cache-to; only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-e2e-name) || '' }} - name: Save E2E image as artifact if: steps.strategy.outputs.use-artifact == 'true' From 30f7b2bb898ce91286fe6a0074d69fdea569c3e3 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 18 Aug 2026 14:45:57 -0400 Subject: [PATCH 10/21] Changed Renovate automerge to rebase instead of squash (#30088) no ref Renovate had no automergeStrategy set, so automerges used the platform default (squash). Squash merges compose the commit message from the PR title and full PR body, and Renovate PR bodies are huge (release-note tables, changelogs), producing giant commit messages on main. Renovate branches are a single concise commit, so rebasing preserves that clean message verbatim. --- .github/renovate.json5 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index efb3cb1d336..2e1792a83e8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,6 +45,13 @@ // We have to disable platform based automerge (forcing renovate to do it manually) // as otherwise renovate wont follow our schedule "platformAutomerge": false, + // Merge automerged PRs by rebasing the branch's own commits onto the base + // instead of squashing. Renovate branches are a single, concise commit + // (e.g. "chore(deps): update X to Y"), so a squash merge would replace that + // clean message with the PR title *and* the full PR body — release-note + // tables, changelogs, dependency dashboards — producing a giant commit + // message on main. Rebase preserves the branch commit message verbatim. + "automergeStrategy": "rebase", // Branch protection does not require up-to-date branches (the required // status checks ruleset has strict: false), so a green-but-behind branch // is still mergeable. Rebasing on every `main` commit therefore buys From 7ae798cef88d71ac829fa592392a9e36d5e7a785 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Tue, 18 Aug 2026 14:46:46 -0400 Subject: [PATCH 11/21] Changed the Koenig font install to fail fast on a stuck apt fetch (#30087) no ref The msttcorefonts installer downloads the font files from SourceForge at install time, and its mirror-redirect roulette can hang indefinitely. Without any timeout, a stuck fetch sat until the whole job timeout killed it, wasting a full CI slot on the koenig-lexical acceptance leg. Bound apt's network ops with Acquire retries/timeouts and wrap the install in a timeout-guarded retry loop (SIGKILL 5s after SIGTERM) so a stuck mirror is abandoned after 120s and re-rolled onto a different one. Track success across attempts and exit 1 if all three fail, so a missing font install can't let the step pass. timeout-minutes: 10 is a backstop above the worst-case retry path for any hang the inner timeout can't catch. --- .github/workflows/ci.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aacb2d3d026..f8733703013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1039,15 +1039,30 @@ jobs: # which depends on Arial's font metrics # - playwright firefox deps: system media codecs so firefox can decode # the H.264 fixtures in the video card tests + # msttcorefonts downloads the font files from SourceForge at install time; + # mirror-redirect roulette there can hang indefinitely. timeout-minutes is + # the backstop, and each network op is bounded + retried so a stuck fetch + # fails fast and re-rolls onto a different mirror instead of sitting. - name: Install Koenig editor test dependencies (fonts + media codecs) if: matrix.app == '@tryghost/koenig-lexical' + timeout-minutes: 10 env: DEBIAN_FRONTEND: noninteractive DEBCONF_NONINTERACTIVE_SEEN: "true" run: | sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" - sudo apt-get update -yq - sudo apt-get install -yq msttcorefonts + sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 + installed= + for i in 1 2 3; do + if sudo timeout -k 5 120 apt-get install -yq \ + -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then + installed=1; break + fi + echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 + done + if [ -z "$installed" ]; then + echo "msttcorefonts failed after 3 attempts"; exit 1 + fi pnpm exec playwright install-deps firefox - name: Run Playwright tests From 15f6da95a7a7958d2f885f06876ab84f4b73af05 Mon Sep 17 00:00:00 2001 From: Vitrix Date: Tue, 18 Aug 2026 22:57:08 +0400 Subject: [PATCH 12/21] Add ActivityPub sensitive media controls (#29054) --- apps/activitypub/src/api/activitypub.test.ts | 181 ++++++- apps/activitypub/src/api/activitypub.ts | 43 ++ .../src/components/feed/feed-item.tsx | 448 +++++++++++++++--- .../src/hooks/use-activity-pub-queries.ts | 57 +++ .../hooks/use-sensitive-media-disclosure.ts | 121 +++++ apps/activitypub/src/utils/posts.ts | 2 + .../src/views/inbox/components/customizer.tsx | 5 +- .../src/views/inbox/components/reader.tsx | 143 +++++- .../src/views/notifications/notifications.tsx | 109 ++++- .../preferences/components/moderation.tsx | 40 +- .../views/preferences/components/settings.tsx | 10 +- .../test/acceptance/dom-validation.test.ts | 148 ++++++ apps/activitypub/test/acceptance/feed.test.ts | 238 ++++++++++ .../activitypub/test/acceptance/inbox.test.ts | 340 +++++++++++++ .../test/acceptance/preferences.test.ts | 136 ++++++ .../activitypub/test/unit/utils/posts.test.ts | 15 + apps/admin-x-framework/src/api/activitypub.ts | 2 + 17 files changed, 1918 insertions(+), 120 deletions(-) create mode 100644 apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 0c541e9f32f..7fe442480d7 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -157,6 +157,153 @@ describe('ActivityPubAPI', function () { }); }); + describe('getPreferences', function () { + test('It returns the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(init?.method).toEqual('GET'); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + showSensitiveMedia: true + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.getPreferences()).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + + test('It defaults showSensitiveMedia to false when it is missing', async function () { + const fakeFetch = Fetch({ + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + response: JSONResponse({}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.getPreferences()).resolves.toEqual({ + showSensitiveMedia: false + }); + }); + }); + + describe('updatePreferences', function () { + test('It saves the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(init?.method).toEqual('PUT'); + expect(headers.get('Authorization')).toContain('fake-token'); + expect(headers.get('Content-Type')).toEqual('application/json'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: true + })); + }, + response: JSONResponse({ + showSensitiveMedia: true + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + + test('It can disable the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + expect(init?.method).toEqual('PUT'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: false + })); + }, + response: JSONResponse({ + showSensitiveMedia: false + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: false})).resolves.toEqual({ + showSensitiveMedia: false + }); + }); + + test('It preserves the submitted preference when the update response has no body', async function () { + const fakeFetch = Fetch({ + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + expect(init?.method).toEqual('PUT'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: true + })); + }, + response: new Response(null, {status: 204}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + }); + describe('follow', function () { test('It passes the token to the follow endpoint', async function () { const fakeFetch = Fetch({ @@ -1356,7 +1503,22 @@ describe('ActivityPubAPI', function () { notifications: [ { id: 'https://example.com/notifications/abc123', - type: 'like' + type: 'like', + post: { + id: 'https://example.com/posts/sensitive', + type: 'note', + title: null, + content: '

Sensitive post

', + url: 'https://example.com/posts/sensitive', + sensitive: true, + contentWarning: 'Sensitive topic', + likeCount: 0, + likedByMe: false, + repostCount: 0, + repostedByMe: false, + replyCount: 0, + attachments: [] + } }, { id: 'https://example.com/notifications/def456', @@ -1380,7 +1542,22 @@ describe('ActivityPubAPI', function () { expect(actual.notifications).toEqual([ { id: 'https://example.com/notifications/abc123', - type: 'like' + type: 'like', + post: { + id: 'https://example.com/posts/sensitive', + type: 'note', + title: null, + content: '

Sensitive post

', + url: 'https://example.com/posts/sensitive', + sensitive: true, + contentWarning: 'Sensitive topic', + likeCount: 0, + likedByMe: false, + repostCount: 0, + repostedByMe: false, + replyCount: 0, + attachments: [] + } }, { id: 'https://example.com/notifications/def456', diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index 8f6cd6a23c4..ece9ed9a4d4 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -159,6 +159,8 @@ export interface Notification { title: string | null; content: string; url: string; + sensitive?: boolean; + contentWarning?: string | null; likeCount: number; likedByMe: boolean; repostCount: number; @@ -177,6 +179,8 @@ export interface Notification { title: string | null; content: string; url: string; + sensitive?: boolean; + contentWarning?: string | null; }, createdAt: string; } @@ -206,6 +210,23 @@ export interface SocialWebDomain { actorUrl: string; } +export interface Preferences { + showSensitiveMedia: boolean; +} + +/** + * Fails closed: anything the server doesn't explicitly confirm leaves sensitive + * media hidden. + */ +function parsePreferences(json: object | null): Preferences { + return { + showSensitiveMedia: + json !== null && + 'showSensitiveMedia' in json && + json.showSensitiveMedia === true + }; +} + export const PostType = { Note: 0, Article: 1, @@ -220,6 +241,8 @@ export interface Post { title: string; excerpt: string; summary: string | null; + sensitive?: boolean; + contentWarning?: string | null; content: string; url: string; featureImageUrl: string | null; @@ -684,6 +707,26 @@ export class ActivityPubAPI { return {count}; } + async getPreferences(): Promise { + const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl); + + return parsePreferences(await this.fetchJSON(url)); + } + + async updatePreferences(preferences: Preferences): Promise { + const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl); + const json = await this.fetchJSON(url, 'PUT', preferences); + + // A body-less success means the write was accepted but not echoed back, + // so the submitted value is what's now stored. Parsing null here would + // silently report the preference as disabled. + if (json === null) { + return preferences; + } + + return parsePreferences(json); + } + async resetNotificationsCount() { const url = new URL('.ghost/activitypub/v1/notifications/unread/reset', this.apiUrl); diff --git a/apps/activitypub/src/components/feed/feed-item.tsx b/apps/activitypub/src/components/feed/feed-item.tsx index 5aea3212cb2..e28f06ad5ea 100644 --- a/apps/activitypub/src/components/feed/feed-item.tsx +++ b/apps/activitypub/src/components/feed/feed-item.tsx @@ -2,7 +2,7 @@ import FeedItemMenu from './feed-item-menu'; import React, {useEffect, useRef, useState} from 'react'; import {ActivityPubAttachment, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Skeleton} from '@tryghost/shade/components'; -import {H4} from '@tryghost/shade/primitives'; +import {H4, Text} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {toast} from 'sonner'; @@ -19,8 +19,16 @@ import {openLinksInNewTab, sanitizeHtml, stripHtml} from '../../utils/content-fo import {renderTimestamp} from '../../utils/render-timestamp'; import {useDeleteMutationForUser, useFollowMutationForUser, useUnfollowMutationForUser} from '../../hooks/use-activity-pub-queries'; import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path'; +import {useSensitiveMediaDisclosure} from '@src/hooks/use-sensitive-media-disclosure'; -export function getAttachment(object: ObjectProperties): ActivityPubAttachment | ActivityPubAttachment[] | null { +/** + * The subset of an object the attachment helpers below actually read. Keeping it + * narrow lets callers that only have media (such as notification previews) pass + * what they have without inventing a whole ObjectProperties. + */ +export type AttachmentSource = Pick; + +export function getAttachment(object: AttachmentSource): ActivityPubAttachment | ActivityPubAttachment[] | null { let attachment: ActivityPubAttachment | ActivityPubAttachment[] | undefined; if (object.image) { @@ -55,7 +63,7 @@ export function getAttachment(object: ObjectProperties): ActivityPubAttachment | } export function renderFeedAttachment( - object: ObjectProperties, + object: AttachmentSource, onImageClick?: (url: string) => void, brokenImages?: Set, onImageError?: (url: string) => void @@ -178,6 +186,179 @@ export function renderFeedAttachment( } } +export function SensitiveMediaOverlay({ + className = '', + isLayered = false, + size = 'default', + showLabel = true, + onReveal +}: { + className?: string; + isLayered?: boolean; + size?: 'default' | 'compact'; + showLabel?: boolean; + onReveal: (event: React.MouseEvent) => void; +}) { + const isCompact = size === 'compact'; + + return ( +
event.stopPropagation()} + > +
+ {isCompact ? ( +
+
+ ) : ( +
+
+ )} +
+ ); +} + +export function SensitiveMediaHideButton({ + label = 'Hide media', + layout = 'overlay', + onHide +}: { + label?: string; + layout?: 'overlay' | 'inline'; + onHide: (event: React.MouseEvent) => void; +}) { + return ( + + ); +} + +export function ContentWarningOverlay({ + className = '', + isLayered = false, + label, + size = 'default', + onReveal +}: { + className?: string; + isLayered?: boolean; + label: string; + size?: 'default' | 'compact'; + onReveal: (event: React.MouseEvent) => void; +}) { + const isCompact = size === 'compact'; + + return ( +
event.stopPropagation()} + > +
+ {isCompact ? ( +
+
+ ) : ( +
+
+ )} +
+ ); +} + function renderInboxAttachment(object: ObjectProperties, isLoading: boolean | undefined) { const attachment = getAttachment(object); @@ -293,6 +474,25 @@ const FeedItem: React.FC = ({ const deleteMutation = useDeleteMutationForUser('index'); const navigate = useNavigateWithBasePath(); + const { + contentWarning, + shouldHideContentWarning, + shouldHideSensitiveMedia, + canHideSensitiveMedia, + isContentWarningRevealed, + showContentWarningOverlay, + contentWarningMinHeight, + contentWarningWrapperRef, + revealSensitiveMedia, + hideSensitiveMedia, + revealContentWarning + } = useSensitiveMediaDisclosure({ + contentWarning: object?.contentWarning, + sensitive: object?.sensitive, + hasMedia: getAttachment(object) !== null, + resetKey: object?.id + }); + const followMutation = useFollowMutationForUser( 'index', () => { @@ -381,6 +581,132 @@ const FeedItem: React.FC = ({ setBrokenImages(prev => new Set(prev).add(url)); }; + const renderFeedMedia = (mediaClickHandler?: (url: string) => void) => { + if (shouldHideSensitiveMedia) { + const media = renderFeedAttachment(object, undefined, brokenImages, handleImageError); + + if (!media) { + return ; + } + + const mediaWrapperClassName = clsx( + 'relative mt-3 overflow-hidden rounded-md [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block', + Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full' + ); + + return ( +
+ {media} + +
+ ); + } + + const media = renderFeedAttachment(object, mediaClickHandler, brokenImages, handleImageError); + + if (!media) { + return null; + } + + if (canHideSensitiveMedia) { + const mediaWrapperClassName = clsx( + 'relative mt-3 [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block', + Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full' + ); + + return ( +
+ {media} + +
+ ); + } + + return media; + }; + + const renderContentWarningOverlay = (isLayered = false) => { + if (!contentWarning) { + return null; + } + + return ( + + ); + }; + + const renderNoteContent = (options?: { + contentClassName?: string; + mediaClickHandler?: (url: string) => void; + showName?: boolean; + }) => { + const { + contentClassName = 'ap-note-content break-anywhere line-clamp-[10] leading-[1.4285714286] tracking-[-0.006em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3', + mediaClickHandler = openLightbox, + showName = false + } = options ?? {}; + + return ( + <> + {showName && object.name && ( +

{object.name}

+ )} +
+ {!isLoading ? +
{ + const target = e.target as HTMLElement; + if ( + target.tagName === 'A' || + target.closest('a') + ) { + e.stopPropagation(); + } + }} + /> + : + + } +
+ {isTruncated && ( + + )} + {renderFeedMedia(mediaClickHandler)} + + ); + }; + + const renderNoteContentWithWarning = (options?: { + contentClassName?: string; + mediaClickHandler?: (url: string) => void; + showName?: boolean; + }) => { + if (!contentWarning) { + return renderNoteContent(options); + } + + return ( +
+ {isContentWarningRevealed && renderNoteContent(options)} + {showContentWarningOverlay && renderContentWarningOverlay(isContentWarningRevealed)} +
+ ); + }; + let author = actor; if (type === 'Announce') { author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; @@ -487,37 +813,18 @@ const FeedItem: React.FC = ({
- {(object.type === 'Article') ?
- {renderFeedAttachment(object, onClick, brokenImages, handleImageError)} -
-
{object.name}
-
{object.preview?.content}
-
-
: -
-
- {!isLoading ? -
{ - const target = e.target as HTMLElement; - if ( - target.tagName === 'A' || - target.closest('a') - ) { - e.stopPropagation(); - } - }} - /> - : - - } + {(object.type === 'Article') ? ( + shouldHideContentWarning ? renderContentWarningOverlay() : +
+ {renderFeedMedia(onClick)} +
+
{object.name}
+
{object.preview?.content}
+
- {isTruncated && ( - - )} - {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} + ) : +
+ {renderNoteContentWithWarning()}
}
@@ -577,9 +884,10 @@ const FeedItem: React.FC = ({ }
- {object.name &&

{object.name}

} -
- {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} + {renderNoteContentWithWarning({ + contentClassName: 'ap-note-content-large break-anywhere text-[1.6rem] tracking-[-0.011em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3', + showName: true + })}
{showStats && = ({
- {(object.type === 'Article') && renderFeedAttachment(object, onClick, brokenImages, handleImageError)} - {object.name &&

{object.name}

} - {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} - {(object.type === 'Note') && renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} - {(object.type === 'Article') && } + {shouldHideContentWarning ? renderContentWarningOverlay() : <> + {(object.type === 'Article') && renderFeedMedia(onClick)} + {object.name &&

{object.name}

} + {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} + {(object.type === 'Note') && renderFeedMedia(openLightbox)} + {(object.type === 'Article') && } + } {!isCompact &&
{showStats && = ({
-

- {isLoading ? : (object.name ? object.name : ( - - ))} -

-
- {!isLoading ? -
- : - - } -
- - {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : } - + {shouldHideContentWarning ? renderContentWarningOverlay() : <> +

+ {isLoading ? : (object.name ? object.name : ( + + ))} +

+
+ {!isLoading ? +
+ : + + } +
+ + {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : } + + }
{showStats && = ({
- {renderInboxAttachment(object, isLoading)} + {shouldHideContentWarning ? null : shouldHideSensitiveMedia ? ( + + ) : renderInboxAttachment(object, isLoading)}
)} diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts index 6550d048dd0..cd7bf63102f 100644 --- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts +++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts @@ -9,6 +9,7 @@ import { type GetAccountFollowsResponse, type Notification, type Post, + type Preferences, type ReplyChainResponse, type SearchResults, type SocialWebDomain, @@ -95,6 +96,7 @@ const QUERY_KEYS = { postsLikedByAccount: ['account_liked_posts'], notifications: (handle: string) => ['notifications', handle], notificationsCount: (handle: string) => ['notifications_count', handle], + preferences: ['preferences'], blockedAccounts: (handle: string) => ['blocked_accounts', handle], blockedDomains: (handle: string) => ['blocked_domains', handle], topics: () => ['topics'] @@ -1784,6 +1786,61 @@ export function useFeedForUser(options: {enabled: boolean}) { return {feedQuery, updateFeedActivity}; } +export function usePreferencesForUser() { + return useQuery({ + queryKey: QUERY_KEYS.preferences, + // Preferences are owner/admin only, so a failure here is usually a 403 + // that won't resolve by asking again. Callers fall back to hiding + // sensitive media. + retry: false, + async queryFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI('index', siteUrl); + + return api.getPreferences(); + } + }); +} + +export function useUpdatePreferencesForUser({onError}: {onError?: () => void} = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + scope: {id: 'preferences'}, + async mutationFn(preferences: Preferences) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI('index', siteUrl); + + return api.updatePreferences(preferences); + }, + async onMutate(preferences) { + await queryClient.cancelQueries({ + queryKey: QUERY_KEYS.preferences + }); + + const previousPreferences = queryClient.getQueryData(QUERY_KEYS.preferences); + + queryClient.setQueryData(QUERY_KEYS.preferences, preferences); + + return {previousPreferences}; + }, + onError(_error, _preferences, context) { + if (context?.previousPreferences === undefined) { + // There was nothing to roll back to, so drop the optimistic + // value rather than leaving showSensitiveMedia: true cached. + queryClient.removeQueries({queryKey: QUERY_KEYS.preferences}); + } else { + queryClient.setQueryData(QUERY_KEYS.preferences, context.previousPreferences); + } + + onError?.(); + }, + onSuccess(preferences) { + queryClient.setQueryData(QUERY_KEYS.preferences, preferences); + } + }); +} + export function useInboxForUser(options: {enabled: boolean}) { const queryKey = QUERY_KEYS.inbox; const queryClient = useQueryClient(); diff --git a/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts new file mode 100644 index 00000000000..c992016221e --- /dev/null +++ b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts @@ -0,0 +1,121 @@ +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {usePreferencesForUser} from '@hooks/use-activity-pub-queries'; + +interface SensitiveMediaDisclosureOptions { + contentWarning?: string | null; + sensitive?: boolean; + /** + * Whether the post actually carries media worth concealing. Each surface + * detects this differently — the feed looks at attachments, the reader also + * considers the feature image and media embedded in the article body. + */ + hasMedia: boolean; + /** + * Resets the disclosure state when it changes, so a revealed post never + * leaks its reveal into the next post rendered by the same component. + */ + resetKey?: string; +} + +interface SensitiveMediaDisclosure { + contentWarning: string | null; + shouldHideContentWarning: boolean; + shouldHideSensitiveMedia: boolean; + canHideSensitiveMedia: boolean; + isContentWarningRevealed: boolean; + showContentWarningOverlay: boolean; + contentWarningMinHeight: number | undefined; + contentWarningWrapperRef: React.RefObject; + revealSensitiveMedia: (event: React.MouseEvent) => void; + hideSensitiveMedia: (event: React.MouseEvent) => void; + revealContentWarning: (event: React.MouseEvent) => void; +} + +/** + * Owns the reveal/hide state for sensitive media and content warnings. + * + * Both disclosures fail closed: until the preferences query resolves, and + * whenever it fails, sensitive media stays hidden. + * + * A content warning covers the whole post, so it also covers any media inside + * it. Revealing the warning therefore reveals the media too, rather than + * stacking a second warning on top of the first. + */ +export function useSensitiveMediaDisclosure({ + contentWarning: rawContentWarning, + sensitive, + hasMedia, + resetKey +}: SensitiveMediaDisclosureOptions): SensitiveMediaDisclosure { + const {data: preferences} = usePreferencesForUser(); + const showSensitiveMediaByDefault = preferences?.showSensitiveMedia ?? false; + + const [isSensitiveMediaRevealed, setIsSensitiveMediaRevealed] = useState(false); + const [isSensitiveMediaManuallyHidden, setIsSensitiveMediaManuallyHidden] = useState(false); + const [isContentWarningRevealed, setIsContentWarningRevealed] = useState(false); + const [showContentWarningOverlay, setShowContentWarningOverlay] = useState(true); + const [contentWarningMinHeight, setContentWarningMinHeight] = useState(undefined); + const contentWarningWrapperRef = useRef(null); + + useEffect(() => { + setIsSensitiveMediaRevealed(false); + setIsSensitiveMediaManuallyHidden(false); + setIsContentWarningRevealed(false); + setShowContentWarningOverlay(true); + setContentWarningMinHeight(undefined); + }, [resetKey]); + + useLayoutEffect(() => { + if (!isContentWarningRevealed || !showContentWarningOverlay) { + return; + } + + // Content is mounted under the overlay; remove the overlay before paint + // so the browser never paints an empty intermediate frame. + setShowContentWarningOverlay(false); + setContentWarningMinHeight(undefined); + }, [isContentWarningRevealed, showContentWarningOverlay]); + + const contentWarning = rawContentWarning?.trim() || null; + const hasContentWarning = contentWarning !== null; + const hasSensitiveMedia = sensitive === true && hasMedia; + const isSensitiveMediaGated = hasSensitiveMedia && !hasContentWarning && !showSensitiveMediaByDefault; + const shouldHideSensitiveMedia = isSensitiveMediaGated && (isSensitiveMediaManuallyHidden || !isSensitiveMediaRevealed); + + const revealSensitiveMedia = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsSensitiveMediaManuallyHidden(false); + setIsSensitiveMediaRevealed(true); + }; + + const hideSensitiveMedia = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsSensitiveMediaManuallyHidden(true); + setIsSensitiveMediaRevealed(false); + }; + + const revealContentWarning = (event: React.MouseEvent) => { + event.stopPropagation(); + // Pin the collapsed height so the post doesn't jump while the revealed + // content mounts underneath the overlay. + const height = contentWarningWrapperRef.current?.offsetHeight; + if (height) { + setContentWarningMinHeight(height); + } + setIsContentWarningRevealed(true); + }; + + return { + contentWarning, + shouldHideContentWarning: hasContentWarning && !isContentWarningRevealed, + shouldHideSensitiveMedia, + canHideSensitiveMedia: isSensitiveMediaGated && !shouldHideSensitiveMedia, + isContentWarningRevealed, + showContentWarningOverlay, + contentWarningMinHeight, + contentWarningWrapperRef, + revealSensitiveMedia, + hideSensitiveMedia, + revealContentWarning + }; +} diff --git a/apps/activitypub/src/utils/posts.ts b/apps/activitypub/src/utils/posts.ts index 721aef0d030..8e039c4d85a 100644 --- a/apps/activitypub/src/utils/posts.ts +++ b/apps/activitypub/src/utils/posts.ts @@ -94,6 +94,8 @@ export function mapPostToActivity(post: Post): Activity { name: post.title, content: post.content, summary: post.summary, + sensitive: post.sensitive, + contentWarning: post.contentWarning, url: post.url, attributedTo: actor, image: post.featureImageUrl ?? '', diff --git a/apps/activitypub/src/views/inbox/components/customizer.tsx b/apps/activitypub/src/views/inbox/components/customizer.tsx index bb2e70daf27..c860c29d39c 100644 --- a/apps/activitypub/src/views/inbox/components/customizer.tsx +++ b/apps/activitypub/src/views/inbox/components/customizer.tsx @@ -161,7 +161,7 @@ const CustomizerView: React.FC = ({ }) => ( - @@ -208,6 +208,7 @@ const CustomizerView: React.FC = ({
} + {errors.code ? {errors.code} : overrides.code.value !== '' &&
{homepageUrl}{overrides.code.value}
}
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx rename to apps/admin/src/settings/growth/offers/edit-offer-modal.tsx index 70afcdeaf89..0ba5e7c992a 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx @@ -55,7 +55,7 @@ const Sidebar: React.FC<{ setTimeout(() => setIsCopied(false), 2000); }; - const confirmStatusChange = async () => { + const confirmStatusChange = () => { if (offer?.status === 'active') { confirm({ title: 'Archive offer', @@ -139,7 +139,7 @@ const Sidebar: React.FC<{ Offer code updateOffer({code: e.target.value})} onKeyDown={() => clearError('code')} /> - {offer?.code !== '' && {isCopied ? 'Copied!' : 'Copy link'}} + {offer?.code !== '' && void handleCopyClick()}>{isCopied ? 'Copied!' : 'Copy link'}} {errors.code ? {errors.code} : offer?.code !== '' && {homepageUrl}{offer?.code}} diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx rename to apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx index 13039c0dc74..c593d544af0 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx @@ -358,7 +358,7 @@ const RetentionOfferSidebar: React.FC<{ ); }; -const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { +const EditRetentionOfferModal: React.FC<{cadence: 'monthly' | 'yearly'}> = ({cadence}) => { const {updateRoute} = useSettingsNavigation(); const {siteData} = useGlobalData(); const {data: {tiers = []} = {}} = useBrowseTiers(); @@ -367,7 +367,6 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { const {mutateAsync: editOffer} = useEditOffer(); const invalidateOffers = useInvalidateOffers(); const [href, setHref] = useState(''); - const cadence = id === 'monthly' ? 'monthly' : 'yearly' as const; const breadcrumbTitle = cadence === 'monthly' ? 'Monthly retention' : 'Yearly retention'; const offerCadence = cadence === 'monthly' ? 'month' : 'year'; const activePaidTiers = getPaidActiveTiers(tiers || []); @@ -540,7 +539,7 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { }); const activeRetentionOfferId = editableRetentionOffer?.id || 'none'; - const currentOfferKey = `${id}:${activeRetentionOfferId}`; + const currentOfferKey = `${cadence}:${activeRetentionOfferId}`; useEffect(() => { if (!hasFetchedOffers || isFetchingOffers || saveState === 'unsaved' || initializedOfferKey === currentOfferKey) { diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts similarity index 96% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts rename to apps/admin/src/settings/growth/offers/offer-helpers.test.ts index cfc54c60f3d..ce631aabe02 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/app/components/settings/growth/offers/offer-helpers'; +import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/growth/offers/offer-helpers'; describe('generateRetentionOfferName', function () { it('keeps the full 8-character hash when the preferred wording fits', function () { diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts b/apps/admin/src/settings/growth/offers/offer-helpers.ts similarity index 53% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts rename to apps/admin/src/settings/growth/offers/offer-helpers.ts index fcfa651cdb8..f626814a868 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.ts @@ -1,3 +1,6 @@ +import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; +import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; const MAX_RETENTION_OFFER_NAME_LENGTH = 40; @@ -64,3 +67,53 @@ export const generateRetentionOfferName = (input: {amount: number; duration: str export { MAX_RETENTION_OFFER_NAME_LENGTH }; + +export type OfferType = 'percent' | 'fixed' | 'trial'; + +export const getOfferCadence = (cadence: string): string => { + return cadence === 'month' ? 'monthly' : 'yearly'; +}; + +export const getOfferDuration = (duration: string): string => { + return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever'); +}; + +export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { + let discountOffer = ''; + const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; + let updatedPrice = originalPrice; + + const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); + const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2}); + + let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice); + + switch (type) { + case 'percent': + discountOffer = `${formatNumber(amount)}% off`; + updatedPrice = originalPrice - ((originalPrice * amount) / 100); + break; + case 'fixed': + discountOffer = `${formatPrice(amount)} ${currency} off`; + updatedPrice = originalPrice - amount; + break; + case 'trial': + discountOffer = `${formatNumber(amount)} days free`; + originalPriceWithCurrency = ''; + break; + default: + break; + }; + + if (updatedPrice < 0) { + updatedPrice = 0; + } + + const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice); + + return { + discountOffer, + originalPriceWithCurrency, + updatedPriceWithCurrency + }; +}; diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx rename to apps/admin/src/settings/growth/offers/offer-success.tsx index a44d9708ff0..886675f9fe7 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx +++ b/apps/admin/src/settings/growth/offers/offer-success.tsx @@ -95,7 +95,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => {
- +
OR
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx similarity index 65% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx rename to apps/admin/src/settings/growth/offers/offers-container-modal.tsx index a52a860888f..32d88808901 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx +++ b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx @@ -9,13 +9,24 @@ type OffersRouteHandlerProps = { route: string; }; +type RetentionCadence = 'monthly' | 'yearly'; + +const RETENTION_ROUTE_PREFIX = 'offers/edit/retention/'; + +const getRetentionCadence = (route: string): RetentionCadence | null => { + const suffix = route.slice(RETENTION_ROUTE_PREFIX.length).replace(/\/+$/, ''); + return suffix === 'monthly' || suffix === 'yearly' ? suffix : null; +}; + const OffersRouteHandler: React.FC = ({route}) => { if (route === 'offers/new') { return ; - } else if (route.startsWith('offers/edit/retention/') && route.length > 'offers/edit/retention/'.length) { - const retentionId = route.split('/').pop(); - return ; - } else if (route === 'offers/edit/retention/' || route === 'offers/edit/retention') { + } else if (route === 'offers/edit/retention' || route.startsWith(RETENTION_ROUTE_PREFIX)) { + const retentionCadence = route.startsWith(RETENTION_ROUTE_PREFIX) ? getRetentionCadence(route) : null; + if (retentionCadence) { + return ; + } + return ; } else if (route.startsWith('offers/edit/') && route.length > 'offers/edit/'.length) { const offerId = route.split('/').pop(); diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx b/apps/admin/src/settings/growth/offers/offers-index.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx rename to apps/admin/src/settings/growth/offers/offers-index.tsx index 55521fe5de3..c05440910b2 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx +++ b/apps/admin/src/settings/growth/offers/offers-index.tsx @@ -5,62 +5,11 @@ import {type Offer, useBrowseOffers} from '@tryghost/admin-x-framework/api/offer import {type RetentionOffer, getRetentionOffers} from './offers-retention'; import {SettingsModal} from '@tryghost/shade/patterns'; import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl} from './offer-helpers'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl, getOfferCadence, getOfferDiscount, getOfferDuration} from './offer-helpers'; import {toast} from 'sonner'; import {useOffersShowArchived, useSortingState} from '@/settings/app/components/providers/settings-app-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -export type OfferType = 'percent' | 'fixed' | 'trial'; - -export const getOfferCadence = (cadence: string): string => { - return cadence === 'month' ? 'monthly' : 'yearly'; -}; - -export const getOfferDuration = (duration: string): string => { - return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever'); -}; - -export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { - let discountOffer = ''; - const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; - let updatedPrice = originalPrice; - - const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); - const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2}); - - let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice); - - switch (type) { - case 'percent': - discountOffer = `${formatNumber(amount)}% off`; - updatedPrice = originalPrice - ((originalPrice * amount) / 100); - break; - case 'fixed': - discountOffer = `${formatPrice(amount)} ${currency} off`; - updatedPrice = originalPrice - amount; - break; - case 'trial': - discountOffer = `${formatNumber(amount)} days free`; - originalPriceWithCurrency = ''; - break; - default: - break; - }; - - if (updatedPrice < 0) { - updatedPrice = 0; - } - - const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice); - - return { - discountOffer, - originalPriceWithCurrency, - updatedPriceWithCurrency - }; -}; - const OffersFilterMenu: React.FC<{ showArchived: boolean; setShowArchived: (show: boolean) => void; diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx b/apps/admin/src/settings/growth/offers/offers-retention.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx rename to apps/admin/src/settings/growth/offers/offers-retention.tsx diff --git a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx index f99937593bb..b3288141eb4 100644 --- a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx +++ b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx @@ -1,4 +1,4 @@ -import {describe, expect, it} from "vitest"; +import {describe, expect, it, vi} from "vitest"; import {browseResponse, currentRoute, fakeAdminEndpoint, fakeSettingsScreens, renderAdminApp} from "@test-utils/acceptance"; import {settingsScreen} from "@/settings/settings.screen"; @@ -33,7 +33,7 @@ const incomingRecommendations = [ excerpt: "Incoming recommendation 1 excerpt", featured_image: "https://incoming1.com/image.jpg", favicon: "https://incoming1.com/favicon.ico", - url: "https://incoming1.com/?ref=ghost", + url: "https://incoming1.com/path?ref=ghost&source=email#featured", recommending_back: false, }, { @@ -182,7 +182,40 @@ describe("Recommendations settings", () => { await expect.element(rows.last()).toHaveTextContent("Incoming recommendation 2 title"); await expect.element(rows.last()).toHaveTextContent("Recommending"); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await rows.first().getByText("Incoming recommendation 1 title").click(); + expect(openSpy).toHaveBeenCalledWith(incomingRecommendations[0].url, "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + await rows.first().getByRole("button", {name: "Recommend back"}).click(); - await expect.poll(currentRoute).toBe("/settings/recommendations/add?url=https://incoming1.com/?ref=ghost"); + await expect.poll(currentRoute).toBe(`/settings/recommendations/add?url=${encodeURIComponent(incomingRecommendations[0].url)}`); + }); + + it.each([ + {scheme: "javascript", url: "javascript:alert(document.domain)"}, + {scheme: "data", url: "data:text/html,"}, + ])("blocks $scheme incoming recommendation URLs", async ({url}) => { + const unsafeRecommendation = { + ...incomingRecommendations[0], + id: `unsafe-${url}`, + title: "Unsafe incoming recommendation", + url, + }; + fakeSettingsScreens(); + fakeRecommendations(); + fakeAdminEndpoint("GET", /^\/incoming_recommendations\/\?/, browseResponse("recommendations", [unsafeRecommendation], {limit: 5})); + await renderAdminApp("/settings/recommendations"); + + const section = settingsScreen.section("recommendations"); + await section.getByRole("tab", {name: "Recommending you"}).click(); + const row = section.getByTestId("incoming-recommendation-list-item"); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + + await row.getByText("Unsafe incoming recommendation").click(); + expect(openSpy).not.toHaveBeenCalled(); + + await row.getByRole("button", {name: "Recommend back"}).click(); + await expect.poll(currentRoute).toBe("/settings/recommendations"); + openSpy.mockRestore(); }); }); diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations.tsx b/apps/admin/src/settings/growth/recommendations.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/recommendations.tsx rename to apps/admin/src/settings/growth/recommendations.tsx diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx index 827068a6651..edb92496993 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form'; +import RecommendationDescriptionForm from './recommendation-description-form'; +import {validateDescriptionForm} from './recommendation-validation'; import trackEvent from '@/settings/app/utils/analytics'; import {Button} from '@tryghost/shade/components'; import {type EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx index 3917f0032c6..b0af9b4644c 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx @@ -139,13 +139,13 @@ const AddRecommendationModal: React.FC = () => { React.useEffect(() => { if (showLoadingView && !didInitialSubmit.current) { didInitialSubmit.current = true; - onOk(); + void onOk(); } }, [showLoadingView, onOk]); useEffect(() => { if (enterPressed) { - onOk(); + void onOk(); setEnterPressed(false); // Reset for future use } }, [formState]); diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx rename to apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx index 44b50f2c728..61c4a3d9c3e 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form'; +import RecommendationDescriptionForm from './recommendation-description-form'; +import {validateDescriptionForm} from './recommendation-validation'; import {Button} from '@tryghost/shade/components'; import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {SettingsModal} from '@tryghost/shade/patterns'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx rename to apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx index fcd0653cb55..9d8cfe23df6 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx @@ -15,6 +15,15 @@ interface IncomingRecommendationListProps { isLoading: boolean } +const getHttpRecommendationUrl = (url: string): string | null => { + try { + const protocol = new URL(url).protocol; + return protocol === 'http:' || protocol === 'https:' ? url : null; + } catch { + return null; + } +}; + const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => { const {updateRoute} = useSettingsNavigation(); @@ -36,11 +45,17 @@ const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingReco }, [stats, incomingRecommendation.url]); const recommendBack = () => { - updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`}); + const url = getHttpRecommendationUrl(incomingRecommendation.url); + if (url) { + updateRoute({route: `recommendations/add?url=${encodeURIComponent(url)}`}); + } }; const showDetails = () => { - window.open(incomingRecommendation.url, '_blank'); + const url = getHttpRecommendationUrl(incomingRecommendation.url); + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } }; const freeMembersLabel = signups === 1 ? 'free member' : 'free members'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx similarity index 81% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx index d9b2ded5275..ad8db68c384 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx @@ -5,6 +5,7 @@ import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Textarea} from '@tryghost/shade/components'; import {Text} from '@tryghost/shade/primitives'; import {formatNumber} from '@tryghost/shade/utils'; +import {validateDescriptionForm} from './recommendation-validation'; interface Props { showURL?: boolean, @@ -15,39 +16,6 @@ interface Props { setErrors: (errors: ErrorMessages) => void } -export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) { - const cloned = {...errors}; - switch (field) { - case 'title': - if (!value) { - cloned.title = 'Title is required'; - } else { - delete cloned.title; - } - break; - case 'description': - if (value && value.length > 200) { - cloned.description = 'Description cannot be longer than 200 characters'; - } else { - delete cloned.description; - } - break; - default: { - // Will throw a compile error if we forget to add a case for a field - const f: never = field; - throw new Error(`Unknown field ${f}`); - } - } - return cloned; -}; - -export const validateDescriptionForm = function (formState: EditOrAddRecommendation) { - let newErrors: ErrorMessages = {}; - newErrors = validateDescriptionFormField(newErrors, 'title', formState.title); - newErrors = validateDescriptionFormField(newErrors, 'description', formState.description); - return newErrors; -}; - function RecommendationDescriptionForm({showURL, formState, updateForm, errors, clearError, setErrors}: Props) { const [descriptionLength, setDescriptionLength] = React.useState(formState?.description?.length || 0); const descriptionLengthColor = descriptionLength > 200 ? 'text-destructive' : 'text-foreground'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-list.tsx index 07e42d790b2..a595b982442 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx @@ -68,7 +68,7 @@ const RecommendationList: React.FC = ({recommendations, const [editingRecommendation, setEditingRecommendation] = useState(null); const copyRecommendationsUrl = () => { - navigator.clipboard.writeText(recommendationsURL); + void navigator.clipboard.writeText(recommendationsURL); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts new file mode 100644 index 00000000000..35815ae1115 --- /dev/null +++ b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts @@ -0,0 +1,35 @@ +import {type EditOrAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; +import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks'; + +export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) { + const cloned = {...errors}; + switch (field) { + case 'title': + if (!value) { + cloned.title = 'Title is required'; + } else { + delete cloned.title; + } + break; + case 'description': + if (value && value.length > 200) { + cloned.description = 'Description cannot be longer than 200 characters'; + } else { + delete cloned.description; + } + break; + default: { + // Will throw a compile error if we forget to add a case for a field + const f: never = field; + throw new Error(`Unknown field ${String(f)}`); + } + } + return cloned; +}; + +export const validateDescriptionForm = function (formState: EditOrAddRecommendation) { + let newErrors: ErrorMessages = {}; + newErrors = validateDescriptionFormField(newErrors, 'title', formState.title); + newErrors = validateDescriptionFormField(newErrors, 'description', formState.description); + return newErrors; +}; diff --git a/apps/admin/src/settings/growth/search-keywords.ts b/apps/admin/src/settings/growth/search-keywords.ts new file mode 100644 index 00000000000..d626653e1ab --- /dev/null +++ b/apps/admin/src/settings/growth/search-keywords.ts @@ -0,0 +1,7 @@ +export const searchKeywords = { + network: ['growth', 'network', 'activitypub', 'blog', 'fediverse', 'sharing'], + explore: ['ghost explore', 'explore', 'growth', 'share', 'list', 'listing'], + recommendations: ['growth', 'recommendations', 'recommend', 'blogroll'], + embedSignupForm: ['growth', 'embeddable signup form', 'embeddable form', 'embeddable sign up form', 'embeddable sign up'], + offers: ['growth', 'offers', 'discounts', 'coupons', 'promotions'] +}; diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts new file mode 100644 index 00000000000..43629aeb308 --- /dev/null +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest'; +import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; + +describe('parseTipsAndDonationsSettings', () => { + it('uses safe defaults when an older backend omits donation settings', () => { + expect(parseTipsAndDonationsSettings({})).toEqual({ + donations_currency: 'USD', + donations_suggested_amount: 500 + }); + }); + + it.each([ + {amount: 725, description: 'a backend number'}, + {amount: '725', description: 'a dirty local string'} + ])('accepts the suggested amount as $description', ({amount}) => { + expect(parseTipsAndDonationsSettings({ + donations_currency: 'EUR', + donations_suggested_amount: amount + })).toEqual({ + donations_currency: 'EUR', + donations_suggested_amount: 725 + }); + }); + + it.each([ + { + settings: {donations_currency: 'ZZZ', donations_suggested_amount: 500}, + description: 'an unsupported currency' + }, + { + settings: {donations_currency: null, donations_suggested_amount: 500}, + description: 'a null currency' + }, + { + settings: {donations_currency: 'GBP', donations_suggested_amount: 'not-a-number'}, + description: 'a malformed amount' + }, + { + settings: {donations_currency: 'GBP', donations_suggested_amount: null}, + description: 'a null amount' + } + ])('rejects $description instead of applying a missing-value default', ({settings}) => { + expect(() => parseTipsAndDonationsSettings(settings)).toThrow(); + }); + + it.each([null, [], 'invalid'])('rejects a non-object settings payload', (settings) => { + expect(() => parseTipsAndDonationsSettings(settings)).toThrow(); + }); + + it.each([ + {amount: -1, description: 'a negative amount'}, + {amount: 1.5, description: 'a fractional amount'}, + {amount: NaN, description: 'a NaN amount'}, + {amount: Infinity, description: 'a positive infinite amount'}, + {amount: -Infinity, description: 'a negative infinite amount'}, + {amount: Number.MAX_SAFE_INTEGER + 1, description: 'an unsafe integer amount'}, + {amount: '9007199254740992', description: 'an overflowing digit-only amount'} + ])('rejects $description', ({amount}) => { + expect(() => parseTipsAndDonationsSettings({ + donations_currency: 'USD', + donations_suggested_amount: amount + })).toThrow(); + }); +}); diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.ts new file mode 100644 index 00000000000..bf3a98b8378 --- /dev/null +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.ts @@ -0,0 +1,35 @@ +import {currencySelectGroups} from '@/settings/app/utils/currency'; +import {z} from 'zod'; + +const DEFAULT_DONATIONS_CURRENCY = 'USD'; +const DEFAULT_DONATIONS_SUGGESTED_AMOUNT = 500; + +const supportedCurrencies = new Set( + currencySelectGroups().flatMap(group => group.options.map(option => option.value)) +); + +const donationsCurrencySchema = z.string() + .refine(currency => supportedCurrencies.has(currency)); + +const donationsSuggestedAmountNumberSchema = z.number() + .finite() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); + +const donationsSuggestedAmountSchema = z.union([ + donationsSuggestedAmountNumberSchema, + z.string() + .regex(/^\d+$/) + .transform(Number) + .pipe(donationsSuggestedAmountNumberSchema) +]); + +export const tipsAndDonationsSettingsSchema = z.object({ + donations_currency: donationsCurrencySchema.default(DEFAULT_DONATIONS_CURRENCY), + donations_suggested_amount: donationsSuggestedAmountSchema.default(DEFAULT_DONATIONS_SUGGESTED_AMOUNT) +}); + +export type TipsAndDonationsSettings = z.infer; + +export const parseTipsAndDonationsSettings = (settings: unknown): TipsAndDonationsSettings => tipsAndDonationsSettingsSchema.parse(settings); diff --git a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx index 0a2c75796d6..98b0c724450 100644 --- a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx @@ -4,12 +4,13 @@ import { page, userEvent } from "vitest/browser"; import { currentRoute, fakeEditSettings, fakeSettingsScreens, renderAdminApp, settingsResponse, type RenderAdminAppOptions } from "@test-utils/acceptance"; import { settingsScreen } from "@/settings/settings.screen"; -function withStripe(): RenderAdminAppOptions { +function withStripe(settings: Record = {}): RenderAdminAppOptions { return { boot: { browseSettings: { response: settingsResponse({ settings: { + ...settings, donations_enabled: true, stripe_connect_publishable_key: "pk_test_123", stripe_connect_secret_key: "sk_test_123", @@ -54,7 +55,7 @@ describe("Tips and donations settings", () => { const section = settingsScreen.tipsAndDonations(); await expect.element(section).toBeVisible(); await expect.element(settingsScreen.suggestedAmount()).toHaveValue("5"); - await expect.element(section.getByRole("combobox")).toBeVisible(); + await expect.element(section.getByRole("combobox", {name: "Currency"})).toHaveTextContent("USD"); await expect.element(settingsScreen.donateUrl()).toHaveTextContent("http://test.com/#/portal/support"); await userEvent.hover(settingsScreen.donateUrl().element()); @@ -62,6 +63,28 @@ describe("Tips and donations settings", () => { await expect.element(settingsScreen.copyShareableLink()).toBeVisible(); }); + it.each([ + {amount: 725, expected: "7.25", source: "a backend number"}, + {amount: "825", expected: "8.25", source: "a dirty local string"}, + ])("accepts a valid suggested amount from $source", async ({amount, expected}) => { + fakeSettingsScreens(); + await renderAdminApp("/settings", withStripe({donations_suggested_amount: amount})); + + await expect.element(settingsScreen.suggestedAmount()).toHaveValue(expected); + }); + + it("shows the section error boundary for malformed donation settings", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings", withStripe({ + donations_currency: "ZZZ", + donations_suggested_amount: "not-a-number", + })); + + await expect.element(page.getByRole("alert")).toHaveTextContent("An error occurred loading Tips & donations. Please refresh and try again."); + await expect(settingsScreen.tipsAndDonations()).toHaveCount(0); + await expect(settingsScreen.suggestedAmount()).toHaveCount(0); + }); + it("saves an updated suggested amount", async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); diff --git a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx b/apps/admin/src/settings/growth/tips-and-donations.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx rename to apps/admin/src/settings/growth/tips-and-donations.tsx index 3bf14f7bf3b..c780ef7df6c 100644 --- a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.tsx @@ -8,6 +8,7 @@ import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns import {SettingGroupContent} from '@tryghost/shade/patterns'; import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; // Stripe doesn't allow amounts over 10,000 as a preset amount @@ -36,12 +37,18 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => { } }); - const [donationsCurrency = 'USD', donationsSuggestedAmount = '500'] = getSettingValues( + const [donationsCurrencySetting, donationsSuggestedAmountSetting] = getSettingValues( localSettings, ['donations_currency', 'donations_suggested_amount'] ); + const { + donations_currency: donationsCurrency, + donations_suggested_amount: suggestedAmountInCents + } = parseTipsAndDonationsSettings({ + donations_currency: donationsCurrencySetting, + donations_suggested_amount: donationsSuggestedAmountSetting + }); - const suggestedAmountInCents = parseInt(donationsSuggestedAmount); const suggestedAmountInput = useCurrencyInput(suggestedAmountInCents, cents => handleSettingChange('donations_suggested_amount', cents.toString())); const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`; const currencyOptions = currencySelectGroups().flatMap(group => group.options.map(option => ({...option, metadata: {groupKey: group.key, groupLabel: group.label}}))); diff --git a/apps/admin/src/settings/membership/membership-settings.tsx b/apps/admin/src/settings/membership/membership-settings.tsx index 8a675b45fbd..5ea66fe386a 100644 --- a/apps/admin/src/settings/membership/membership-settings.tsx +++ b/apps/admin/src/settings/membership/membership-settings.tsx @@ -7,7 +7,7 @@ import React from 'react'; import SearchableSection from '@/settings/app/components/searchable-section'; import SpamFilters from '@/settings/app/components/settings/advanced/spam-filters'; import Tiers from './tiers'; -import TipsAndDonations from '@/settings/app/components/settings/growth/tips-and-donations'; +import TipsAndDonations from '@/settings/growth/tips-and-donations'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {searchKeywords} from './search-keywords'; diff --git a/apps/admin/src/settings/offers.acceptance.test.tsx b/apps/admin/src/settings/offers.acceptance.test.tsx index 2b7a0031b35..3028acbe59a 100644 --- a/apps/admin/src/settings/offers.acceptance.test.tsx +++ b/apps/admin/src/settings/offers.acceptance.test.tsx @@ -423,6 +423,14 @@ describe("Offers", () => { await expect.element(yearlyModal.getByLabelText("Amount off")).toHaveValue(30); }); + it("falls back to the offers list for an invalid retention cadence", async () => { + retentionWorld([]); + await renderAdminApp("/settings/offers/edit/retention/not-a-cadence", withStripe()); + + await expect.element(offersScreen.listModal()).toBeVisible(); + await expect(offersScreen.retentionModal()).toHaveCount(0); + }); + it("shows validation errors for invalid retention values on save", async () => { retentionWorld([retentionOffer({ id: "retention-month-active" })]); await renderAdminApp("/settings/offers/edit/retention/monthly", withStripe()); diff --git a/apps/admin/src/settings/routes.tsx b/apps/admin/src/settings/routes.tsx index 1ea5aecafea..b3b601cda26 100644 --- a/apps/admin/src/settings/routes.tsx +++ b/apps/admin/src/settings/routes.tsx @@ -39,19 +39,19 @@ export const settingsRouteChildren: RouteObject[] = [ {path: 'integrations/firstpromoter', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/first-promoter-modal'))}, {path: 'integrations/pintura', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/pintura-modal'))}, {path: 'integrations/:integrationId', lazy: lazyComponent(() => import('./app/components/settings/advanced/integrations/custom-integration-modal'))}, - {path: 'recommendations/add', lazy: lazyComponent(() => import('./app/components/settings/growth/recommendations/add-recommendation-modal'))}, + {path: 'recommendations/add', lazy: lazyComponent(() => import('./growth/recommendations/add-recommendation-modal'))}, // The edit flow opens from the recommendations list with the loaded record // (never URL-driven); the legacy route only ever redirected back in effect. {path: 'recommendations/edit', element: }, - {path: 'embed-signup-form/show', lazy: lazyComponent(() => import('./app/components/settings/growth/embed-signup/embed-signup-form-modal'))}, + {path: 'embed-signup-form/show', lazy: lazyComponent(() => import('./growth/embed-signup/embed-signup-form-modal'))}, // The offers container owns list/add/edit/retention/success views and // reads the path to pick between them. - {path: 'offers/new', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, - {path: 'offers/edit', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, - {path: 'offers/edit/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, - {path: 'offers/edit/retention/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, - {path: 'offers/success/:offerId', lazy: lazyComponent(() => import('./app/components/settings/growth/offers/offers-container-modal'))}, - {path: 'explore/testimonial', lazy: lazyComponent(() => import('./app/components/settings/growth/explore/testimonials-modal'))}, + {path: 'offers/new', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))}, + {path: 'offers/edit', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))}, + {path: 'offers/edit/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))}, + {path: 'offers/edit/retention/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))}, + {path: 'offers/success/:offerId', lazy: lazyComponent(() => import('./growth/offers/offers-container-modal'))}, + {path: 'explore/testimonial', lazy: lazyComponent(() => import('./growth/explore/testimonials-modal'))}, {path: 'about', lazy: lazyComponent(() => import('./general/about'))}, // The lock-site setting was merged into the Access section. {path: 'locksite', element: }, diff --git a/packages/testing/test-data/src/selectors/offers.ts b/packages/testing/test-data/src/selectors/offers.ts index 0b9d187e1bd..dcb53854bff 100644 --- a/packages/testing/test-data/src/selectors/offers.ts +++ b/packages/testing/test-data/src/selectors/offers.ts @@ -1,6 +1,6 @@ /** * Offers screen selector strings, consumed by the admin screen helpers. - * Source of truth: apps/admin/src/settings/app/components/settings/growth/offers. + * Source of truth: apps/admin/src/settings/growth/offers. */ export const offersSelectors = { testIds: {