@sentry/junior-dashboard 0.232.0 → 0.234.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 +163 -46
- package/dist/client.js +55 -52
- package/dist/index.js +163 -46
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -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 +11 -2
- 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/conversations/ConversationEventLog.tsx +49 -9
- package/src/client/conversations/EventDetails.tsx +94 -11
- package/src/client/conversations/EventUsage.tsx +118 -0
- package/src/client/conversations/eventLog.ts +59 -6
- package/src/client/conversations/eventTranscript.ts +2 -0
- package/src/client/conversations/transcriptBottomPinning.ts +2 -2
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -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 +171 -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 +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- 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/fixtures.ts +82 -32
- package/src/mock-reporting/routes.ts +14 -9
|
@@ -0,0 +1,358 @@
|
|
|
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 { 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, formatTime, peoplePath } from "../../format";
|
|
15
|
+
import { cn } from "../../styles";
|
|
16
|
+
|
|
17
|
+
const desktopColumns =
|
|
18
|
+
"xl:grid-cols-[minmax(0,1fr)_9rem_16rem_10rem_7rem_2.75rem]";
|
|
19
|
+
|
|
20
|
+
/** Keep the management headings aligned with the row fields. */
|
|
21
|
+
export function AutomationListHeader() {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
className={cn(
|
|
26
|
+
"hidden items-center gap-4 border-b border-dashboard-border-subtle px-4 py-2.5 text-xs text-dashboard-text-muted xl:grid",
|
|
27
|
+
desktopColumns,
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<span>Automation</span>
|
|
31
|
+
<span>Creator</span>
|
|
32
|
+
<span>Trigger</span>
|
|
33
|
+
<span>Destination</span>
|
|
34
|
+
<span>Last run</span>
|
|
35
|
+
<span className="sr-only">Actions</span>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show purpose, ownership, and trigger state without opening the details. */
|
|
41
|
+
export function AutomationRow(props: {
|
|
42
|
+
deleting: boolean;
|
|
43
|
+
onDelete(): void;
|
|
44
|
+
onSelect(): void;
|
|
45
|
+
selected: boolean;
|
|
46
|
+
automation: AutomationSummary;
|
|
47
|
+
}) {
|
|
48
|
+
const { automation } = props;
|
|
49
|
+
const location = useLocation();
|
|
50
|
+
const creator = (
|
|
51
|
+
<>
|
|
52
|
+
<ActorAvatar
|
|
53
|
+
imageUrl={automation.createdByAvatarUrl}
|
|
54
|
+
name={automation.createdBy}
|
|
55
|
+
size="list"
|
|
56
|
+
/>
|
|
57
|
+
<span className="truncate">{automation.createdBy}</span>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
return (
|
|
61
|
+
<article role="listitem">
|
|
62
|
+
<SelectableRow
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-4 py-3 xl:gap-x-4",
|
|
65
|
+
desktopColumns,
|
|
66
|
+
)}
|
|
67
|
+
onSelect={props.onSelect}
|
|
68
|
+
selected={props.selected}
|
|
69
|
+
>
|
|
70
|
+
<button
|
|
71
|
+
aria-expanded={props.selected}
|
|
72
|
+
aria-label={`View automation details: ${automation.title}`}
|
|
73
|
+
className="col-span-2 pr-11 xl:pr-0 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 xl:col-span-1"
|
|
74
|
+
onClick={props.onSelect}
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
<span
|
|
78
|
+
className="block truncate font-display text-base font-medium text-dashboard-text"
|
|
79
|
+
title={automation.title}
|
|
80
|
+
>
|
|
81
|
+
{automation.title}
|
|
82
|
+
</span>
|
|
83
|
+
<span
|
|
84
|
+
className="mt-1 block truncate text-xs text-dashboard-text-muted"
|
|
85
|
+
title={automation.instruction}
|
|
86
|
+
>
|
|
87
|
+
{automation.instruction}
|
|
88
|
+
</span>
|
|
89
|
+
</button>
|
|
90
|
+
<div className="row-start-2 min-w-0 xl:row-auto">
|
|
91
|
+
{automation.createdByEmail ? (
|
|
92
|
+
<Link
|
|
93
|
+
className="inline-flex max-w-full items-center gap-2 rounded text-sm text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
94
|
+
title={automation.createdBy}
|
|
95
|
+
to={peoplePath(automation.createdByEmail)}
|
|
96
|
+
>
|
|
97
|
+
{creator}
|
|
98
|
+
</Link>
|
|
99
|
+
) : (
|
|
100
|
+
<span
|
|
101
|
+
className="inline-flex max-w-full items-center gap-2 text-sm text-dashboard-text"
|
|
102
|
+
title={automation.createdBy}
|
|
103
|
+
>
|
|
104
|
+
{creator}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="col-span-2 row-start-3 min-w-0 xl:col-span-1 xl:row-auto">
|
|
109
|
+
<AutomationTrigger automation={automation} />
|
|
110
|
+
</div>
|
|
111
|
+
<div className="col-start-2 row-start-2 min-w-0 text-right xl:col-auto xl:row-auto xl:text-left">
|
|
112
|
+
<span className="inline-flex max-w-full items-center gap-1 text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text">
|
|
113
|
+
{automation.destination.visibility === "private" ? (
|
|
114
|
+
<LockKeyhole
|
|
115
|
+
aria-label="Private destination"
|
|
116
|
+
role="img"
|
|
117
|
+
size={12}
|
|
118
|
+
className="shrink-0"
|
|
119
|
+
/>
|
|
120
|
+
) : null}
|
|
121
|
+
<span className="truncate">{automation.destination.label}</span>
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="col-span-2 min-w-0 xl:col-span-1">
|
|
125
|
+
<span className="text-xs text-dashboard-text-muted xl:hidden">
|
|
126
|
+
Last run{" "}
|
|
127
|
+
</span>
|
|
128
|
+
<span
|
|
129
|
+
className="text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text"
|
|
130
|
+
title={
|
|
131
|
+
automation.lastRunAt
|
|
132
|
+
? formatTime(automation.lastRunAt, {
|
|
133
|
+
dateStyle: "medium",
|
|
134
|
+
timeStyle: "short",
|
|
135
|
+
})
|
|
136
|
+
: undefined
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{automation.lastRunAt
|
|
140
|
+
? formatRelativeTime(automation.lastRunAt)
|
|
141
|
+
: "Never"}
|
|
142
|
+
</span>
|
|
143
|
+
{automation.lastRunStatus === "failed" ||
|
|
144
|
+
automation.lastRunStatus === "blocked" ? (
|
|
145
|
+
<Link
|
|
146
|
+
className="block text-xs text-amber-300 underline"
|
|
147
|
+
to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
|
|
148
|
+
>
|
|
149
|
+
Last run {automation.lastRunStatus}
|
|
150
|
+
</Link>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
<div className="absolute right-3 top-3 xl:static">
|
|
154
|
+
{automation.ownedByViewer ? (
|
|
155
|
+
<AutomationActions
|
|
156
|
+
editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
|
|
157
|
+
deleting={props.deleting}
|
|
158
|
+
onDelete={props.onDelete}
|
|
159
|
+
title={automation.title}
|
|
160
|
+
/>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
</SelectableRow>
|
|
164
|
+
</article>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function AutomationTrigger({ automation }: { automation: AutomationSummary }) {
|
|
169
|
+
const scheduled = automation.kind === "scheduled";
|
|
170
|
+
const Icon = scheduled ? CalendarClock : Zap;
|
|
171
|
+
let summary: string;
|
|
172
|
+
let detail: string;
|
|
173
|
+
let status: string | undefined;
|
|
174
|
+
let conditions: string | undefined;
|
|
175
|
+
|
|
176
|
+
if (automation.kind === "scheduled") {
|
|
177
|
+
summary = automation.schedule.includes(automation.timezone)
|
|
178
|
+
? automation.schedule
|
|
179
|
+
: `${automation.schedule} (${automation.timezone})`;
|
|
180
|
+
status = automation.status === "active" ? undefined : automation.status;
|
|
181
|
+
detail = "No next run";
|
|
182
|
+
if (automation.status === "blocked") {
|
|
183
|
+
detail = "Future runs are blocked";
|
|
184
|
+
} else if (automation.status === "paused") {
|
|
185
|
+
detail = "Future triggers will not start work";
|
|
186
|
+
} else if (automation.status === "active" && automation.nextRunAt) {
|
|
187
|
+
const nextRun = new Date(automation.nextRunAt).toLocaleString(undefined, {
|
|
188
|
+
month: "short",
|
|
189
|
+
day: "numeric",
|
|
190
|
+
hour: "numeric",
|
|
191
|
+
minute: "2-digit",
|
|
192
|
+
timeZone: automation.timezone,
|
|
193
|
+
timeZoneName: "short",
|
|
194
|
+
});
|
|
195
|
+
detail = `Next ${nextRun}`;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
summary = automation.resource;
|
|
199
|
+
detail = automation.events
|
|
200
|
+
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
201
|
+
.join(", ");
|
|
202
|
+
status =
|
|
203
|
+
automation.status === "paused"
|
|
204
|
+
? "paused"
|
|
205
|
+
: automation.triggerAvailable
|
|
206
|
+
? undefined
|
|
207
|
+
: "Trigger unavailable";
|
|
208
|
+
conditions = Object.entries(automation.match ?? {})
|
|
209
|
+
.map(
|
|
210
|
+
([field, value]) =>
|
|
211
|
+
`${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
|
|
212
|
+
)
|
|
213
|
+
.join(" · ");
|
|
214
|
+
}
|
|
215
|
+
return (
|
|
216
|
+
<div className="flex items-start gap-2">
|
|
217
|
+
<Icon
|
|
218
|
+
aria-label={
|
|
219
|
+
scheduled
|
|
220
|
+
? "Scheduled automation"
|
|
221
|
+
: `${automation.source} event automation`
|
|
222
|
+
}
|
|
223
|
+
className="mt-0.5 shrink-0 text-dashboard-text-muted"
|
|
224
|
+
role="img"
|
|
225
|
+
size={16}
|
|
226
|
+
/>
|
|
227
|
+
<div className="min-w-0">
|
|
228
|
+
<div className="break-words text-sm text-dashboard-text">{summary}</div>
|
|
229
|
+
<div className="mt-1 break-words text-xs text-dashboard-text-muted">
|
|
230
|
+
{detail}
|
|
231
|
+
</div>
|
|
232
|
+
{conditions ? (
|
|
233
|
+
<div className="mt-1 break-words text-xs text-dashboard-text-muted">
|
|
234
|
+
{conditions}
|
|
235
|
+
</div>
|
|
236
|
+
) : null}
|
|
237
|
+
{status ? (
|
|
238
|
+
<StatusChip
|
|
239
|
+
className="mt-1 xl:mt-2"
|
|
240
|
+
size="compact"
|
|
241
|
+
tone={
|
|
242
|
+
status === "completed" || status === "paused"
|
|
243
|
+
? "neutral"
|
|
244
|
+
: "warning"
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
{status}
|
|
248
|
+
</StatusChip>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function AutomationActions(props: {
|
|
256
|
+
editPath: string;
|
|
257
|
+
deleting: boolean;
|
|
258
|
+
onDelete(): void;
|
|
259
|
+
title: string;
|
|
260
|
+
}) {
|
|
261
|
+
const [open, setOpen] = useState(false);
|
|
262
|
+
const root = useRef<HTMLDivElement>(null);
|
|
263
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
264
|
+
const item = useRef<HTMLButtonElement>(null);
|
|
265
|
+
const menuId = useId();
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!open) return;
|
|
269
|
+
item.current?.focus();
|
|
270
|
+
function closeOutside(event: PointerEvent) {
|
|
271
|
+
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener("pointerdown", closeOutside);
|
|
274
|
+
return () => document.removeEventListener("pointerdown", closeOutside);
|
|
275
|
+
}, [open]);
|
|
276
|
+
|
|
277
|
+
function close() {
|
|
278
|
+
setOpen(false);
|
|
279
|
+
trigger.current?.focus();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
className="relative"
|
|
285
|
+
onBlur={(event) => {
|
|
286
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
|
287
|
+
}}
|
|
288
|
+
onKeyDown={(event) => {
|
|
289
|
+
if (event.key === "Escape") {
|
|
290
|
+
event.stopPropagation();
|
|
291
|
+
close();
|
|
292
|
+
} else if (
|
|
293
|
+
["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
|
|
294
|
+
) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
setOpen(true);
|
|
297
|
+
const items = Array.from(
|
|
298
|
+
root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ??
|
|
299
|
+
[],
|
|
300
|
+
);
|
|
301
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
302
|
+
const next =
|
|
303
|
+
event.key === "Home"
|
|
304
|
+
? 0
|
|
305
|
+
: event.key === "End"
|
|
306
|
+
? items.length - 1
|
|
307
|
+
: (index + (event.key === "ArrowUp" ? -1 : 1) + items.length) %
|
|
308
|
+
items.length;
|
|
309
|
+
items[next]?.focus();
|
|
310
|
+
}
|
|
311
|
+
}}
|
|
312
|
+
ref={root}
|
|
313
|
+
>
|
|
314
|
+
<button
|
|
315
|
+
aria-controls={open ? menuId : undefined}
|
|
316
|
+
aria-expanded={open}
|
|
317
|
+
aria-haspopup="menu"
|
|
318
|
+
aria-label={`Actions: ${props.title}`}
|
|
319
|
+
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"
|
|
320
|
+
disabled={props.deleting}
|
|
321
|
+
onClick={() => setOpen(!open)}
|
|
322
|
+
ref={trigger}
|
|
323
|
+
type="button"
|
|
324
|
+
>
|
|
325
|
+
<MoreHorizontal aria-hidden="true" size={18} />
|
|
326
|
+
</button>
|
|
327
|
+
{open ? (
|
|
328
|
+
<div
|
|
329
|
+
aria-label={`Actions: ${props.title}`}
|
|
330
|
+
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"
|
|
331
|
+
id={menuId}
|
|
332
|
+
onClick={(event) => event.stopPropagation()}
|
|
333
|
+
role="menu"
|
|
334
|
+
>
|
|
335
|
+
<Link
|
|
336
|
+
role="menuitem"
|
|
337
|
+
to={props.editPath}
|
|
338
|
+
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"
|
|
339
|
+
>
|
|
340
|
+
Edit automation
|
|
341
|
+
</Link>
|
|
342
|
+
<button
|
|
343
|
+
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"
|
|
344
|
+
onClick={() => {
|
|
345
|
+
close();
|
|
346
|
+
props.onDelete();
|
|
347
|
+
}}
|
|
348
|
+
ref={item}
|
|
349
|
+
role="menuitem"
|
|
350
|
+
type="button"
|
|
351
|
+
>
|
|
352
|
+
<Trash2 aria-hidden="true" size={15} /> Delete
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
) : null}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -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
|
+
}
|