@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,172 @@
1
+ import { Link, useLocation, useParams } from "react-router";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import {
5
+ automationEditSchema,
6
+ type AutomationSummary,
7
+ } from "@sentry/junior/api/schema";
8
+ import { useAutomationData } from "../../api";
9
+ import { fetchDashboardJson } from "../../http";
10
+ import { Button } from "../../components/Button";
11
+ import { FormNotice } from "../../components/FormNotice";
12
+ import { StatusChip } from "../../components/StatusChip";
13
+ import { AutomationsNavigation } from "./AutomationsPageLayout";
14
+ import { AutomationEditor } from "./AutomationEditor";
15
+ import { AutomationEditorLoading } from "./AutomationEditorLoading";
16
+ import { AutomationFormSection } from "./AutomationFormSection";
17
+ import { automationOutcomeLabel } from "./automationOutcomes";
18
+
19
+ /** Load a linkable editor, with readable public values for non-creators. */
20
+ export function AutomationEditPage(props: { enabled: boolean }) {
21
+ const { kind, automationId } = useParams();
22
+ const location = useLocation();
23
+ const summary = useAutomationData(props.enabled, automationId);
24
+ const canEdit =
25
+ summary.data?.ownedByViewer &&
26
+ !(summary.data.kind === "scheduled" && summary.data.status === "completed");
27
+ const edit = useQuery({
28
+ queryKey: ["dashboard", "automations", "edit", kind, automationId],
29
+ enabled: props.enabled && Boolean(canEdit) && summary.data?.kind === kind,
30
+ retry: false,
31
+ queryFn: ({ signal }) =>
32
+ fetchDashboardJson(
33
+ automationEditSchema,
34
+ `/api/automations/${kind}/${encodeURIComponent(automationId!)}/edit`,
35
+ signal,
36
+ ),
37
+ });
38
+ const returnPath = `/automations/list${location.search}`;
39
+ const value = summary.data;
40
+ const status =
41
+ value?.kind === "event"
42
+ ? value.status === "paused"
43
+ ? "paused"
44
+ : value.triggerAvailable
45
+ ? "active"
46
+ : "unavailable"
47
+ : value?.status;
48
+ const statusTone =
49
+ status === "active"
50
+ ? "success"
51
+ : status === "blocked" || status === "unavailable"
52
+ ? "warning"
53
+ : "neutral";
54
+ return (
55
+ <>
56
+ <AutomationsNavigation />
57
+ <div className="mx-auto w-full min-w-0 max-w-5xl px-5 pt-7 sm:px-8 sm:pt-9">
58
+ <Link
59
+ to={returnPath}
60
+ className="inline-flex min-h-8 items-center gap-2 text-sm text-dashboard-text-muted no-underline hover:text-dashboard-text"
61
+ >
62
+ <ArrowLeft aria-hidden size={15} />
63
+ All automations
64
+ </Link>
65
+ <div className="mt-4 mb-8 flex flex-wrap items-start justify-between gap-4">
66
+ <div className="min-w-0">
67
+ <h1 className="m-0 break-words font-display text-3xl font-light tracking-tight">
68
+ {value && !canEdit ? value.title : "Edit automation"}
69
+ </h1>
70
+ {value ? (
71
+ <p className="mt-2 mb-0 text-sm text-dashboard-text-muted">
72
+ Created by {value.ownedByViewer ? "you" : value.createdBy} ·{" "}
73
+ {value.destination.label}
74
+ </p>
75
+ ) : null}
76
+ </div>
77
+ {value ? (
78
+ <StatusChip tone={statusTone}>
79
+ {status === "unavailable" ? "Trigger unavailable" : status}
80
+ </StatusChip>
81
+ ) : null}
82
+ </div>
83
+ {(!value && summary.error) ||
84
+ (!edit.data && edit.error) ||
85
+ (value && value.kind !== kind) ? (
86
+ <FormNotice tone="error" title="Automation could not be loaded.">
87
+ It may no longer be available, or you may not have access.{" "}
88
+ <Button
89
+ onClick={() => {
90
+ void summary.refetch();
91
+ if (canEdit) void edit.refetch();
92
+ }}
93
+ >
94
+ Try again
95
+ </Button>
96
+ </FormNotice>
97
+ ) : !value || (canEdit && !edit.data) ? (
98
+ <AutomationEditorLoading />
99
+ ) : canEdit && edit.data ? (
100
+ <AutomationEditor
101
+ key={`${kind}:${automationId}`}
102
+ automation={edit.data}
103
+ summary={value}
104
+ returnPath={returnPath}
105
+ />
106
+ ) : (
107
+ <AutomationReadOnly automation={value} />
108
+ )}
109
+ </div>
110
+ </>
111
+ );
112
+ }
113
+
114
+ function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
115
+ return (
116
+ <>
117
+ <p className="mb-6 text-sm text-dashboard-text-muted">
118
+ {automation.ownedByViewer
119
+ ? "This automation has completed and cannot be edited. It will not run again."
120
+ : `Only ${automation.createdBy} can edit this automation.`}
121
+ </p>
122
+ <AutomationFormSection title="What to do" detail="The saved instruction.">
123
+ <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
124
+ {automation.instruction}
125
+ </p>
126
+ </AutomationFormSection>
127
+ <AutomationFormSection title="When to run" detail="The saved trigger.">
128
+ {automation.kind === "scheduled" ? (
129
+ <p className="m-0 text-sm">
130
+ {automation.schedule} · {automation.timezone}
131
+ </p>
132
+ ) : (
133
+ <div className="grid gap-3 text-sm">
134
+ <p className="m-0 break-words">{automation.resource}</p>
135
+ <p className="m-0 break-words">{automation.events.join(", ")}</p>
136
+ {automation.match ? (
137
+ <pre className="whitespace-pre-wrap break-words text-xs">
138
+ {JSON.stringify(automation.match, null, 2)}
139
+ </pre>
140
+ ) : null}
141
+ </div>
142
+ )}
143
+ </AutomationFormSection>
144
+ <AutomationFormSection
145
+ title="Where results go"
146
+ detail="Messages after successful work."
147
+ >
148
+ {automation.outcomes.length ? (
149
+ <ol className="m-0 grid gap-2 pl-5 text-sm">
150
+ {automation.outcomes.map((outcome, index) => (
151
+ <li key={index}>
152
+ {automationOutcomeLabel(outcome, automation.destination)}
153
+ </li>
154
+ ))}
155
+ </ol>
156
+ ) : (
157
+ <p className="m-0 text-sm">No success message</p>
158
+ )}
159
+ </AutomationFormSection>
160
+ <AutomationFormSection
161
+ title="Credentials"
162
+ detail="The accounts Junior can use."
163
+ >
164
+ <p className="m-0 text-sm">
165
+ {automation.credentialMode === "creator"
166
+ ? `Uses ${automation.ownedByViewer ? "your" : `${automation.createdBy}’s`} connected accounts.`
167
+ : "System credentials only."}
168
+ </p>
169
+ </AutomationFormSection>
170
+ </>
171
+ );
172
+ }
@@ -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-5 border-t border-dashboard-border-emphasis bg-dashboard-bg px-5 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
+ }