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..56f06d5f76 100644 --- a/tests/e2e/schedules.spec.ts +++ b/tests/e2e/schedules.spec.ts @@ -34,11 +34,32 @@ test.describe('Schedules Page', () => { await page .locator('#schedule-payload-input') .getByRole('textbox') - .fill('abc'); - await page.getByRole('textbox', { name: 'hrs' }).fill('1'); + .fill('"abc"'); + 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')).toContainText( + '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; + }); +});