From 50308b84f946c317ab177e8386b879d7d8e0f044 Mon Sep 17 00:00:00 2001 From: Tegan Churchill Date: Thu, 11 Jun 2026 11:09:59 -0700 Subject: [PATCH 1/2] Schedule view page, list, and action modals Replaces the schedule view page with card-based components (spec, advanced settings, workflow runs, input, search attributes), adds the trigger/backfill/pause modals behind a conditionally-mounted host so modal state resets on every open, and moves the list components under schedules-list/. The backfill modal interprets times in the schedule's timezone and validates that the end time follows the start time. Removes the superseded view components, legacy schedule types and i18n keys, and schedule-spec-label. Adds action-modal integration coverage and extends the e2e spec to a full create/verify/delete loop. Co-Authored-By: Claude Fable 5 --- .../backfill-schedule-modal.svelte | 184 +++++ .../pause-schedule-modal.svelte | 80 ++ .../schedule-action-modals.svelte | 43 ++ .../trigger-schedule-modal.svelte | 80 ++ .../schedule-advanced-settings.svelte | 93 --- .../components/schedule/schedule-error.svelte | 13 - .../schedule/schedule-frequency-panel.svelte | 46 -- .../components/schedule/schedule-notes.svelte | 10 - .../schedule/schedule-recent-runs.svelte | 94 --- .../schedule/schedule-upcoming-runs.svelte | 29 - .../advanced-settings-card.svelte | 141 ++++ .../custom-search-attributes-card.svelte} | 11 +- .../schedule-view/schedule-spec-card.svelte | 58 ++ .../schedule-view/schedule-view-error.svelte | 34 + .../schedule-view-loading.svelte | 25 + .../schedule-view/schedule-view.svelte | 233 ++++++ .../workflow-input-card.svelte} | 15 +- .../schedule-view/workflow-runs-card.svelte | 80 ++ .../schedule-view/workflow-runs-empty.svelte | 55 ++ .../schedule-view/workflow-runs-recent.svelte | 86 +++ .../workflow-runs-upcoming.svelte | 52 ++ .../schedule-frequency.svelte | 23 +- .../schedules-table-row.svelte | 0 .../utilities/get-form-schedule-defaults.ts | 2 +- src/lib/i18n/locales/en/schedules.ts | 24 - src/lib/pages/schedule-view.svelte | 622 +-------------- src/lib/pages/schedules.svelte | 2 +- src/lib/types/schedule.ts | 61 +- src/lib/utilities/schedule-spec-label.test.ts | 712 ------------------ src/lib/utilities/schedule-spec-label.ts | 287 ------- tests/e2e/schedules.spec.ts | 23 +- tests/integration/schedule-actions.spec.ts | 159 ++++ 32 files changed, 1381 insertions(+), 1996 deletions(-) create mode 100644 src/lib/components/schedule/schedule-action-modals/backfill-schedule-modal.svelte create mode 100644 src/lib/components/schedule/schedule-action-modals/pause-schedule-modal.svelte create mode 100644 src/lib/components/schedule/schedule-action-modals/schedule-action-modals.svelte create mode 100644 src/lib/components/schedule/schedule-action-modals/trigger-schedule-modal.svelte delete mode 100644 src/lib/components/schedule/schedule-advanced-settings.svelte delete mode 100644 src/lib/components/schedule/schedule-error.svelte delete mode 100644 src/lib/components/schedule/schedule-frequency-panel.svelte delete mode 100644 src/lib/components/schedule/schedule-notes.svelte delete mode 100644 src/lib/components/schedule/schedule-recent-runs.svelte delete mode 100644 src/lib/components/schedule/schedule-upcoming-runs.svelte create mode 100644 src/lib/components/schedule/schedule-view/advanced-settings-card.svelte rename src/lib/components/schedule/{schedule-search-attributes.svelte => schedule-view/custom-search-attributes-card.svelte} (82%) create mode 100644 src/lib/components/schedule/schedule-view/schedule-spec-card.svelte create mode 100644 src/lib/components/schedule/schedule-view/schedule-view-error.svelte create mode 100644 src/lib/components/schedule/schedule-view/schedule-view-loading.svelte create mode 100644 src/lib/components/schedule/schedule-view/schedule-view.svelte rename src/lib/components/schedule/{schedule-input.svelte => schedule-view/workflow-input-card.svelte} (58%) create mode 100644 src/lib/components/schedule/schedule-view/workflow-runs-card.svelte create mode 100644 src/lib/components/schedule/schedule-view/workflow-runs-empty.svelte create mode 100644 src/lib/components/schedule/schedule-view/workflow-runs-recent.svelte create mode 100644 src/lib/components/schedule/schedule-view/workflow-runs-upcoming.svelte rename src/lib/components/schedule/{ => schedules-list}/schedule-frequency.svelte (54%) rename src/lib/components/schedule/{ => schedules-list}/schedules-table-row.svelte (100%) delete mode 100644 src/lib/utilities/schedule-spec-label.test.ts delete mode 100644 src/lib/utilities/schedule-spec-label.ts create mode 100644 tests/integration/schedule-actions.spec.ts diff --git a/src/lib/components/schedule/schedule-action-modals/backfill-schedule-modal.svelte b/src/lib/components/schedule/schedule-action-modals/backfill-schedule-modal.svelte new file mode 100644 index 0000000000..fa1156be3e --- /dev/null +++ b/src/lib/components/schedule/schedule-action-modals/backfill-schedule-modal.svelte @@ -0,0 +1,184 @@ + + + + submitBackfillSchedule( + { + startTime: startTimestamp, + endTime: endTimestamp, + overlapPolicy: $selectedOverlapPolicy, + }, + { + identity, + scheduleId, + namespace, + }, + )} + on:cancelModal={() => { + closeConfirmationModal('backfill'); + }} +> +

+ {translate('schedules.schedule')} + {translate('schedules.backfill')} +

+
+
+ (startDate = startOfDay(d))} + selected={startDate} + todayLabel={translate('common.today')} + closeLabel={translate('common.close')} + clearLabel={translate('common.clear-input-button-label')} + /> + + (endDate = startOfDay(d))} + selected={endDate} + todayLabel={translate('common.today')} + closeLabel={translate('common.close')} + clearLabel={translate('common.clear-input-button-label')} + isAllowed={(d) => !isBefore(d, startDate)} + /> + + {#if invalidEndTime} + + {translate('schedules.backfill-end-before-start')} + + {/if} +
+
+
+ + + + + {#each Object.entries(overlapPolicyContent) as [value, content] (value)} + + {/each} + +
+
diff --git a/src/lib/components/schedule/schedule-action-modals/pause-schedule-modal.svelte b/src/lib/components/schedule/schedule-action-modals/pause-schedule-modal.svelte new file mode 100644 index 0000000000..5df41565e2 --- /dev/null +++ b/src/lib/components/schedule/schedule-action-modals/pause-schedule-modal.svelte @@ -0,0 +1,80 @@ + + + closeConfirmationModal('pause')} + on:confirmModal={() => + submitPauseSchedule(reason, { + identity, + scheduleId, + namespace, + isPaused: isSchedulePaused, + })} +> +

+ {isSchedulePaused + ? translate('schedules.unpause-modal-title') + : translate('schedules.pause-modal-title')} +

+
+

+ {isSchedulePaused + ? translate('schedules.unpause-modal-confirmation', { + schedule: scheduleId, + }) + : translate('schedules.pause-modal-confirmation', { + schedule: scheduleId, + })} +

+

+ {isSchedulePaused + ? translate('schedules.unpause-reason') + : translate('schedules.pause-reason')} +

+ +
+
diff --git a/src/lib/components/schedule/schedule-action-modals/schedule-action-modals.svelte b/src/lib/components/schedule/schedule-action-modals/schedule-action-modals.svelte new file mode 100644 index 0000000000..af33993105 --- /dev/null +++ b/src/lib/components/schedule/schedule-action-modals/schedule-action-modals.svelte @@ -0,0 +1,43 @@ + + +{#if $confirmationModal === 'pause'} + +{:else if $confirmationModal === 'trigger'} + +{:else if $confirmationModal === 'backfill'} + +{:else if $confirmationModal === 'delete'} + +{/if} diff --git a/src/lib/components/schedule/schedule-action-modals/trigger-schedule-modal.svelte b/src/lib/components/schedule/schedule-action-modals/trigger-schedule-modal.svelte new file mode 100644 index 0000000000..f891919259 --- /dev/null +++ b/src/lib/components/schedule/schedule-action-modals/trigger-schedule-modal.svelte @@ -0,0 +1,80 @@ + + + + submitTriggerImmediatelySchedule($selectedOverlapPolicy, { + identity, + scheduleId, + namespace, + })} + on:cancelModal={() => { + closeConfirmationModal('trigger'); + }} +> +

+ {translate('schedules.trigger-modal-title')} +

+
+ + {#each Object.entries(overlapPolicyContent) as [value, content] (value)} + + {/each} + +
+
diff --git a/src/lib/components/schedule/schedule-advanced-settings.svelte b/src/lib/components/schedule/schedule-advanced-settings.svelte deleted file mode 100644 index 195facaa51..0000000000 --- a/src/lib/components/schedule/schedule-advanced-settings.svelte +++ /dev/null @@ -1,93 +0,0 @@ - - - - -
    -
  • - {translate('schedules.start-time')} - {spec?.startTime ?? translate('common.none')} -
  • -
  • - {translate('schedules.end-time')} - {spec?.endTime ?? translate('common.none')} -
  • -
  • - {translate('schedules.jitter')} - {spec?.jitter ?? translate('common.none')} -
  • - -
  • - {translate('schedules.exclusion-calendar')} - {spec?.excludeCalendar?.[0] ?? translate('common.none')} -
  • - {#if state?.limitedActions} -
  • - {translate('schedules.remaining-actions')} - {state?.remainingActions ?? translate('common.none')} -
  • - {/if} -
  • - {translate('schedules.overlap-policy')} - {policies?.overlapPolicy ?? translate('common.none')} -
  • -
  • - {translate('schedules.catchup-window')} - {policies?.catchupWindow != null - ? formatDuration(policies.catchupWindow.toString()) - : translate('common.none')} - -
  • - {#if policies?.pauseOnFailure != null} -
  • - {translate('schedules.pause-on-failure')} - {policies.pauseOnFailure - ? translate('common.true') - : translate('common.false')} -
  • - {/if} - {#if policies?.keepOriginalWorkflowId != null} -
  • - {translate('schedules.keep-original-workflow-id')} - {policies.keepOriginalWorkflowId - ? translate('common.true') - : translate('common.false')} -
  • - {/if} -
-
- - diff --git a/src/lib/components/schedule/schedule-error.svelte b/src/lib/components/schedule/schedule-error.svelte deleted file mode 100644 index aac7d46a49..0000000000 --- a/src/lib/components/schedule/schedule-error.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - -

{translate('schedules.error-title')}

-

- {error} -

-
diff --git a/src/lib/components/schedule/schedule-frequency-panel.svelte b/src/lib/components/schedule/schedule-frequency-panel.svelte deleted file mode 100644 index 1ea37e7ef4..0000000000 --- a/src/lib/components/schedule/schedule-frequency-panel.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - -
-

- {hasCronString - ? translate('schedules.cron-string') - : translate('schedules.schedule-spec')} -

- -
- - {#if viewFullSpec} - - {/if} -
diff --git a/src/lib/components/schedule/schedule-notes.svelte b/src/lib/components/schedule/schedule-notes.svelte deleted file mode 100644 index a133f50bfd..0000000000 --- a/src/lib/components/schedule/schedule-notes.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -
-

{translate('common.notes')}

-

{notes}

-
diff --git a/src/lib/components/schedule/schedule-recent-runs.svelte b/src/lib/components/schedule/schedule-recent-runs.svelte deleted file mode 100644 index fb0fe471b5..0000000000 --- a/src/lib/components/schedule/schedule-recent-runs.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - -
-

{translate('schedules.recent-runs')}

- - {translate('common.view-all-runs')} - -
- {#each sortRecentRuns(recentRuns) as run, i (`${run?.startWorkflowResult?.workflowId ?? i}:${run?.startWorkflowResult?.runId ?? i + 1}`)} - {#await fetchWorkflowForSchedule({ namespace, workflowId: decodeURIForSvelte(run.startWorkflowResult.workflowId), runId: run.startWorkflowResult.runId }, fetch) then workflow} -
-
- -
-
- - {run.startWorkflowResult.workflowId} - -
-
- -
-
- {:catch} -
-
-
- {run.startWorkflowResult.workflowId} -
-
- -
-
- {/await} - {/each} - {#if !recentRuns.length} - - {/if} -
- - diff --git a/src/lib/components/schedule/schedule-upcoming-runs.svelte b/src/lib/components/schedule/schedule-upcoming-runs.svelte deleted file mode 100644 index ba55bf7729..0000000000 --- a/src/lib/components/schedule/schedule-upcoming-runs.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - -

{translate('schedules.upcoming-runs')}

- {#each futureRuns.slice(0, 5) as run} -
- -
- {:else} - - {/each} -
- - diff --git a/src/lib/components/schedule/schedule-view/advanced-settings-card.svelte b/src/lib/components/schedule/schedule-view/advanced-settings-card.svelte new file mode 100644 index 0000000000..f6c4571e88 --- /dev/null +++ b/src/lib/components/schedule/schedule-view/advanced-settings-card.svelte @@ -0,0 +1,141 @@ + + + +
+
+
{translate('common.start-date')}
+
+ {spec?.startTime + ? $timestamp(spec.startTime) + : translate('common.none')} +
+
+ +
+
{translate('common.end-date')}
+
+ {spec?.endTime ? $timestamp(spec.endTime) : translate('common.never')} +
+
+ +
+
{translate('common.timezone-label')}
+
+ {spec?.timezoneName || 'UTC'} +
+
+ +
+
{translate('schedules.jitter')}
+
+ {spec?.jitter && spec.jitter !== '0s' + ? String(spec.jitter) + : translate('common.none')} +
+
+ +
+
{translate('schedules.overlap-policy')}
+
+ {String( + fromScreamingEnum(policies?.overlapPolicy, 'ScheduleOverlapPolicy') ?? + translate('common.none'), + )} +
+
+ +
+
+ {translate('schedules.catchup-window-policy')} +
+
+ {policies?.catchupWindow != null + ? formatDuration(policies.catchupWindow.toString()) + : translate('common.none')} +
+
+ +
+
+ {translate('schedules.pause-on-failure')} +
+
+ {policies?.pauseOnFailure + ? translate('common.true') + : translate('common.false')} +
+
+ +
+
+ {translate('schedules.exclusion-calendar')} +
+
+ {#if spec?.excludeStructuredCalendar} +
    + {#each summarizeScheduleSpec( { structuredCalendar: spec.excludeStructuredCalendar, timezoneName: spec.timezoneName }, ) as summary (summary)} +
  • {summary}
  • + {:else} +
  • {translate('common.none')}
  • + {/each} +
+ {:else} + {translate('common.none')} + {/if} +
+
+ + {#if state.limitedActions} +
+
+ {translate('schedules.remaining-actions')} +
+
+ {state?.remainingActions?.toString() ?? translate('common.none')} +
+
+ {/if} + + {#if policies?.keepOriginalWorkflowId != null} +
+
+ {translate('schedules.keep-original-workflow-id')} +
+
+ {policies?.keepOriginalWorkflowId + ? translate('common.true') + : translate('common.false')} +
+
+ {/if} + +
+
+ {translate('common.notes')} +
+
+ {notes ?? translate('common.none')} +
+
+
+
diff --git a/src/lib/components/schedule/schedule-search-attributes.svelte b/src/lib/components/schedule/schedule-view/custom-search-attributes-card.svelte similarity index 82% rename from src/lib/components/schedule/schedule-search-attributes.svelte rename to src/lib/components/schedule/schedule-view/custom-search-attributes-card.svelte index 5a2b6ed6eb..a3ae3ae654 100644 --- a/src/lib/components/schedule/schedule-search-attributes.svelte +++ b/src/lib/components/schedule/schedule-view/custom-search-attributes-card.svelte @@ -1,12 +1,17 @@ + + +
+

+ {translate('schedules.schedule-specs')} +

+ +
+ +

+ {translate('schedules.schedule-specs-description')} +

+ +
    + {#each specs as spec (spec)} +
  • + {getScheduleSpecSummary(spec)} +
  • + {/each} +
+ + {#if isFullSpecVisible} + + {/if} +
diff --git a/src/lib/components/schedule/schedule-view/schedule-view-error.svelte b/src/lib/components/schedule/schedule-view/schedule-view-error.svelte new file mode 100644 index 0000000000..7516712eaa --- /dev/null +++ b/src/lib/components/schedule/schedule-view/schedule-view-error.svelte @@ -0,0 +1,34 @@ + + +
+
+ + {translate('schedules.back-to-schedules')} + +

+ {scheduleId} +

+

+ {namespace} +

+
+
+ +

{translate('schedules.error-title')}

+

+ {errorMessage} +

+
diff --git a/src/lib/components/schedule/schedule-view/schedule-view-loading.svelte b/src/lib/components/schedule/schedule-view/schedule-view-loading.svelte new file mode 100644 index 0000000000..c3e794dac8 --- /dev/null +++ b/src/lib/components/schedule/schedule-view/schedule-view-loading.svelte @@ -0,0 +1,25 @@ + + +
+
+ + {translate('schedules.back-to-schedules')} + +

+ {scheduleId} +

+
+
+ diff --git a/src/lib/components/schedule/schedule-view/schedule-view.svelte b/src/lib/components/schedule/schedule-view/schedule-view.svelte new file mode 100644 index 0000000000..0a2f07251e --- /dev/null +++ b/src/lib/components/schedule/schedule-view/schedule-view.svelte @@ -0,0 +1,233 @@ + + +
+ + {translate('schedules.back-to-schedules')} + +
+
+

+ + {scheduleId} +

+ +
+
+
Workflow Type
+
+ {schedule?.schedule?.action?.startWorkflow?.workflowType?.name} +
+ + + + +
+
+
+ +
+
Created
+
+ {$timestamp(schedule?.info?.createTime)} +
+
+ + {#if schedule?.info.updateTime} +
+
Last Updated
+
+ {$timestamp(schedule?.info?.updateTime)} +
+
+ {/if} +
+
+ + openConfirmationModal('pause')} + > + openConfirmationModal('trigger')} + > + {translate('schedules.trigger')} + + openConfirmationModal('backfill')} + > + {translate('schedules.backfill')} + + + {translate('common.edit')} + + openConfirmationModal('delete')} + > + {translate('common.delete')} + + +
+ +
+
Total Workflows
+
+ {$workflowCount.count.toLocaleString()} + + + +
+
+
+ +
+ {#if schedule?.info?.invalidScheduleError} +
+ +

{translate('schedules.error-title')}

+

+ {schedule?.info?.invalidScheduleError} +

+
+
+ {/if} +
+
+ openConfirmationModal('trigger')} + openBackfillConfirmationModal={() => openConfirmationModal('backfill')} + /> + + +
+ +
+ + +
+
+
+ diff --git a/src/lib/components/schedule/schedule-input.svelte b/src/lib/components/schedule/schedule-view/workflow-input-card.svelte similarity index 58% rename from src/lib/components/schedule/schedule-input.svelte rename to src/lib/components/schedule/schedule-view/workflow-input-card.svelte index 33729b176c..d74190c128 100644 --- a/src/lib/components/schedule/schedule-input.svelte +++ b/src/lib/components/schedule/schedule-view/workflow-input-card.svelte @@ -4,18 +4,23 @@ import { translate } from '$lib/i18n/translate'; import type { Payloads } from '$lib/types'; - let { input, scheduleId }: { input: Payloads; scheduleId: string } = $props(); + interface Props { + input: Payloads; + scheduleId: string; + } + + let { input, scheduleId }: Props = $props(); - + {#snippet titleSnippet()} -

- {translate('schedules.schedule-input')} +

+ {translate('schedules.workflow-input')}

{/snippet}
diff --git a/src/lib/components/schedule/schedule-view/workflow-runs-card.svelte b/src/lib/components/schedule/schedule-view/workflow-runs-card.svelte new file mode 100644 index 0000000000..9924506b78 --- /dev/null +++ b/src/lib/components/schedule/schedule-view/workflow-runs-card.svelte @@ -0,0 +1,80 @@ + + + +
+

{translate('schedules.workflow-runs')}

+ + {translate('common.view-all-runs')} + +
+ + + handleViewClick('recent')} + id="recent">Recent Runs + handleViewClick('upcoming')} + id="upcoming">Upcoming Runs + + + {#if view === 'recent'} + + {:else} + + {/if} +
diff --git a/src/lib/components/schedule/schedule-view/workflow-runs-empty.svelte b/src/lib/components/schedule/schedule-view/workflow-runs-empty.svelte new file mode 100644 index 0000000000..0299c9787d --- /dev/null +++ b/src/lib/components/schedule/schedule-view/workflow-runs-empty.svelte @@ -0,0 +1,55 @@ + + +
+ + {#if title} +

{title}

+ {/if} + {#if description} +

+ {description} +

+ {/if} + +
+ + +
+
diff --git a/src/lib/components/schedule/schedule-view/workflow-runs-recent.svelte b/src/lib/components/schedule/schedule-view/workflow-runs-recent.svelte new file mode 100644 index 0000000000..b34db92fb9 --- /dev/null +++ b/src/lib/components/schedule/schedule-view/workflow-runs-recent.svelte @@ -0,0 +1,86 @@ + + +{#if !sortedRecentRuns.length} + +{:else} +
    + {#each sortedRecentRuns as run, i (run?.startWorkflowResult?.runId ?? i)} +
  • +
    + +
    + +
    + + {run.startWorkflowResult.workflowId} + +
    + +

    + {$timestamp(run.actualTime)} +

    +
  • + {/each} +
+{/if} diff --git a/src/lib/components/schedule/schedule-view/workflow-runs-upcoming.svelte b/src/lib/components/schedule/schedule-view/workflow-runs-upcoming.svelte new file mode 100644 index 0000000000..ad1389d891 --- /dev/null +++ b/src/lib/components/schedule/schedule-view/workflow-runs-upcoming.svelte @@ -0,0 +1,52 @@ + + +{#if !sortedUpcomingRuns.length} + +{:else} +
    + {#each sortedUpcomingRuns as run (run)} +
  • + {$timestamp(run)} +
  • + {/each} +
+{/if} diff --git a/src/lib/components/schedule/schedule-frequency.svelte b/src/lib/components/schedule/schedules-list/schedule-frequency.svelte similarity index 54% rename from src/lib/components/schedule/schedule-frequency.svelte rename to src/lib/components/schedule/schedules-list/schedule-frequency.svelte index 4536f34a40..e0e3c5c5a0 100644 --- a/src/lib/components/schedule/schedule-frequency.svelte +++ b/src/lib/components/schedule/schedules-list/schedule-frequency.svelte @@ -2,7 +2,8 @@ import { type ClassNameValue, twMerge } from 'tailwind-merge'; import { translate } from '$lib/i18n/translate'; - import { getScheduleSpecLabel } from '$lib/utilities/schedule-spec-label'; + + import { summarizeScheduleSpec } from '../utilities/summarize'; import type { ScheduleSpec } from '$types'; @@ -14,23 +15,19 @@ let { spec, class: className = '' }: Props = $props(); const timezoneName = $derived(spec?.timezoneName ?? 'UTC'); - const cronString = $derived( - spec?.structuredCalendar?.length > 0 && - !!spec?.structuredCalendar[0].comment - ? spec?.structuredCalendar[0].comment - : '', - );
- {#if cronString} -

{cronString}

- {:else} -

{getScheduleSpecLabel(spec, timezoneName)}

- {/if} +
    + {#each summarizeScheduleSpec(spec) as summary (summary)} +
  • {summary}
  • + {:else} +
  • {translate('common.none')}
  • + {/each} +

- {@html translate('common.timezone', { timezone: timezoneName })} + {translate('common.timezone', { timezone: timezoneName })}

diff --git a/src/lib/components/schedule/schedules-table-row.svelte b/src/lib/components/schedule/schedules-list/schedules-table-row.svelte similarity index 100% rename from src/lib/components/schedule/schedules-table-row.svelte rename to src/lib/components/schedule/schedules-list/schedules-table-row.svelte diff --git a/src/lib/components/schedule/utilities/get-form-schedule-defaults.ts b/src/lib/components/schedule/utilities/get-form-schedule-defaults.ts index e5b21fb022..1ef6ffd830 100644 --- a/src/lib/components/schedule/utilities/get-form-schedule-defaults.ts +++ b/src/lib/components/schedule/utilities/get-form-schedule-defaults.ts @@ -144,7 +144,7 @@ export function getFormScheduleDefaults( String(spec?.startTime || new Date().toISOString()), spec?.timezoneName || 'UTC', ), - jitter: Number(parseDuration(String(spec?.jitter ?? '')) || 0), + jitter: Number(parseDuration(spec?.jitter ?? '') || 0), ...getEndCondition(describeFullSchedule), overlapPolicy: parseOverlapPolicy(policies?.overlapPolicy), diff --git a/src/lib/i18n/locales/en/schedules.ts b/src/lib/i18n/locales/en/schedules.ts index 51d8520e5e..294d2806d5 100644 --- a/src/lib/i18n/locales/en/schedules.ts +++ b/src/lib/i18n/locales/en/schedules.ts @@ -271,28 +271,4 @@ export const Strings = { 'add-another-spec': '+ Add another schedule spec', 'run-time-based-on-timezone': 'Based on the specified timezone ({{- timezoneName}})', - 'schedule-input': 'Schedule Input', - 'trigger-unspecified-title': 'Use Policy', - 'trigger-unspecified-description': "Use the Schedule's overlap policy.", - 'trigger-skip-title': 'Skip', - 'trigger-skip-description': - 'When the workflow completes, the next occurrence that is scheduled after that time is considered.', - 'trigger-buffer-one-title': 'Buffer One', - 'trigger-buffer-one-description': - 'Start the workflow again as soon as the current workflow completes, but buffer only one start. If another start is scheduled to happen while the workflow is running, and a workflow is already buffered, only the first workflow starts after the running workflow completes.', - 'trigger-buffer-all-title': 'Buffer All', - 'trigger-buffer-all-description': - 'Buffer any number of workflow starts to happen sequentially, beginning immediately after the running workflow completes.', - 'trigger-cancel-other-title': 'Cancel Other', - 'trigger-cancel-other-description': - 'If another workflow is running, cancel it. After the previous workflow completes cancellation, start the new workflow.', - 'trigger-terminate-other-title': 'Terminate Other', - 'trigger-terminate-other-description': - 'If another workflow is running, terminate it and start the new workflow immediately.', - 'trigger-allow-all-title': 'Allow All', - 'trigger-allow-all-description': - "Start any number of concurrent workflows. Last completion result and last failure aren't available because the workflows aren't sequential.", - 'catchup-window': 'Catchup Window', - 'crow-view-example-description': - 'Temporal Workflow Schedule Cron strings follow this format:', } as const; diff --git a/src/lib/pages/schedule-view.svelte b/src/lib/pages/schedule-view.svelte index 5fef2a453e..ea2516b967 100644 --- a/src/lib/pages/schedule-view.svelte +++ b/src/lib/pages/schedule-view.svelte @@ -1,619 +1,33 @@ -{#await scheduleFetch} -
-
- - {translate('schedules.back-to-schedules')} - -

- {scheduleId} -

-
-
- -{:then schedule} - {#if $loading} +{#await $currentScheduleFetch} + +{:then currentSchedule} + {#if $loading || !currentSchedule} {:else} -
-
- - {translate('schedules.back-to-schedules')} - -

- - {scheduleId} - -

-
- -

- {schedule?.schedule?.action?.startWorkflow?.workflowType?.name} -

-
- - {#snippet leading()} - Created - {/snippet} - - {#if schedule?.info?.updateTime} - - {#snippet leading()} - Last Updated - {/snippet} - - {/if} -
- (pauseConfirmationModalOpen = true)} - > - (triggerConfirmationModalOpen = true)} - > - {translate('schedules.trigger')} - - (backfillConfirmationModalOpen = true)} - > - {translate('schedules.backfill')} - - - {translate('common.edit')} - - (deleteConfirmationModalOpen = true)} - > - {translate('common.delete')} - - -
- -
- {#if schedule?.info?.invalidScheduleError} -
- -
- {/if} -
-
- {$workflowCount.count.toLocaleString()} - - - -
- -
-
-
- - - - -
-
- - -
-
-
- handlePause(schedule)} - on:cancelModal={resetReason} - > -

- {schedule?.schedule.state.paused - ? translate('schedules.unpause-modal-title') - : translate('schedules.pause-modal-title')} -

-
-

- {schedule?.schedule.state.paused - ? translate('schedules.unpause-modal-confirmation', { - schedule: scheduleId, - }) - : translate('schedules.pause-modal-confirmation', { - schedule: scheduleId, - })} -

-

- {schedule?.schedule.state.paused - ? translate('schedules.unpause-reason') - : translate('schedules.pause-reason')} -

- -
-
- handleTriggerImmediately()} - on:cancelModal={closeTriggerModal} - > -

- {translate('schedules.trigger-modal-title')} -

-
- - {#each policies as policy} - - {/each} - -
-
- handleBackfill()} - on:cancelModal={closeBackfillModal} - > -

- {translate('schedules.schedule')} - {translate('schedules.backfill')} -

-
-
- - - - -
-
-
- - - -
- - {#each policies.slice(0, viewMoreBackfillOptions ? policies.length : 3) as policy} - - {/each} - - {#if !viewMoreBackfillOptions} - - {/if} -
-
-
- -

{translate('schedules.delete-modal-title')}

-
-

- {translate('schedules.delete-modal-confirmation', { - schedule: scheduleId, - })} -

-
-
+ {/if} {:catch error} -
-
- - {translate('schedules.back-to-schedules')} - -

- {scheduleId} -

-

- {namespace} -

-
-
- + {/await} diff --git a/src/lib/pages/schedules.svelte b/src/lib/pages/schedules.svelte index 5042c09b8e..aa1f1cec72 100644 --- a/src/lib/pages/schedules.svelte +++ b/src/lib/pages/schedules.svelte @@ -4,7 +4,7 @@ import { page } from '$app/state'; import CountRefreshButton from '$lib/components/count-refresh-button.svelte'; - import SchedulesTableRow from '$lib/components/schedule/schedules-table-row.svelte'; + import SchedulesTableRow from '$lib/components/schedule/schedules-list/schedules-table-row.svelte'; import FilterBar from '$lib/components/search-attribute-filter/filter-bar.svelte'; import { timestamp } from '$lib/components/timestamp.svelte'; import ConfigurableTableHeadersDrawer from '$lib/components/workflow/configurable-table-headers-drawer/index.svelte'; diff --git a/src/lib/types/schedule.ts b/src/lib/types/schedule.ts index 914ae8e746..f01ad577b6 100644 --- a/src/lib/types/schedule.ts +++ b/src/lib/types/schedule.ts @@ -1,13 +1,9 @@ import type { temporal } from '@temporalio/proto'; -import type { PayloadInputEncoding } from '$lib/models/payload-encoding'; -import type { SearchAttributesSchema } from '$lib/stores/search-attributes'; import type { - CalendarSpec, DescribeScheduleResponse, Duration, IntervalSpec, - RangeSpec, Schedule, SchedulePolicies, ScheduleSpec, @@ -40,7 +36,7 @@ export type ScheduleResponse = Override< export type DescribeFullSchedule = DescribeScheduleResponse & { schedule_id: string; - schedule?: Schedule; + schedule?: ScheduleResponse; }; /** @@ -101,67 +97,12 @@ export type ScheduleRequestBody = { schedule: ScheduleRequest; }; -export type FullSchedule = Schedule; -export type FullScheduleSpec = ScheduleSpec; -export type FullCalendarSpec = CalendarSpec; -export type StructuredCalendars = StructuredCalendarSpec[]; export type StructuredCalendar = StructuredCalendarSpec; export type ScheduleInterval = IntervalSpec; -export type ScheduleRange = RangeSpec; - -export type SchedulePreset = - | 'existing' - | 'interval' - | 'week' - | 'month' - | 'string'; - -export type ScheduleOffsetUnit = 'days' | 'hrs' | 'min' | 'sec'; - -export type ScheduleActionParameters = { - identity?: string; - namespace: string; - name: string; - workflowType: string; - workflowId: string; - taskQueue: string; - input: string; - encoding: PayloadInputEncoding; - messageType?: string; - searchAttributes: SearchAttributesSchema; - workflowSearchAttributes?: SearchAttributesSchema; -}; - -export type ScheduleSpecParameters = { - dayOfWeek: string; - dayOfMonth: string; - month: string; - hour: string; - minute: string; - second: string; - phase: string; - cronString: string; - searchAttributes: SearchAttributesSchema; - workflowSearchAttributes?: SearchAttributesSchema; -}; - -// For UI Only -export type SchedulePresetsParameters = { - preset: SchedulePreset; - days: string; - daysOfWeek: string[]; - daysOfMonth: number[]; - months: string[]; -}; - -export type ScheduleParameters = ScheduleActionParameters & - ScheduleSpecParameters & - SchedulePresetsParameters; export type ScheduleStatus = 'Paused' | 'Running'; export type OverlapPolicy = - | 'Unspecified' | 'Skip' | 'BufferOne' | 'BufferAll' diff --git a/src/lib/utilities/schedule-spec-label.test.ts b/src/lib/utilities/schedule-spec-label.test.ts deleted file mode 100644 index fd986762b5..0000000000 --- a/src/lib/utilities/schedule-spec-label.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - getCalendarSpecLabel, - getIntervalLabel, - getScheduleSpecLabel, -} from './schedule-spec-label'; - -describe('getIntervalLabel', () => { - it('should return empty string for zero interval', () => { - expect(getIntervalLabel({ interval: { seconds: '0', nanos: 0 } })).toBe(''); - }); - - it('should return empty string for missing interval', () => { - expect(getIntervalLabel({})).toBe(''); - }); - - it('should handle every 5 minutes', () => { - expect(getIntervalLabel({ interval: { seconds: '300', nanos: 0 } })).toBe( - 'Every 5 minutes', - ); - }); - - it('should handle every 10 minutes', () => { - expect(getIntervalLabel({ interval: { seconds: '600', nanos: 0 } })).toBe( - 'Every 10 minutes', - ); - }); - - it('should handle every 30 minutes', () => { - expect(getIntervalLabel({ interval: { seconds: '1800', nanos: 0 } })).toBe( - 'Every 30 minutes', - ); - }); - - it('should handle every hour', () => { - expect(getIntervalLabel({ interval: { seconds: '3600', nanos: 0 } })).toBe( - 'Every 1 hour', - ); - }); - - it('should handle every 5 hours', () => { - expect(getIntervalLabel({ interval: { seconds: '18000', nanos: 0 } })).toBe( - 'Every 5 hours', - ); - }); - - it('should handle every 12 hours', () => { - expect(getIntervalLabel({ interval: { seconds: '43200', nanos: 0 } })).toBe( - 'Every 12 hours', - ); - }); - - it('should handle every day', () => { - expect(getIntervalLabel({ interval: { seconds: '86400', nanos: 0 } })).toBe( - 'Every 1 day', - ); - }); - - it('should handle every 2 days', () => { - expect( - getIntervalLabel({ interval: { seconds: '172800', nanos: 0 } }), - ).toBe('Every 2 days'); - }); - - it('should handle phase offset', () => { - expect( - getIntervalLabel({ - interval: { seconds: '3600', nanos: 0 }, - phase: { seconds: '1800', nanos: 0 }, - }), - ).toBe('Every 1 hour, offset by 30 minutes'); - }); - - it('should handle string format interval', () => { - expect(getIntervalLabel({ interval: '18000s', phase: '0s' })).toBe( - 'Every 5 hours', - ); - }); - - it('should handle string format with phase', () => { - expect(getIntervalLabel({ interval: '3600s', phase: '900s' })).toBe( - 'Every 1 hour, offset by 15 minutes', - ); - }); - - it('should handle numeric seconds field', () => { - expect(getIntervalLabel({ interval: { seconds: 300, nanos: 0 } })).toBe( - 'Every 5 minutes', - ); - }); - - it('should handle non-standard durations in seconds', () => { - expect(getIntervalLabel({ interval: { seconds: '45', nanos: 0 } })).toBe( - 'Every 45 seconds', - ); - }); - - it('should handle large interval of 7 days', () => { - expect( - getIntervalLabel({ interval: { seconds: '604800', nanos: 0 } }), - ).toBe('Every 7 days'); - }); - - it('should handle large interval of 30 days', () => { - expect( - getIntervalLabel({ interval: { seconds: '2592000', nanos: 0 } }), - ).toBe('Every 30 days'); - }); - - it('should handle mixed duration of days and hours', () => { - expect(getIntervalLabel({ interval: { seconds: '90000', nanos: 0 } })).toBe( - 'Every 1 day, 1 hour', - ); - }); - - it('should handle mixed duration of hours and minutes', () => { - expect(getIntervalLabel({ interval: { seconds: '5450', nanos: 0 } })).toBe( - 'Every 1 hour, 30 minutes, 50 seconds', - ); - }); -}); - -describe('getCalendarSpecLabel', () => { - it('should return empty string for empty specs', () => { - expect(getCalendarSpecLabel([])).toBe(''); - }); - - it('should return cron comment when present', () => { - expect(getCalendarSpecLabel([{ comment: '0 30 8 * * 1-5' }])).toBe( - '0 30 8 * * 1-5', - ); - }); - - it('should handle every day at specific time', () => { - expect( - getCalendarSpecLabel([ - { - second: [{ start: 0, end: 0, step: 1 }], - minute: [{ start: 30, end: 30, step: 1 }], - hour: [{ start: 8, end: 8, step: 1 }], - dayOfMonth: [{ start: 1, end: 31, step: 1 }], - month: [{ start: 1, end: 12, step: 1 }], - dayOfWeek: [{ start: 0, end: 6, step: 1 }], - }, - ]), - ).toBe('Every day at 8:30 AM UTC'); - }); - - it('should handle every weekday at specific time', () => { - expect( - getCalendarSpecLabel([ - { - second: [{ start: 0, end: 0, step: 1 }], - minute: [{ start: 30, end: 30, step: 1 }], - hour: [{ start: 8, end: 8, step: 1 }], - dayOfMonth: [{ start: 1, end: 31, step: 1 }], - month: [{ start: 1, end: 12, step: 1 }], - dayOfWeek: [ - { start: 1, end: 1, step: 1 }, - { start: 2, end: 2, step: 1 }, - { start: 3, end: 3, step: 1 }, - { start: 4, end: 4, step: 1 }, - { start: 5, end: 5, step: 1 }, - ], - }, - ]), - ).toBe('Every weekday at 8:30 AM UTC'); - }); - - it('should handle weekdays with range shorthand', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - ]), - ).toBe('Every weekday at 9:00 AM UTC'); - }); - - it('should handle every weekend at specific time', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 10 }], - dayOfWeek: [{ start: 0 }, { start: 6 }], - }, - ]), - ).toBe('Every weekend at 10:00 AM UTC'); - }); - - it('should handle specific days of the week', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 14 }], - dayOfWeek: [{ start: 1 }, { start: 3 }, { start: 5 }], - }, - ]), - ).toBe('Every Monday, Wednesday, Friday at 2:00 PM UTC'); - }); - - it('should handle monthly on specific day', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 8 }], - dayOfMonth: [{ start: 15 }], - }, - ]), - ).toBe('Monthly on the 15th at 8:00 AM UTC'); - }); - - it('should handle monthly on the 1st', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 1 }], - }, - ]), - ).toBe('Monthly on the 1st at 12:00 AM UTC'); - }); - - it('should handle every unique end of month for a year', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 1, - end: 1, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 28 }], - month: [ - { - start: 2, - end: 2, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 3, - end: 3, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 30 }], - month: [ - { - start: 4, - end: 4, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 5, - end: 5, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 30 }], - month: [ - { - start: 6, - end: 6, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 7, - end: 7, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 8, - end: 8, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 30 }], - month: [ - { - start: 9, - end: 9, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 10, - end: 10, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 30 }], - month: [ - { - start: 11, - end: 11, - step: 1, - }, - ], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - dayOfMonth: [{ start: 31 }], - month: [ - { - start: 12, - end: 12, - step: 1, - }, - ], - }, - ]), - ).toBe( - 'Annually on January 31st at 12:00 AM UTC; Annually on February 28th at 12:00 AM UTC; Annually on March 31st at 12:00 AM UTC; Annually on April 30th at 12:00 AM UTC; Annually on May 31st at 12:00 AM UTC; Annually on June 30th at 12:00 AM UTC; Annually on July 31st at 12:00 AM UTC; Annually on August 31st at 12:00 AM UTC; Annually on September 30th at 12:00 AM UTC; Annually on October 31st at 12:00 AM UTC; Annually on November 30th at 12:00 AM UTC; Annually on December 31st at 12:00 AM UTC', - ); - }); - - it('should handle annual schedule', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 25 }], - month: [{ start: 12 }], - }, - ]), - ).toBe('Annually on December 25th at 9:00 AM UTC'); - }); - - it('should handle multiple times per day', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }, { start: 17 }], - }, - ]), - ).toBe('Every day at 9:00 AM and 5:00 PM UTC'); - }); - - it('should handle midnight', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 0 }], - }, - ]), - ).toBe('Every day at 12:00 AM UTC'); - }); - - it('should handle noon', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 12 }], - }, - ]), - ).toBe('Every day at 12:00 PM UTC'); - }); - - it('should handle custom timezone', () => { - expect( - getCalendarSpecLabel( - [ - { - minute: [{ start: 30 }], - hour: [{ start: 8 }], - }, - ], - 'America/New_York', - ), - ).toBe('Every day at 8:30 AM America/New_York'); - }); - - it('should handle multiple calendar specs', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 12 }], - dayOfWeek: [{ start: 0 }, { start: 6 }], - }, - ]), - ).toBe('Every weekday at 9:00 AM UTC; Every weekend at 12:00 PM UTC'); - }); - - it('should handle three times per day', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 8 }, { start: 12 }, { start: 18 }], - }, - ]), - ).toBe('Every day at 8:00 AM, 12:00 PM, and 6:00 PM UTC'); - }); - - it('should produce fallback for complex patterns', () => { - const label = getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }, { start: 15 }], - month: [{ start: 3 }, { start: 6 }, { start: 9 }], - dayOfWeek: [{ start: 1 }], - }, - ]); - expect(label).toContain('March'); - expect(label).toContain('Monday'); - }); - - it('should handle weekday mornings and weekend afternoons as separate specs', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 7 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 14 }], - dayOfWeek: [{ start: 0 }, { start: 6 }], - }, - ]), - ).toBe('Every weekday at 7:00 AM UTC; Every weekend at 2:00 PM UTC'); - }); - - it('should handle quarterly schedule on the 1st', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }], - month: [{ start: 1 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }], - month: [{ start: 4 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }], - month: [{ start: 7 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }], - month: [{ start: 10 }], - }, - ]), - ).toBe( - 'Annually on January 1st at 9:00 AM UTC; Annually on April 1st at 9:00 AM UTC; Annually on July 1st at 9:00 AM UTC; Annually on October 1st at 9:00 AM UTC', - ); - }); - - it('should handle every other day via step', () => { - const label = getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 6 }], - dayOfWeek: [{ start: 0, end: 6, step: 2 }], - }, - ]); - expect(label).toBe( - 'Every Sunday, Tuesday, Thursday, Saturday at 6:00 AM UTC', - ); - }); - - it('should handle single day Sunday', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 8 }], - dayOfWeek: [{ start: 0 }], - }, - ]), - ).toBe('Every Sunday at 8:00 AM UTC'); - }); - - it('should handle four times per day on weekdays', () => { - expect( - getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 6 }, { start: 10 }, { start: 14 }, { start: 18 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - ]), - ).toBe('Every weekday at 6:00 AM, 10:00 AM, 2:00 PM, and 6:00 PM UTC'); - }); - - it('should handle bi-monthly on the 15th', () => { - const label = getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 12 }], - dayOfMonth: [{ start: 15 }], - month: [{ start: 1, end: 12, step: 2 }], - }, - ]); - expect(label).toContain('January'); - expect(label).toContain('15th'); - expect(label).toContain('12:00 PM'); - }); - - it('should handle multiple days of month', () => { - const label = getCalendarSpecLabel([ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfMonth: [{ start: 1 }, { start: 15 }], - }, - ]); - expect(label).toContain('1st'); - expect(label).toContain('15th'); - }); -}); - -describe('getScheduleSpecLabel', () => { - it('should return empty string for empty spec', () => { - expect(getScheduleSpecLabel({})).toBe(''); - }); - - it('should dispatch to calendar spec', () => { - expect( - getScheduleSpecLabel({ - structuredCalendar: [ - { - minute: [{ start: 30 }], - hour: [{ start: 8 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - ], - }), - ).toBe('Every weekday at 8:30 AM UTC'); - }); - - it('should dispatch to interval spec', () => { - expect( - getScheduleSpecLabel({ - interval: [{ interval: { seconds: '18000', nanos: 0 } }], - }), - ).toBe('Every 5 hours'); - }); - - it('should handle both calendar and interval', () => { - expect( - getScheduleSpecLabel({ - structuredCalendar: [ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - }, - ], - interval: [{ interval: { seconds: '3600', nanos: 0 } }], - }), - ).toBe('Every day at 9:00 AM UTC; Every 1 hour'); - }); - - it('should handle multiple intervals', () => { - expect( - getScheduleSpecLabel({ - interval: [ - { interval: { seconds: '3600', nanos: 0 } }, - { interval: { seconds: '300', nanos: 0 } }, - ], - }), - ).toBe('Every 1 hour; Every 5 minutes'); - }); - - it('should pass timezone to calendar spec', () => { - expect( - getScheduleSpecLabel( - { - structuredCalendar: [ - { - minute: [{ start: 0 }], - hour: [{ start: 17 }], - }, - ], - }, - 'Europe/London', - ), - ).toBe('Every day at 5:00 PM Europe/London'); - }); - - it('should handle calendar specs with intervals combined', () => { - expect( - getScheduleSpecLabel({ - structuredCalendar: [ - { - minute: [{ start: 0 }], - hour: [{ start: 9 }], - dayOfWeek: [{ start: 1, end: 5, step: 1 }], - }, - { - minute: [{ start: 0 }], - hour: [{ start: 12 }], - dayOfWeek: [{ start: 0 }, { start: 6 }], - }, - ], - interval: [{ interval: { seconds: '1800', nanos: 0 } }], - }), - ).toBe( - 'Every weekday at 9:00 AM UTC; Every weekend at 12:00 PM UTC; Every 30 minutes', - ); - }); - - it('should handle empty calendar with intervals', () => { - expect( - getScheduleSpecLabel({ - structuredCalendar: [], - interval: [{ interval: { seconds: '7200', nanos: 0 } }], - }), - ).toBe('Every 2 hours'); - }); - - it('should handle calendar with empty intervals', () => { - expect( - getScheduleSpecLabel({ - structuredCalendar: [ - { - minute: [{ start: 30 }], - hour: [{ start: 14 }], - }, - ], - interval: [], - }), - ).toBe('Every day at 2:30 PM UTC'); - }); -}); diff --git a/src/lib/utilities/schedule-spec-label.ts b/src/lib/utilities/schedule-spec-label.ts deleted file mode 100644 index b4e064f598..0000000000 --- a/src/lib/utilities/schedule-spec-label.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { - IntervalSpec, - RangeSpec, - StructuredCalendarSpec, -} from '$lib/types'; - -import { formatDuration } from './format-time'; - -function toSecondsString(d: unknown): string { - if (d == null) return ''; - if (typeof d === 'string') return d.endsWith('s') ? d : ''; - if (typeof d === 'object' && 'seconds' in d) { - const obj = d as { seconds: string | number }; - return `${obj.seconds}s`; - } - return ''; -} - -const DAY_NAMES = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; - -const MONTH_NAMES = [ - '', - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - -function expandRange(range: RangeSpec): number[] { - const start = range.start ?? 0; - const end = range.end ?? start; - const step = range.step && range.step > 0 ? range.step : 1; - - if (end <= start) return [start]; - - const values: number[] = []; - for (let i = start; i <= end; i += step) { - values.push(i); - } - return values; -} - -function expandRanges(ranges: RangeSpec[] | undefined | null): number[] { - if (!ranges || ranges.length === 0) return []; - const values = new Set(); - for (const range of ranges) { - for (const v of expandRange(range)) { - values.add(v); - } - } - return [...values].sort((a, b) => a - b); -} - -function isFullRange(values: number[], min: number, max: number): boolean { - if (values.length === 0) return true; - if (values.length !== max - min + 1) return false; - return values.every((v, i) => v === min + i); -} - -function isWeekdays(dayOfWeek: RangeSpec[] | undefined | null): boolean { - const values = expandRanges(dayOfWeek); - return ( - values.length === 5 && - values[0] === 1 && - values[1] === 2 && - values[2] === 3 && - values[3] === 4 && - values[4] === 5 - ); -} - -function isWeekends(dayOfWeek: RangeSpec[] | undefined | null): boolean { - const values = expandRanges(dayOfWeek); - return ( - (values.length === 2 && values[0] === 0 && values[1] === 6) || - (values.length === 2 && values[0] === 6 && values[1] === 0) - ); -} - -function isAllDays(dayOfWeek: RangeSpec[] | undefined | null): boolean { - if (!dayOfWeek || dayOfWeek.length === 0) return true; - const values = expandRanges(dayOfWeek); - return isFullRange(values, 0, 6); -} - -function isAllMonths(month: RangeSpec[] | undefined | null): boolean { - if (!month || month.length === 0) return true; - const values = expandRanges(month); - return isFullRange(values, 1, 12); -} - -function isAllDaysOfMonth(dayOfMonth: RangeSpec[] | undefined | null): boolean { - if (!dayOfMonth || dayOfMonth.length === 0) return true; - const values = expandRanges(dayOfMonth); - return isFullRange(values, 1, 31); -} - -function formatHour12(hour: number): { hour: number; period: string } { - if (hour === 0) return { hour: 12, period: 'AM' }; - if (hour < 12) return { hour, period: 'AM' }; - if (hour === 12) return { hour: 12, period: 'PM' }; - return { hour: hour - 12, period: 'PM' }; -} - -function formatSingleTime(hour: number, minute: number): string { - const { hour: h, period } = formatHour12(hour); - const min = minute.toString().padStart(2, '0'); - return `${h}:${min} ${period}`; -} - -function formatTime( - hour: RangeSpec[] | undefined | null, - minute: RangeSpec[] | undefined | null, -): string { - const hours = expandRanges(hour); - const minutes = expandRanges(minute); - const min = minutes.length === 1 ? minutes[0] : 0; - - if (hours.length === 0) return ''; - - if (hours.length === 1) { - return formatSingleTime(hours[0], min); - } - - if (hours.length === 2) { - return `${formatSingleTime(hours[0], min)} and ${formatSingleTime(hours[1], min)}`; - } - - const times = hours.map((h) => formatSingleTime(h, min)); - return `${times.slice(0, -1).join(', ')}, and ${times[times.length - 1]}`; -} - -function getDayNames(dayOfWeek: RangeSpec[] | undefined | null): string { - const values = expandRanges(dayOfWeek); - return values.map((v) => DAY_NAMES[v] ?? `Day ${v}`).join(', '); -} - -function ordinal(n: number): string { - const s = ['th', 'st', 'nd', 'rd']; - const v = n % 100; - return n + (s[(v - 20) % 10] || s[v] || s[0]); -} - -function getSingleCalendarLabel( - spec: StructuredCalendarSpec, - timezone: string, -): string { - const time = formatTime(spec.hour, spec.minute); - const timeWithTz = time ? `at ${time} ${timezone}` : ''; - const allDays = isAllDays(spec.dayOfWeek); - const allMonths = isAllMonths(spec.month); - const allDaysOfMonth = isAllDaysOfMonth(spec.dayOfMonth); - - if (isWeekdays(spec.dayOfWeek) && allMonths && allDaysOfMonth) { - return `Every weekday ${timeWithTz}`.trim(); - } - - if (isWeekends(spec.dayOfWeek) && allMonths && allDaysOfMonth) { - return `Every weekend ${timeWithTz}`.trim(); - } - - if (allDays && allMonths && allDaysOfMonth && time) { - return `Every day ${timeWithTz}`.trim(); - } - - const dayOfMonthValues = expandRanges(spec.dayOfMonth); - const monthValues = expandRanges(spec.month); - - if ( - !allMonths && - monthValues.length === 1 && - !allDaysOfMonth && - dayOfMonthValues.length === 1 - ) { - const monthName = MONTH_NAMES[monthValues[0]] ?? `Month ${monthValues[0]}`; - const day = ordinal(dayOfMonthValues[0]); - return `Annually on ${monthName} ${day} ${timeWithTz}`.trim(); - } - - if ( - allMonths && - !allDaysOfMonth && - dayOfMonthValues.length === 1 && - allDays - ) { - const day = ordinal(dayOfMonthValues[0]); - return `Monthly on the ${day} ${timeWithTz}`.trim(); - } - - if (!allDays && allMonths && allDaysOfMonth) { - const dayNames = getDayNames(spec.dayOfWeek); - return `Every ${dayNames} ${timeWithTz}`.trim(); - } - - if (allDays && allMonths && allDaysOfMonth && !time) { - return 'Every second'; - } - - const parts: string[] = []; - if (!allMonths) { - parts.push( - `In ${monthValues.map((m) => MONTH_NAMES[m] ?? `Month ${m}`).join(', ')}`, - ); - } - if (!allDaysOfMonth) { - parts.push(`on the ${dayOfMonthValues.map((d) => ordinal(d)).join(', ')}`); - } - if (!allDays) { - parts.push(`on ${getDayNames(spec.dayOfWeek)}`); - } - if (time) { - parts.push(`at ${time} ${timezone}`); - } - - return parts.join(' ').trim() || 'Custom schedule'; -} - -export function getIntervalLabel(spec: IntervalSpec): string { - const intervalStr = toSecondsString(spec.interval); - const label = formatDuration(intervalStr); - if (!label) return ''; - - const result = `Every ${label}`; - - const phaseStr = toSecondsString(spec.phase); - const phaseLabel = formatDuration(phaseStr); - if (phaseLabel) return `${result}, offset by ${phaseLabel}`; - - return result; -} - -export function getCalendarSpecLabel( - specs: StructuredCalendarSpec[], - timezone = 'UTC', -): string { - if (!specs || specs.length === 0) return ''; - - if (specs[0].comment) { - return specs[0].comment; - } - - if (specs.length === 1) { - return getSingleCalendarLabel(specs[0], timezone); - } - - return specs.map((spec) => getSingleCalendarLabel(spec, timezone)).join('; '); -} - -export function getScheduleSpecLabel( - spec: { - structuredCalendar?: StructuredCalendarSpec[]; - interval?: IntervalSpec[]; - }, - timezone = 'UTC', -): string { - const parts: string[] = []; - - if (spec.structuredCalendar && spec.structuredCalendar.length > 0) { - const label = getCalendarSpecLabel(spec.structuredCalendar, timezone); - if (label) parts.push(label); - } - - if (spec.interval && spec.interval.length > 0) { - for (const interval of spec.interval) { - const label = getIntervalLabel(interval); - if (label) parts.push(label); - } - } - - return parts.join('; '); -} diff --git a/tests/e2e/schedules.spec.ts b/tests/e2e/schedules.spec.ts index 14a203967b..1a5bb66714 100644 --- a/tests/e2e/schedules.spec.ts +++ b/tests/e2e/schedules.spec.ts @@ -35,10 +35,31 @@ test.describe('Schedules Page', () => { .locator('#schedule-payload-input') .getByRole('textbox') .fill('abc'); - await page.getByRole('textbox', { name: 'hrs' }).fill('1'); + await page.getByTestId('spec-type-0-button').click(); + await page.getByRole('option', { name: 'Interval' }).click(); + await page.getByLabel('Time Interval').fill('90'); const createSchedule = page.getByRole('button', { name: 'Create Schedule', }); await expect(createSchedule).toBeEnabled(); + await createSchedule.click(); + + await expect(page).toHaveURL(/schedules$/); + const scheduleLink = page.getByRole('link', { name: /e2e-schedule-1/ }); + await expect(scheduleLink).toBeVisible(); + await scheduleLink.click(); + + await expect(page.getByTestId('schedule-name')).toHaveText( + 'e2e-schedule-1', + ); + await expect(page.getByText('Every 90 minute(s)').first()).toBeVisible(); + + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('delete-schedule').click(); + await page + .locator('#delete-schedule-modal') + .getByTestId('confirm-modal-button') + .click(); + await expect(page).toHaveURL(/schedules$/); }); }); diff --git a/tests/integration/schedule-actions.spec.ts b/tests/integration/schedule-actions.spec.ts new file mode 100644 index 0000000000..709086056b --- /dev/null +++ b/tests/integration/schedule-actions.spec.ts @@ -0,0 +1,159 @@ +import { expect, type Request, test } from '@playwright/test'; + +import { + mockSchedule, + mockScheduleApi, + mockSchedulesApis, +} from '~/test-utilities/mock-apis'; + +const scheduleUrl = '/namespaces/default/schedules/test-schedule'; + +const isPatchRequest = (request: Request) => + request.method() === 'POST' && + request.url().includes('/schedules/test-schedule/patch'); + +test.describe('Schedule actions', () => { + test.beforeEach(async ({ page }) => { + await mockSchedulesApis(page); + await mockScheduleApi(page); + await page.goto(scheduleUrl); + await expect(page.getByTestId('schedule-name')).toContainText( + 'test-schedule', + ); + }); + + test('trigger pre-selects Allow All and sends it', async ({ page }) => { + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('trigger-schedule').click(); + + const modal = page.locator('#trigger-schedule-modal'); + await expect(modal).toBeVisible(); + await expect(modal.locator('#overlap-policy-AllowAll')).toBeChecked(); + + const patchRequest = page.waitForRequest(isPatchRequest); + await modal.getByTestId('confirm-modal-button').click(); + const body = (await patchRequest).postDataJSON(); + + expect(body.patch.triggerImmediately.overlapPolicy).toBe('AllowAll'); + }); + + test('trigger modal resets its overlap policy when reopened', async ({ + page, + }) => { + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('trigger-schedule').click(); + + const modal = page.locator('#trigger-schedule-modal'); + await expect(modal).toBeVisible(); + await modal.locator('#overlap-policy-Skip').check(); + await expect(modal.locator('#overlap-policy-Skip')).toBeChecked(); + + await modal.getByTestId('cancel-modal-button').click(); + await expect(modal).toBeHidden(); + + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('trigger-schedule').click(); + await expect(modal).toBeVisible(); + await expect(modal.locator('#overlap-policy-AllowAll')).toBeChecked(); + }); + + test('action modals stay usable after a successful backfill', async ({ + page, + }) => { + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('backfill-schedule').click(); + + const backfillModal = page.locator('#backfill-schedule-modal'); + await expect(backfillModal).toBeVisible(); + + const patchRequest = page.waitForRequest(isPatchRequest); + await backfillModal.getByTestId('confirm-modal-button').click(); + const body = (await patchRequest).postDataJSON(); + + expect(body.patch.backfillRequest).toHaveLength(1); + expect(body.patch.backfillRequest[0].startTime).toBeTruthy(); + expect(body.patch.backfillRequest[0].endTime).toBeTruthy(); + + await expect(backfillModal).toBeHidden(); + + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('trigger-schedule').click(); + + const triggerModal = page.locator('#trigger-schedule-modal'); + await expect(triggerModal).toBeVisible(); + await expect( + triggerModal.getByTestId('confirm-modal-button'), + ).toBeEnabled(); + }); + + test('backfill modal shows times based on the schedule timezone', async ({ + page, + }) => { + await mockScheduleApi(page, { + ...mockSchedule, + schedule: { + ...mockSchedule.schedule, + spec: { ...mockSchedule.schedule.spec, timezoneName: 'Asia/Tokyo' }, + }, + }); + await page.goto(scheduleUrl); + await expect(page.getByTestId('schedule-name')).toContainText( + 'test-schedule', + ); + + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('backfill-schedule').click(); + + const modal = page.locator('#backfill-schedule-modal'); + await expect(modal).toBeVisible(); + await expect(modal.getByText('Based on Asia/Tokyo')).toBeVisible(); + }); + + test('backfill rejects an end time before the start time', async ({ + page, + }) => { + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('backfill-schedule').click(); + + const modal = page.locator('#backfill-schedule-modal'); + await expect(modal).toBeVisible(); + await expect( + modal.getByText('Based on Universal Standard Time (UTC)'), + ).toBeVisible(); + + const popup = modal.locator('.z-30'); + await modal.locator('#backfill-start-date').click(); + await popup.getByRole('button', { name: 'Next Month' }).click(); + await popup.getByRole('button', { name: '15', exact: true }).click(); + + await expect( + modal.getByText('End time must be after the start time.'), + ).toBeVisible(); + await expect(modal.getByTestId('confirm-modal-button')).toBeDisabled(); + + await modal.locator('#backfill-end-date').click(); + await popup.getByRole('button', { name: 'Next Month' }).click(); + await popup.getByRole('button', { name: '16', exact: true }).click(); + + await expect( + modal.getByText('End time must be after the start time.'), + ).toBeHidden(); + await expect(modal.getByTestId('confirm-modal-button')).toBeEnabled(); + }); + + test('delete confirms and issues a delete request', async ({ page }) => { + await page.getByLabel('Schedule Actions').click(); + await page.getByTestId('delete-schedule').click(); + + const modal = page.locator('#delete-schedule-modal'); + await expect(modal).toBeVisible(); + + const deleteRequest = page.waitForRequest( + (request) => + request.method() === 'DELETE' && + request.url().includes('/schedules/test-schedule'), + ); + await modal.getByTestId('confirm-modal-button').click(); + await deleteRequest; + }); +}); From 889107d3d3aace172b63489158aea2d5926ae89a Mon Sep 17 00:00:00 2001 From: Tegan Churchill Date: Thu, 11 Jun 2026 11:26:21 -0700 Subject: [PATCH 2/2] Fix schedules e2e test for redesigned form The payload input now validates JSON for json/plain encoding, so submit a JSON string, and the schedule heading contains the status badge, so match on containment. Co-Authored-By: Claude Fable 5 --- tests/e2e/schedules.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/schedules.spec.ts b/tests/e2e/schedules.spec.ts index 1a5bb66714..56f06d5f76 100644 --- a/tests/e2e/schedules.spec.ts +++ b/tests/e2e/schedules.spec.ts @@ -34,7 +34,7 @@ test.describe('Schedules Page', () => { await page .locator('#schedule-payload-input') .getByRole('textbox') - .fill('abc'); + .fill('"abc"'); await page.getByTestId('spec-type-0-button').click(); await page.getByRole('option', { name: 'Interval' }).click(); await page.getByLabel('Time Interval').fill('90'); @@ -49,7 +49,7 @@ test.describe('Schedules Page', () => { await expect(scheduleLink).toBeVisible(); await scheduleLink.click(); - await expect(page.getByTestId('schedule-name')).toHaveText( + await expect(page.getByTestId('schedule-name')).toContainText( 'e2e-schedule-1', ); await expect(page.getByText('Every 90 minute(s)').first()).toBeVisible();