@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.
- package/dist/app.js +14 -7
- package/dist/client.js +37 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -7
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/app.ts +7 -1
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/client/App.tsx +22 -0
- package/src/client/api.ts +20 -0
- package/src/client/conversations/ConversationEventLog.tsx +45 -5
- package/src/client/conversations/ConversationForkLinks.tsx +51 -0
- package/src/client/conversations/ConversationHeader.tsx +12 -1
- package/src/client/conversations/ConversationPage.tsx +72 -2
- package/src/client/conversations/ForkConversationButton.tsx +125 -0
- package/src/client/conversations/TranscriptMessageView.tsx +28 -7
- package/src/client/conversations/TranscriptView.tsx +4 -0
- package/src/client/conversations/conversationRoutes.ts +10 -0
- package/src/client/conversations/queries.ts +45 -0
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +14 -6
- package/src/client/pages/automations/AutomationEditPage.tsx +20 -20
- package/src/client/pages/automations/AutomationEditor.tsx +65 -12
- package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
- package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
- package/src/client/pages/automations/README.md +14 -3
- package/src/mock-reporting/fixtures.ts +8 -2
|
@@ -114,16 +114,24 @@ export function AutomationDetailsDrawer(props: {
|
|
|
114
114
|
)}
|
|
115
115
|
</Detail>
|
|
116
116
|
</DetailList>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)}/
|
|
130
|
+
to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/versions${location.search}`}
|
|
123
131
|
>
|
|
124
|
-
|
|
132
|
+
Version history
|
|
125
133
|
</Link>
|
|
126
|
-
|
|
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 {
|
|
17
|
+
import { AutomationOutcomeList } from "./AutomationOutcomeFields";
|
|
18
18
|
|
|
19
|
-
/** Load a linkable editor
|
|
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
|
|
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
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 {
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
}
|