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
9 changes: 6 additions & 3 deletions assets/js/collaborative-editor/components/ManualRunPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
QueueListIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { useURLState } from '#/react/lib/use-url-state';
import _logger from '#/utils/logger';
Expand Down Expand Up @@ -97,6 +97,10 @@ export function ManualRunPanel({
const [dataclips, setDataclips] = useState<Dataclip[]>([]);
const [manuallyUnselected, setManuallyUnselected] = useState(false);

// Ref to avoid stale closure in async fetch callback
const selectedDataclipRef = useRef(selectedDataclip);
selectedDataclipRef.current = selectedDataclip;

const setSelectedTab = useCallback(
(tab: TabValue) => {
setSelectedTabInternal(tab);
Expand Down Expand Up @@ -294,8 +298,7 @@ export function ManualRunPanel({
response.next_cron_run_dataclip_id &&
!disableAutoSelection &&
!followedRunId &&
!isDataclipControlled &&
!selectedDataclip &&
!selectedDataclipRef.current &&
!manuallyUnselected
) {
const nextCronDataclip = response.data.find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ export function FullScreenIDE({
selectedTab={selectedTab}
selectedDataclip={selectedDataclipState}
customBody={customBody}
disableAutoSelection
disableAutoSelection={manuallyUnselectedDataclip}
/>
</ManualRunPanelErrorBoundary>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export function TriggerForm({ trigger }: TriggerFormProps) {
const kafkaTriggersEnabled =
sessionContext.config?.kafka_triggers_enabled ?? false;

// Get jobs for cron input source dropdown
const jobs = useWorkflowState(state => state.jobs);

// Get active trigger auth methods from workflow store
const activeTriggerAuthMethods = useWorkflowState(
state => state.activeTriggerAuthMethods
Expand Down Expand Up @@ -443,7 +446,6 @@ export function TriggerForm({ trigger }: TriggerFormProps) {
if (currentType === 'cron') {
return (
<div className="space-y-4">
{/* <div className="border-t pt-4"> */}
{/* Cron Expression Field */}
<form.Field
name="cron_expression"
Expand Down Expand Up @@ -474,7 +476,83 @@ export function TriggerForm({ trigger }: TriggerFormProps) {
);
}}
</form.Field>
{/* </div> */}

{/* Cron Input Source */}
<div
className="space-y-2 pt-4 border-t
border-slate-200"
>
<form.Field name="cron_cursor_job_id">
{field => (
<div>
<div
className="flex items-center
gap-1 mb-1"
>
<label
htmlFor={field.name}
className="block text-sm font-medium text-slate-800"
>
Cron Input Source
</label>
<Tooltip
content="Select which step's output to use as the input for each cron-triggered run."
side="right"
>
<span className="hero-information-circle h-4 w-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<select
id={field.name}
aria-describedby={`${field.name}-description`}
value={field.state.value ?? ''}
onChange={e =>
field.handleChange(
e.target.value === '' ? null : e.target.value
)
}
onBlur={field.handleBlur}
disabled={isReadOnly}
className={cn(
'block w-full px-3 py-2',
'border rounded-md text-sm',
field.state.meta.errors.length > 0
? 'border-red-300 text-red-900 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 focus:border-indigo-500 focus:ring-indigo-500',
'focus:outline-none',
'focus:ring-1',
'disabled:opacity-50',
'disabled:cursor-not-allowed'
)}
>
<option value="">
Final run state (default)
</option>
{jobs.map(job => (
<option key={job.id} value={job.id}>
{job.name}
</option>
))}
</select>
<p
id={`${field.name}-description`}
className="mt-1 text-xs text-slate-500"
>
Choose which step&apos;s output to use as input
for cron-triggered runs.
</p>
{field.state.meta.errors.map(error => (
<p
key={error}
className="mt-1 text-xs text-red-600"
>
{error}
</p>
))}
</div>
)}
</form.Field>
</div>
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions assets/js/collaborative-editor/hooks/useUnsavedChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function transformTrigger(trigger: Trigger) {
switch (trigger.type) {
case 'cron':
output.cron_expression = trigger.cron_expression ?? '0 0 * * *'; // default cron expression
output.cron_cursor_job_id = trigger.cron_cursor_job_id ?? null;
break;
case 'kafka':
output.kafka_configuration = trigger.kafka_configuration;
Expand Down
6 changes: 6 additions & 0 deletions assets/js/collaborative-editor/types/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const baseTriggerSchema = z.object({
const webhookTriggerSchema = baseTriggerSchema.extend({
type: z.literal('webhook'),
cron_expression: z.null().default(null),
cron_cursor_job_id: z.null().default(null),
kafka_configuration: z.null().default(null),
webhook_reply: z
.enum(['before_start', 'after_completion'])
Expand Down Expand Up @@ -46,6 +47,7 @@ const cronTriggerSchema = baseTriggerSchema.extend({
'Invalid cron expression. Use format: minute hour day month weekday',
}
),
cron_cursor_job_id: z.string().uuid().nullable().default(null),
kafka_configuration: z.null().default(null),
webhook_reply: z.null().default(null).catch(null),
});
Expand Down Expand Up @@ -99,6 +101,7 @@ const kafkaConfigSchema = z
const kafkaTriggerSchema = baseTriggerSchema.extend({
type: z.literal('kafka'),
cron_expression: z.null().default(null),
cron_cursor_job_id: z.null().default(null),
kafka_configuration: kafkaConfigSchema,
webhook_reply: z.null().default(null).catch(null),
});
Expand Down Expand Up @@ -131,6 +134,7 @@ export const createDefaultTrigger = (
...base,
type: 'webhook' as const,
cron_expression: null,
cron_cursor_job_id: null,
kafka_configuration: null,
webhook_reply: 'before_start' as const,
};
Expand All @@ -140,6 +144,7 @@ export const createDefaultTrigger = (
...base,
type: 'cron' as const,
cron_expression: '0 0 * * *', // Daily at midnight default
cron_cursor_job_id: null,
kafka_configuration: null,
webhook_reply: null,
};
Expand All @@ -149,6 +154,7 @@ export const createDefaultTrigger = (
...base,
type: 'kafka' as const,
cron_expression: null,
cron_cursor_job_id: null,
kafka_configuration: {
hosts_string: '',
topics_string: '',
Expand Down
161 changes: 161 additions & 0 deletions assets/test/collaborative-editor/components/ManualRunPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1388,5 +1388,166 @@ describe('ManualRunPanel', () => {
expect(runButton).toBeDisabled();
});
});

test('shows cron banner when controlled selectedDataclip matches next_cron_run_dataclip_id', async () => {
// FullScreenIDE passes selectedDataclip as a controlled prop after it receives
// the onDataclipChange callback. This test verifies that when the parent passes
// the cron dataclip back via selectedDataclip, the banner is displayed.
vi.mocked(dataclipApi.searchDataclips).mockResolvedValue({
data: [mockDataclip],
next_cron_run_dataclip_id: 'dataclip-1',
can_edit_dataclip: true,
});

renderManualRunPanel({
workflow: mockWorkflow,
projectId: 'project-1',
workflowId: 'workflow-1',
jobId: 'job-1',
onClose: () => {},
renderMode: 'embedded',
selectedDataclip: mockDataclip,
});

await waitFor(() => {
expect(
screen.getByText('Default Next Input for Cron')
).toBeInTheDocument();
expect(
screen.getByText(/This workflow has a "cron" trigger/)
).toBeInTheDocument();
});
});

test('does not show cron banner when controlled selectedDataclip does not match next_cron_run_dataclip_id', async () => {
const otherDataclip: dataclipApi.Dataclip = {
...mockDataclip,
id: 'dataclip-other',
name: 'Other Dataclip',
};

vi.mocked(dataclipApi.searchDataclips).mockResolvedValue({
data: [mockDataclip, otherDataclip],
next_cron_run_dataclip_id: 'dataclip-1',
can_edit_dataclip: true,
});

renderManualRunPanel({
workflow: mockWorkflow,
projectId: 'project-1',
workflowId: 'workflow-1',
jobId: 'job-1',
onClose: () => {},
renderMode: 'embedded',
selectedDataclip: otherDataclip,
});

await waitFor(() => {
expect(screen.getByText('Other Dataclip')).toBeInTheDocument();
});

expect(
screen.queryByText('Default Next Input for Cron')
).not.toBeInTheDocument();
});
});

describe('cron auto-selection with disableAutoSelection', () => {
test('does not auto-select cron dataclip when disableAutoSelection is true', async () => {
// This simulates FullScreenIDE after the user manually unselects a dataclip:
// manuallyUnselectedDataclip=true → disableAutoSelection=true is passed down.
vi.mocked(dataclipApi.searchDataclips).mockResolvedValue({
data: [mockDataclip],
next_cron_run_dataclip_id: 'dataclip-1',
can_edit_dataclip: true,
});

renderManualRunPanel({
workflow: mockWorkflow,
projectId: 'project-1',
workflowId: 'workflow-1',
jobId: 'job-1',
onClose: () => {},
disableAutoSelection: true,
});

// Give time for the fetch to complete and any auto-selection to occur
await waitFor(() => {
expect(dataclipApi.searchDataclips).toHaveBeenCalledOnce();
});

// The cron banner must not appear — auto-selection was suppressed
expect(
screen.queryByText('Default Next Input for Cron')
).not.toBeInTheDocument();
});

test('auto-selects cron dataclip in embedded mode when disableAutoSelection is false', async () => {
// This simulates the initial FullScreenIDE load where the user has not yet
// manually unselected anything: manuallyUnselectedDataclip=false.
vi.mocked(dataclipApi.searchDataclips).mockResolvedValue({
data: [mockDataclip],
next_cron_run_dataclip_id: 'dataclip-1',
can_edit_dataclip: true,
});

const onDataclipChange = vi.fn();

renderManualRunPanel({
workflow: mockWorkflow,
projectId: 'project-1',
workflowId: 'workflow-1',
jobId: 'job-1',
onClose: () => {},
renderMode: 'embedded',
disableAutoSelection: false,
onDataclipChange,
});

// The component should auto-select and notify the parent via onDataclipChange
await waitFor(() => {
expect(onDataclipChange).toHaveBeenCalledWith(mockDataclip);
});
});

test('shows cron banner when user manually selects the cron dataclip', async () => {
const user = userEvent.setup();

vi.mocked(dataclipApi.searchDataclips).mockResolvedValue({
data: [mockDataclip],
next_cron_run_dataclip_id: 'dataclip-1',
can_edit_dataclip: true,
});

// disableAutoSelection=true prevents initial auto-select, but the user can
// still click the dataclip in the list and the banner should appear.
renderManualRunPanel({
workflow: mockWorkflow,
projectId: 'project-1',
workflowId: 'workflow-1',
jobId: 'job-1',
onClose: () => {},
disableAutoSelection: true,
});

// Switch to Existing tab and manually click the cron dataclip
await user.click(screen.getByText('Existing'));

await waitFor(() => {
expect(screen.getByText('Test Dataclip')).toBeInTheDocument();
});

await user.click(screen.getByText('Test Dataclip'));

// Banner must appear because the selected dataclip IS the cron dataclip
await waitFor(() => {
expect(
screen.getByText('Default Next Input for Cron')
).toBeInTheDocument();
expect(
screen.getByText(/This workflow has a "cron" trigger/)
).toBeInTheDocument();
});
});
});
});
Loading
Loading