diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md deleted file mode 100644 index fbff870d83f..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.2.md +++ /dev/null @@ -1,7 +0,0 @@ -## 0.2.2 - -### Patch Changes - -- Update framework dependencies - -- Updated logging requests to avoid process crashes on redirects. diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md deleted file mode 100644 index a7e11d25ee4..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.2.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.1.2 - -### Patch Changes - -- Update framework dependencies diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md deleted file mode 100644 index 25df9398a6f..00000000000 --- a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.0.md +++ /dev/null @@ -1,11 +0,0 @@ -## 2.2.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Removed the unused emailCustomization and emailCustomizationAlpha feature options. - -- Documented the package API and corrected the development instructions in the README diff --git a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md b/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md deleted file mode 100644 index c80a8b4cc25..00000000000 --- a/.changeset/changelogs/@tryghost!kg-html-to-lexical@1.4.0.md +++ /dev/null @@ -1,12 +0,0 @@ -## 1.4.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Documented the package API and corrected the development instructions in the README - -- Updated dependencies: - - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md b/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md deleted file mode 100644 index bdd78e70a09..00000000000 --- a/.changeset/changelogs/@tryghost!kg-lexical-html-renderer@1.5.0.md +++ /dev/null @@ -1,12 +0,0 @@ -## 1.5.0 - -### Minor Changes - -- Update jsdom to 30 & node engines to match jsdom's - -### Patch Changes - -- Documented the package API and corrected the development instructions in the README - -- Updated dependencies: - - @tryghost/kg-default-nodes@2.2.0 diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md deleted file mode 100644 index 43c4597d652..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.2.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1.9.2 - -### Patch Changes - -- Updated the test commands in the README - -- Updated dependencies - -- Updated Koenig Lexical testing documentation. - -- Added a package description for npm - -- Updated dependencies - -- Updated dependencies - -- Update jsdom to 30 & node engines to match jsdom's diff --git a/.changeset/clean-flags-retire.md b/.changeset/clean-flags-retire.md deleted file mode 100644 index 8af68dbcdd8..00000000000 --- a/.changeset/clean-flags-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/kg-default-nodes": patch ---- - -Removed the unused emailCustomization and emailCustomizationAlpha feature options. diff --git a/.changeset/easy-wasps-appear.md b/.changeset/easy-wasps-appear.md deleted file mode 100644 index 57ee67be712..00000000000 --- a/.changeset/easy-wasps-appear.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@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/.changeset/funky-bikes-chew.md b/.changeset/funky-bikes-chew.md deleted file mode 100644 index 844f52d6738..00000000000 --- a/.changeset/funky-bikes-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated the test commands in the README diff --git a/.changeset/funky-rice-shine.md b/.changeset/funky-rice-shine.md deleted file mode 100644 index 2f762631c3a..00000000000 --- a/.changeset/funky-rice-shine.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated dependencies diff --git a/.changeset/loose-pans-argue.md b/.changeset/loose-pans-argue.md deleted file mode 100644 index 2da772de325..00000000000 --- a/.changeset/loose-pans-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated Koenig Lexical testing documentation. diff --git a/.changeset/olive-regions-stop.md b/.changeset/olive-regions-stop.md deleted file mode 100644 index 286b0090c18..00000000000 --- a/.changeset/olive-regions-stop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Added a package description for npm diff --git a/.changeset/open-shirts-arrive.md b/.changeset/open-shirts-arrive.md deleted file mode 100644 index bb7ecca6cad..00000000000 --- a/.changeset/open-shirts-arrive.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch -"@tryghost/adapter-base-sso": patch ---- - -Update framework dependencies diff --git a/.changeset/petite-forks-lick.md b/.changeset/petite-forks-lick.md deleted file mode 100644 index 2f762631c3a..00000000000 --- a/.changeset/petite-forks-lick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated dependencies diff --git a/.changeset/young-spiders-request.md b/.changeset/young-spiders-request.md deleted file mode 100644 index 22c87141a74..00000000000 --- a/.changeset/young-spiders-request.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch ---- - -Updated logging requests to avoid process crashes on redirects. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8733703013..fffe7021525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1031,39 +1031,59 @@ jobs: - name: Setup Playwright uses: ./.github/actions/setup-playwright - # The Koenig editor suite is the only one that needs apt packages on top - # of the runner image (setup-playwright deliberately avoids apt-get, see - # its README note about hangs — scoping this to one matrix leg contains - # that risk): - # - MS core fonts: caret-position assertions depend on where text wraps, - # 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) + # Cache the msttcorefonts .ttf files so only a cache miss hits the + # SourceForge download that regularly hangs the koenig-lexical job. Save + # is a separate step right after the fonts are in place, so a later codec + # or test failure on a cache-miss run still populates the cache. + - name: Restore MS core fonts cache + if: matrix.app == '@tryghost/koenig-lexical' + id: mscorefonts-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/msttcorefonts + key: msttcorefonts-ttf-v1 + - name: Install Koenig editor test fonts 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 -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 + FONT_CACHE="$HOME/.cache/msttcorefonts" + FONT_DIR=/usr/share/fonts/truetype/msttcorefonts + sudo mkdir -p "$FONT_DIR" + if ls "$FONT_CACHE"/*.ttf >/dev/null 2>&1; then + echo "Restoring MS core fonts from cache" + sudo cp "$FONT_CACHE"/*.ttf "$FONT_DIR"/ + else + echo "Cache miss — installing msttcorefonts from apt" + sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" + 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 - echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 - done - if [ -z "$installed" ]; then - echo "msttcorefonts failed after 3 attempts"; exit 1 + mkdir -p "$FONT_CACHE" + cp "$FONT_DIR"/*.ttf "$FONT_CACHE"/ fi - pnpm exec playwright install-deps firefox + sudo fc-cache -f + - name: Save MS core fonts cache + if: matrix.app == '@tryghost/koenig-lexical' && steps.mscorefonts-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/msttcorefonts + key: msttcorefonts-ttf-v1 + - name: Install Koenig editor test media codecs + if: matrix.app == '@tryghost/koenig-lexical' + timeout-minutes: 10 + run: pnpm exec playwright install-deps firefox - name: Run Playwright tests run: pnpm nx run ${{ matrix.app }}:test:acceptance diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 770f20a88cc..e0960805e9c 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -12,6 +12,16 @@ export type Automation = { status: AutomationStatus; } +export type AutomationStats = { + last_run_created_at: string | null; + total_run_count: number; + in_progress_run_count: number; +} + +export type AutomationBrowseItem = Automation & { + stats: AutomationStats; +} + export type AutomationWaitAction = { id: string; type: 'wait'; @@ -62,7 +72,7 @@ export type EditAutomationPayload = { export interface AutomationsResponseType { meta?: Meta; - automations: Automation[]; + automations: AutomationBrowseItem[]; } export interface AutomationDetailResponseType { diff --git a/apps/admin/src/automations/automations.acceptance.test.tsx b/apps/admin/src/automations/automations.acceptance.test.tsx index 1f6615884c0..fc13c8badaa 100644 --- a/apps/admin/src/automations/automations.acceptance.test.tsx +++ b/apps/admin/src/automations/automations.acceptance.test.tsx @@ -5,6 +5,7 @@ import { automationsScreen } from "./automations.screen"; // Automations ships behind the `automations` beta labs flag. const AUTOMATIONS_ENABLED = { labs: { automations: true } }; +const RUN_ANALYTICS_ENABLED = { labs: { automations: true, automationRunAnalytics: true } }; describe("Automations list", () => { it("renders the automations page", async () => { @@ -12,18 +13,42 @@ describe("Automations list", () => { await renderAdminApp("/automations", AUTOMATIONS_ENABLED); await expect.element(automationsScreen.heading()).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Last entry")).not.toBeInTheDocument(); }); it("lists the welcome automations", async () => { fakeAutomations([ - automation({ name: "Free member welcome flow", slug: "member-welcome-email-free", status: "active" }), - automation({ name: "Paid member welcome flow", slug: "member-welcome-email-paid", status: "inactive" }), + automation({ + name: "Free member welcome flow", + slug: "member-welcome-email-free", + status: "active", + stats: { + last_run_created_at: "2026-07-21T07:12:00.000Z", + total_run_count: 1432, + in_progress_run_count: 118, + }, + }), + automation({ + name: "Paid member welcome flow", + slug: "member-welcome-email-paid", + status: "inactive", + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0, + }, + }), ]); - await renderAdminApp("/automations", AUTOMATIONS_ENABLED); + await renderAdminApp("/automations", RUN_ANALYTICS_ENABLED); await expect.element(automationsScreen.link("Free member welcome flow")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Last entry")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("Total entries")).toBeVisible(); + await expect.element(automationsScreen.columnHeader("In progress")).toBeVisible(); const row = automationsScreen.rows(); await expect.element(row).toHaveTextContent("Welcome new free members after they sign up."); + await expect.element(row).toHaveTextContent("1,432"); + await expect.element(row).toHaveTextContent("118"); await expect.element(row).toHaveTextContent("Live"); // Stripe is disconnected in the default boot, which hides the paid welcome flow. await expect(automationsScreen.rows()).toHaveCount(1); diff --git a/apps/admin/src/automations/automations.screen.ts b/apps/admin/src/automations/automations.screen.ts index 603edbcf0fd..5a335c64562 100644 --- a/apps/admin/src/automations/automations.screen.ts +++ b/apps/admin/src/automations/automations.screen.ts @@ -5,6 +5,7 @@ import { automationListRow, automationsList, automationsPage } from "@tryghost/t export const automationsScreen = { heading: () => page.getByTestId(automationsPage).getByRole("heading", { name: "Automations" }), list: () => page.getByTestId(automationsList), + columnHeader: (name: string) => page.getByTestId(automationsList).getByRole("columnheader", { name }), rows: () => page.getByTestId(automationListRow), link: (name: string) => page.getByRole("link", { name, exact: true }), }; diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index eec29da4979..f3150abf7ae 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -3,6 +3,16 @@ import {MemoryRouter} from 'react-router'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; +const mockRunAnalyticsFlag = vi.hoisted(() => ({enabled: true})); + +vi.mock('@tryghost/admin-x-framework/hooks', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/hooks'); + return { + ...actual, + useFeatureFlag: () => mockRunAnalyticsFlag.enabled + }; +}); + const {mockUseBrowseAutomations, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({ mockUseBrowseAutomations: vi.fn(), mockUseBrowseSettings: vi.fn(), @@ -65,12 +75,22 @@ const automations = [{ id: 'automation-id-1', name: 'Free member welcome flow', slug: 'member-welcome-email-free', - status: 'active' as const + status: 'active' as const, + stats: { + last_run_created_at: '2026-07-21T07:12:00.000Z', + total_run_count: 1432, + in_progress_run_count: 118 + } }, { id: 'automation-id-2', name: 'Paid member welcome flow', slug: 'member-welcome-email-paid', - status: 'inactive' as const + status: 'inactive' as const, + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }]; const stripeConnectedSettings = { @@ -85,12 +105,24 @@ const renderPage = () => render(); describe('Automations', () => { beforeEach(() => { vi.clearAllMocks(); + mockRunAnalyticsFlag.enabled = true; mockUseBrowseAutomations.mockReturnValue({data: {automations}, isError: false, isLoading: false}); mockUseBrowseSettings.mockReturnValue({data: stripeConnectedSettings, isLoading: false}); mockUseBrowseConfig.mockReturnValue({data: {config: {}}, isLoading: false}); mockUseCurrentUser.mockReturnValue({data: {id: 'user-1', roles: [{name: 'Owner'}]}}); }); + it('hides run analytics when the private feature is disabled', () => { + mockRunAnalyticsFlag.enabled = false; + + renderPage(); + + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Total entries'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); + expect(screen.queryByText('1,432')).not.toBeInTheDocument(); + }); + it('shows free and paid sequences when Stripe is connected', () => { renderPage(); diff --git a/apps/admin/src/automations/automations.tsx b/apps/admin/src/automations/automations.tsx index 1e48a183597..008834b19dc 100644 --- a/apps/admin/src/automations/automations.tsx +++ b/apps/admin/src/automations/automations.tsx @@ -6,9 +6,11 @@ import {Box, Container} from '@tryghost/shade/primitives'; import {ListPage} from '@tryghost/shade/page-templates'; import {PageHeader} from '@tryghost/shade/patterns'; import {useVisibleAutomations} from './hooks/use-visible-automations'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; const Automations: React.FC = () => { const {automations, error, isError, isLoading} = useVisibleAutomations(); + const showRunAnalytics = useFeatureFlag('automationRunAnalytics'); if (isError) { throw error instanceof Error ? error : new Error('Failed to load automations'); @@ -31,7 +33,7 @@ const Automations: React.FC = () => { - + diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index f5de0d8b84c..b6d20714d99 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -1,26 +1,45 @@ import AutomationsList from './automations-list'; import React from 'react'; import {MemoryRouter} from 'react-router'; -import {describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; const automations = [{ id: 'automation-id-1', name: 'Free member welcome flow', slug: 'member-welcome-email-free', - status: 'active' as const + status: 'active' as const, + stats: { + last_run_created_at: '2026-07-21T07:12:00.000Z', + total_run_count: 1432, + in_progress_run_count: 118 + } }, { id: 'automation-id-2', name: 'Paid member welcome flow', slug: 'member-welcome-email-paid', - status: 'inactive' as const + status: 'inactive' as const, + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }]; const renderWithRouter = (ui: React.ReactElement) => render({ui}); describe('AutomationsList', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-04T07:12:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('renders fetched automations with private beta copy and status labels', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(2); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); @@ -29,17 +48,40 @@ describe('AutomationsList', () => { expect(screen.getByText('Welcome new paid members after they start their subscription.')).toBeInTheDocument(); expect(screen.getByText('Live')).toBeInTheDocument(); expect(screen.getByText('Off')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Last entry'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Total entries'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'In progress'})).toBeInTheDocument(); + expect(screen.getByText('1,432')).toBeInTheDocument(); + expect(screen.getByText('118')).toBeInTheDocument(); + expect(screen.getByText('14 days ago')).toHaveAttribute('datetime', '2026-07-21T07:12:00.000Z'); + }); + + it('renders Never when an automation has no last entry', () => { + renderWithRouter(); + + expect(screen.getByText('Never')).toBeInTheDocument(); + }); + + it('hides run analytics when the feature is disabled', () => { + renderWithRouter(); + + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Total entries'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); + expect(screen.queryByText('1,432')).not.toBeInTheDocument(); }); it('links each row to the automation sequence by id', () => { renderWithRouter(); + expect(screen.getByRole('table', {name: 'Automations'})).toBeInTheDocument(); + expect(screen.getAllByRole('rowheader')).toHaveLength(2); expect(screen.getByRole('link', {name: 'Free member welcome flow'})).toHaveAttribute('href', '/automations/automation-id-1'); expect(screen.getByRole('link', {name: 'Paid member welcome flow'})).toHaveAttribute('href', '/automations/automation-id-2'); }); it('renders a table skeleton while loading', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getByTestId('automations-list-loading')).toBeInTheDocument(); }); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 6ad5ec513d0..3179c0d617b 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -1,35 +1,49 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; -import type {Automation} from '@tryghost/admin-x-framework/api/automations'; +import type {AutomationBrowseItem} from '@tryghost/admin-x-framework/api/automations'; import {Link} from '@tryghost/admin-x-framework'; -import {Skeleton, Table, TableBody, TableCell, TableRow} from '@tryghost/shade/components'; +import {Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; +import {cn, formatNumber} from '@tryghost/shade/utils'; +import moment from 'moment'; const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-free': 'Welcome new free members after they sign up.', 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.' }; +const AUTOMATION_STAT_COLUMNS = [ + {key: 'lastEntry', label: 'Last entry', widthClassName: 'w-40', skeletonWidthClassName: 'w-20'}, + {key: 'totalEntries', label: 'Total entries', widthClassName: 'w-32', skeletonWidthClassName: 'w-10'}, + {key: 'inProgressEntries', label: 'In progress', widthClassName: 'w-32', skeletonWidthClassName: 'w-10'} +] as const; + interface AutomationsListProps { - automations?: Automation[]; + automations?: AutomationBrowseItem[]; isLoading?: boolean; + showRunAnalytics?: boolean; } -const AutomationsListSkeleton: React.FC = () => { +const AutomationsListSkeleton: React.FC<{showRunAnalytics: boolean}> = ({showRunAnalytics}) => { return ( - - +
+ {Array.from({length: 2}, (_, index) => ( ))} @@ -38,24 +52,52 @@ const AutomationsListSkeleton: React.FC = () => { ); }; -const AutomationsList: React.FC = ({automations = [], isLoading = false}) => { +const AutomationsList: React.FC = ({automations = [], isLoading = false, showRunAnalytics = false}) => { if (isLoading) { - return ; + return ; } return ( -
- +
+ {showRunAnalytics && ( + + + Name + {AUTOMATION_STAT_COLUMNS.map(column => ( + {column.label} + ))} + Status + + + )} + {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; + const lastEntry = automation.stats.last_run_created_at; + const totalEntries = automation.stats.total_run_count; + const inProgressEntries = automation.stats.in_progress_run_count; + const statCells = { + lastEntry: { + content: lastEntry ? : 'Never', + isEmpty: !lastEntry + }, + totalEntries: { + content: formatNumber(totalEntries), + isEmpty: totalEntries === 0 + }, + inProgressEntries: { + content: formatNumber(inProgressEntries), + isEmpty: inProgressEntries === 0 + } + }; return ( - + = ({automations = [], isLo {description} )} - - + + {showRunAnalytics && AUTOMATION_STAT_COLUMNS.map((column) => { + const cell = statCells[column.key]; + + return ( + + {cell.content} + + ); + })} + diff --git a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx index 3e0e4caecf2..ac47c94e19e 100644 --- a/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx +++ b/apps/admin/src/members/components/bulk-action-modals/import-members/custom-fields/import-members-modal.tsx @@ -15,7 +15,7 @@ import {createInitialImportState, importReducer} from '@/members/components/bulk import {isImportMembersCompleteResponse, useImportMembers} from '@tryghost/admin-x-framework/api/members'; import {memberCustomFieldCsvColumns, useBrowseMemberCustomFields} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {parseCSV} from '@/members/components/bulk-action-modals/import-members/csv'; -import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef} from 'react'; import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; import {useLabelPicker} from '@/members/hooks/use-label-picker'; @@ -82,7 +82,7 @@ export function ImportMembersModal({ const labelPicker = useLabelPicker({ selectedSlugs: state.selectedLabelSlugs, onSelectionChange: (slugs: string[]) => { - setHasEdits(true); + hasEditsRef.current = true; dispatch({type: 'SET_SELECTED_LABEL_SLUGS', selectedLabelSlugs: slugs}); } }); @@ -100,7 +100,7 @@ export function ImportMembersModal({ revokeErrorCsvUrl(); // The modal is never unmounted (members-actions keeps it mounted and toggles `open`), // so anything held outside the reducer outlives Start over unless it is cleared here. - setHasEdits(false); + hasEditsRef.current = false; dispatch({type: 'RESET'}); }, [revokeErrorCsvUrl]); @@ -115,10 +115,14 @@ export function ImportMembersModal({ // a mapped file: which columns are in, what each fills, the labels chosen. So dismissal // asks first, using the same confirmation the settings modals use. const {confirm, dialogProps} = useDirtyConfirmation(); - // Whether anything would actually be lost by leaving. A file that has only been parsed is - // not worth asking about: re-uploading it reproduces the same detected mapping. What cannot - // be reproduced is what the publisher changed since, so that is what this tracks. - const [hasEdits, setHasEdits] = useState(false); + // Tracks mapping changes that would be lost on close. Parsing a file does not count because + // re-uploading it recreates the detected mapping. + // + // Radix refreshes its onOpenChange callback ref in a passive effect, and handles Escape on + // document outside React's event dispatch. Escape can therefore call the old closure before + // React commits the edit. Nothing renders from this flag, so a ref gives the handler the current + // value. + const hasEditsRef = useRef(false); const handleOpenChange = useCallback((isOpen: boolean) => { if (!isOpen && state.status === 'UPLOADING') { @@ -127,7 +131,7 @@ export function ImportMembersModal({ if (!isOpen) { // Only the mapping step holds anything: every other one is either empty or shows a // result that closing is the natural end of. - confirm(state.status === 'MAPPING' && hasEdits, () => { + confirm(state.status === 'MAPPING' && hasEditsRef.current, () => { const importResponse = state.importResponse ?? undefined; reset(); onClose?.(importResponse); @@ -136,7 +140,7 @@ export function ImportMembersModal({ return; } onOpenChange(isOpen); - }, [confirm, hasEdits, onClose, onOpenChange, reset, state.importResponse, state.status]); + }, [confirm, onClose, onOpenChange, reset, state.importResponse, state.status]); useEffect(() => { if (!state.file || !customFieldsReady) { @@ -231,7 +235,7 @@ export function ImportMembersModal({ return; } - setHasEdits(true); + hasEditsRef.current = true; const nextMapping = state.mapping.updateMapping(from, to); const nextError = state.fileData && state.fileData.length === 0 @@ -411,7 +415,9 @@ export function ImportMembersModal({ showMappingErrors={state.showMappingErrors} status={state.status} targets={targets} - onColumnsChanged={() => setHasEdits(true)} + onColumnsChanged={() => { + hasEditsRef.current = true; + }} onDataPreviewIndexChange={(nextIndex) => { dispatch({ type: 'SET_DATA_PREVIEW_INDEX', diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx index 5c5bc06e655..76b356de4a7 100644 --- a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -641,4 +641,19 @@ describe('Import members custom fields', () => { await expect.element(fieldSelect('email')).not.toBeInTheDocument(); }); + // Fire both DOM events in one task to cover Escape arriving before React commits the edit. + // userEvent adds task boundaries that let React settle and hide this race. + it('asks even when the dismissal lands before React has settled', async () => { + fakeCustomFieldsWorld(); + await renderAdminApp('/members', FLAGS); + await openMappingStep(); + await expect.element(fieldSelect('email')).toBeVisible(); + + const toggle = importToggle('nickname').element() as HTMLElement; + toggle.click(); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); + + await expect.element(page.getByText('Leave without importing?')).toBeVisible(); + }); + }); \ No newline at end of file diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index 707121fc837..9f0a6225630 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.58.1-rc.0", + "version": "6.59.1-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/apps/portal/src/utils/contrast-color.js b/apps/portal/src/utils/contrast-color.js deleted file mode 100644 index 74b77560bb4..00000000000 --- a/apps/portal/src/utils/contrast-color.js +++ /dev/null @@ -1,39 +0,0 @@ -function padZero(str, len) { - len = len || 2; - const zeros = new Array(len).join('0'); - return (zeros + str).slice(-len); -} - -function invertColor(hex = '', bw = true) { - if (!hex || !hex.match(/#[0-9A-Fa-f]{6}$/)) { - // Return white in case not a valid hex - return '#000000'; - } - if (hex.indexOf('#') === 0) { - hex = hex.slice(1); - } - // convert 3-digit hex to 6-digits. - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - if (hex.length !== 6) { - throw new Error('Invalid HEX color.'); - } - let r = parseInt(hex.slice(0, 2), 16), - g = parseInt(hex.slice(2, 4), 16), - b = parseInt(hex.slice(4, 6), 16); - if (bw) { - // http://stackoverflow.com/a/3943023/112731 - return (r * 0.299 + g * 0.587 + b * 0.114) > 186 - ? '#000000' - : '#FFFFFF'; - } - // invert color components - r = (255 - r).toString(16); - g = (255 - g).toString(16); - b = (255 - b).toString(16); - // pad each with zeros and return - return '#' + padZero(r) + padZero(g) + padZero(b); -} - -export default invertColor; diff --git a/ghost/core/content/themes/casper b/ghost/core/content/themes/casper index bb3d0de3c70..f968f19dc9b 160000 --- a/ghost/core/content/themes/casper +++ b/ghost/core/content/themes/casper @@ -1 +1 @@ -Subproject commit bb3d0de3c7065677bb18b38d8185a8a980b163bd +Subproject commit f968f19dc9b2fa8cda4508c40d82d86c03bb6c2f diff --git a/ghost/core/content/themes/source b/ghost/core/content/themes/source index 3a5643b42a4..fb629deb392 160000 --- a/ghost/core/content/themes/source +++ b/ghost/core/content/themes/source @@ -1 +1 @@ -Subproject commit 3a5643b42a4700e961e65ebda92f6a6c56bf8c86 +Subproject commit fb629deb3922e24bc27ab8aea566ddc76db93ceb diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index c949c43660c..8f7b1a75ca3 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -157,11 +157,13 @@ module.exports = function navigation(options) { out.current = isCurrentNavigationUrl(e.url, currentUrl); out.icon = icon || null; out.iconAlt = hasLabel ? '' : iconName; - out.label = e.label; + out.label = hasLabel ? e.label : null; out.slug = slugify(hasLabel ? e.label : iconName); out.url = e.url; return out; - }); + }) + // Nothing to render: an icon-only item with the icons flag off would be an empty link + .filter(item => item.icon || item.label); if (output.length === 0) { return new SafeString(''); @@ -172,6 +174,8 @@ module.exports = function navigation(options) { // CASE: The navigation helper will forward attributes passed to it. _.merge(this, options.hash); const data = createFrame(options.data); + // The template only wraps labels/renders icons when the flag is on, so pre-flag markup is unchanged + data.navigationIcons = navigationIconsEnabled; return templates.execute('navigation', this, {data}); }; diff --git a/ghost/core/core/frontend/helpers/tpl/navigation.hbs b/ghost/core/core/frontend/helpers/tpl/navigation.hbs index 08f3344f0bd..3faccca4180 100644 --- a/ghost/core/core/frontend/helpers/tpl/navigation.hbs +++ b/ghost/core/core/frontend/helpers/tpl/navigation.hbs @@ -1,5 +1,5 @@ diff --git a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js index 1756a7806ee..d22113277ff 100644 --- a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js +++ b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-11-21-22-48-rename-email-batches-provider-id.js @@ -4,5 +4,5 @@ module.exports = createRenameColumnMigration( 'email_batches', 'provider_id', 'mailgun_message_id', - {algorithm: 'instant'} + {algorithm: 'auto'} ); diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index b2075a55dc5..999885a013b 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -99,6 +99,38 @@ function dropNullable(tableName, column, transaction = db.knex) { }); } +// MySQL raises these when an explicit ALGORITHM isn't supported for the operation, +// which varies by server version (RENAME COLUMN only accepts INSTANT from 8.0.28). +const UNSUPPORTED_ALGORITHM_ERRORS = new Set([ + 'ER_ALTER_OPERATION_NOT_SUPPORTED', + 'ER_ALTER_OPERATION_NOT_SUPPORTED_REASON' +]); + +/** + * Runs an ALTER TABLE with an explicit MySQL algorithm, retrying without it if the + * server doesn't support that algorithm for the operation. + * + * @param {import('knex').Knex} transaction + * @param {string} sql - ALTER statement without a trailing semicolon or algorithm clause + * @param {'instant'|'inplace'|'copy'|'auto'} [algorithm] + */ +async function rawWithAlgorithm(transaction, sql, algorithm) { + if (!algorithm || algorithm === 'auto') { + return await transaction.raw(sql); + } + + try { + return await transaction.raw(`${sql}, algorithm=${algorithm}`); + } catch (err) { + if (!UNSUPPORTED_ALGORITHM_ERRORS.has(err.code)) { + throw err; + } + + logging.warn(`ALGORITHM=${algorithm} is not supported by this server for: ${sql} - retrying without it`); + return await transaction.raw(sql); + } +} + /** * @param {string} tableName * @param {string} column @@ -120,19 +152,15 @@ async function addColumn(tableName, column, transaction = db.knex, columnSpec, o } for (const sqlQuery of addColumnBuilder.toSQL()) { - let sql = sqlQuery.sql; - - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, ''); - if (options?.algorithm !== 'auto') { - // default to copy if not specified - const algorithm = options?.algorithm || 'copy'; - sql += `, algorithm=${algorithm}`; - } + if (!DatabaseInfo.isMySQL(transaction)) { + await transaction.raw(sqlQuery.sql); + continue; } - await transaction.raw(sql); + // Guard against an ending semicolon + const sql = sqlQuery.sql.replace(/;\s*$/, ''); + // default to copy if not specified + await rawWithAlgorithm(transaction, sql, options?.algorithm || 'copy'); } } @@ -162,19 +190,15 @@ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = } for (const sqlQuery of dropColumnBuilder.toSQL()) { - let sql = sqlQuery.sql; - - if (DatabaseInfo.isMySQL(transaction)) { - // Guard against an ending semicolon - sql = sql.replace(/;\s*$/, ''); - if (options?.algorithm !== 'auto') { - // default to copy if not specified - const algorithm = options?.algorithm || 'copy'; - sql += `, algorithm=${algorithm}`; - } + if (!DatabaseInfo.isMySQL(transaction)) { + await transaction.raw(sqlQuery.sql); + continue; } - await transaction.raw(sql); + // Guard against an ending semicolon + const sql = sqlQuery.sql.replace(/;\s*$/, ''); + // default to copy if not specified + await rawWithAlgorithm(transaction, sql, options?.algorithm || 'copy'); } } @@ -191,8 +215,8 @@ async function renameColumn(tableName, from, to, transaction = db.knex, options if (DatabaseInfo.isMySQL(transaction)) { // The knex helper does a lot of interesting things with foreign keys that are slow on bigger MySQL clusters - const algorithm = options.algorithm && options.algorithm !== 'auto' ? `, algorithm=${options.algorithm}` : ''; - return await transaction.raw(`ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\`${algorithm};`); + const sql = `ALTER TABLE \`${tableName}\` RENAME COLUMN \`${from}\` TO \`${to}\``; + return await rawWithAlgorithm(transaction, sql, options.algorithm); } return await transaction.schema.table(tableName, function (table) { diff --git a/ghost/core/package.json b/ghost/core/package.json index 39e1cafca2d..9f443a4031e 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.59.0-rc.0", + "version": "6.59.1-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/ghost/core/test/unit/frontend/helpers/navigation.test.js b/ghost/core/test/unit/frontend/helpers/navigation.test.js index 47a8388ad56..b6c7dc0d41f 100644 --- a/ghost/core/test/unit/frontend/helpers/navigation.test.js +++ b/ghost/core/test/unit/frontend/helpers/navigation.test.js @@ -327,7 +327,8 @@ describe('{{navigation}} helper', function () { assertExists(rendered); assert(!rendered.string.includes('class="nav-icon"')); - assert(rendered.string.includes('Foo')); + assert(!rendered.string.includes('class="nav-label"')); + assert(rendered.string.includes('/foo">Foo')); }); it('does not filter items by visibility', function () { @@ -339,8 +340,58 @@ describe('{{navigation}} helper', function () { const rendered = runHelper(optionsData); assertExists(rendered); - assert(rendered.string.includes('Hidden')); - assert(rendered.string.includes('Paid')); + assert(rendered.string.includes('/hidden">Hidden')); + assert(rendered.string.includes('/paid">Paid')); + }); + + it('drops icon-only items rather than rendering an empty link', function () { + optionsData.data.site.navigation = [ + {url: '/icon-only', icon: 'https://example.com/icon.svg'}, + {label: 'Foo', url: '/foo'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('/icon-only')); + assert(!rendered.string.includes('class="nav-"')); + assert(rendered.string.includes('/foo">Foo')); + }); + + it('drops items whose label is only whitespace', function () { + optionsData.data.site.navigation = [ + {label: ' ', url: '/blank', icon: 'https://example.com/icon.svg'}, + {label: 'Foo', url: '/foo'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('/blank')); + assert(rendered.string.includes('/foo">Foo')); + }); + + it('renders empty nav when every item is icon-only', function () { + optionsData.data.site.navigation = [ + {url: '/icon-only', icon: 'https://example.com/icon.svg'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal(rendered.string, ''); + }); + + it('renders the same markup as before the flag existed', function () { + optionsData.data.site.navigation = [{label: 'Foo', url: '/foo'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal( + rendered.string.trim(), + '' + ); }); }); diff --git a/ghost/core/test/unit/server/data/schema/commands.test.js b/ghost/core/test/unit/server/data/schema/commands.test.js index a98f4f6e6b3..d5e421e8283 100644 --- a/ghost/core/test/unit/server/data/schema/commands.test.js +++ b/ghost/core/test/unit/server/data/schema/commands.test.js @@ -154,7 +154,7 @@ describe('schema commands', function () { await commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}); assert.deepEqual(rawStatements, [ - 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant;' + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant' ]); }); @@ -171,8 +171,53 @@ describe('schema commands', function () { await commands.renameColumn('table', 'old_column', 'new_column', fakeKnex); assert.deepEqual(rawStatements, [ - 'ALTER TABLE `table` RENAME COLUMN `old_column` TO `new_column`;' + 'ALTER TABLE `table` RENAME COLUMN `old_column` TO `new_column`' ]); }); + + it('retries without the algorithm when the server does not support it', async function () { + const rawStatements = []; + const fakeKnex = { + client: {config: {client: 'mysql2'}}, + raw: (sql) => { + rawStatements.push(sql); + + if (sql.includes('algorithm=')) { + const error = new Error('ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.'); + error.code = 'ER_ALTER_OPERATION_NOT_SUPPORTED'; + return Promise.reject(error); + } + + return Promise.resolve(); + } + }; + + await commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}); + + assert.deepEqual(rawStatements, [ + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`, algorithm=instant', + 'ALTER TABLE `email_batches` RENAME COLUMN `provider_id` TO `mailgun_message_id`' + ]); + }); + + it('does not retry on unrelated errors', async function () { + const rawStatements = []; + const fakeKnex = { + client: {config: {client: 'mysql2'}}, + raw: (sql) => { + rawStatements.push(sql); + const error = new Error("Table 'email_batches' doesn't exist"); + error.code = 'ER_NO_SUCH_TABLE'; + return Promise.reject(error); + } + }; + + await assert.rejects( + commands.renameColumn('email_batches', 'provider_id', 'mailgun_message_id', fakeKnex, {algorithm: 'instant'}), + /doesn't exist/ + ); + + assert.equal(rawStatements.length, 1); + }); }); }); diff --git a/packages/testing/test-data/src/builders/automation.ts b/packages/testing/test-data/src/builders/automation.ts index 20f914c79c2..47f6078c206 100644 --- a/packages/testing/test-data/src/builders/automation.ts +++ b/packages/testing/test-data/src/builders/automation.ts @@ -8,6 +8,11 @@ export interface Automation { name: string; slug: string; status: "active" | "inactive"; + stats: { + last_run_created_at: string | null; + total_run_count: number; + in_progress_run_count: number; + }; } export const automation = createBuilder(() => { @@ -17,6 +22,11 @@ export const automation = createBuilder(() => { id: generateId(), name, slug: `${generateSlug(name)}-${faker.string.alphanumeric(6).toLowerCase()}`, - status: "inactive" + status: "inactive", + stats: { + last_run_created_at: null, + total_run_count: 0, + in_progress_run_count: 0 + } }; });