@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.
- package/dist/app.js +89 -15
- package/dist/client.js +55 -52
- package/dist/index.js +89 -15
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +11 -2
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +171 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/fixtures.ts +9 -3
- 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
|
+
}
|