@sentry/junior-dashboard 0.235.0 → 0.237.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.
@@ -114,16 +114,24 @@ export function AutomationDetailsDrawer(props: {
114
114
  )}
115
115
  </Detail>
116
116
  </DetailList>
117
- {automation.totalRuns > 0 &&
118
- automation.lastRunStatus !== "failed" &&
119
- automation.lastRunStatus !== "blocked" ? (
117
+ <div className="flex flex-wrap gap-x-5">
118
+ {automation.totalRuns > 0 &&
119
+ automation.lastRunStatus !== "failed" &&
120
+ automation.lastRunStatus !== "blocked" ? (
121
+ <Link
122
+ className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
123
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
124
+ >
125
+ View all runs
126
+ </Link>
127
+ ) : null}
120
128
  <Link
121
129
  className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
122
- to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
130
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/versions${location.search}`}
123
131
  >
124
- View all runs
132
+ Version history
125
133
  </Link>
126
- ) : null}
134
+ </div>
127
135
  <details>
128
136
  <summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
129
137
  More details
@@ -14,15 +14,15 @@ import { AutomationsNavigation } from "./AutomationsPageLayout";
14
14
  import { AutomationEditor } from "./AutomationEditor";
15
15
  import { AutomationEditorLoading } from "./AutomationEditorLoading";
16
16
  import { AutomationFormSection } from "./AutomationFormSection";
17
- import { automationOutcomeLabel } from "./automationOutcomes";
17
+ import { AutomationOutcomeList } from "./AutomationOutcomeFields";
18
18
 
19
- /** Load a linkable editor, with readable public values for non-creators. */
19
+ /** Load a linkable editor. Owners and public readers can edit until work completes. */
20
20
  export function AutomationEditPage(props: { enabled: boolean }) {
21
21
  const { kind, automationId } = useParams();
22
22
  const location = useLocation();
23
23
  const summary = useAutomationData(props.enabled, automationId);
24
24
  const canEdit =
25
- summary.data?.ownedByViewer &&
25
+ summary.data &&
26
26
  !(summary.data.kind === "scheduled" && summary.data.status === "completed");
27
27
  const edit = useQuery({
28
28
  queryKey: ["dashboard", "automations", "edit", kind, automationId],
@@ -74,9 +74,17 @@ export function AutomationEditPage(props: { enabled: boolean }) {
74
74
  ) : null}
75
75
  </div>
76
76
  {value ? (
77
- <StatusChip tone={statusTone}>
78
- {status === "unavailable" ? "Trigger unavailable" : status}
79
- </StatusChip>
77
+ <div className="flex items-center gap-4">
78
+ <Link
79
+ to={`/automations/${value.kind}/${encodeURIComponent(value.id)}/versions${location.search}`}
80
+ className="text-sm text-dashboard-text-muted underline underline-offset-2 hover:text-dashboard-text"
81
+ >
82
+ Version history
83
+ </Link>
84
+ <StatusChip tone={statusTone}>
85
+ {status === "unavailable" ? "Trigger unavailable" : status}
86
+ </StatusChip>
87
+ </div>
80
88
  ) : null}
81
89
  </div>
82
90
  {(!value && summary.error) ||
@@ -114,9 +122,8 @@ function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
114
122
  return (
115
123
  <>
116
124
  <p className="mb-6 text-sm text-dashboard-text-muted">
117
- {automation.ownedByViewer
118
- ? "This automation has completed and cannot be edited. It will not run again."
119
- : `Only ${automation.createdBy} can edit this automation.`}
125
+ This automation has completed and cannot be edited. It will not run
126
+ again.
120
127
  </p>
121
128
  <AutomationFormSection title="What to do" detail="The saved instruction.">
122
129
  <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
@@ -144,17 +151,10 @@ function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
144
151
  title="Where results go"
145
152
  detail="Messages after successful work."
146
153
  >
147
- {automation.outcomes.length ? (
148
- <ol className="m-0 grid gap-2 pl-5 text-sm">
149
- {automation.outcomes.map((outcome, index) => (
150
- <li key={index}>
151
- {automationOutcomeLabel(outcome, automation.destination)}
152
- </li>
153
- ))}
154
- </ol>
155
- ) : (
156
- <p className="m-0 text-sm">No success message</p>
157
- )}
154
+ <AutomationOutcomeList
155
+ outcomes={automation.outcomes}
156
+ destination={automation.destination}
157
+ />
158
158
  </AutomationFormSection>
159
159
  <AutomationFormSection
160
160
  title="Credentials"
@@ -16,7 +16,10 @@ import { DashboardApiError, fetchDashboardJson, patch } from "../../http";
16
16
  import { AutomationFormSection } from "./AutomationFormSection";
17
17
  import { AutomationScheduleFields } from "./AutomationScheduleFields";
18
18
  import { AutomationEventFields } from "./AutomationEventFields";
19
- import { AutomationOutcomeFields } from "./AutomationOutcomeFields";
19
+ import {
20
+ AutomationOutcomeFields,
21
+ AutomationOutcomeList,
22
+ } from "./AutomationOutcomeFields";
20
23
  import {
21
24
  automationDraftChanges,
22
25
  createAutomationDraft,
@@ -110,6 +113,14 @@ export function AutomationEditor(props: {
110
113
  .flatMap(([, messages]) => messages)
111
114
  .join(" ") || undefined;
112
115
  const triggerField = original.kind === "scheduled" ? "schedule" : "trigger";
116
+ const owned = original.ownedByViewer;
117
+ const creator = props.summary.createdBy;
118
+ // Core switches a non-creator's execution change to system credentials.
119
+ const dropsCreatorCredentials =
120
+ !owned &&
121
+ original.credentialMode === "creator" &&
122
+ (changes.instruction !== undefined ||
123
+ (original.kind === "event" && changes.trigger !== undefined));
113
124
  const triggerError = fieldError(triggerField);
114
125
  const inputState = (key: string) => ({
115
126
  "aria-invalid": Boolean(fieldError(key)) || undefined,
@@ -223,6 +234,13 @@ export function AutomationEditor(props: {
223
234
  </FormNotice>
224
235
  </div>
225
236
  ) : null}
237
+ {owned ? null : (
238
+ <p className="mt-0 mb-6 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
239
+ {creator} created this automation. You can edit it because it is in a
240
+ public channel. Only {creator} can change where results go or turn on
241
+ their connected accounts. Version history keeps every saved change.
242
+ </p>
243
+ )}
226
244
  <fieldset disabled={save.isPending} className="min-w-0">
227
245
  <AutomationFormSection
228
246
  title="What to do"
@@ -300,12 +318,24 @@ export function AutomationEditor(props: {
300
318
  {fieldError("outcomes")}
301
319
  </p>
302
320
  ) : null}
303
- <AutomationOutcomeFields
304
- value={draft.outcomes}
305
- original={original.outcomes}
306
- destination={props.summary.destination}
307
- onChange={(outcomes) => change({ outcomes })}
308
- />
321
+ {owned ? (
322
+ <AutomationOutcomeFields
323
+ value={draft.outcomes}
324
+ original={original.outcomes}
325
+ destination={props.summary.destination}
326
+ onChange={(outcomes) => change({ outcomes })}
327
+ />
328
+ ) : (
329
+ <>
330
+ <AutomationOutcomeList
331
+ outcomes={original.outcomes}
332
+ destination={props.summary.destination}
333
+ />
334
+ <p className="m-0 text-xs text-dashboard-text-muted">
335
+ Only {creator} can change where results go.
336
+ </p>
337
+ </>
338
+ )}
309
339
  </AutomationFormSection>
310
340
  <AutomationFormSection
311
341
  title="Credentials"
@@ -317,25 +347,41 @@ export function AutomationEditor(props: {
317
347
  [
318
348
  [
319
349
  "creator",
320
- "Your connected accounts",
321
- "Uses accounts connected by you, the creator.",
350
+ owned
351
+ ? "Your connected accounts"
352
+ : `${creator}’s connected accounts`,
353
+ owned
354
+ ? "Uses accounts connected by you, the creator."
355
+ : `Uses accounts connected by ${creator}. Only ${creator} can turn this on.`,
322
356
  ],
323
357
  [
324
358
  "system",
325
359
  "System credentials only",
326
- "Does not use your connected accounts. Some work may not be possible.",
360
+ owned
361
+ ? "Does not use your connected accounts. Some work may not be possible."
362
+ : "Does not use anyone’s connected accounts. Some work may not be possible.",
327
363
  ],
328
364
  ] as const
329
365
  ).map(([value, label, detail]) => (
330
366
  <label
331
367
  key={value}
332
- className="flex cursor-pointer items-start gap-3 px-4 py-4 has-checked:bg-dashboard-fill-soft"
368
+ className="flex cursor-pointer items-start gap-3 px-4 py-4 has-checked:bg-dashboard-fill-soft has-disabled:cursor-not-allowed has-disabled:opacity-60"
333
369
  >
334
370
  <input
335
371
  type="radio"
336
372
  name="credentials"
337
373
  className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
338
- checked={draft.credentialMode === value}
374
+ checked={
375
+ dropsCreatorCredentials
376
+ ? value === "system"
377
+ : draft.credentialMode === value
378
+ }
379
+ disabled={
380
+ value === "creator" &&
381
+ !owned &&
382
+ (original.credentialMode !== "creator" ||
383
+ dropsCreatorCredentials)
384
+ }
339
385
  onChange={() => change({ credentialMode: value })}
340
386
  />
341
387
  <span>
@@ -347,6 +393,13 @@ export function AutomationEditor(props: {
347
393
  </label>
348
394
  ))}
349
395
  </fieldset>
396
+ {dropsCreatorCredentials ? (
397
+ <FormNotice title="Saving switches this automation to system credentials.">
398
+ Only {creator} can let a changed instruction
399
+ {original.kind === "event" ? " or trigger" : ""} use their
400
+ connected accounts.
401
+ </FormNotice>
402
+ ) : null}
350
403
  {fieldError("credentialMode") ? (
351
404
  <p role="alert">{fieldError("credentialMode")}</p>
352
405
  ) : null}
@@ -9,7 +9,7 @@ import { StatusChip } from "../../components/StatusChip";
9
9
  import { FormNotice } from "../../components/FormNotice";
10
10
  import { DashboardApiError, fetchDashboardJson, post } from "../../http";
11
11
 
12
- /** Explain lifecycle separately from the last execution and offer creator-only actions. */
12
+ /** Explain lifecycle separately from the last execution. Pause and resume are creator-only. */
13
13
  export function AutomationLifecycle({
14
14
  automation,
15
15
  editPath,
@@ -23,7 +23,9 @@ export function AutomationLifecycle({
23
23
  const blocked = automation.status === "blocked";
24
24
  const unavailable =
25
25
  automation.kind === "event" && !automation.triggerAvailable;
26
- const canEdit = automation.ownedByViewer && !completed;
26
+ // Public readers can edit settings, but only the creator controls lifecycle.
27
+ const canEdit = !completed;
28
+ const canPause = automation.ownedByViewer && !completed;
27
29
  const blockReason =
28
30
  automation.kind === "scheduled" ? automation.statusReason : undefined;
29
31
  const action = paused || blocked ? "resume" : "pause";
@@ -78,7 +80,7 @@ export function AutomationLifecycle({
78
80
  <ButtonLink className="min-h-11" to={editPath}>
79
81
  {canEdit ? "Edit automation" : "View settings"}
80
82
  </ButtonLink>
81
- {canEdit ? (
83
+ {canPause ? (
82
84
  <Button
83
85
  className="min-h-11"
84
86
  disabled={mutation.isPending}
@@ -119,7 +121,7 @@ export function AutomationLifecycle({
119
121
  </Link>
120
122
  </p>
121
123
  ) : null}
122
- {canEdit ? (
124
+ {canPause ? (
123
125
  <details className="text-dashboard-text-muted">
124
126
  <summary className="w-fit cursor-pointer py-2 text-xs hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
127
  About pausing
@@ -9,6 +9,24 @@ import { cn } from "../../styles";
9
9
  import type { AutomationDraft } from "./automationDraft";
10
10
  import { automationOutcomeLabel } from "./automationOutcomes";
11
11
 
12
+ /** Show saved outcomes in order for a reader who cannot change them. */
13
+ export function AutomationOutcomeList(props: {
14
+ outcomes: AutomationEdit["outcomes"];
15
+ destination: AutomationSummary["destination"];
16
+ }) {
17
+ if (!props.outcomes.length)
18
+ return <p className="m-0 text-sm">No success message</p>;
19
+ return (
20
+ <ol className="m-0 grid gap-2 pl-5 text-sm">
21
+ {props.outcomes.map((outcome, index) => (
22
+ <li key={index}>
23
+ {automationOutcomeLabel(outcome, props.destination)}
24
+ </li>
25
+ ))}
26
+ </ol>
27
+ );
28
+ }
29
+
12
30
  /** Keep outcome order and offer only supported message Destinations. */
13
31
  export function AutomationOutcomeFields(props: {
14
32
  value: AutomationDraft["outcomes"];
@@ -0,0 +1,356 @@
1
+ /** Version history for one Automation. Making a version active saves it again. */
2
+ import { useState } from "react";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { Link, Navigate, useParams, useSearchParams } from "react-router";
5
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
6
+ import {
7
+ automationEditSchema,
8
+ type AutomationSummary,
9
+ type AutomationVersion,
10
+ type AutomationVersionList,
11
+ } from "@sentry/junior/api/schema";
12
+ import { useAutomationData, useAutomationVersionsData } from "../../api";
13
+ import { Button } from "../../components/Button";
14
+ import { Detail, DetailList } from "../../components/DetailList";
15
+ import { FormNotice } from "../../components/FormNotice";
16
+ import { InlineError } from "../../components/InlineError";
17
+ import { PageContentSkeleton } from "../../components/PageContentSkeleton";
18
+ import { StatusChip } from "../../components/StatusChip";
19
+ import { Card } from "../../components/layout/Card";
20
+ import { PageHeader } from "../../components/layout/PageHeader";
21
+ import { automationPath } from "../../format";
22
+ import { DashboardApiError, fetchDashboardJson, post } from "../../http";
23
+ import { pathWithSearch } from "../../searchParams";
24
+ import { formatAutomationRunTime } from "./automationFormat";
25
+ import { automationOutcomeLabel } from "./automationOutcomes";
26
+
27
+ const DESCRIPTION = "Saved settings for this automation, newest first.";
28
+
29
+ /** Load one Automation's versions with its summary for labels and access. */
30
+ export function AutomationVersionsPage(props: { enabled: boolean }) {
31
+ const { automationId, kind } = useParams();
32
+ const [searchParams] = useSearchParams();
33
+ const taskKind = kind === "scheduled" || kind === "event" ? kind : undefined;
34
+ const enabled = props.enabled && Boolean(automationId && taskKind);
35
+ const summary = useAutomationData(enabled, automationId);
36
+ const versions = useAutomationVersionsData(enabled, taskKind, automationId);
37
+ if (!automationId || !taskKind) {
38
+ return <Navigate replace to="/automations" />;
39
+ }
40
+ const backTo = pathWithSearch(automationPath(automationId), searchParams);
41
+ const error = summary.error ?? versions.error;
42
+ const notFound =
43
+ (error instanceof DashboardApiError && error.status === 404) ||
44
+ (summary.data && summary.data.kind !== taskKind);
45
+ if (error || notFound) {
46
+ return (
47
+ <>
48
+ <PageHeader description={DESCRIPTION} title="Version history" />
49
+ <Card padding="md">
50
+ <InlineError>
51
+ {notFound
52
+ ? "This automation was not found or is not visible to you."
53
+ : "Version history could not be loaded. Try again."}
54
+ </InlineError>
55
+ <BackLink to={backTo} />
56
+ </Card>
57
+ </>
58
+ );
59
+ }
60
+ if (!summary.data || !versions.data) {
61
+ return (
62
+ <>
63
+ <PageHeader description={DESCRIPTION} title="Version history" />
64
+ <PageContentSkeleton label="Loading version history" variant="list" />
65
+ </>
66
+ );
67
+ }
68
+ return (
69
+ <AutomationVersionsView
70
+ automation={summary.data}
71
+ backTo={backTo}
72
+ data={versions.data}
73
+ />
74
+ );
75
+ }
76
+
77
+ function BackLink(props: { to: string }) {
78
+ return (
79
+ <Link
80
+ className="mb-3 inline-flex items-center gap-2 font-mono text-xs text-dashboard-text-muted no-underline hover:text-dashboard-text"
81
+ to={props.to}
82
+ >
83
+ <ArrowLeft aria-hidden="true" size={14} />
84
+ Back to automation
85
+ </Link>
86
+ );
87
+ }
88
+
89
+ function AutomationVersionsView(props: {
90
+ automation: AutomationSummary;
91
+ backTo: string;
92
+ data: AutomationVersionList;
93
+ }) {
94
+ const { automation, data } = props;
95
+ const queryClient = useQueryClient();
96
+ const [activated, setActivated] = useState<number>();
97
+ const canActivate = !(
98
+ automation.kind === "scheduled" && automation.status === "completed"
99
+ );
100
+ const path = `/api/automations/${automation.kind}/${encodeURIComponent(automation.id)}`;
101
+ const activate = useMutation({
102
+ mutationFn: async (version: number) => {
103
+ // The user chose an exact saved version, so the latest revision is safe here.
104
+ const current = await fetchDashboardJson(
105
+ automationEditSchema,
106
+ `${path}/edit`,
107
+ );
108
+ return post(
109
+ automationEditSchema,
110
+ `${path}/versions/${version}/activate`,
111
+ { revision: current.revision },
112
+ );
113
+ },
114
+ onMutate: () => setActivated(undefined),
115
+ onSuccess: async (_, version) => {
116
+ setActivated(version);
117
+ await queryClient.invalidateQueries({
118
+ queryKey: ["dashboard", "automations"],
119
+ });
120
+ },
121
+ });
122
+ return (
123
+ <>
124
+ <div>
125
+ <BackLink to={props.backTo} />
126
+ <PageHeader
127
+ description={`${automation.kind} automation · ${automation.destination.label} · ${data.versions.length} ${data.versions.length === 1 ? "version" : "versions"}`}
128
+ title={automation.title}
129
+ />
130
+ </div>
131
+ <p className="m-0 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
132
+ {canActivate
133
+ ? "Making a version active saves its settings as a new version. Earlier versions stay in this list. It does not run the automation."
134
+ : "This automation has completed. Its versions are read-only."}
135
+ </p>
136
+ {activated !== undefined ? (
137
+ <p role="status" className="m-0 text-sm text-emerald-300">
138
+ Version {activated} is active again. It was saved as a new version.
139
+ </p>
140
+ ) : null}
141
+ {data.versions.length === 0 ? (
142
+ <Card padding="md">
143
+ <p className="m-0 text-sm text-dashboard-text-muted">
144
+ No versions are saved yet. Junior saves a version each time the
145
+ settings change.
146
+ </p>
147
+ </Card>
148
+ ) : (
149
+ <Card>
150
+ <ol className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
151
+ {data.versions.map((version, index) => (
152
+ <VersionRow
153
+ key={version.version}
154
+ active={version.version === data.activeVersion}
155
+ automation={automation}
156
+ canActivate={canActivate}
157
+ error={
158
+ activate.variables === version.version ? activate.error : null
159
+ }
160
+ onActivate={() => {
161
+ const creatorOnly = automation.ownedByViewer
162
+ ? ""
163
+ : ` Only ${automation.createdBy} can turn on their connected accounts or change where results go.`;
164
+ if (
165
+ window.confirm(
166
+ `Make version ${version.version} active? Junior saves these settings as a new version. Future runs use them.${creatorOnly}`,
167
+ )
168
+ )
169
+ activate.mutate(version.version);
170
+ }}
171
+ pending={
172
+ activate.isPending && activate.variables === version.version
173
+ }
174
+ previous={data.versions[index + 1]}
175
+ truncated={data.truncated}
176
+ version={version}
177
+ />
178
+ ))}
179
+ </ol>
180
+ </Card>
181
+ )}
182
+ {data.truncated ? (
183
+ <p className="m-0 text-center text-xs text-dashboard-text-muted">
184
+ Showing the 100 most recent versions.
185
+ </p>
186
+ ) : null}
187
+ </>
188
+ );
189
+ }
190
+
191
+ function VersionRow(props: {
192
+ active: boolean;
193
+ automation: AutomationSummary;
194
+ canActivate: boolean;
195
+ error: Error | null;
196
+ onActivate(): void;
197
+ pending: boolean;
198
+ previous: AutomationVersion | undefined;
199
+ truncated: boolean;
200
+ version: AutomationVersion;
201
+ }) {
202
+ const { automation, version } = props;
203
+ const editor = version.editedBy
204
+ ? (version.editedBy.fullName ??
205
+ version.editedBy.userName ??
206
+ version.editedBy.slackUserId)
207
+ : "Editor unknown";
208
+ const changes = props.previous
209
+ ? changedFields(props.previous, version)
210
+ : undefined;
211
+ const changeText = changes
212
+ ? changes.length
213
+ ? `Changed ${changes.join(", ")}`
214
+ : "No setting changes"
215
+ : version.version === 1 || !props.truncated
216
+ ? "First saved version"
217
+ : undefined;
218
+ const titleId = `automation-version-${version.version}`;
219
+ return (
220
+ <li aria-labelledby={titleId} className="grid gap-3 px-4 py-4 sm:px-5">
221
+ <div className="flex flex-wrap items-start justify-between gap-3">
222
+ <div className="min-w-0">
223
+ <div className="flex flex-wrap items-center gap-3">
224
+ <h3 id={titleId} className="m-0 text-sm font-medium">
225
+ Version {version.version}
226
+ </h3>
227
+ {props.active ? (
228
+ <StatusChip size="compact" tone="success">
229
+ Active
230
+ </StatusChip>
231
+ ) : null}
232
+ </div>
233
+ <p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
234
+ {editor} · {formatAutomationRunTime(version.createdAt)}
235
+ </p>
236
+ {changeText ? (
237
+ <p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
238
+ {changeText}
239
+ </p>
240
+ ) : null}
241
+ </div>
242
+ {props.canActivate && !props.active ? (
243
+ <Button
244
+ aria-label={`Make version ${version.version} active`}
245
+ disabled={props.pending}
246
+ onClick={props.onActivate}
247
+ >
248
+ {props.pending ? "Saving…" : "Make active"}
249
+ </Button>
250
+ ) : null}
251
+ </div>
252
+ {props.error ? (
253
+ <FormNotice tone="error" title="This version could not be made active.">
254
+ {props.error instanceof DashboardApiError
255
+ ? props.error.code === "conflict"
256
+ ? "The automation changed. Try again."
257
+ : (props.error.apiError ?? "Try again.")
258
+ : "Try again."}
259
+ </FormNotice>
260
+ ) : null}
261
+ <details>
262
+ <summary className="w-fit cursor-pointer py-1 text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
263
+ Show settings
264
+ </summary>
265
+ <VersionSettings automation={automation} version={version} />
266
+ </details>
267
+ </li>
268
+ );
269
+ }
270
+
271
+ function VersionSettings(props: {
272
+ automation: AutomationSummary;
273
+ version: AutomationVersion;
274
+ }) {
275
+ const { automation, version } = props;
276
+ const { definition } = version;
277
+ const destination =
278
+ definition.destination.channelId === automation.destination.channelId &&
279
+ definition.destination.teamId === automation.destination.teamId
280
+ ? automation.destination.label
281
+ : `Another destination · ${definition.destination.channelId}`;
282
+ return (
283
+ <DetailList className="mt-2">
284
+ <Detail label="Title">
285
+ {definition.title ?? "First line of the instruction"}
286
+ </Detail>
287
+ <Detail label="What to do" valueClassName="whitespace-pre-wrap">
288
+ {definition.instruction}
289
+ </Detail>
290
+ <Detail label="When to run">
291
+ {version.kind === "scheduled" ? (
292
+ version.definition.schedule.description.includes(
293
+ version.definition.schedule.timezone,
294
+ ) ? (
295
+ version.definition.schedule.description
296
+ ) : (
297
+ `${version.definition.schedule.description} (${version.definition.schedule.timezone})`
298
+ )
299
+ ) : (
300
+ <>
301
+ {version.definition.trigger.label} ·{" "}
302
+ {version.definition.trigger.identifier}
303
+ <br />
304
+ {version.definition.trigger.events.join(", ")}
305
+ {Object.keys(version.definition.trigger.match ?? {}).length ? (
306
+ <pre className="mt-2 mb-0 whitespace-pre-wrap break-words text-xs">
307
+ {JSON.stringify(version.definition.trigger.match, null, 2)}
308
+ </pre>
309
+ ) : null}
310
+ </>
311
+ )}
312
+ </Detail>
313
+ <Detail label="Destination">{destination}</Detail>
314
+ <Detail label="On success">
315
+ {definition.outcomes.length ? (
316
+ <ol className="m-0 grid gap-1 p-0">
317
+ {definition.outcomes.map((outcome, index) => (
318
+ <li key={index}>
319
+ {automationOutcomeLabel(outcome, automation.destination)}
320
+ </li>
321
+ ))}
322
+ </ol>
323
+ ) : (
324
+ "No message on success"
325
+ )}
326
+ </Detail>
327
+ <Detail label="Connected accounts">
328
+ {definition.credentialMode === "creator"
329
+ ? `Uses accounts connected by ${automation.ownedByViewer ? "you" : automation.createdBy}.`
330
+ : "System credentials only"}
331
+ </Detail>
332
+ </DetailList>
333
+ );
334
+ }
335
+
336
+ /** Name the settings that differ from the previous version. */
337
+ function changedFields(
338
+ previous: AutomationVersion,
339
+ version: AutomationVersion,
340
+ ): string[] {
341
+ const before: Record<string, unknown> = previous.definition;
342
+ const after: Record<string, unknown> = version.definition;
343
+ return (
344
+ [
345
+ ["title", "title"],
346
+ ["instruction", "instruction"],
347
+ ["schedule", "schedule"],
348
+ ["trigger", "trigger"],
349
+ ["destination", "destination"],
350
+ ["outcomes", "success messages"],
351
+ ["credentialMode", "connected accounts"],
352
+ ] as const
353
+ ).flatMap(([key, label]) =>
354
+ JSON.stringify(before[key]) === JSON.stringify(after[key]) ? [] : [label],
355
+ );
356
+ }