diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index b6a9b053e..58079bda3 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -6,11 +6,13 @@ import { screen, waitFor, } from '@testing-library/react'; +import { toast } from 'sonner'; const managerInstructionsPlaceholder = /Optional guidance for which ideas to prioritize or avoid/; const state = vi.hoisted(() => ({ customAutomationsPending: false, + customAutomationRunPendingId: null as string | null, customAutomations: [] as Array<{ id: string; name: string; @@ -252,9 +254,13 @@ const mutations = vi.hoisted(() => ({ } | null, latestTriggerOptions: null as { onSuccess?: ( - result: NonNullable, + result: { outcome: 'launched'; taskId: string }, + variables: { automationKey: string }, ) => void; } | null, + latestCustomTriggerOptions: null as { + onSuccess?: (result: { outcome: 'launched'; taskId: string }) => void; + } | null, })); vi.mock('next/link', () => ({ @@ -276,6 +282,8 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), + info: vi.fn(), + message: vi.fn(), }, })); @@ -364,9 +372,13 @@ vi.mock('@tanstack/react-query', () => ({ ) => void; onError?: (...args: unknown[]) => void; mutationKind?: 'triggerCustomAutomation'; + mutationKey?: unknown[]; }) => { return { - isPending: false, + isPending: + _options?.mutationKind === 'triggerCustomAutomation' && + (typeof _options.mutationKey?.[1] !== 'string' || + _options.mutationKey[1] === state.customAutomationRunPendingId), mutate: vi.fn((variables: unknown) => { if (_options?.mutationKind === 'triggerCustomAutomation') { mutations.triggerCustomAutomation(variables); @@ -421,10 +433,15 @@ vi.mock('@/trpc/client', () => ({ mutationOptions: (options?: Record) => options ?? {}, }, triggerCustomAutomation: { - mutationOptions: (options?: Record) => ({ - ...options, - mutationKind: 'triggerCustomAutomation', - }), + mutationOptions: (options?: Record) => { + mutations.latestCustomTriggerOptions = + (options as typeof mutations.latestCustomTriggerOptions) ?? null; + + return { + ...options, + mutationKind: 'triggerCustomAutomation', + }; + }, }, resolveCustomAutomationSchedule: { mutationOptions: (options?: Record) => options ?? {}, @@ -504,8 +521,10 @@ describe('AutomationsSettings', () => { beforeEach(() => { vi.clearAllMocks(); state.nextUpdateSettingsResult = null; + state.customAutomationRunPendingId = null; mutations.latestSettingsOptions = null; mutations.latestTriggerOptions = null; + mutations.latestCustomTriggerOptions = null; state.settingsQuery.data.capabilities.slackConnected = true; state.settingsQuery.data.capabilities.discordConnected = false; state.settingsQuery.data.capabilities.telegramConnected = false; @@ -895,6 +914,24 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); }); + it('names built-in automations in run-now task toasts', () => { + render(); + + act(() => { + mutations.latestTriggerOptions?.onSuccess?.( + { outcome: 'launched', taskId: 'task-built-in-1' }, + { automationKey: 'suggester' }, + ); + }); + + expect(toast.success).toHaveBeenCalledWith( + 'Running Suggest Ideas now', + expect.objectContaining({ + action: expect.objectContaining({ label: 'View task' }), + }), + ); + }); + it('renders custom automations as a compact control list and honors their permalinks', async () => { state.environments = [{ id: 'env-1', name: 'Production' }]; state.customAutomations = [ @@ -937,6 +974,35 @@ describe('AutomationsSettings', () => { expect(mutations.triggerCustomAutomation).toHaveBeenCalledWith({ id: 'automation-1', }); + act(() => { + mutations.latestCustomTriggerOptions?.onSuccess?.({ + outcome: 'launched', + taskId: 'task-custom-1', + }); + }); + expect(toast.success).toHaveBeenCalledWith( + 'Running Weekly flaky-test scan now', + expect.objectContaining({ + action: expect.objectContaining({ label: 'View task' }), + }), + ); + + state.customAutomations.push({ + ...state.customAutomations[0]!, + id: 'automation-2', + name: 'Daily dependency scan', + }); + state.customAutomationRunPendingId = 'automation-1'; + rerender(); + expect( + screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Run Daily dependency scan now' }), + ).toBeEnabled(); + expect(screen.getByRole('button', { name: 'New' })).toBeEnabled(); + + state.customAutomationRunPendingId = null; state.customAutomations[0]!.enabled = false; rerender(); expect( diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index e529c4e6a..27935608e 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -1800,9 +1800,9 @@ export function AutomationsSettings() { switch (data.outcome) { case 'launched': - toast.success(`${automationLabel} started a task.`, { + toast.success(`Running ${automationLabel} now`, { action: { - label: 'Open task', + label: 'View task', onClick: () => window.open(`/task/${data.taskId}`, '_blank'), }, }); diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index 1d24f7950..8e218f234 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -119,6 +119,62 @@ function cadenceLabel(row: CustomAutomationListItem): string { : 'Custom schedule'; } +function CustomAutomationRunButton({ + automation, + disabled, +}: { + automation: CustomAutomationListItem; + disabled: boolean; +}) { + const trpc = useTRPC(); + const triggerMutation = useMutation({ + ...trpc.automations.triggerCustomAutomation.mutationOptions({ + onSuccess: (result) => { + switch (result.outcome) { + case 'launched': + toast.success(`Running ${automation.name} now`, { + action: { + label: 'View task', + onClick: () => window.open(`/task/${result.taskId}`, '_blank'), + }, + }); + break; + case 'completed': + toast.success(`${automation.name} ran successfully.`); + break; + case 'skipped': + toast.info( + `${automation.name} had nothing to do: ${result.reason}`, + ); + break; + case 'failed': + toast.error(`${automation.name} failed: ${result.error}`); + break; + } + }, + onError: (error) => { + toast.error(error.message || `Failed to run ${automation.name}`); + }, + }), + mutationKey: ['customAutomationRun', automation.id], + }); + + return ( + + + + ); +} + function targetFromRow(row: CustomAutomationListItem): { provider: CustomAutomationFormState['targetProvider']; channelId: string; @@ -350,35 +406,6 @@ export function CustomAutomationsSection() { }), ); - const triggerMutation = useMutation( - trpc.automations.triggerCustomAutomation.mutationOptions({ - onSuccess: (result) => { - switch (result.outcome) { - case 'launched': - toast.success('Custom automation started a task.', { - action: { - label: 'Open task', - onClick: () => window.open(`/task/${result.taskId}`, '_blank'), - }, - }); - break; - case 'completed': - toast.success('Custom automation ran successfully.'); - break; - case 'skipped': - toast.info(`Custom automation had nothing to do: ${result.reason}`); - break; - case 'failed': - toast.error(`Custom automation failed: ${result.error}`); - break; - } - }, - onError: (error) => { - toast.error(error.message || 'Failed to run custom automation'); - }, - }), - ); - const resolveScheduleMutation = useMutation( trpc.automations.resolveCustomAutomationSchedule.mutationOptions({ onSuccess: (result, variables) => { @@ -431,8 +458,7 @@ export function CustomAutomationsSection() { createMutation.isPending || updateMutation.isPending || deleteMutation.isPending || - toggleMutation.isPending || - triggerMutation.isPending; + toggleMutation.isPending; const closeEditor = () => { setIsCreating(false); @@ -1062,18 +1088,10 @@ export function CustomAutomationsSection() {

- - - +