@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,177 @@
|
|
|
1
|
+
import { ArrowUp, Trash2, Plus } from "lucide-react";
|
|
2
|
+
import type {
|
|
3
|
+
AutomationEdit,
|
|
4
|
+
AutomationSummary,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
import { Button } from "../../components/Button";
|
|
7
|
+
import { Select } from "../../components/Select";
|
|
8
|
+
import { cn } from "../../styles";
|
|
9
|
+
import type { AutomationDraft } from "./automationDraft";
|
|
10
|
+
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
11
|
+
|
|
12
|
+
/** Keep outcome order and offer only supported message Destinations. */
|
|
13
|
+
export function AutomationOutcomeFields(props: {
|
|
14
|
+
value: AutomationDraft["outcomes"];
|
|
15
|
+
original: AutomationEdit["outcomes"];
|
|
16
|
+
destination: AutomationSummary["destination"];
|
|
17
|
+
onChange(value: AutomationDraft["outcomes"]): void;
|
|
18
|
+
}) {
|
|
19
|
+
const defaultOutcomes: AutomationDraft["outcomes"] = props.original.length
|
|
20
|
+
? props.original
|
|
21
|
+
: [{ action: "send_message", destination: "current_conversation" }];
|
|
22
|
+
const options = [
|
|
23
|
+
...props.original.flatMap((outcome, index) =>
|
|
24
|
+
typeof outcome.destination === "string"
|
|
25
|
+
? []
|
|
26
|
+
: [
|
|
27
|
+
{
|
|
28
|
+
value: `saved:${index}`,
|
|
29
|
+
label: automationOutcomeLabel(outcome, props.destination),
|
|
30
|
+
outcome,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
{
|
|
35
|
+
value: "current_conversation",
|
|
36
|
+
label: `${props.destination.label} · ${props.destination.visibility}`,
|
|
37
|
+
outcome: {
|
|
38
|
+
action: "send_message",
|
|
39
|
+
destination: "current_conversation",
|
|
40
|
+
} as const,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
value: "task_creator",
|
|
44
|
+
label: "Creator · Direct message",
|
|
45
|
+
outcome: { action: "send_message", destination: "task_creator" } as const,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<fieldset>
|
|
51
|
+
<legend className="sr-only">Success messages</legend>
|
|
52
|
+
<div className="flex flex-wrap gap-x-6 gap-y-3">
|
|
53
|
+
{[true, false].map((send) => (
|
|
54
|
+
<label
|
|
55
|
+
key={String(send)}
|
|
56
|
+
className="inline-flex min-h-8 cursor-pointer items-center gap-2 text-sm"
|
|
57
|
+
>
|
|
58
|
+
<input
|
|
59
|
+
type="radio"
|
|
60
|
+
name="messages"
|
|
61
|
+
className="size-4 accent-dashboard-focus"
|
|
62
|
+
checked={Boolean(props.value.length) === send}
|
|
63
|
+
onChange={() => props.onChange(send ? defaultOutcomes : [])}
|
|
64
|
+
/>
|
|
65
|
+
{send ? "Send a message" : "No success message"}
|
|
66
|
+
</label>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</fieldset>
|
|
70
|
+
{props.value.length ? (
|
|
71
|
+
<div className="grid gap-4 sm:gap-2">
|
|
72
|
+
{props.value.map((outcome, index) => {
|
|
73
|
+
const retainedIndex = props.original.findIndex(
|
|
74
|
+
(item) => JSON.stringify(item) === JSON.stringify(outcome),
|
|
75
|
+
);
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
key={index}
|
|
79
|
+
className="flex min-w-0 flex-wrap items-center gap-2"
|
|
80
|
+
>
|
|
81
|
+
<span className="w-4 text-xs text-dashboard-text-muted">
|
|
82
|
+
{index + 1}.
|
|
83
|
+
</span>
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
"min-w-0 flex-1",
|
|
87
|
+
props.value.length > 1 &&
|
|
88
|
+
"order-last basis-full sm:order-none sm:basis-auto",
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<Select
|
|
92
|
+
aria-label={`Message ${index + 1} destination`}
|
|
93
|
+
value={
|
|
94
|
+
typeof outcome.destination === "string"
|
|
95
|
+
? outcome.destination
|
|
96
|
+
: `saved:${retainedIndex}`
|
|
97
|
+
}
|
|
98
|
+
onChange={(e) => {
|
|
99
|
+
const selected = options.find(
|
|
100
|
+
(option) => option.value === e.target.value,
|
|
101
|
+
);
|
|
102
|
+
if (selected)
|
|
103
|
+
props.onChange(
|
|
104
|
+
props.value.map((item, i) =>
|
|
105
|
+
i === index ? selected.outcome : item,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{options.map((option) => (
|
|
111
|
+
<option key={option.value} value={option.value}>
|
|
112
|
+
{option.label}
|
|
113
|
+
</option>
|
|
114
|
+
))}
|
|
115
|
+
</Select>
|
|
116
|
+
</div>
|
|
117
|
+
{props.value.length > 1 ? (
|
|
118
|
+
<>
|
|
119
|
+
<Button
|
|
120
|
+
size="icon"
|
|
121
|
+
className="ml-auto sm:ml-0"
|
|
122
|
+
aria-label={`Move message ${index + 1} up`}
|
|
123
|
+
disabled={index === 0}
|
|
124
|
+
onClick={() => {
|
|
125
|
+
const next = [...props.value];
|
|
126
|
+
[next[index - 1], next[index]] = [
|
|
127
|
+
next[index],
|
|
128
|
+
next[index - 1],
|
|
129
|
+
];
|
|
130
|
+
props.onChange(next);
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<ArrowUp size={16} />
|
|
134
|
+
</Button>
|
|
135
|
+
<Button
|
|
136
|
+
size="icon"
|
|
137
|
+
aria-label={`Remove message ${index + 1}`}
|
|
138
|
+
onClick={() =>
|
|
139
|
+
props.onChange(
|
|
140
|
+
props.value.filter((_, i) => i !== index),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
<Trash2 size={16} />
|
|
145
|
+
</Button>
|
|
146
|
+
</>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
{props.value.length < 5 ? (
|
|
152
|
+
<Button
|
|
153
|
+
className="w-fit"
|
|
154
|
+
onClick={() =>
|
|
155
|
+
props.onChange([
|
|
156
|
+
...props.value,
|
|
157
|
+
{ action: "send_message", destination: "task_creator" },
|
|
158
|
+
])
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<Plus size={15} />
|
|
162
|
+
Add message
|
|
163
|
+
</Button>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
) : (
|
|
167
|
+
<p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
|
|
168
|
+
Junior still does the work. No message is sent after success.
|
|
169
|
+
</p>
|
|
170
|
+
)}
|
|
171
|
+
<p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
172
|
+
{props.value.length ? "Messages are sent in this order. " : ""}This does
|
|
173
|
+
not limit actions in the instruction.
|
|
174
|
+
</p>
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
2
|
+
import {
|
|
3
|
+
CalendarClock,
|
|
4
|
+
LockKeyhole,
|
|
5
|
+
MoreHorizontal,
|
|
6
|
+
Trash2,
|
|
7
|
+
Zap,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
10
|
+
import { Link, useLocation } from "react-router";
|
|
11
|
+
import { ActorAvatar } from "../../components/ActorAvatar";
|
|
12
|
+
import { SelectableRow } from "../../components/SelectableRow";
|
|
13
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
14
|
+
import { formatRelativeTime, formatTime, peoplePath } from "../../format";
|
|
15
|
+
import { cn } from "../../styles";
|
|
16
|
+
|
|
17
|
+
const desktopColumns =
|
|
18
|
+
"xl:grid-cols-[minmax(0,1fr)_9rem_16rem_10rem_7rem_2.75rem]";
|
|
19
|
+
|
|
20
|
+
/** Keep the management headings aligned with the row fields. */
|
|
21
|
+
export function AutomationListHeader() {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
className={cn(
|
|
26
|
+
"hidden items-center gap-4 border-b border-dashboard-border-subtle px-4 py-2.5 text-xs text-dashboard-text-muted xl:grid",
|
|
27
|
+
desktopColumns,
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<span>Automation</span>
|
|
31
|
+
<span>Creator</span>
|
|
32
|
+
<span>Trigger</span>
|
|
33
|
+
<span>Destination</span>
|
|
34
|
+
<span>Last run</span>
|
|
35
|
+
<span className="sr-only">Actions</span>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show purpose, ownership, and trigger state without opening the details. */
|
|
41
|
+
export function AutomationRow(props: {
|
|
42
|
+
deleting: boolean;
|
|
43
|
+
onDelete(): void;
|
|
44
|
+
onSelect(): void;
|
|
45
|
+
selected: boolean;
|
|
46
|
+
automation: AutomationSummary;
|
|
47
|
+
}) {
|
|
48
|
+
const { automation } = props;
|
|
49
|
+
const location = useLocation();
|
|
50
|
+
const creator = (
|
|
51
|
+
<>
|
|
52
|
+
<ActorAvatar
|
|
53
|
+
imageUrl={automation.createdByAvatarUrl}
|
|
54
|
+
name={automation.createdBy}
|
|
55
|
+
size="list"
|
|
56
|
+
/>
|
|
57
|
+
<span className="truncate">{automation.createdBy}</span>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
return (
|
|
61
|
+
<article role="listitem">
|
|
62
|
+
<SelectableRow
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-4 py-3 xl:gap-x-4",
|
|
65
|
+
desktopColumns,
|
|
66
|
+
)}
|
|
67
|
+
onSelect={props.onSelect}
|
|
68
|
+
selected={props.selected}
|
|
69
|
+
>
|
|
70
|
+
<button
|
|
71
|
+
aria-expanded={props.selected}
|
|
72
|
+
aria-label={`View automation details: ${automation.title}`}
|
|
73
|
+
className="col-span-2 pr-11 xl:pr-0 min-w-0 cursor-pointer rounded border-0 bg-transparent p-0 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus xl:col-span-1"
|
|
74
|
+
onClick={props.onSelect}
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
<span
|
|
78
|
+
className="block truncate font-display text-base font-medium text-dashboard-text"
|
|
79
|
+
title={automation.title}
|
|
80
|
+
>
|
|
81
|
+
{automation.title}
|
|
82
|
+
</span>
|
|
83
|
+
<span
|
|
84
|
+
className="mt-1 block truncate text-xs text-dashboard-text-muted"
|
|
85
|
+
title={automation.instruction}
|
|
86
|
+
>
|
|
87
|
+
{automation.instruction}
|
|
88
|
+
</span>
|
|
89
|
+
</button>
|
|
90
|
+
<div className="row-start-2 min-w-0 xl:row-auto">
|
|
91
|
+
{automation.createdByEmail ? (
|
|
92
|
+
<Link
|
|
93
|
+
className="inline-flex max-w-full items-center gap-2 rounded text-sm text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
94
|
+
title={automation.createdBy}
|
|
95
|
+
to={peoplePath(automation.createdByEmail)}
|
|
96
|
+
>
|
|
97
|
+
{creator}
|
|
98
|
+
</Link>
|
|
99
|
+
) : (
|
|
100
|
+
<span
|
|
101
|
+
className="inline-flex max-w-full items-center gap-2 text-sm text-dashboard-text"
|
|
102
|
+
title={automation.createdBy}
|
|
103
|
+
>
|
|
104
|
+
{creator}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="col-span-2 row-start-3 min-w-0 xl:col-span-1 xl:row-auto">
|
|
109
|
+
<AutomationTrigger automation={automation} />
|
|
110
|
+
</div>
|
|
111
|
+
<div className="col-start-2 row-start-2 min-w-0 text-right xl:col-auto xl:row-auto xl:text-left">
|
|
112
|
+
<span className="inline-flex max-w-full items-center gap-1 text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text">
|
|
113
|
+
{automation.destination.visibility === "private" ? (
|
|
114
|
+
<LockKeyhole
|
|
115
|
+
aria-label="Private destination"
|
|
116
|
+
role="img"
|
|
117
|
+
size={12}
|
|
118
|
+
className="shrink-0"
|
|
119
|
+
/>
|
|
120
|
+
) : null}
|
|
121
|
+
<span className="truncate">{automation.destination.label}</span>
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="col-span-2 min-w-0 xl:col-span-1">
|
|
125
|
+
<span className="text-xs text-dashboard-text-muted xl:hidden">
|
|
126
|
+
Last run{" "}
|
|
127
|
+
</span>
|
|
128
|
+
<span
|
|
129
|
+
className="text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text"
|
|
130
|
+
title={
|
|
131
|
+
automation.lastRunAt
|
|
132
|
+
? formatTime(automation.lastRunAt, {
|
|
133
|
+
dateStyle: "medium",
|
|
134
|
+
timeStyle: "short",
|
|
135
|
+
})
|
|
136
|
+
: undefined
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{automation.lastRunAt
|
|
140
|
+
? formatRelativeTime(automation.lastRunAt)
|
|
141
|
+
: "Never"}
|
|
142
|
+
</span>
|
|
143
|
+
{automation.lastRunStatus === "failed" ||
|
|
144
|
+
automation.lastRunStatus === "blocked" ? (
|
|
145
|
+
<Link
|
|
146
|
+
className="block text-xs text-amber-300 underline"
|
|
147
|
+
to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
|
|
148
|
+
>
|
|
149
|
+
Last run {automation.lastRunStatus}
|
|
150
|
+
</Link>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
<div className="absolute right-3 top-3 xl:static">
|
|
154
|
+
{automation.ownedByViewer ? (
|
|
155
|
+
<AutomationActions
|
|
156
|
+
editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
|
|
157
|
+
deleting={props.deleting}
|
|
158
|
+
onDelete={props.onDelete}
|
|
159
|
+
title={automation.title}
|
|
160
|
+
/>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
</SelectableRow>
|
|
164
|
+
</article>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function AutomationTrigger({ automation }: { automation: AutomationSummary }) {
|
|
169
|
+
const scheduled = automation.kind === "scheduled";
|
|
170
|
+
const Icon = scheduled ? CalendarClock : Zap;
|
|
171
|
+
let summary: string;
|
|
172
|
+
let detail: string;
|
|
173
|
+
let status: string | undefined;
|
|
174
|
+
let conditions: string | undefined;
|
|
175
|
+
|
|
176
|
+
if (automation.kind === "scheduled") {
|
|
177
|
+
summary = automation.schedule.includes(automation.timezone)
|
|
178
|
+
? automation.schedule
|
|
179
|
+
: `${automation.schedule} (${automation.timezone})`;
|
|
180
|
+
status = automation.status === "active" ? undefined : automation.status;
|
|
181
|
+
detail = "No next run";
|
|
182
|
+
if (automation.status === "blocked") {
|
|
183
|
+
detail = "Future runs are blocked";
|
|
184
|
+
} else if (automation.status === "paused") {
|
|
185
|
+
detail = "Future triggers will not start work";
|
|
186
|
+
} else if (automation.status === "active" && automation.nextRunAt) {
|
|
187
|
+
const nextRun = new Date(automation.nextRunAt).toLocaleString(undefined, {
|
|
188
|
+
month: "short",
|
|
189
|
+
day: "numeric",
|
|
190
|
+
hour: "numeric",
|
|
191
|
+
minute: "2-digit",
|
|
192
|
+
timeZone: automation.timezone,
|
|
193
|
+
timeZoneName: "short",
|
|
194
|
+
});
|
|
195
|
+
detail = `Next ${nextRun}`;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
summary = automation.resource;
|
|
199
|
+
detail = automation.events
|
|
200
|
+
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
201
|
+
.join(", ");
|
|
202
|
+
status =
|
|
203
|
+
automation.status === "paused"
|
|
204
|
+
? "paused"
|
|
205
|
+
: automation.triggerAvailable
|
|
206
|
+
? undefined
|
|
207
|
+
: "Trigger unavailable";
|
|
208
|
+
conditions = Object.entries(automation.match ?? {})
|
|
209
|
+
.map(
|
|
210
|
+
([field, value]) =>
|
|
211
|
+
`${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
|
|
212
|
+
)
|
|
213
|
+
.join(" · ");
|
|
214
|
+
}
|
|
215
|
+
return (
|
|
216
|
+
<div className="flex items-start gap-2">
|
|
217
|
+
<Icon
|
|
218
|
+
aria-label={
|
|
219
|
+
scheduled
|
|
220
|
+
? "Scheduled automation"
|
|
221
|
+
: `${automation.source} event automation`
|
|
222
|
+
}
|
|
223
|
+
className="mt-0.5 shrink-0 text-dashboard-text-muted"
|
|
224
|
+
role="img"
|
|
225
|
+
size={16}
|
|
226
|
+
/>
|
|
227
|
+
<div className="min-w-0">
|
|
228
|
+
<div className="break-words text-sm text-dashboard-text">{summary}</div>
|
|
229
|
+
<div className="mt-1 break-words text-xs text-dashboard-text-muted">
|
|
230
|
+
{detail}
|
|
231
|
+
</div>
|
|
232
|
+
{conditions ? (
|
|
233
|
+
<div className="mt-1 break-words text-xs text-dashboard-text-muted">
|
|
234
|
+
{conditions}
|
|
235
|
+
</div>
|
|
236
|
+
) : null}
|
|
237
|
+
{status ? (
|
|
238
|
+
<StatusChip
|
|
239
|
+
className="mt-1 xl:mt-2"
|
|
240
|
+
size="compact"
|
|
241
|
+
tone={
|
|
242
|
+
status === "completed" || status === "paused"
|
|
243
|
+
? "neutral"
|
|
244
|
+
: "warning"
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
{status}
|
|
248
|
+
</StatusChip>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function AutomationActions(props: {
|
|
256
|
+
editPath: string;
|
|
257
|
+
deleting: boolean;
|
|
258
|
+
onDelete(): void;
|
|
259
|
+
title: string;
|
|
260
|
+
}) {
|
|
261
|
+
const [open, setOpen] = useState(false);
|
|
262
|
+
const root = useRef<HTMLDivElement>(null);
|
|
263
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
264
|
+
const item = useRef<HTMLButtonElement>(null);
|
|
265
|
+
const menuId = useId();
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!open) return;
|
|
269
|
+
item.current?.focus();
|
|
270
|
+
function closeOutside(event: PointerEvent) {
|
|
271
|
+
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener("pointerdown", closeOutside);
|
|
274
|
+
return () => document.removeEventListener("pointerdown", closeOutside);
|
|
275
|
+
}, [open]);
|
|
276
|
+
|
|
277
|
+
function close() {
|
|
278
|
+
setOpen(false);
|
|
279
|
+
trigger.current?.focus();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
className="relative"
|
|
285
|
+
onBlur={(event) => {
|
|
286
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
|
|
287
|
+
}}
|
|
288
|
+
onKeyDown={(event) => {
|
|
289
|
+
if (event.key === "Escape") {
|
|
290
|
+
event.stopPropagation();
|
|
291
|
+
close();
|
|
292
|
+
} else if (
|
|
293
|
+
["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
|
|
294
|
+
) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
setOpen(true);
|
|
297
|
+
const items = Array.from(
|
|
298
|
+
root.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ??
|
|
299
|
+
[],
|
|
300
|
+
);
|
|
301
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
302
|
+
const next =
|
|
303
|
+
event.key === "Home"
|
|
304
|
+
? 0
|
|
305
|
+
: event.key === "End"
|
|
306
|
+
? items.length - 1
|
|
307
|
+
: (index + (event.key === "ArrowUp" ? -1 : 1) + items.length) %
|
|
308
|
+
items.length;
|
|
309
|
+
items[next]?.focus();
|
|
310
|
+
}
|
|
311
|
+
}}
|
|
312
|
+
ref={root}
|
|
313
|
+
>
|
|
314
|
+
<button
|
|
315
|
+
aria-controls={open ? menuId : undefined}
|
|
316
|
+
aria-expanded={open}
|
|
317
|
+
aria-haspopup="menu"
|
|
318
|
+
aria-label={`Actions: ${props.title}`}
|
|
319
|
+
className="grid size-11 cursor-pointer place-items-center rounded-md border-0 bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus disabled:cursor-not-allowed disabled:opacity-50 xl:size-9"
|
|
320
|
+
disabled={props.deleting}
|
|
321
|
+
onClick={() => setOpen(!open)}
|
|
322
|
+
ref={trigger}
|
|
323
|
+
type="button"
|
|
324
|
+
>
|
|
325
|
+
<MoreHorizontal aria-hidden="true" size={18} />
|
|
326
|
+
</button>
|
|
327
|
+
{open ? (
|
|
328
|
+
<div
|
|
329
|
+
aria-label={`Actions: ${props.title}`}
|
|
330
|
+
className="absolute right-0 top-full z-20 min-w-36 rounded-md border border-dashboard-border bg-dashboard-surface-raised p-1 shadow-lg"
|
|
331
|
+
id={menuId}
|
|
332
|
+
onClick={(event) => event.stopPropagation()}
|
|
333
|
+
role="menu"
|
|
334
|
+
>
|
|
335
|
+
<Link
|
|
336
|
+
role="menuitem"
|
|
337
|
+
to={props.editPath}
|
|
338
|
+
className="flex min-h-11 items-center rounded px-3 text-sm text-dashboard-text no-underline hover:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-dashboard-focus"
|
|
339
|
+
>
|
|
340
|
+
Edit automation
|
|
341
|
+
</Link>
|
|
342
|
+
<button
|
|
343
|
+
className="flex min-h-11 w-full cursor-pointer items-center gap-2 rounded border-0 bg-transparent px-3 text-sm text-rose-300 hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
344
|
+
onClick={() => {
|
|
345
|
+
close();
|
|
346
|
+
props.onDelete();
|
|
347
|
+
}}
|
|
348
|
+
ref={item}
|
|
349
|
+
role="menuitem"
|
|
350
|
+
type="button"
|
|
351
|
+
>
|
|
352
|
+
<Trash2 aria-hidden="true" size={15} /> Delete
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
) : null}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|