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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/admin-x-framework/src/api/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,37 @@ export type AutomationActionLinksResponseType = {
automation_action_links: AutomationActionLink[];
}

export type AutomationRunAnalyticsPoint = {
date: string;
count: number;
}

export type AutomationRunAnalytics = {
automation_id: string;
total_runs: number;
in_progress: number;
completed: number;
last_run_at: string | null;
runs_by_day?: AutomationRunAnalyticsPoint[];
}

export type AutomationRunAnalyticsResponseType = {
meta?: Meta;
automation_run_analytics: AutomationRunAnalytics[];
}

const dataType = 'AutomationsResponseType';

export const useBrowseAutomations = createQuery<AutomationsResponseType>({
dataType,
path: '/automations/'
});

export const useBrowseAutomationRunAnalytics = createQuery<AutomationRunAnalyticsResponseType>({
dataType: 'AutomationRunAnalyticsResponseType',
path: '/automations/run-analytics/'
});

export const useReadAutomation = createQueryWithId<AutomationDetailResponseType>({
dataType,
path: id => `/automations/${id}/`
Expand Down
29 changes: 27 additions & 2 deletions apps/admin/src/automations/automations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@ import {MemoryRouter} from 'react-router';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {render, screen} from '@testing-library/react';

const {mockUseBrowseAutomations, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({
const mockRunAnalyticsFlag = vi.hoisted(() => ({enabled: false}));

vi.mock('@tryghost/admin-x-framework/hooks', async () => {
const actual = await vi.importActual<typeof import('@tryghost/admin-x-framework/hooks')>('@tryghost/admin-x-framework/hooks');
return {
...actual,
useFeatureFlag: () => mockRunAnalyticsFlag.enabled
};
});

const {mockUseBrowseAutomations, mockUseBrowseAutomationRunAnalytics, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({
mockUseBrowseAutomations: vi.fn(),
mockUseBrowseAutomationRunAnalytics: vi.fn(),
mockUseBrowseSettings: vi.fn(),
mockUseBrowseConfig: vi.fn(),
mockUseCurrentUser: vi.fn()
Expand All @@ -16,7 +27,8 @@ vi.mock('@tryghost/admin-x-framework/api/automations', async () => {
);
return {
...actual,
useBrowseAutomations: mockUseBrowseAutomations
useBrowseAutomations: mockUseBrowseAutomations,
useBrowseAutomationRunAnalytics: mockUseBrowseAutomationRunAnalytics
};
});

Expand Down Expand Up @@ -85,7 +97,9 @@ const renderPage = () => render(<MemoryRouter><Automations /></MemoryRouter>);
describe('Automations', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRunAnalyticsFlag.enabled = false;
mockUseBrowseAutomations.mockReturnValue({data: {automations}, isError: false, isLoading: false});
mockUseBrowseAutomationRunAnalytics.mockReturnValue({data: {automation_run_analytics: []}, 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'}]}});
Expand All @@ -96,6 +110,17 @@ describe('Automations', () => {

expect(screen.getByText('Free member welcome flow')).toBeInTheDocument();
expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument();
expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: false});
expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument();
});

it('loads and shows run analytics when the feature is enabled', () => {
mockRunAnalyticsFlag.enabled = true;

renderPage();

expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: true});
expect(screen.getByRole('columnheader', {name: 'Last entry'})).toBeInTheDocument();
});

it('hides the paid sequence when Stripe is not connected', () => {
Expand Down
16 changes: 13 additions & 3 deletions apps/admin/src/automations/automations.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import AutomationsHelpCards from './components/automations-help-cards';
import AutomationsList from './components/automations-list';
import React from 'react';
import {useBrowseAutomationRunAnalytics} from '@tryghost/admin-x-framework/api/automations';
import {Badge} from '@tryghost/shade/components';
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 runAnalyticsEnabled = useFeatureFlag('automationRunAnalytics');
const analyticsQuery = useBrowseAutomationRunAnalytics({enabled: runAnalyticsEnabled});

if (isError) {
throw error instanceof Error ? error : new Error('Failed to load automations');
if (isError || (runAnalyticsEnabled && analyticsQuery.isError)) {
const queryError = error || analyticsQuery.error;
throw queryError instanceof Error ? queryError : new Error('Failed to load automations');
}

return (
Expand All @@ -31,7 +36,12 @@ const Automations: React.FC = () => {
</PageHeader>
</ListPage.Header>
<ListPage.Body>
<AutomationsList automations={automations} isLoading={isLoading} />
<AutomationsList
analytics={analyticsQuery.data?.automation_run_analytics}
automations={automations}
isLoading={isLoading || (runAnalyticsEnabled && analyticsQuery.isLoading)}
showRunAnalytics={runAnalyticsEnabled}
/>
<AutomationsHelpCards />
</ListPage.Body>
</ListPage>
Expand Down
32 changes: 31 additions & 1 deletion apps/admin/src/automations/components/automations-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,25 @@ const automations = [{
status: 'inactive' as const
}];

const analytics = [{
automation_id: 'automation-id-1',
total_runs: 1432,
in_progress: 118,
completed: 1225,
last_run_at: '2026-07-21T07:12:00Z'
}, {
automation_id: 'automation-id-2',
total_runs: 412,
in_progress: 61,
completed: 320,
last_run_at: '2026-07-21T05:55:00Z'
}];

const renderWithRouter = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);

describe('AutomationsList', () => {
it('renders fetched automations with private beta copy and status labels', () => {
renderWithRouter(<AutomationsList automations={automations} />);
renderWithRouter(<AutomationsList analytics={analytics} automations={automations} showRunAnalytics={true} />);

expect(screen.getAllByTestId('automation-list-row')).toHaveLength(2);
expect(screen.getByText('Free member welcome flow')).toBeInTheDocument();
Expand All @@ -29,6 +43,22 @@ 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: 'Running'})).toBeInTheDocument();
expect(screen.getByRole('columnheader', {name: 'Done'})).toBeInTheDocument();
expect(screen.getByText('118')).toBeInTheDocument();
expect(screen.getByText('1,225')).toBeInTheDocument();
expect(screen.getByText('61')).toBeInTheDocument();
expect(screen.getByText('320')).toBeInTheDocument();
});

it('keeps run analytics columns hidden when the feature is disabled', () => {
renderWithRouter(<AutomationsList analytics={analytics} automations={automations} showRunAnalytics={false} />);

expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', {name: 'Running'})).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', {name: 'Done'})).not.toBeInTheDocument();
expect(screen.queryByText('1,225')).not.toBeInTheDocument();
});

it('links each row to the automation sequence by id', () => {
Expand Down
64 changes: 54 additions & 10 deletions apps/admin/src/automations/components/automations-list.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import AutomationStatusBadge from './automation-status-badge';
import React from 'react';
import type {Automation} from '@tryghost/admin-x-framework/api/automations';
import type {Automation, AutomationRunAnalytics} 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 {formatLastRun} from '@/automations/format-last-run';

const AUTOMATION_DESCRIPTIONS: Record<string, string> = {
'member-welcome-email-free': 'Welcome new free members after they sign up.',
Expand All @@ -11,23 +13,37 @@ const AUTOMATION_DESCRIPTIONS: Record<string, string> = {

interface AutomationsListProps {
automations?: Automation[];
analytics?: AutomationRunAnalytics[];
isLoading?: boolean;
showRunAnalytics?: boolean;
}

const AutomationsListSkeleton: React.FC = () => {
const getGridColumns = (showRunAnalytics: boolean) => cn(
'grid grid-cols-[1fr_auto]',
showRunAnalytics && 'lg:grid-cols-[minmax(0,1fr)_170px_130px_130px_110px]'
);

const AutomationsListSkeleton: React.FC<{showRunAnalytics: boolean}> = ({showRunAnalytics}) => {
return (
<Table className="flex flex-col border-t" data-testid="automations-list-loading">
<Table className="flex flex-col" data-testid="automations-list-loading">
<TableBody className="flex flex-col">
{Array.from({length: 2}, (_, index) => (
<TableRow
key={index}
aria-hidden="true"
className="grid w-full grid-cols-[1fr_auto] items-center gap-x-4 p-2 lg:p-0"
className={cn('w-full items-center gap-x-4 p-2 lg:p-0', getGridColumns(showRunAnalytics))}
>
<TableCell className="min-w-0 lg:p-4">
<Skeleton className="mb-1 h-3 w-48 max-w-full " />
<Skeleton className="h-3 w-80 max-w-full" />
</TableCell>
{showRunAnalytics && (
<>
<TableCell className="hidden lg:block lg:p-4"><Skeleton className="h-3 w-20" /></TableCell>
<TableCell className="hidden lg:block lg:p-4"><Skeleton className="h-3 w-10" /></TableCell>
<TableCell className="hidden lg:block lg:p-4"><Skeleton className="h-3 w-10" /></TableCell>
</>
)}
<TableCell className="text-right lg:w-32 lg:p-4">
<Skeleton className="ml-auto h-3 w-16" />
</TableCell>
Expand All @@ -38,21 +54,36 @@ const AutomationsListSkeleton: React.FC = () => {
);
};

const AutomationsList: React.FC<AutomationsListProps> = ({automations = [], isLoading = false}) => {
const AutomationsList: React.FC<AutomationsListProps> = ({automations = [], analytics = [], isLoading = false, showRunAnalytics = false}) => {
if (isLoading) {
return <AutomationsListSkeleton />;
return <AutomationsListSkeleton showRunAnalytics={showRunAnalytics} />;
}

return (
<Table className="flex flex-col border-t" data-testid="automations-list">
<Table className="flex flex-col" data-testid="automations-list">
{showRunAnalytics && (
<TableHeader className="hidden lg:flex lg:flex-col">
<TableRow className={cn('w-full items-center gap-x-4 border-b hover:bg-transparent', getGridColumns(showRunAnalytics))}>
<TableHead className="flex items-center lg:px-4">Name</TableHead>
<TableHead className="flex items-center lg:px-4">Last entry</TableHead>
<TableHead className="flex items-center lg:px-4">Running</TableHead>
<TableHead className="flex items-center lg:px-4">Done</TableHead>
<TableHead className="flex items-center lg:px-4">Status</TableHead>
</TableRow>
</TableHeader>
)}
<TableBody className="flex flex-col">
{automations.map((automation) => {
const description = AUTOMATION_DESCRIPTIONS[automation.slug];
const metrics = analytics.find(item => item.automation_id === automation.id);
const lastRunAt = metrics?.last_run_at ?? null;
const inProgress = metrics?.in_progress ?? 0;
const completed = metrics?.completed ?? 0;

return (
<TableRow
key={automation.slug}
className="grid w-full cursor-pointer grid-cols-[1fr_auto] items-center gap-x-4 p-2 hover:bg-table-row-hover lg:p-0"
className={cn('relative w-full cursor-pointer items-center gap-x-4 p-2 hover:bg-table-row-hover lg:p-0', getGridColumns(showRunAnalytics))}
data-testid="automation-list-row"
>
<TableCell className="static min-w-0 lg:p-4">
Expand All @@ -70,7 +101,20 @@ const AutomationsList: React.FC<AutomationsListProps> = ({automations = [], isLo
</span>
)}
</TableCell>
<TableCell className="text-right lg:w-32 lg:p-4">
{showRunAnalytics && (
<>
<TableCell className={cn('hidden lg:block lg:p-4', !lastRunAt && 'text-muted-foreground')}>
{formatLastRun(lastRunAt)}
</TableCell>
<TableCell className={cn('hidden lg:block lg:p-4', inProgress === 0 && 'text-muted-foreground')}>
{formatNumber(inProgress)}
</TableCell>
<TableCell className={cn('hidden lg:block lg:p-4', completed === 0 && 'text-muted-foreground')}>
{formatNumber(completed)}
</TableCell>
</>
)}
<TableCell className={cn('lg:p-4', !showRunAnalytics && 'text-right lg:w-32')}>
<AutomationStatusBadge status={automation.status} />
</TableCell>
</TableRow>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import RunAnalyticsSidebar from './run-analytics-sidebar';
import type {GhAreaChartDataItem} from '@tryghost/shade/patterns';
import {describe, expect, it, vi} from 'vitest';
import {render, screen} from '@testing-library/react';

vi.mock('@tryghost/admin-x-framework/api/automations', async () => {
const actual = await vi.importActual<typeof import('@tryghost/admin-x-framework/api/automations')>('@tryghost/admin-x-framework/api/automations');
return {
...actual,
useBrowseAutomationRunAnalytics: () => ({
data: {
automation_run_analytics: [{
automation_id: 'automation-id-1',
total_runs: 1432,
in_progress: 118,
completed: 1225,
last_run_at: '2026-07-21T07:12:00Z',
runs_by_day: Array.from({length: 30}, (_, index) => ({date: `2026-07-${index + 1}`, count: index}))
}]
}
})
};
});

vi.mock('@tryghost/shade/patterns', () => ({
GhAreaChart: ({data}: {data: GhAreaChartDataItem[]}) => <div data-point-count={data.length} data-testid="runs-chart" />
}));

describe('RunAnalyticsSidebar', () => {
it('shows the run graph and only the requested run status totals', () => {
render(
<RunAnalyticsSidebar automation={{
id: 'automation-id-1',
name: 'Free member welcome flow',
slug: 'member-welcome-email-free',
status: 'active'
}} />
);

expect(screen.getByTestId('runs-chart')).toHaveAttribute('data-point-count', '30');
expect(screen.getByText('Total runs')).toBeInTheDocument();
expect(screen.getByText('1,432')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('118')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('1,225')).toBeInTheDocument();
expect(screen.queryByText('Upgraded')).not.toBeInTheDocument();
expect(screen.queryByText('Unsubscribed')).not.toBeInTheDocument();
});
});
Loading