diff --git a/CHANGELOG.md b/CHANGELOG.md index b2baa0fba31..2357d2af945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to ### Changed +- Cron schedules now show "UTC" after the time, on the canvas and in the trigger + panels [#5247](https://github.com/OpenFn/lightning/issues/5247) - Update Project and Workflow yaml exports to match the v4 portability spec [#4718](https://github.com/OpenFn/lightning/issues/4718) - The workflow version hash no longer includes a trigger's `enabled` state diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx index b03d48a9eb0..ce2a30c298e 100644 --- a/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx +++ b/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx @@ -71,9 +71,13 @@ export function CronConfigureStep({ />
-

- When should this run? -

+ {/* Cron schedules always run in UTC, whatever the viewer's zone. */} +
+

+ When should this run? +

+ All times in UTC +
mergeDraft({ cron_expression: expr })} diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx index bcf8d41a419..7b3f006e6b8 100644 --- a/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx +++ b/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; -import { cn } from '../../../../utils/cn'; +import { humanizeCron } from '#/utils/humanizeCron'; -import { humanizeCron } from './cronSchedule'; +import { cn } from '../../../../utils/cn'; interface CronFieldBuilderProps { value: string; diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx index bf7f717304f..5f62673c051 100644 --- a/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx +++ b/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx @@ -1,8 +1,9 @@ +import { humanizeCron } from '#/utils/humanizeCron'; + import { useWorkflowState } from '../../../hooks/useWorkflow'; import type { Workflow } from '../../../types/workflow'; import { InspectorLayout } from '../InspectorLayout'; -import { humanizeCron } from './cronSchedule'; import { EditFooter } from './EditFooter'; import { ReadOnlyField } from './ReadOnlyField'; import { TriggerTypeBadge } from './TriggerTypeBadge'; diff --git a/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts b/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts deleted file mode 100644 index 606c4554c1c..00000000000 --- a/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Cron humanization helper for the cron trigger inspector. - * - * `humanizeCron` turns a stored cron expression into friendly text (via - * `cronstrue`) for the read-only show panel. Authoring the schedule itself is - * handled by the dropdown-based {@link CronFieldBuilder}, which does its own - * cron parsing/serialization, so no natural-language parser lives here. - */ - -import cronstrue from 'cronstrue'; - -/** - * Humanize a cron expression for display. Returns `null` if cronstrue throws - * (e.g. on a malformed expression). - */ -export function humanizeCron(expr: string): string | null { - try { - return cronstrue.toString(expr); - } catch { - return null; - } -} diff --git a/assets/js/utils/humanizeCron.ts b/assets/js/utils/humanizeCron.ts new file mode 100644 index 00000000000..efb86125c4a --- /dev/null +++ b/assets/js/utils/humanizeCron.ts @@ -0,0 +1,29 @@ +/** + * Cron humanization helper for the cron trigger inspector and diagram node. + * + * `humanizeCron` turns a stored cron expression into friendly text (via + * `cronstrue`) for the show panel, the builder preview and the diagram node. + * Authoring the schedule itself is handled by the dropdown-based + * {@link CronFieldBuilder}, which does its own cron parsing/serialization, so + * no natural-language parser lives here. + */ + +import cronstrue from 'cronstrue'; + +const LAST_CLOCK_TIME = /(\d{2}:\d{2} [AP]M)(?!.*\d{2}:\d{2} [AP]M)/; + +/** + * Humanize a cron expression for display. Returns `null` if cronstrue throws + * (e.g. on a malformed expression). + * + * Cron schedules run in UTC, so "UTC" follows the last clock time in the text + * ("At 09:00 AM UTC, Monday through Friday"). It sits next to the time, not at + * the end, so a clamped label (the diagram node) cuts the days, not the zone. + */ +export function humanizeCron(expr: string): string | null { + try { + return cronstrue.toString(expr).replace(LAST_CLOCK_TIME, '$1 UTC'); + } catch { + return null; + } +} diff --git a/assets/js/workflow-diagram/nodes/Trigger.tsx b/assets/js/workflow-diagram/nodes/Trigger.tsx index 7be04b03e78..f5c48a49a7e 100644 --- a/assets/js/workflow-diagram/nodes/Trigger.tsx +++ b/assets/js/workflow-diagram/nodes/Trigger.tsx @@ -1,8 +1,9 @@ import { ClockIcon, GlobeAltIcon } from '@heroicons/react/24/outline'; import { Position } from '@xyflow/react'; -import cronstrue from 'cronstrue'; import { memo } from 'react'; +import { humanizeCron } from '#/utils/humanizeCron'; + import { lockClosedIcon } from '../components/trigger-icons'; import type { Lightning } from '../types'; @@ -62,17 +63,11 @@ function getTriggerMeta(trigger: Lightning.TriggerNode): TriggerMeta { secondaryIcon: trigger.has_auth_method ? lockClosedIcon : null, }; case 'cron': - let sublabel = 'Not configured'; - try { - if (trigger.cron_expression) { - sublabel = cronstrue.toString(trigger.cron_expression); - } - } catch (_error) { - sublabel = 'Invalid cron expression'; - } return { label: 'Cron trigger', - sublabel, + sublabel: trigger.cron_expression + ? (humanizeCron(trigger.cron_expression) ?? 'Invalid cron expression') + : 'Not configured', primaryIcon: , secondaryIcon: null, }; diff --git a/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx index 092da532dea..2dc9a313ab7 100644 --- a/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx +++ b/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx @@ -485,7 +485,7 @@ describe('CronFieldBuilder - Advanced Section', () => { // cronstrue humanizes "0 9 * * 1-5". expect( - screen.getByText('At 09:00 AM, Monday through Friday') + screen.getByText('At 09:00 AM UTC, Monday through Friday') ).toBeInTheDocument(); }); diff --git a/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx index 6ff9cf894ef..a5ca5f7968b 100644 --- a/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx +++ b/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx @@ -111,7 +111,7 @@ describe('CronShowPanel', () => { expect(screen.getByText('Schedule / Cron')).toBeInTheDocument(); expect(screen.getByText('Frequency')).toBeInTheDocument(); - expect(screen.getByText('At 09:00 AM')).toBeInTheDocument(); + expect(screen.getByText('At 09:00 AM UTC')).toBeInTheDocument(); }); describe('edit button gating', () => { diff --git a/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx index 7fd9416b5b5..720ad6d284b 100644 --- a/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx +++ b/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx @@ -422,6 +422,7 @@ describe('TriggerEditWizard — cron', () => { // Next → Configure (Configure-only control present). await userEvent.click(screen.getByRole('button', { name: 'Next' })); expect(screen.getByLabelText('Cron Input Source')).toBeInTheDocument(); + expect(screen.getByText('All times in UTC')).toBeInTheDocument(); // Breadcrumb "Choose" crumb returns to Choose. await userEvent.click(screen.getByRole('button', { name: 'Choose' })); diff --git a/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts b/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts deleted file mode 100644 index 07b2c8a280b..00000000000 --- a/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * cronSchedule utility tests - * - * Covers `humanizeCron` — the cron → friendly text helper used by the cron - * show panel. (Schedule authoring is handled by the CronFieldBuilder dropdown, - * which does its own parsing, so there is no natural-language parser to test.) - */ - -import { describe, expect, test } from 'vitest'; - -import { humanizeCron } from '../../../../../js/collaborative-editor/components/inspector/trigger/cronSchedule'; - -describe('humanizeCron', () => { - test.each<[string, string, string | null]>([ - ['daily at 9 AM', '0 9 * * *', 'At 09:00 AM'], - ['every 15 minutes', '*/15 * * * *', 'Every 15 minutes'], - ['weekdays at 9 AM', '0 9 * * 1-5', 'At 09:00 AM, Monday through Friday'], - [ - 'monthly on the 15th', - '30 9 15 * *', - 'At 09:30 AM, on day 15 of the month', - ], - ['weekly on Sunday midnight', '0 0 * * 0', 'At 12:00 AM, only on Sunday'], - ['empty string → null', '', null], - ['invalid expression → null', 'not a cron', null], - ])('%s', (_label, expression, expected) => { - expect(humanizeCron(expression)).toBe(expected); - }); -}); diff --git a/assets/test/utils/humanizeCron.test.ts b/assets/test/utils/humanizeCron.test.ts new file mode 100644 index 00000000000..8bb415400cb --- /dev/null +++ b/assets/test/utils/humanizeCron.test.ts @@ -0,0 +1,48 @@ +/** + * humanizeCron tests + * + * Covers `humanizeCron` — the cron → friendly text helper used by the cron + * show panel, the builder preview and the diagram node. (Schedule authoring is + * handled by the CronFieldBuilder dropdown, which does its own parsing, so + * there is no natural-language parser to test.) + */ + +import { describe, expect, test } from 'vitest'; + +import { humanizeCron } from '../../js/utils/humanizeCron'; + +describe('humanizeCron', () => { + test.each<[string, string, string | null]>([ + ['daily at 9 AM', '0 9 * * *', 'At 09:00 AM UTC'], + ['every 15 minutes', '*/15 * * * *', 'Every 15 minutes'], + [ + 'every 2 hours', + '0 */2 * * *', + 'At 0 minutes past the hour, every 2 hours', + ], + [ + 'weekdays at 9 AM', + '0 9 * * 1-5', + 'At 09:00 AM UTC, Monday through Friday', + ], + [ + 'monthly on the 15th', + '30 9 15 * *', + 'At 09:30 AM UTC, on day 15 of the month', + ], + [ + 'weekly on Sunday midnight', + '0 0 * * 0', + 'At 12:00 AM UTC, only on Sunday', + ], + [ + 'hour range', + '0 9-17 * * 1-5', + 'Every hour, between 09:00 AM and 05:00 PM UTC, Monday through Friday', + ], + ['empty string → null', '', null], + ['invalid expression → null', 'not a cron', null], + ])('%s', (_label, expression, expected) => { + expect(humanizeCron(expression)).toBe(expected); + }); +}); diff --git a/assets/test/workflow-diagram/nodes/Trigger.test.tsx b/assets/test/workflow-diagram/nodes/Trigger.test.tsx index 19bc2d16be3..675c1bcea13 100644 --- a/assets/test/workflow-diagram/nodes/Trigger.test.tsx +++ b/assets/test/workflow-diagram/nodes/Trigger.test.tsx @@ -2,7 +2,8 @@ * Trigger Node Component Tests * * Tests that trigger nodes display correct icons for all trigger types - * (webhook, cron) and handle edge cases gracefully. + * (webhook, cron) and handle edge cases gracefully, and that a cron node's + * sublabel shows the humanized schedule. */ import { render } from '@testing-library/react'; @@ -15,11 +16,12 @@ import type { Lightning } from '../../../js/workflow-diagram/types'; /** * Helper to render a trigger node within ReactFlow context * - * Note: TriggerNode expects data.trigger to contain the trigger info, - * which is how from-workflow structures it + * Mirrors from-workflow: data holds the trigger's own fields (which + * TriggerNode reads) plus a data.trigger summary */ function renderTriggerNode(trigger: Lightning.TriggerNode) { const data: any = { + ...trigger, trigger: { type: trigger.type, enabled: trigger.enabled, @@ -127,3 +129,22 @@ describe('Trigger Node - Icon Display', () => { expect(svgElements.length).toBeGreaterThanOrEqual(1); }); }); + +describe('Trigger Node - Cron Sublabel', () => { + test.each([ + ['0 9 * * 1-5', 'At 09:00 AM UTC, Monday through Friday'], + ['invalid cron', 'Invalid cron expression'], + ['', 'Not configured'], + ])('%j shows "%s"', (cron_expression, expected) => { + const { getByText } = renderTriggerNode({ + id: 'trigger-5', + name: 'Cron Trigger', + type: 'cron', + enabled: true, + cron_expression, + workflow_id: 'test-workflow', + }); + + expect(getByText(expected)).toBeInTheDocument(); + }); +});