@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,397 @@
|
|
|
1
|
+
/** Owns one unsaved draft. Query refreshes must never replace it. */
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Link, useBlocker, useNavigate } from "react-router";
|
|
4
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import {
|
|
6
|
+
automationEditSchema,
|
|
7
|
+
automationUpdateSchema,
|
|
8
|
+
type AutomationEdit,
|
|
9
|
+
type AutomationSummary,
|
|
10
|
+
} from "@sentry/junior/api/schema";
|
|
11
|
+
import { Field } from "../../components/Field";
|
|
12
|
+
import { TextArea, TextInput } from "../../components/TextInput";
|
|
13
|
+
import { Button } from "../../components/Button";
|
|
14
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
15
|
+
import { DashboardApiError, fetchDashboardJson, patch } from "../../http";
|
|
16
|
+
import { AutomationFormSection } from "./AutomationFormSection";
|
|
17
|
+
import { AutomationScheduleFields } from "./AutomationScheduleFields";
|
|
18
|
+
import { AutomationEventFields } from "./AutomationEventFields";
|
|
19
|
+
import { AutomationOutcomeFields } from "./AutomationOutcomeFields";
|
|
20
|
+
import {
|
|
21
|
+
automationDraftChanges,
|
|
22
|
+
createAutomationDraft,
|
|
23
|
+
scheduleDraft,
|
|
24
|
+
type AutomationDraft,
|
|
25
|
+
} from "./automationDraft";
|
|
26
|
+
|
|
27
|
+
/** Keep edits until the user saves or discards them. */
|
|
28
|
+
export function AutomationEditor(props: {
|
|
29
|
+
automation: AutomationEdit;
|
|
30
|
+
summary: AutomationSummary;
|
|
31
|
+
returnPath: string;
|
|
32
|
+
}) {
|
|
33
|
+
const [original, setOriginal] = useState(props.automation);
|
|
34
|
+
const [draft, setDraft] = useState(() =>
|
|
35
|
+
createAutomationDraft(props.automation),
|
|
36
|
+
);
|
|
37
|
+
const [fields, setFields] = useState<Record<string, string[]>>({});
|
|
38
|
+
const [latest, setLatest] = useState<AutomationEdit>();
|
|
39
|
+
const form = useRef<HTMLFormElement>(null);
|
|
40
|
+
const allowLeave = useRef(false);
|
|
41
|
+
const navigate = useNavigate();
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
const changes = automationDraftChanges(original, draft);
|
|
44
|
+
const dirty = Object.keys(changes).length > 0;
|
|
45
|
+
const update = {
|
|
46
|
+
kind: original.kind,
|
|
47
|
+
revision: original.revision,
|
|
48
|
+
...changes,
|
|
49
|
+
};
|
|
50
|
+
const blocker = useBlocker(() => dirty && !allowLeave.current);
|
|
51
|
+
const discardNotice = useRef<HTMLDivElement>(null);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (blocker.state === "blocked") discardNotice.current?.focus();
|
|
54
|
+
}, [blocker.state]);
|
|
55
|
+
const url = `/api/automations/${original.kind}/${encodeURIComponent(original.id)}`;
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const prevent = (event: BeforeUnloadEvent) => {
|
|
58
|
+
if (dirty && !allowLeave.current) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
event.returnValue = "";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
window.addEventListener("beforeunload", prevent);
|
|
64
|
+
return () => window.removeEventListener("beforeunload", prevent);
|
|
65
|
+
}, [dirty]);
|
|
66
|
+
const focusError = () =>
|
|
67
|
+
requestAnimationFrame(() =>
|
|
68
|
+
(
|
|
69
|
+
form.current?.querySelector<HTMLElement>('[aria-invalid="true"]') ??
|
|
70
|
+
form.current?.querySelector<HTMLElement>("[data-save-error]")
|
|
71
|
+
)?.focus(),
|
|
72
|
+
);
|
|
73
|
+
const save = useMutation({
|
|
74
|
+
mutationFn: () => patch(automationEditSchema, url, update),
|
|
75
|
+
onSuccess: async () => {
|
|
76
|
+
allowLeave.current = true;
|
|
77
|
+
await queryClient.invalidateQueries({
|
|
78
|
+
queryKey: ["dashboard", "automations"],
|
|
79
|
+
});
|
|
80
|
+
navigate(props.returnPath, { state: { automationSaved: true } });
|
|
81
|
+
},
|
|
82
|
+
onError: (error) => {
|
|
83
|
+
setFields(error instanceof DashboardApiError ? (error.fields ?? {}) : {});
|
|
84
|
+
focusError();
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
function acceptLatest(keepChanges: boolean) {
|
|
88
|
+
if (!latest) return;
|
|
89
|
+
setDraft(
|
|
90
|
+
keepChanges
|
|
91
|
+
? { ...createAutomationDraft(latest), ...changes }
|
|
92
|
+
: createAutomationDraft(latest),
|
|
93
|
+
);
|
|
94
|
+
setOriginal(latest);
|
|
95
|
+
setLatest(undefined);
|
|
96
|
+
save.reset();
|
|
97
|
+
setFields({});
|
|
98
|
+
}
|
|
99
|
+
const review = useMutation({
|
|
100
|
+
mutationFn: () => fetchDashboardJson(automationEditSchema, `${url}/edit`),
|
|
101
|
+
onSuccess: setLatest,
|
|
102
|
+
});
|
|
103
|
+
const conflict =
|
|
104
|
+
save.error instanceof DashboardApiError && save.error.code === "conflict";
|
|
105
|
+
const change = (value: Partial<AutomationDraft>) =>
|
|
106
|
+
setDraft((current) => ({ ...current, ...value }));
|
|
107
|
+
const fieldError = (key: string) =>
|
|
108
|
+
Object.entries(fields)
|
|
109
|
+
.filter(([name]) => name === key || name.startsWith(`${key}.`))
|
|
110
|
+
.flatMap(([, messages]) => messages)
|
|
111
|
+
.join(" ") || undefined;
|
|
112
|
+
const triggerField = original.kind === "scheduled" ? "schedule" : "trigger";
|
|
113
|
+
const triggerError = fieldError(triggerField);
|
|
114
|
+
const inputState = (key: string) => ({
|
|
115
|
+
"aria-invalid": Boolean(fieldError(key)) || undefined,
|
|
116
|
+
"aria-describedby": fieldError(key) ? `${key}-error` : undefined,
|
|
117
|
+
});
|
|
118
|
+
return (
|
|
119
|
+
<form
|
|
120
|
+
ref={form}
|
|
121
|
+
noValidate
|
|
122
|
+
onSubmit={(event) => {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
if (save.isPending || conflict || !dirty) return;
|
|
125
|
+
const parsed = automationUpdateSchema.safeParse(update);
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
const errors: Record<string, string[]> = {};
|
|
128
|
+
for (const issue of parsed.error.issues)
|
|
129
|
+
(errors[issue.path.join(".")] ??= []).push(issue.message);
|
|
130
|
+
setFields(errors);
|
|
131
|
+
focusError();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setFields({});
|
|
135
|
+
save.mutate();
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{blocker.state === "blocked" ? (
|
|
139
|
+
<div tabIndex={-1} ref={discardNotice} className="mb-6 outline-none">
|
|
140
|
+
<FormNotice title="Discard unsaved changes?">
|
|
141
|
+
<p>Your changes have not been saved.</p>
|
|
142
|
+
<div className="flex gap-2">
|
|
143
|
+
<Button onClick={() => blocker.reset()}>Keep editing</Button>
|
|
144
|
+
<Button onClick={() => blocker.proceed()}>Discard changes</Button>
|
|
145
|
+
</div>
|
|
146
|
+
</FormNotice>
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
{save.error || Object.keys(fields).length ? (
|
|
150
|
+
<div tabIndex={-1} data-save-error className="mb-6 outline-none">
|
|
151
|
+
<FormNotice
|
|
152
|
+
tone={conflict ? "warning" : "error"}
|
|
153
|
+
title={
|
|
154
|
+
conflict
|
|
155
|
+
? "This automation changed while you were editing."
|
|
156
|
+
: "Changes could not be saved."
|
|
157
|
+
}
|
|
158
|
+
>
|
|
159
|
+
<p className="mt-1">
|
|
160
|
+
Your edits are still here.{" "}
|
|
161
|
+
{conflict
|
|
162
|
+
? "Review the latest version before saving again."
|
|
163
|
+
: save.error instanceof DashboardApiError
|
|
164
|
+
? save.error.apiError
|
|
165
|
+
: "Check the fields below, or try again."}
|
|
166
|
+
</p>
|
|
167
|
+
{conflict ? (
|
|
168
|
+
<Button
|
|
169
|
+
disabled={review.isPending}
|
|
170
|
+
onClick={() => review.mutate()}
|
|
171
|
+
>
|
|
172
|
+
{review.isPending ? "Loading…" : "Review latest version"}
|
|
173
|
+
</Button>
|
|
174
|
+
) : null}
|
|
175
|
+
{review.error ? (
|
|
176
|
+
<p role="alert">
|
|
177
|
+
The latest version could not be loaded. Try again.
|
|
178
|
+
</p>
|
|
179
|
+
) : null}
|
|
180
|
+
</FormNotice>
|
|
181
|
+
</div>
|
|
182
|
+
) : null}
|
|
183
|
+
{latest ? (
|
|
184
|
+
<div className="mb-6">
|
|
185
|
+
<FormNotice title="Compare your edits with the latest saved version">
|
|
186
|
+
<p>
|
|
187
|
+
Only the fields you changed are shown. Keeping your edits will
|
|
188
|
+
replace these fields. Other saved fields stay unchanged.
|
|
189
|
+
</p>
|
|
190
|
+
{Object.entries(changes).map(([key, value]) => (
|
|
191
|
+
<section key={key} className="my-4">
|
|
192
|
+
<h3 className="text-sm font-semibold capitalize">
|
|
193
|
+
{key === "credentialMode" ? "Credentials" : key}
|
|
194
|
+
</h3>
|
|
195
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
196
|
+
<div>
|
|
197
|
+
<p>Latest saved</p>
|
|
198
|
+
<pre className="whitespace-pre-wrap break-words font-sans text-sm">
|
|
199
|
+
{formatEditValue(
|
|
200
|
+
key === "schedule" && latest.kind === "scheduled"
|
|
201
|
+
? scheduleDraft(latest)
|
|
202
|
+
: latest[key as keyof AutomationEdit],
|
|
203
|
+
)}
|
|
204
|
+
</pre>
|
|
205
|
+
</div>
|
|
206
|
+
<div>
|
|
207
|
+
<p>Your edit</p>
|
|
208
|
+
<pre className="whitespace-pre-wrap break-words font-sans text-sm">
|
|
209
|
+
{formatEditValue(value)}
|
|
210
|
+
</pre>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</section>
|
|
214
|
+
))}
|
|
215
|
+
<div className="flex flex-wrap gap-2">
|
|
216
|
+
<Button onClick={() => acceptLatest(true)}>
|
|
217
|
+
Keep my changed fields
|
|
218
|
+
</Button>
|
|
219
|
+
<Button onClick={() => acceptLatest(false)}>
|
|
220
|
+
Use latest saved version
|
|
221
|
+
</Button>
|
|
222
|
+
</div>
|
|
223
|
+
</FormNotice>
|
|
224
|
+
</div>
|
|
225
|
+
) : null}
|
|
226
|
+
<fieldset disabled={save.isPending} className="min-w-0">
|
|
227
|
+
<AutomationFormSection
|
|
228
|
+
title="What to do"
|
|
229
|
+
detail="Give Junior a clear instruction and a name you can find later."
|
|
230
|
+
>
|
|
231
|
+
<Field label="Title" htmlFor="title" error={fieldError("title")}>
|
|
232
|
+
<TextInput
|
|
233
|
+
size="comfortable"
|
|
234
|
+
id="title"
|
|
235
|
+
value={draft.title}
|
|
236
|
+
placeholder={props.summary.title}
|
|
237
|
+
onChange={(e) => change({ title: e.target.value })}
|
|
238
|
+
{...inputState("title")}
|
|
239
|
+
/>
|
|
240
|
+
</Field>
|
|
241
|
+
<Field
|
|
242
|
+
label="Instruction"
|
|
243
|
+
htmlFor="instruction"
|
|
244
|
+
error={fieldError("instruction")}
|
|
245
|
+
>
|
|
246
|
+
<TextArea
|
|
247
|
+
prose
|
|
248
|
+
id="instruction"
|
|
249
|
+
rows={8}
|
|
250
|
+
value={draft.instruction}
|
|
251
|
+
onChange={(e) => change({ instruction: e.target.value })}
|
|
252
|
+
{...inputState("instruction")}
|
|
253
|
+
className="resize-y text-base sm:text-sm"
|
|
254
|
+
/>
|
|
255
|
+
<p className="m-0 text-right text-xs text-dashboard-text-muted">
|
|
256
|
+
{draft.instruction.length.toLocaleString()} / 4,000
|
|
257
|
+
</p>
|
|
258
|
+
</Field>
|
|
259
|
+
</AutomationFormSection>
|
|
260
|
+
<AutomationFormSection
|
|
261
|
+
title="When to run"
|
|
262
|
+
detail={
|
|
263
|
+
original.kind === "scheduled"
|
|
264
|
+
? "Choose a schedule in a specific timezone."
|
|
265
|
+
: "Run when a matching event occurs."
|
|
266
|
+
}
|
|
267
|
+
>
|
|
268
|
+
<div
|
|
269
|
+
id={`${triggerField}-error`}
|
|
270
|
+
tabIndex={-1}
|
|
271
|
+
aria-invalid={Boolean(triggerError) || undefined}
|
|
272
|
+
className="grid min-w-0 gap-5 outline-none"
|
|
273
|
+
>
|
|
274
|
+
{triggerError ? (
|
|
275
|
+
<p role="alert" className="m-0 text-sm text-rose-300">
|
|
276
|
+
{triggerError}
|
|
277
|
+
</p>
|
|
278
|
+
) : null}
|
|
279
|
+
{original.kind === "scheduled" ? (
|
|
280
|
+
<AutomationScheduleFields
|
|
281
|
+
automation={original}
|
|
282
|
+
value={draft.schedule}
|
|
283
|
+
onChange={(schedule) => change({ schedule })}
|
|
284
|
+
/>
|
|
285
|
+
) : (
|
|
286
|
+
<AutomationEventFields
|
|
287
|
+
value={draft.trigger ?? original.trigger}
|
|
288
|
+
original={original.trigger}
|
|
289
|
+
onChange={(trigger) => change({ trigger })}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
</AutomationFormSection>
|
|
294
|
+
<AutomationFormSection
|
|
295
|
+
title="Where results go"
|
|
296
|
+
detail="Choose who gets a message after successful work."
|
|
297
|
+
>
|
|
298
|
+
{fieldError("outcomes") ? (
|
|
299
|
+
<p role="alert" className="m-0 text-sm text-rose-300">
|
|
300
|
+
{fieldError("outcomes")}
|
|
301
|
+
</p>
|
|
302
|
+
) : null}
|
|
303
|
+
<AutomationOutcomeFields
|
|
304
|
+
value={draft.outcomes}
|
|
305
|
+
original={original.outcomes}
|
|
306
|
+
destination={props.summary.destination}
|
|
307
|
+
onChange={(outcomes) => change({ outcomes })}
|
|
308
|
+
/>
|
|
309
|
+
</AutomationFormSection>
|
|
310
|
+
<AutomationFormSection
|
|
311
|
+
title="Credentials"
|
|
312
|
+
detail="Choose which connected accounts Junior can use."
|
|
313
|
+
>
|
|
314
|
+
<fieldset className="divide-y divide-dashboard-border-subtle rounded-md border border-dashboard-border-emphasis">
|
|
315
|
+
<legend className="sr-only">Credential mode</legend>
|
|
316
|
+
{(
|
|
317
|
+
[
|
|
318
|
+
[
|
|
319
|
+
"creator",
|
|
320
|
+
"Your connected accounts",
|
|
321
|
+
"Uses accounts connected by you, the creator.",
|
|
322
|
+
],
|
|
323
|
+
[
|
|
324
|
+
"system",
|
|
325
|
+
"System credentials only",
|
|
326
|
+
"Does not use your connected accounts. Some work may not be possible.",
|
|
327
|
+
],
|
|
328
|
+
] as const
|
|
329
|
+
).map(([value, label, detail]) => (
|
|
330
|
+
<label
|
|
331
|
+
key={value}
|
|
332
|
+
className="flex cursor-pointer items-start gap-3 px-4 py-4 has-checked:bg-dashboard-fill-soft"
|
|
333
|
+
>
|
|
334
|
+
<input
|
|
335
|
+
type="radio"
|
|
336
|
+
name="credentials"
|
|
337
|
+
className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
|
|
338
|
+
checked={draft.credentialMode === value}
|
|
339
|
+
onChange={() => change({ credentialMode: value })}
|
|
340
|
+
/>
|
|
341
|
+
<span>
|
|
342
|
+
<span className="block text-sm font-medium">{label}</span>
|
|
343
|
+
<span className="mt-1 block text-xs leading-relaxed text-dashboard-text-muted">
|
|
344
|
+
{detail}
|
|
345
|
+
</span>
|
|
346
|
+
</span>
|
|
347
|
+
</label>
|
|
348
|
+
))}
|
|
349
|
+
</fieldset>
|
|
350
|
+
{fieldError("credentialMode") ? (
|
|
351
|
+
<p role="alert">{fieldError("credentialMode")}</p>
|
|
352
|
+
) : null}
|
|
353
|
+
</AutomationFormSection>
|
|
354
|
+
</fieldset>
|
|
355
|
+
<p className="mb-5 text-xs leading-relaxed text-dashboard-text-muted sm:hidden">
|
|
356
|
+
Changes apply to future work. Saving does not run this automation.
|
|
357
|
+
</p>
|
|
358
|
+
<div className="sticky bottom-0 z-20 -mx-4 border-t border-dashboard-border-emphasis bg-dashboard-bg px-4 py-4 sm:-mx-8 sm:px-8">
|
|
359
|
+
<div className="flex items-center justify-between gap-3">
|
|
360
|
+
<div className="min-w-0 flex-1" role="status">
|
|
361
|
+
<p className="m-0 text-xs font-medium sm:text-sm">
|
|
362
|
+
{save.isPending
|
|
363
|
+
? "Saving…"
|
|
364
|
+
: dirty
|
|
365
|
+
? "Unsaved changes"
|
|
366
|
+
: "Unchanged"}
|
|
367
|
+
</p>
|
|
368
|
+
<p className="mt-1 mb-0 hidden text-xs text-dashboard-text-muted sm:block">
|
|
369
|
+
Changes apply to future work. Saving does not run it.
|
|
370
|
+
</p>
|
|
371
|
+
</div>
|
|
372
|
+
<div className="flex shrink-0 gap-2">
|
|
373
|
+
<Link
|
|
374
|
+
to={props.returnPath}
|
|
375
|
+
className="inline-flex min-h-11 items-center rounded border border-dashboard-border-emphasis px-3 text-sm text-dashboard-text no-underline sm:min-h-9"
|
|
376
|
+
>
|
|
377
|
+
Cancel
|
|
378
|
+
</Link>
|
|
379
|
+
<Button
|
|
380
|
+
type="submit"
|
|
381
|
+
tone="primary"
|
|
382
|
+
className="min-h-11 sm:min-h-9"
|
|
383
|
+
disabled={!dirty || save.isPending || conflict}
|
|
384
|
+
>
|
|
385
|
+
Save changes
|
|
386
|
+
</Button>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
</form>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function formatEditValue(value: unknown): string {
|
|
395
|
+
if (value == null) return "Not set";
|
|
396
|
+
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
397
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Skeleton } from "../../components/Skeleton";
|
|
2
|
+
import { AutomationFormSection } from "./AutomationFormSection";
|
|
3
|
+
|
|
4
|
+
/** Keep the editor's field columns stable while exact values load. */
|
|
5
|
+
export function AutomationEditorLoading() {
|
|
6
|
+
return (
|
|
7
|
+
<div role="status" aria-label="Loading automation editor">
|
|
8
|
+
{["What to do", "When to run", "Where results go", "Credentials"].map(
|
|
9
|
+
(title, index) => (
|
|
10
|
+
<AutomationFormSection key={title} title={title}>
|
|
11
|
+
<Skeleton className="h-4 w-24" />
|
|
12
|
+
<Skeleton className={index === 0 ? "h-48 w-full" : "h-20 w-full"} />
|
|
13
|
+
</AutomationFormSection>
|
|
14
|
+
),
|
|
15
|
+
)}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import {
|
|
3
|
+
automationEventCatalogSchema,
|
|
4
|
+
type AutomationEdit,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
import { Field } from "../../components/Field";
|
|
7
|
+
import { Select } from "../../components/Select";
|
|
8
|
+
import { TextInput } from "../../components/TextInput";
|
|
9
|
+
import { Button } from "../../components/Button";
|
|
10
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
11
|
+
import { fetchDashboardJson } from "../../http";
|
|
12
|
+
|
|
13
|
+
type Trigger = Extract<AutomationEdit, { kind: "event" }>["trigger"];
|
|
14
|
+
|
|
15
|
+
/** Render registered Event choices without discarding unsupported saved selectors. */
|
|
16
|
+
export function AutomationEventFields(props: {
|
|
17
|
+
value: Trigger;
|
|
18
|
+
original: Trigger;
|
|
19
|
+
onChange(value: Trigger): void;
|
|
20
|
+
}) {
|
|
21
|
+
const catalog = useQuery({
|
|
22
|
+
queryKey: ["dashboard", "automation-event-catalog"],
|
|
23
|
+
queryFn: ({ signal }) =>
|
|
24
|
+
fetchDashboardJson(
|
|
25
|
+
automationEventCatalogSchema,
|
|
26
|
+
"/api/automations/event-catalog",
|
|
27
|
+
signal,
|
|
28
|
+
),
|
|
29
|
+
retry: false,
|
|
30
|
+
});
|
|
31
|
+
const resource = catalog.data?.find(
|
|
32
|
+
(item) =>
|
|
33
|
+
item.namespace === props.value.namespace &&
|
|
34
|
+
item.type === props.value.resourceType,
|
|
35
|
+
);
|
|
36
|
+
const unsupported =
|
|
37
|
+
!resource ||
|
|
38
|
+
props.value.events.some(
|
|
39
|
+
(event) => !resource.supportedEvents.includes(event),
|
|
40
|
+
) ||
|
|
41
|
+
Object.entries(props.value.match ?? {}).some(([key, value]) => {
|
|
42
|
+
const field = resource.matchFields?.[key];
|
|
43
|
+
if (!field) return true;
|
|
44
|
+
return (Array.isArray(value) ? value : [value]).some(
|
|
45
|
+
(item) =>
|
|
46
|
+
typeof item !== field.kind ||
|
|
47
|
+
(field.enum && !field.enum.includes(String(item))),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const setMatch = (
|
|
51
|
+
key: string,
|
|
52
|
+
value: NonNullable<Trigger["match"]>[string] | undefined,
|
|
53
|
+
) => {
|
|
54
|
+
const match = { ...props.value.match };
|
|
55
|
+
if (value === undefined) delete match[key];
|
|
56
|
+
else match[key] = value;
|
|
57
|
+
props.onChange({ ...props.value, match });
|
|
58
|
+
};
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
{catalog.error ? (
|
|
62
|
+
<FormNotice title="Event choices could not be loaded.">
|
|
63
|
+
Saved values are kept.{" "}
|
|
64
|
+
<Button onClick={() => void catalog.refetch()}>Try again</Button>
|
|
65
|
+
</FormNotice>
|
|
66
|
+
) : catalog.isPending ? (
|
|
67
|
+
<p role="status">Loading event choices…</p>
|
|
68
|
+
) : null}
|
|
69
|
+
{unsupported && catalog.data ? (
|
|
70
|
+
<FormNotice title="Saved trigger · Kept unchanged">
|
|
71
|
+
Some saved values are not in the current catalog. You can edit other
|
|
72
|
+
fields, or select a new resource type to replace the trigger.
|
|
73
|
+
<pre className="whitespace-pre-wrap break-all text-xs">
|
|
74
|
+
{JSON.stringify(props.value, null, 2)}
|
|
75
|
+
</pre>
|
|
76
|
+
</FormNotice>
|
|
77
|
+
) : null}
|
|
78
|
+
<Field
|
|
79
|
+
label={unsupported ? "Replace trigger" : "Resource type"}
|
|
80
|
+
htmlFor="trigger-resource"
|
|
81
|
+
>
|
|
82
|
+
<Select
|
|
83
|
+
id="trigger-resource"
|
|
84
|
+
value={
|
|
85
|
+
unsupported
|
|
86
|
+
? ""
|
|
87
|
+
: `${props.value.namespace}:${props.value.resourceType}`
|
|
88
|
+
}
|
|
89
|
+
onChange={(e) => {
|
|
90
|
+
const next = catalog.data?.find(
|
|
91
|
+
(item) => `${item.namespace}:${item.type}` === e.target.value,
|
|
92
|
+
);
|
|
93
|
+
if (!next) return;
|
|
94
|
+
if (
|
|
95
|
+
!window.confirm(
|
|
96
|
+
"Replace the saved trigger? Its resource, events, and conditions will be cleared.",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return;
|
|
100
|
+
props.onChange({
|
|
101
|
+
namespace: next.namespace,
|
|
102
|
+
resourceType: next.type,
|
|
103
|
+
identifier: "",
|
|
104
|
+
label: "",
|
|
105
|
+
events: [],
|
|
106
|
+
});
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<option value="" disabled>
|
|
110
|
+
Keep saved trigger
|
|
111
|
+
</option>
|
|
112
|
+
{catalog.data?.map((item) => (
|
|
113
|
+
<option
|
|
114
|
+
key={`${item.namespace}:${item.type}`}
|
|
115
|
+
value={`${item.namespace}:${item.type}`}
|
|
116
|
+
>
|
|
117
|
+
{item.namespace} · {item.type.replaceAll("_", " ")}
|
|
118
|
+
</option>
|
|
119
|
+
))}
|
|
120
|
+
</Select>
|
|
121
|
+
</Field>
|
|
122
|
+
{!unsupported && resource ? (
|
|
123
|
+
<>
|
|
124
|
+
<Field label="Resource name" htmlFor="trigger-label">
|
|
125
|
+
<TextInput
|
|
126
|
+
size="comfortable"
|
|
127
|
+
id="trigger-label"
|
|
128
|
+
value={props.value.label}
|
|
129
|
+
onChange={(e) =>
|
|
130
|
+
props.onChange({ ...props.value, label: e.target.value })
|
|
131
|
+
}
|
|
132
|
+
/>
|
|
133
|
+
</Field>
|
|
134
|
+
<Field
|
|
135
|
+
label="Resource identifier"
|
|
136
|
+
htmlFor="trigger-identifier"
|
|
137
|
+
help="Use the exact provider resource identifier, not its display name."
|
|
138
|
+
>
|
|
139
|
+
<TextInput
|
|
140
|
+
size="comfortable"
|
|
141
|
+
id="trigger-identifier"
|
|
142
|
+
value={props.value.identifier}
|
|
143
|
+
onChange={(e) =>
|
|
144
|
+
props.onChange({ ...props.value, identifier: e.target.value })
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
</Field>
|
|
148
|
+
<fieldset>
|
|
149
|
+
<legend className="mb-2 text-sm font-semibold">
|
|
150
|
+
Run on these events
|
|
151
|
+
</legend>
|
|
152
|
+
<div className="divide-y divide-dashboard-border-subtle rounded-md border border-dashboard-border-emphasis">
|
|
153
|
+
{resource.supportedEvents.map((event) => (
|
|
154
|
+
<label
|
|
155
|
+
key={event}
|
|
156
|
+
className="flex min-h-11 cursor-pointer items-start gap-3 px-3 py-3 text-sm"
|
|
157
|
+
>
|
|
158
|
+
<input
|
|
159
|
+
type="checkbox"
|
|
160
|
+
className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
|
|
161
|
+
checked={props.value.events.includes(event)}
|
|
162
|
+
onChange={() =>
|
|
163
|
+
props.onChange({
|
|
164
|
+
...props.value,
|
|
165
|
+
events: props.value.events.includes(event)
|
|
166
|
+
? props.value.events.filter((v) => v !== event)
|
|
167
|
+
: [...props.value.events, event],
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
/>
|
|
171
|
+
<span className="min-w-0 break-words">
|
|
172
|
+
{event.replaceAll("_", " ").replaceAll(".", " · ")}
|
|
173
|
+
{resource.guidance?.[event] ? (
|
|
174
|
+
<span className="mt-1 block text-xs leading-relaxed text-dashboard-text-muted">
|
|
175
|
+
{resource.guidance[event]}
|
|
176
|
+
</span>
|
|
177
|
+
) : null}
|
|
178
|
+
</span>
|
|
179
|
+
</label>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</fieldset>
|
|
183
|
+
{Object.keys(resource.matchFields ?? {}).length ? (
|
|
184
|
+
<fieldset className="grid gap-4">
|
|
185
|
+
<legend className="mb-3 text-sm font-semibold">Only when</legend>
|
|
186
|
+
<p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
187
|
+
All conditions must match. Leave a field blank to match any
|
|
188
|
+
value. Existing lists match any one listed value.
|
|
189
|
+
</p>
|
|
190
|
+
{Object.entries(resource.matchFields ?? {}).map(
|
|
191
|
+
([key, field]) => {
|
|
192
|
+
const value = props.value.match?.[key];
|
|
193
|
+
return (
|
|
194
|
+
<Field
|
|
195
|
+
key={key}
|
|
196
|
+
label={key}
|
|
197
|
+
htmlFor={`match-${key}`}
|
|
198
|
+
help={field.description}
|
|
199
|
+
>
|
|
200
|
+
{Array.isArray(value) ? (
|
|
201
|
+
<div className="text-sm">
|
|
202
|
+
<span className="break-all">{value.join(", ")}</span>{" "}
|
|
203
|
+
<Button onClick={() => setMatch(key, undefined)}>
|
|
204
|
+
Remove condition
|
|
205
|
+
</Button>
|
|
206
|
+
</div>
|
|
207
|
+
) : field.kind === "boolean" || field.enum ? (
|
|
208
|
+
<Select
|
|
209
|
+
id={`match-${key}`}
|
|
210
|
+
value={value === undefined ? "" : String(value)}
|
|
211
|
+
onChange={(e) =>
|
|
212
|
+
setMatch(
|
|
213
|
+
key,
|
|
214
|
+
e.target.value === ""
|
|
215
|
+
? undefined
|
|
216
|
+
: field.kind === "boolean"
|
|
217
|
+
? e.target.value === "true"
|
|
218
|
+
: e.target.value,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
<option value="">Any value</option>
|
|
223
|
+
{(field.enum ?? ["true", "false"]).map((v) => (
|
|
224
|
+
<option key={v}>{v}</option>
|
|
225
|
+
))}
|
|
226
|
+
</Select>
|
|
227
|
+
) : (
|
|
228
|
+
<TextInput
|
|
229
|
+
size="comfortable"
|
|
230
|
+
id={`match-${key}`}
|
|
231
|
+
type={field.kind === "number" ? "number" : "text"}
|
|
232
|
+
step="any"
|
|
233
|
+
value={value === undefined ? "" : String(value)}
|
|
234
|
+
onChange={(e) =>
|
|
235
|
+
setMatch(
|
|
236
|
+
key,
|
|
237
|
+
e.target.value === ""
|
|
238
|
+
? undefined
|
|
239
|
+
: field.kind === "number"
|
|
240
|
+
? e.target.valueAsNumber
|
|
241
|
+
: e.target.value,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
246
|
+
</Field>
|
|
247
|
+
);
|
|
248
|
+
},
|
|
249
|
+
)}
|
|
250
|
+
</fieldset>
|
|
251
|
+
) : null}
|
|
252
|
+
</>
|
|
253
|
+
) : null}
|
|
254
|
+
{JSON.stringify(props.value) !== JSON.stringify(props.original) ? (
|
|
255
|
+
<Button
|
|
256
|
+
className="w-fit"
|
|
257
|
+
onClick={() => props.onChange(props.original)}
|
|
258
|
+
>
|
|
259
|
+
Keep saved trigger
|
|
260
|
+
</Button>
|
|
261
|
+
) : null}
|
|
262
|
+
</>
|
|
263
|
+
);
|
|
264
|
+
}
|