@sentry/junior-dashboard 0.233.0 → 0.235.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +178 -29
- package/dist/client.js +53 -50
- package/dist/index.js +178 -29
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +372 -345
- package/src/client/api.ts +60 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +19 -6
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
- package/src/client/pages/automations/AutomationEditPage.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +179 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +343 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +186 -439
- package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +55 -26
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/code-repository.ts +77 -0
- package/src/mock-reporting/fixtures.ts +9 -3
- package/src/mock-reporting/routes.ts +39 -17
- package/src/shell.ts +21 -5
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
2
|
+
import {
|
|
3
|
+
CalendarClock,
|
|
4
|
+
LockKeyhole,
|
|
5
|
+
MoreHorizontal,
|
|
6
|
+
Trash2,
|
|
7
|
+
Zap,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
10
|
+
import { Link, useLocation } from "react-router";
|
|
11
|
+
import { ActorAvatar } from "../../components/ActorAvatar";
|
|
12
|
+
import { SelectableRow } from "../../components/SelectableRow";
|
|
13
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
14
|
+
import { formatRelativeTime, peoplePath } from "../../format";
|
|
15
|
+
import {
|
|
16
|
+
automationEventConditions,
|
|
17
|
+
automationEventNames,
|
|
18
|
+
automationScheduleLabel,
|
|
19
|
+
formatAutomationRunTime,
|
|
20
|
+
} from "./automationFormat";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Show one automation as a compact card: what it is, where it posts, who owns
|
|
24
|
+
* it, and when it runs. Instructions, timezones, and event conditions stay in
|
|
25
|
+
* the details drawer.
|
|
26
|
+
*/
|
|
27
|
+
export function AutomationRow(props: {
|
|
28
|
+
deleting: boolean;
|
|
29
|
+
onDelete(): void;
|
|
30
|
+
onSelect(): void;
|
|
31
|
+
selected: boolean;
|
|
32
|
+
automation: AutomationSummary;
|
|
33
|
+
}) {
|
|
34
|
+
const { automation } = props;
|
|
35
|
+
const location = useLocation();
|
|
36
|
+
const status = automationStatus(automation);
|
|
37
|
+
const creator = (
|
|
38
|
+
<>
|
|
39
|
+
<ActorAvatar
|
|
40
|
+
imageUrl={automation.createdByAvatarUrl}
|
|
41
|
+
name={automation.createdBy}
|
|
42
|
+
size="list"
|
|
43
|
+
/>
|
|
44
|
+
<span className="truncate">{automation.createdBy}</span>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
return (
|
|
48
|
+
<article
|
|
49
|
+
className="min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint"
|
|
50
|
+
role="listitem"
|
|
51
|
+
>
|
|
52
|
+
<SelectableRow
|
|
53
|
+
className="flex items-start gap-3 rounded-lg px-4 py-3.5"
|
|
54
|
+
onSelect={props.onSelect}
|
|
55
|
+
selected={props.selected}
|
|
56
|
+
>
|
|
57
|
+
<div className="min-w-0 flex-1">
|
|
58
|
+
<button
|
|
59
|
+
aria-expanded={props.selected}
|
|
60
|
+
aria-label={`View automation details: ${automation.title}`}
|
|
61
|
+
className="block w-full min-w-0 cursor-pointer rounded border-0 bg-transparent p-0 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
62
|
+
onClick={props.onSelect}
|
|
63
|
+
type="button"
|
|
64
|
+
>
|
|
65
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
66
|
+
<span
|
|
67
|
+
className="truncate font-display text-base font-medium text-dashboard-text"
|
|
68
|
+
title={automation.title}
|
|
69
|
+
>
|
|
70
|
+
{automation.title}
|
|
71
|
+
</span>
|
|
72
|
+
{status ? (
|
|
73
|
+
<StatusChip
|
|
74
|
+
className="shrink-0"
|
|
75
|
+
size="compact"
|
|
76
|
+
tone={
|
|
77
|
+
status === "completed" || status === "paused"
|
|
78
|
+
? "neutral"
|
|
79
|
+
: "warning"
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
{status}
|
|
83
|
+
</StatusChip>
|
|
84
|
+
) : null}
|
|
85
|
+
</span>
|
|
86
|
+
<AutomationSummaryLine automation={automation} />
|
|
87
|
+
</button>
|
|
88
|
+
<div className="mt-2.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-dashboard-text-muted">
|
|
89
|
+
{automation.createdByEmail ? (
|
|
90
|
+
<Link
|
|
91
|
+
className="inline-flex min-w-0 max-w-full items-center gap-2 rounded text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
92
|
+
title={automation.createdBy}
|
|
93
|
+
to={peoplePath(automation.createdByEmail)}
|
|
94
|
+
>
|
|
95
|
+
{creator}
|
|
96
|
+
</Link>
|
|
97
|
+
) : (
|
|
98
|
+
<span
|
|
99
|
+
className="inline-flex min-w-0 max-w-full items-center gap-2 text-dashboard-text"
|
|
100
|
+
title={automation.createdBy}
|
|
101
|
+
>
|
|
102
|
+
{creator}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
<AutomationTiming automation={automation} />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{automation.ownedByViewer ? (
|
|
109
|
+
<div className="-mr-2 -mt-1.5 shrink-0">
|
|
110
|
+
<AutomationActions
|
|
111
|
+
editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
|
|
112
|
+
deleting={props.deleting}
|
|
113
|
+
onDelete={props.onDelete}
|
|
114
|
+
title={automation.title}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
</SelectableRow>
|
|
119
|
+
</article>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return the one state worth flagging in the list, if the automation is not simply active. */
|
|
124
|
+
function automationStatus(automation: AutomationSummary): string | undefined {
|
|
125
|
+
if (automation.kind === "scheduled") {
|
|
126
|
+
return automation.status === "active" ? undefined : automation.status;
|
|
127
|
+
}
|
|
128
|
+
if (automation.status === "paused") return "paused";
|
|
129
|
+
return automation.triggerAvailable ? undefined : "Trigger unavailable";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Combine the trigger and Destination into one muted line under the title. */
|
|
133
|
+
function AutomationSummaryLine({
|
|
134
|
+
automation,
|
|
135
|
+
}: {
|
|
136
|
+
automation: AutomationSummary;
|
|
137
|
+
}) {
|
|
138
|
+
const scheduled = automation.kind === "scheduled";
|
|
139
|
+
const Icon = scheduled ? CalendarClock : Zap;
|
|
140
|
+
const trigger =
|
|
141
|
+
automation.kind === "scheduled"
|
|
142
|
+
? automation.schedule
|
|
143
|
+
: `${automation.resource} · ${automationEventNames(automation)}`;
|
|
144
|
+
const conditions =
|
|
145
|
+
automation.kind === "event" ? automationEventConditions(automation) : "";
|
|
146
|
+
const triggerDetail =
|
|
147
|
+
automation.kind === "scheduled"
|
|
148
|
+
? automationScheduleLabel(automation)
|
|
149
|
+
: conditions
|
|
150
|
+
? `${trigger} · ${conditions}`
|
|
151
|
+
: trigger;
|
|
152
|
+
return (
|
|
153
|
+
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-dashboard-text-muted">
|
|
154
|
+
<Icon
|
|
155
|
+
aria-label={
|
|
156
|
+
scheduled
|
|
157
|
+
? "Scheduled automation"
|
|
158
|
+
: `${automation.source} event automation`
|
|
159
|
+
}
|
|
160
|
+
className="shrink-0"
|
|
161
|
+
role="img"
|
|
162
|
+
size={13}
|
|
163
|
+
/>
|
|
164
|
+
<span className="min-w-0 truncate" title={triggerDetail}>
|
|
165
|
+
{trigger}
|
|
166
|
+
</span>
|
|
167
|
+
<span aria-hidden="true" className="shrink-0 opacity-45">
|
|
168
|
+
·
|
|
169
|
+
</span>
|
|
170
|
+
{automation.destination.visibility === "private" ? (
|
|
171
|
+
<LockKeyhole
|
|
172
|
+
aria-label="Private destination"
|
|
173
|
+
className="shrink-0"
|
|
174
|
+
role="img"
|
|
175
|
+
size={12}
|
|
176
|
+
/>
|
|
177
|
+
) : null}
|
|
178
|
+
<span
|
|
179
|
+
className="min-w-0 max-w-[45%] truncate"
|
|
180
|
+
title={automation.destination.label}
|
|
181
|
+
>
|
|
182
|
+
{automation.destination.label}
|
|
183
|
+
</span>
|
|
184
|
+
</span>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Show next and last run as short relative times after the creator. */
|
|
189
|
+
function AutomationTiming({ automation }: { automation: AutomationSummary }) {
|
|
190
|
+
const nextRunAt =
|
|
191
|
+
automation.kind === "scheduled" && automation.status === "active"
|
|
192
|
+
? automation.nextRunAt
|
|
193
|
+
: undefined;
|
|
194
|
+
const lastRunTitle = automation.lastRunAt
|
|
195
|
+
? formatAutomationRunTime(automation.lastRunAt)
|
|
196
|
+
: undefined;
|
|
197
|
+
const lastRunFailed =
|
|
198
|
+
automation.lastRunStatus === "failed" ||
|
|
199
|
+
automation.lastRunStatus === "blocked";
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
{nextRunAt ? (
|
|
203
|
+
<MetaItem title={formatAutomationRunTime(nextRunAt)}>
|
|
204
|
+
Next run {formatRelativeTime(nextRunAt)}
|
|
205
|
+
</MetaItem>
|
|
206
|
+
) : null}
|
|
207
|
+
<MetaItem title={lastRunTitle}>
|
|
208
|
+
{lastRunFailed ? (
|
|
209
|
+
<Link
|
|
210
|
+
className="text-amber-300 underline"
|
|
211
|
+
to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
|
|
212
|
+
>
|
|
213
|
+
Last run {automation.lastRunStatus}
|
|
214
|
+
{automation.lastRunAt
|
|
215
|
+
? ` ${formatRelativeTime(automation.lastRunAt)}`
|
|
216
|
+
: null}
|
|
217
|
+
</Link>
|
|
218
|
+
) : automation.lastRunAt ? (
|
|
219
|
+
`Last run ${formatRelativeTime(automation.lastRunAt)}`
|
|
220
|
+
) : (
|
|
221
|
+
"Never run"
|
|
222
|
+
)}
|
|
223
|
+
</MetaItem>
|
|
224
|
+
</>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Keep each separator with the item after it so wrapped lines never end in a dot. */
|
|
229
|
+
function MetaItem(props: { children: ReactNode; title?: string }) {
|
|
230
|
+
return (
|
|
231
|
+
<span className="whitespace-nowrap" title={props.title}>
|
|
232
|
+
<span aria-hidden="true" className="mr-2 opacity-45">
|
|
233
|
+
·
|
|
234
|
+
</span>
|
|
235
|
+
{props.children}
|
|
236
|
+
</span>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function AutomationActions(props: {
|
|
241
|
+
editPath: string;
|
|
242
|
+
deleting: boolean;
|
|
243
|
+
onDelete(): void;
|
|
244
|
+
title: string;
|
|
245
|
+
}) {
|
|
246
|
+
const [open, setOpen] = useState(false);
|
|
247
|
+
const root = useRef<HTMLDivElement>(null);
|
|
248
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
249
|
+
const item = useRef<HTMLButtonElement>(null);
|
|
250
|
+
const menuId = useId();
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!open) return;
|
|
254
|
+
item.current?.focus();
|
|
255
|
+
function closeOutside(event: PointerEvent) {
|
|
256
|
+
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
|
257
|
+
}
|
|
258
|
+
document.addEventListener("pointerdown", closeOutside);
|
|
259
|
+
return () => document.removeEventListener("pointerdown", closeOutside);
|
|
260
|
+
}, [open]);
|
|
261
|
+
|
|
262
|
+
function close() {
|
|
263
|
+
setOpen(false);
|
|
264
|
+
trigger.current?.focus();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
className="relative"
|
|
270
|
+
onBlur={(event) => {
|
|
271
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
|
272
|
+
}}
|
|
273
|
+
onKeyDown={(event) => {
|
|
274
|
+
if (event.key === "Escape") {
|
|
275
|
+
event.stopPropagation();
|
|
276
|
+
close();
|
|
277
|
+
} else if (
|
|
278
|
+
["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
|
|
279
|
+
) {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
setOpen(true);
|
|
282
|
+
const items = Array.from(
|
|
283
|
+
root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ??
|
|
284
|
+
[],
|
|
285
|
+
);
|
|
286
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
287
|
+
const next =
|
|
288
|
+
event.key === "Home"
|
|
289
|
+
? 0
|
|
290
|
+
: event.key === "End"
|
|
291
|
+
? items.length - 1
|
|
292
|
+
: (index + (event.key === "ArrowUp" ? -1 : 1) + items.length) %
|
|
293
|
+
items.length;
|
|
294
|
+
items[next]?.focus();
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
ref={root}
|
|
298
|
+
>
|
|
299
|
+
<button
|
|
300
|
+
aria-controls={open ? menuId : undefined}
|
|
301
|
+
aria-expanded={open}
|
|
302
|
+
aria-haspopup="menu"
|
|
303
|
+
aria-label={`Actions: ${props.title}`}
|
|
304
|
+
className="grid size-11 cursor-pointer place-items-center rounded-md border-0 bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus disabled:cursor-not-allowed disabled:opacity-50 xl:size-9"
|
|
305
|
+
disabled={props.deleting}
|
|
306
|
+
onClick={() => setOpen(!open)}
|
|
307
|
+
ref={trigger}
|
|
308
|
+
type="button"
|
|
309
|
+
>
|
|
310
|
+
<MoreHorizontal aria-hidden="true" size={18} />
|
|
311
|
+
</button>
|
|
312
|
+
{open ? (
|
|
313
|
+
<div
|
|
314
|
+
aria-label={`Actions: ${props.title}`}
|
|
315
|
+
className="absolute right-0 top-full z-20 min-w-36 rounded-md border border-dashboard-border bg-dashboard-surface-raised p-1 shadow-lg"
|
|
316
|
+
id={menuId}
|
|
317
|
+
onClick={(event) => event.stopPropagation()}
|
|
318
|
+
role="menu"
|
|
319
|
+
>
|
|
320
|
+
<Link
|
|
321
|
+
role="menuitem"
|
|
322
|
+
to={props.editPath}
|
|
323
|
+
className="flex min-h-11 items-center rounded px-3 text-sm text-dashboard-text no-underline hover:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-dashboard-focus"
|
|
324
|
+
>
|
|
325
|
+
Edit automation
|
|
326
|
+
</Link>
|
|
327
|
+
<button
|
|
328
|
+
className="flex min-h-11 w-full cursor-pointer items-center gap-2 rounded border-0 bg-transparent px-3 text-sm text-rose-300 hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
329
|
+
onClick={() => {
|
|
330
|
+
close();
|
|
331
|
+
props.onDelete();
|
|
332
|
+
}}
|
|
333
|
+
ref={item}
|
|
334
|
+
role="menuitem"
|
|
335
|
+
type="button"
|
|
336
|
+
>
|
|
337
|
+
<Trash2 aria-hidden="true" size={15} /> Delete
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
) : null}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { CalendarClock } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
automationScheduleIntentSchema,
|
|
6
|
+
automationSchedulePreviewSchema,
|
|
7
|
+
type AutomationEdit,
|
|
8
|
+
} from "@sentry/junior/api/schema";
|
|
9
|
+
import { Field } from "../../components/Field";
|
|
10
|
+
import { Select } from "../../components/Select";
|
|
11
|
+
import { TextInput } from "../../components/TextInput";
|
|
12
|
+
import { Button } from "../../components/Button";
|
|
13
|
+
import { post, DashboardApiError } from "../../http";
|
|
14
|
+
import { cn } from "../../styles";
|
|
15
|
+
import {
|
|
16
|
+
scheduleDraft,
|
|
17
|
+
weekdays,
|
|
18
|
+
type AutomationScheduleDraft,
|
|
19
|
+
} from "./automationDraft";
|
|
20
|
+
|
|
21
|
+
/** Edit calendar fields and preview the next run without saving. */
|
|
22
|
+
export function AutomationScheduleFields(props: {
|
|
23
|
+
automation: Extract<AutomationEdit, { kind: "scheduled" }>;
|
|
24
|
+
value?: AutomationScheduleDraft;
|
|
25
|
+
onChange(value: AutomationScheduleDraft | undefined): void;
|
|
26
|
+
}) {
|
|
27
|
+
const value = props.value ?? scheduleDraft(props.automation);
|
|
28
|
+
const [previewValue, setPreviewValue] = useState(props.value);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const timer = setTimeout(() => setPreviewValue(props.value), 300);
|
|
31
|
+
return () => clearTimeout(timer);
|
|
32
|
+
}, [props.value]);
|
|
33
|
+
const parsed = automationScheduleIntentSchema.safeParse(previewValue);
|
|
34
|
+
const preview = useQuery({
|
|
35
|
+
queryKey: [
|
|
36
|
+
"dashboard",
|
|
37
|
+
"automation-schedule-preview",
|
|
38
|
+
props.automation.id,
|
|
39
|
+
previewValue,
|
|
40
|
+
],
|
|
41
|
+
enabled: Boolean(previewValue) && parsed.success,
|
|
42
|
+
retry: false,
|
|
43
|
+
queryFn: () =>
|
|
44
|
+
post(
|
|
45
|
+
automationSchedulePreviewSchema,
|
|
46
|
+
`/api/automations/scheduled/${encodeURIComponent(props.automation.id)}/preview`,
|
|
47
|
+
previewValue,
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
const frequency = value.kind === "recurring" ? value.frequency : "once";
|
|
51
|
+
const time = value.kind === "recurring" ? value.time : value.timing.time;
|
|
52
|
+
const nextRun = props.value
|
|
53
|
+
? preview.data?.nextRunAtMs
|
|
54
|
+
: props.automation.nextRunAtMs;
|
|
55
|
+
const waiting = props.value !== previewValue || preview.isFetching;
|
|
56
|
+
const issue =
|
|
57
|
+
preview.error instanceof DashboardApiError
|
|
58
|
+
? preview.error.apiError
|
|
59
|
+
: preview.error
|
|
60
|
+
? "Schedule preview could not be loaded. Try again."
|
|
61
|
+
: props.value && !parsed.success && !waiting
|
|
62
|
+
? parsed.error.issues[0]?.message
|
|
63
|
+
: undefined;
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
|
|
67
|
+
<Field label="Repeat" htmlFor="schedule-frequency">
|
|
68
|
+
<Select
|
|
69
|
+
id="schedule-frequency"
|
|
70
|
+
value={frequency}
|
|
71
|
+
onChange={(e) => {
|
|
72
|
+
const frequency = e.target.value;
|
|
73
|
+
if (
|
|
74
|
+
frequency !== "once" &&
|
|
75
|
+
frequency !== "daily" &&
|
|
76
|
+
frequency !== "weekly" &&
|
|
77
|
+
frequency !== "monthly" &&
|
|
78
|
+
frequency !== "yearly"
|
|
79
|
+
)
|
|
80
|
+
return;
|
|
81
|
+
props.onChange(
|
|
82
|
+
frequency === "once"
|
|
83
|
+
? {
|
|
84
|
+
kind: "one_off",
|
|
85
|
+
timezone:
|
|
86
|
+
value.timezone ?? props.automation.schedule.timezone,
|
|
87
|
+
timing: { type: "at", date: "", time },
|
|
88
|
+
}
|
|
89
|
+
: {
|
|
90
|
+
kind: "recurring",
|
|
91
|
+
timezone: value.timezone,
|
|
92
|
+
time,
|
|
93
|
+
frequency,
|
|
94
|
+
interval: 1,
|
|
95
|
+
weekdays: frequency === "weekly" ? ["monday"] : undefined,
|
|
96
|
+
dayOfMonth: ["monthly", "yearly"].includes(frequency)
|
|
97
|
+
? 1
|
|
98
|
+
: undefined,
|
|
99
|
+
month: frequency === "yearly" ? 1 : undefined,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<option value="once">Once</option>
|
|
105
|
+
<option value="daily">Every day</option>
|
|
106
|
+
<option value="weekly">Every week</option>
|
|
107
|
+
<option value="monthly">Every month</option>
|
|
108
|
+
<option value="yearly">Every year</option>
|
|
109
|
+
</Select>
|
|
110
|
+
</Field>
|
|
111
|
+
<Field label="Time" htmlFor="schedule-time">
|
|
112
|
+
<TextInput
|
|
113
|
+
size="comfortable"
|
|
114
|
+
id="schedule-time"
|
|
115
|
+
type="time"
|
|
116
|
+
value={time}
|
|
117
|
+
onChange={(e) =>
|
|
118
|
+
props.onChange(
|
|
119
|
+
value.kind === "recurring"
|
|
120
|
+
? { ...value, time: e.target.value }
|
|
121
|
+
: {
|
|
122
|
+
...value,
|
|
123
|
+
timing: {
|
|
124
|
+
type: "at",
|
|
125
|
+
date: value.timing.date,
|
|
126
|
+
time: e.target.value,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
/>
|
|
132
|
+
</Field>
|
|
133
|
+
</div>
|
|
134
|
+
{value.kind === "recurring" ? (
|
|
135
|
+
<>
|
|
136
|
+
{value.frequency === "weekly" ? (
|
|
137
|
+
<fieldset>
|
|
138
|
+
<legend className="mb-2 text-sm font-semibold">On</legend>
|
|
139
|
+
<div className="grid grid-cols-7 gap-1.5">
|
|
140
|
+
{[...weekdays.slice(1), weekdays[0]].map((day) => (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
key={day}
|
|
144
|
+
aria-label={day}
|
|
145
|
+
aria-pressed={value.weekdays?.includes(day) ?? false}
|
|
146
|
+
onClick={() =>
|
|
147
|
+
props.onChange({
|
|
148
|
+
...value,
|
|
149
|
+
weekdays: value.weekdays?.includes(day)
|
|
150
|
+
? value.weekdays.filter((d) => d !== day)
|
|
151
|
+
: [...(value.weekdays ?? []), day],
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
className={cn(
|
|
155
|
+
"min-h-11 cursor-pointer rounded border px-1 text-xs font-medium capitalize focus-visible:outline-2 focus-visible:outline-dashboard-focus sm:min-h-10 sm:text-sm",
|
|
156
|
+
value.weekdays?.includes(day)
|
|
157
|
+
? "border-dashboard-focus/50 bg-dashboard-focus/10 text-dashboard-focus"
|
|
158
|
+
: "border-dashboard-border-emphasis text-dashboard-text-muted hover:bg-dashboard-fill-hover",
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{day.slice(0, 3)}
|
|
162
|
+
</button>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</fieldset>
|
|
166
|
+
) : null}
|
|
167
|
+
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
|
|
168
|
+
<Field
|
|
169
|
+
label="Interval"
|
|
170
|
+
htmlFor="schedule-interval"
|
|
171
|
+
help="Number of calendar periods between runs."
|
|
172
|
+
>
|
|
173
|
+
<TextInput
|
|
174
|
+
size="comfortable"
|
|
175
|
+
id="schedule-interval"
|
|
176
|
+
type="number"
|
|
177
|
+
min={1}
|
|
178
|
+
max={365}
|
|
179
|
+
value={value.interval ?? 1}
|
|
180
|
+
onChange={(e) =>
|
|
181
|
+
props.onChange({ ...value, interval: e.target.valueAsNumber })
|
|
182
|
+
}
|
|
183
|
+
/>
|
|
184
|
+
</Field>
|
|
185
|
+
<Field label="Start date" htmlFor="schedule-start">
|
|
186
|
+
<TextInput
|
|
187
|
+
size="comfortable"
|
|
188
|
+
id="schedule-start"
|
|
189
|
+
type="date"
|
|
190
|
+
value={value.startDate ?? ""}
|
|
191
|
+
onChange={(e) =>
|
|
192
|
+
props.onChange({
|
|
193
|
+
...value,
|
|
194
|
+
startDate: e.target.value || undefined,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
</Field>
|
|
199
|
+
</div>
|
|
200
|
+
{value.frequency === "monthly" || value.frequency === "yearly" ? (
|
|
201
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
202
|
+
<Field label="Day of month" htmlFor="schedule-day">
|
|
203
|
+
<TextInput
|
|
204
|
+
size="comfortable"
|
|
205
|
+
id="schedule-day"
|
|
206
|
+
type="number"
|
|
207
|
+
min={1}
|
|
208
|
+
max={31}
|
|
209
|
+
value={value.dayOfMonth ?? 1}
|
|
210
|
+
onChange={(e) =>
|
|
211
|
+
props.onChange({
|
|
212
|
+
...value,
|
|
213
|
+
dayOfMonth: e.target.valueAsNumber,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
</Field>
|
|
218
|
+
{value.frequency === "yearly" ? (
|
|
219
|
+
<Field label="Month" htmlFor="schedule-month">
|
|
220
|
+
<Select
|
|
221
|
+
id="schedule-month"
|
|
222
|
+
value={value.month ?? 1}
|
|
223
|
+
onChange={(e) =>
|
|
224
|
+
props.onChange({
|
|
225
|
+
...value,
|
|
226
|
+
month: Number(e.target.value),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
{Array.from({ length: 12 }, (_, index) => (
|
|
231
|
+
<option key={index} value={index + 1}>
|
|
232
|
+
{new Intl.DateTimeFormat("en", {
|
|
233
|
+
month: "long",
|
|
234
|
+
timeZone: "UTC",
|
|
235
|
+
}).format(new Date(Date.UTC(2026, index, 1)))}
|
|
236
|
+
</option>
|
|
237
|
+
))}
|
|
238
|
+
</Select>
|
|
239
|
+
</Field>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
) : null}
|
|
243
|
+
</>
|
|
244
|
+
) : (
|
|
245
|
+
<Field label="Date" htmlFor="schedule-date">
|
|
246
|
+
<TextInput
|
|
247
|
+
size="comfortable"
|
|
248
|
+
id="schedule-date"
|
|
249
|
+
type="date"
|
|
250
|
+
value={value.timing.date}
|
|
251
|
+
onChange={(e) =>
|
|
252
|
+
props.onChange({
|
|
253
|
+
...value,
|
|
254
|
+
timing: { type: "at", time, date: e.target.value },
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</Field>
|
|
259
|
+
)}
|
|
260
|
+
<Field
|
|
261
|
+
label="Timezone"
|
|
262
|
+
htmlFor="schedule-timezone"
|
|
263
|
+
help="Use an IANA timezone. The schedule follows local time, including daylight saving time."
|
|
264
|
+
>
|
|
265
|
+
<TextInput
|
|
266
|
+
size="comfortable"
|
|
267
|
+
id="schedule-timezone"
|
|
268
|
+
list="automation-timezones"
|
|
269
|
+
value={value.timezone ?? ""}
|
|
270
|
+
onChange={(e) =>
|
|
271
|
+
props.onChange({ ...value, timezone: e.target.value })
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
<datalist id="automation-timezones">
|
|
275
|
+
{["UTC", ...Intl.supportedValuesOf("timeZone")].map((zone) => (
|
|
276
|
+
<option key={zone}>{zone}</option>
|
|
277
|
+
))}
|
|
278
|
+
</datalist>
|
|
279
|
+
</Field>
|
|
280
|
+
<div
|
|
281
|
+
role="status"
|
|
282
|
+
className="flex gap-3 rounded-md bg-dashboard-fill-soft p-3 text-sm leading-relaxed"
|
|
283
|
+
>
|
|
284
|
+
<CalendarClock
|
|
285
|
+
aria-hidden
|
|
286
|
+
size={17}
|
|
287
|
+
className="mt-0.5 shrink-0 text-dashboard-text-muted"
|
|
288
|
+
/>
|
|
289
|
+
<div>
|
|
290
|
+
{waiting ? (
|
|
291
|
+
"Checking schedule…"
|
|
292
|
+
) : issue ? (
|
|
293
|
+
<>
|
|
294
|
+
<span className="text-rose-300">{issue}</span>
|
|
295
|
+
{preview.error ? (
|
|
296
|
+
<Button onClick={() => void preview.refetch()}>
|
|
297
|
+
Try again
|
|
298
|
+
</Button>
|
|
299
|
+
) : null}
|
|
300
|
+
</>
|
|
301
|
+
) : nextRun ? (
|
|
302
|
+
<>
|
|
303
|
+
Next run{" "}
|
|
304
|
+
{new Intl.DateTimeFormat(undefined, {
|
|
305
|
+
year: "numeric",
|
|
306
|
+
month: "short",
|
|
307
|
+
day: "numeric",
|
|
308
|
+
hour: "numeric",
|
|
309
|
+
minute: "2-digit",
|
|
310
|
+
timeZoneName: "short",
|
|
311
|
+
timeZone: props.value
|
|
312
|
+
? (preview.data?.schedule.timezone ?? "UTC")
|
|
313
|
+
: props.automation.schedule.timezone,
|
|
314
|
+
}).format(nextRun)}
|
|
315
|
+
</>
|
|
316
|
+
) : (
|
|
317
|
+
"No next run."
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
{props.value ? (
|
|
322
|
+
<Button className="w-fit" onClick={() => props.onChange(undefined)}>
|
|
323
|
+
Keep saved schedule
|
|
324
|
+
</Button>
|
|
325
|
+
) : null}
|
|
326
|
+
</>
|
|
327
|
+
);
|
|
328
|
+
}
|