Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ export function CronConfigureStep({
/>

<div className="space-y-2">
<h3 className="text-base font-semibold text-slate-900">
When should this run?
</h3>
{/* Cron schedules always run in UTC, whatever the viewer's zone. */}
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-slate-900">
When should this run?
</h3>
<span className="text-xs text-slate-500">All times in UTC</span>
</div>
<CronFieldBuilder
value={draft.cron_expression ?? ''}
onChange={expr => mergeDraft({ cron_expression: expr })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down

This file was deleted.

29 changes: 29 additions & 0 deletions assets/js/utils/humanizeCron.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
15 changes: 5 additions & 10 deletions assets/js/workflow-diagram/nodes/Trigger.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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: <ClockIcon />,
secondaryIcon: null,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down

This file was deleted.

48 changes: 48 additions & 0 deletions assets/test/utils/humanizeCron.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 24 additions & 3 deletions assets/test/workflow-diagram/nodes/Trigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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();
});
});
Loading