@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.
Files changed (59) hide show
  1. package/dist/app.js +178 -29
  2. package/dist/client.js +53 -50
  3. package/dist/index.js +178 -29
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/assets/junior-avatar.png +0 -0
  7. package/src/assets/junior-favicon.png +0 -0
  8. package/src/assets.ts +1 -0
  9. package/src/client/App.tsx +372 -345
  10. package/src/client/api.ts +60 -1
  11. package/src/client/components/ActorAvatar.tsx +37 -0
  12. package/src/client/components/Button.tsx +12 -10
  13. package/src/client/components/Field.tsx +14 -1
  14. package/src/client/components/FormNotice.tsx +40 -0
  15. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  16. package/src/client/components/README.md +19 -6
  17. package/src/client/components/SegmentedNav.tsx +167 -0
  18. package/src/client/components/SegmentedTabs.tsx +24 -5
  19. package/src/client/components/Select.tsx +18 -0
  20. package/src/client/components/StatusChip.tsx +15 -12
  21. package/src/client/components/TextInput.tsx +25 -5
  22. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
  25. package/src/client/pages/automations/AutomationEditPage.tsx +171 -0
  26. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  27. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  28. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  29. package/src/client/pages/automations/AutomationFilters.tsx +179 -0
  30. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  31. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  32. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  33. package/src/client/pages/automations/AutomationRow.tsx +343 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +186 -439
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
  37. package/src/client/pages/automations/README.md +19 -0
  38. package/src/client/pages/automations/automationDraft.ts +110 -0
  39. package/src/client/pages/automations/automationFormat.ts +43 -0
  40. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  41. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  42. package/src/client/pages/code/CodePage.tsx +18 -93
  43. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  44. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  45. package/src/client/pages/code/RepositorySections.tsx +108 -0
  46. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  47. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  48. package/src/client/pages/dev/ComponentsPage.tsx +55 -26
  49. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  50. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  51. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  52. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  53. package/src/client/searchParams.ts +4 -2
  54. package/src/client.tsx +7 -4
  55. package/src/mock-reporting/automation-collection.ts +101 -0
  56. package/src/mock-reporting/code-repository.ts +77 -0
  57. package/src/mock-reporting/fixtures.ts +9 -3
  58. package/src/mock-reporting/routes.ts +39 -17
  59. 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
+ }