@sentry/junior-dashboard 0.233.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.
Files changed (39) hide show
  1. package/dist/app.js +89 -15
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +89 -15
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/http.ts +31 -3
  18. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  19. package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
  20. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  21. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  22. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  23. package/src/client/pages/automations/AutomationFilters.tsx +171 -0
  24. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  25. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  26. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  27. package/src/client/pages/automations/AutomationRow.tsx +358 -0
  28. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  29. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  30. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  31. package/src/client/pages/automations/README.md +19 -0
  32. package/src/client/pages/automations/automationDraft.ts +110 -0
  33. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  34. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  35. package/src/client/searchParams.ts +4 -2
  36. package/src/client.tsx +7 -4
  37. package/src/mock-reporting/automation-collection.ts +101 -0
  38. package/src/mock-reporting/fixtures.ts +9 -3
  39. package/src/mock-reporting/routes.ts +14 -9
@@ -0,0 +1,177 @@
1
+ import { ArrowUp, Trash2, Plus } from "lucide-react";
2
+ import type {
3
+ AutomationEdit,
4
+ AutomationSummary,
5
+ } from "@sentry/junior/api/schema";
6
+ import { Button } from "../../components/Button";
7
+ import { Select } from "../../components/Select";
8
+ import { cn } from "../../styles";
9
+ import type { AutomationDraft } from "./automationDraft";
10
+ import { automationOutcomeLabel } from "./automationOutcomes";
11
+
12
+ /** Keep outcome order and offer only supported message Destinations. */
13
+ export function AutomationOutcomeFields(props: {
14
+ value: AutomationDraft["outcomes"];
15
+ original: AutomationEdit["outcomes"];
16
+ destination: AutomationSummary["destination"];
17
+ onChange(value: AutomationDraft["outcomes"]): void;
18
+ }) {
19
+ const defaultOutcomes: AutomationDraft["outcomes"] = props.original.length
20
+ ? props.original
21
+ : [{ action: "send_message", destination: "current_conversation" }];
22
+ const options = [
23
+ ...props.original.flatMap((outcome, index) =>
24
+ typeof outcome.destination === "string"
25
+ ? []
26
+ : [
27
+ {
28
+ value: `saved:${index}`,
29
+ label: automationOutcomeLabel(outcome, props.destination),
30
+ outcome,
31
+ },
32
+ ],
33
+ ),
34
+ {
35
+ value: "current_conversation",
36
+ label: `${props.destination.label} · ${props.destination.visibility}`,
37
+ outcome: {
38
+ action: "send_message",
39
+ destination: "current_conversation",
40
+ } as const,
41
+ },
42
+ {
43
+ value: "task_creator",
44
+ label: "Creator · Direct message",
45
+ outcome: { action: "send_message", destination: "task_creator" } as const,
46
+ },
47
+ ];
48
+ return (
49
+ <>
50
+ <fieldset>
51
+ <legend className="sr-only">Success messages</legend>
52
+ <div className="flex flex-wrap gap-x-6 gap-y-3">
53
+ {[true, false].map((send) => (
54
+ <label
55
+ key={String(send)}
56
+ className="inline-flex min-h-8 cursor-pointer items-center gap-2 text-sm"
57
+ >
58
+ <input
59
+ type="radio"
60
+ name="messages"
61
+ className="size-4 accent-dashboard-focus"
62
+ checked={Boolean(props.value.length) === send}
63
+ onChange={() => props.onChange(send ? defaultOutcomes : [])}
64
+ />
65
+ {send ? "Send a message" : "No success message"}
66
+ </label>
67
+ ))}
68
+ </div>
69
+ </fieldset>
70
+ {props.value.length ? (
71
+ <div className="grid gap-4 sm:gap-2">
72
+ {props.value.map((outcome, index) => {
73
+ const retainedIndex = props.original.findIndex(
74
+ (item) => JSON.stringify(item) === JSON.stringify(outcome),
75
+ );
76
+ return (
77
+ <div
78
+ key={index}
79
+ className="flex min-w-0 flex-wrap items-center gap-2"
80
+ >
81
+ <span className="w-4 text-xs text-dashboard-text-muted">
82
+ {index + 1}.
83
+ </span>
84
+ <div
85
+ className={cn(
86
+ "min-w-0 flex-1",
87
+ props.value.length > 1 &&
88
+ "order-last basis-full sm:order-none sm:basis-auto",
89
+ )}
90
+ >
91
+ <Select
92
+ aria-label={`Message ${index + 1} destination`}
93
+ value={
94
+ typeof outcome.destination === "string"
95
+ ? outcome.destination
96
+ : `saved:${retainedIndex}`
97
+ }
98
+ onChange={(e) => {
99
+ const selected = options.find(
100
+ (option) => option.value === e.target.value,
101
+ );
102
+ if (selected)
103
+ props.onChange(
104
+ props.value.map((item, i) =>
105
+ i === index ? selected.outcome : item,
106
+ ),
107
+ );
108
+ }}
109
+ >
110
+ {options.map((option) => (
111
+ <option key={option.value} value={option.value}>
112
+ {option.label}
113
+ </option>
114
+ ))}
115
+ </Select>
116
+ </div>
117
+ {props.value.length > 1 ? (
118
+ <>
119
+ <Button
120
+ size="icon"
121
+ className="ml-auto sm:ml-0"
122
+ aria-label={`Move message ${index + 1} up`}
123
+ disabled={index === 0}
124
+ onClick={() => {
125
+ const next = [...props.value];
126
+ [next[index - 1], next[index]] = [
127
+ next[index],
128
+ next[index - 1],
129
+ ];
130
+ props.onChange(next);
131
+ }}
132
+ >
133
+ <ArrowUp size={16} />
134
+ </Button>
135
+ <Button
136
+ size="icon"
137
+ aria-label={`Remove message ${index + 1}`}
138
+ onClick={() =>
139
+ props.onChange(
140
+ props.value.filter((_, i) => i !== index),
141
+ )
142
+ }
143
+ >
144
+ <Trash2 size={16} />
145
+ </Button>
146
+ </>
147
+ ) : null}
148
+ </div>
149
+ );
150
+ })}
151
+ {props.value.length < 5 ? (
152
+ <Button
153
+ className="w-fit"
154
+ onClick={() =>
155
+ props.onChange([
156
+ ...props.value,
157
+ { action: "send_message", destination: "task_creator" },
158
+ ])
159
+ }
160
+ >
161
+ <Plus size={15} />
162
+ Add message
163
+ </Button>
164
+ ) : null}
165
+ </div>
166
+ ) : (
167
+ <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
168
+ Junior still does the work. No message is sent after success.
169
+ </p>
170
+ )}
171
+ <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
172
+ {props.value.length ? "Messages are sent in this order. " : ""}This does
173
+ not limit actions in the instruction.
174
+ </p>
175
+ </>
176
+ );
177
+ }
@@ -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
+ }