@checkstack/automation-frontend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/package.json +13 -9
  3. package/src/components/AutomationGroupCombobox.tsx +133 -0
  4. package/src/editor/ActionEditor.tsx +180 -90
  5. package/src/editor/ActionListEditor.tsx +27 -1
  6. package/src/editor/AddActionDialog.tsx +15 -45
  7. package/src/editor/AddConditionDialog.tsx +86 -0
  8. package/src/editor/AddTriggerDialog.tsx +97 -0
  9. package/src/editor/AutomationDefinitionEditor.tsx +41 -2
  10. package/src/editor/ConditionEditor.tsx +359 -70
  11. package/src/editor/ConditionsEditor.tsx +113 -44
  12. package/src/editor/ItemSheet.tsx +51 -0
  13. package/src/editor/RunReplayPicker.tsx +97 -0
  14. package/src/editor/ScriptServicesBooter.tsx +53 -0
  15. package/src/editor/ScriptTestRenderer.tsx +150 -0
  16. package/src/editor/SystemEntityPicker.test.ts +37 -0
  17. package/src/editor/SystemEntityPicker.tsx +109 -0
  18. package/src/editor/TriggersEditor.tsx +345 -137
  19. package/src/editor/action-helpers.test.ts +107 -0
  20. package/src/editor/action-helpers.ts +72 -0
  21. package/src/editor/action-leaf-cards.tsx +98 -1
  22. package/src/editor/condition-kind.test.ts +126 -0
  23. package/src/editor/condition-kind.ts +130 -0
  24. package/src/editor/item-summary.test.ts +171 -0
  25. package/src/editor/item-summary.ts +210 -0
  26. package/src/editor/picker-dialog.tsx +156 -0
  27. package/src/editor/registry-context.tsx +9 -2
  28. package/src/editor/script-actions.test.ts +184 -0
  29. package/src/editor/script-actions.ts +146 -0
  30. package/src/editor/system-entity-picker.logic.ts +23 -0
  31. package/src/editor/template-completion.test.ts +22 -3
  32. package/src/editor/template-completion.ts +16 -8
  33. package/src/editor/template-helpers.ts +4 -0
  34. package/src/editor/trigger-helpers.test.ts +28 -0
  35. package/src/editor/trigger-helpers.ts +17 -0
  36. package/src/editor/useScriptDiagnostics.ts +108 -0
  37. package/src/index.tsx +2 -0
  38. package/src/pages/AutomationEditPage.tsx +95 -47
  39. package/src/pages/AutomationListPage.tsx +172 -123
  40. package/src/pages/automation-grouping.test.ts +86 -0
  41. package/src/pages/automation-grouping.ts +65 -0
  42. package/src/script-context.test.ts +142 -1
  43. package/src/script-context.ts +115 -0
  44. package/tsconfig.json +12 -0
@@ -1,6 +1,11 @@
1
1
  import React from "react";
2
2
  import {
3
+ Accordion,
4
+ AccordionContent,
5
+ AccordionItem,
6
+ AccordionTrigger,
3
7
  ActionCard,
8
+ type ActionCardMenuItem,
4
9
  Checkbox,
5
10
  cn,
6
11
  Input,
@@ -20,6 +25,7 @@ import type {
20
25
  StopInput,
21
26
  VariablesInput,
22
27
  WaitForTriggerInput,
28
+ WaitUntilInput,
23
29
  } from "@checkstack/automation-common";
24
30
  import {
25
31
  ACTION_KIND_META,
@@ -28,6 +34,8 @@ import {
28
34
  collectActionIds,
29
35
  defaultActionId,
30
36
  } from "./action-helpers";
37
+ import { summarizeAction } from "./item-summary";
38
+ import { ItemSheet } from "./ItemSheet";
31
39
  import { useAutomationRegistry, useVariableScope } from "./registry-context";
32
40
  import { useActionIssues } from "./editor-validation";
33
41
  import {
@@ -37,6 +45,7 @@ import {
37
45
  StopActionBody,
38
46
  VariablesActionBody,
39
47
  WaitForTriggerActionBody,
48
+ WaitUntilActionBody,
40
49
  } from "./action-leaf-cards";
41
50
  import {
42
51
  ChooseActionBody,
@@ -49,6 +58,8 @@ export interface ActionEditorProps {
49
58
  value: ActionInput;
50
59
  onChange: (next: ActionInput) => void;
51
60
  onDelete: () => void;
61
+ /** Clone this action (fresh ids) directly after itself. Omit to hide. */
62
+ onDuplicate?: () => void;
52
63
  path: ActionPath;
53
64
  definition: AutomationDefinition;
54
65
  dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
@@ -91,6 +102,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
91
102
  value,
92
103
  onChange,
93
104
  onDelete,
105
+ onDuplicate,
94
106
  path,
95
107
  definition,
96
108
  dragHandleProps,
@@ -100,6 +112,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
100
112
  const { actions } = useAutomationRegistry();
101
113
  const kind = actionKindOf(value);
102
114
  const meta = ACTION_KIND_META[kind];
115
+ const [sheetOpen, setSheetOpen] = React.useState(false);
103
116
  const {
104
117
  templateProperties,
105
118
  variableNodes,
@@ -116,101 +129,73 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
116
129
  actions.find((a) => a.qualifiedId === qualified)?.displayName,
117
130
  );
118
131
 
119
- const description =
120
- value.description ?? (value.id ? `id: ${value.id}` : undefined);
132
+ // Collapsed-row summary derived from the config; falls back to the
133
+ // operator's description/id note when there's no derivable summary.
134
+ const summary =
135
+ summarizeAction(value) ??
136
+ value.description ??
137
+ (value.id ? `id: ${value.id}` : undefined);
121
138
 
122
139
  const enabledValue = value.enabled !== false;
123
140
  const issues = useActionIssues(path);
141
+ // `id` auto-fills on blur, so it isn't a meaningful "advanced was
142
+ // customised" signal; open the metadata disclosure only when the operator
143
+ // set a description or flipped the failure behaviour.
144
+ const hasAdvancedMeta =
145
+ value.description !== undefined || value.continue_on_error === true;
124
146
 
125
- return (
126
- <ActionCard
127
- id={stableId}
128
- title={title}
129
- description={description}
130
- category={meta.label}
131
- icon={meta.icon}
132
- enabled={enabledValue}
133
- onEnabledChange={
134
- disabled
135
- ? undefined
136
- : (next) => onChange({ ...value, enabled: next })
137
- }
138
- onDelete={disabled ? undefined : onDelete}
139
- dragHandleProps={disabled ? undefined : dragHandleProps}
140
- errors={issues}
141
- >
142
- <div className="space-y-4">
143
- {/* Action settings — identity and failure behaviour. Grouped in a
144
- quiet panel and labelled with small uppercase eyebrows so the
145
- action's own configuration below reads as the primary content of
146
- the card rather than competing with it. The action's kind is
147
- fixed at creation; to change it, add a new step and delete this
148
- one. */}
149
- <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
150
- <div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
151
- <MetaField label="Id" htmlFor={`${stableId}-id`}>
152
- <Input
153
- id={`${stableId}-id`}
154
- value={value.id ?? ""}
155
- onChange={(event) =>
156
- onChange({ ...value, id: event.target.value || undefined })
157
- }
158
- onBlur={() => {
159
- // Never leave the id blank: re-fill a unique, log-friendly
160
- // default so the action stays referenceable
161
- // (artifacts.<id>.<name>) and parseable in run logs.
162
- if (value.id) return;
163
- const taken = collectActionIds(definition.actions);
164
- onChange({ ...value, id: defaultActionId(value, taken) });
165
- }}
166
- placeholder="Generated on blur"
167
- disabled={disabled}
168
- className="h-8 text-xs"
169
- />
170
- </MetaField>
147
+ // Validation issues (structural + inline-script type errors) surface only as
148
+ // the card's error badge - never auto-open the sheet. Auto-opening would pop
149
+ // multiple sheets at once when several cards have errors (e.g. every script
150
+ // action after a trigger change), so the badge is the single, calm signal and
151
+ // the operator clicks in for detail.
171
152
 
172
- <MetaField label="Description" htmlFor={`${stableId}-desc`}>
173
- <Input
174
- id={`${stableId}-desc`}
175
- value={value.description ?? ""}
176
- onChange={(event) =>
177
- onChange({
178
- ...value,
179
- description: event.target.value || undefined,
180
- })
181
- }
182
- placeholder="Optional note"
183
- disabled={disabled}
184
- className="h-8 text-xs"
185
- />
186
- </MetaField>
187
- </div>
188
-
189
- <MetaField label="On failure">
190
- <div
191
- className={cn(
192
- "flex h-8 items-center gap-2 select-none",
193
- disabled ? "cursor-not-allowed" : "cursor-pointer",
194
- )}
195
- onClick={() =>
196
- !disabled &&
197
- onChange({
198
- ...value,
199
- continue_on_error: value.continue_on_error !== true,
200
- })
201
- }
202
- >
203
- <Checkbox
204
- checked={value.continue_on_error === true}
205
- disabled={disabled}
206
- />
207
- <Label className="text-xs font-normal text-muted-foreground cursor-pointer">
208
- Continue on error
209
- </Label>
210
- </div>
211
- </MetaField>
212
- </div>
153
+ const menuItems: ActionCardMenuItem[] = disabled
154
+ ? []
155
+ : [
156
+ {
157
+ label: enabledValue ? "Disable" : "Enable",
158
+ icon: enabledValue ? "PowerOff" : "Power",
159
+ onClick: () => onChange({ ...value, enabled: !enabledValue }),
160
+ },
161
+ ...(onDuplicate
162
+ ? [
163
+ {
164
+ label: "Duplicate",
165
+ icon: "Copy" as const,
166
+ onClick: onDuplicate,
167
+ },
168
+ ]
169
+ : []),
170
+ {
171
+ label: "Delete",
172
+ icon: "Trash2",
173
+ onClick: onDelete,
174
+ variant: "destructive",
175
+ },
176
+ ];
213
177
 
178
+ return (
179
+ <>
180
+ <ActionCard
181
+ id={stableId}
182
+ title={title}
183
+ summary={summary}
184
+ category={meta.label}
185
+ icon={meta.icon}
186
+ enabled={enabledValue}
187
+ onOpenSheet={() => setSheetOpen(true)}
188
+ actions={menuItems}
189
+ dragHandleProps={disabled ? undefined : dragHandleProps}
190
+ errors={issues}
191
+ />
192
+ <ItemSheet
193
+ open={sheetOpen}
194
+ onOpenChange={setSheetOpen}
195
+ title={title}
196
+ description={meta.label}
197
+ >
198
+ <div className="space-y-4">
214
199
  <ActionBody
215
200
  kind={kind}
216
201
  value={value}
@@ -224,8 +209,102 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
224
209
  shellEnvVars={shellEnvVars}
225
210
  disabled={disabled}
226
211
  />
212
+
213
+ {/* Per-action metadata — id, description, failure behaviour — tucked
214
+ behind a collapsed disclosure so the action's own configuration
215
+ above reads as the primary content of the card. The action's kind
216
+ is fixed at creation; to change it, add a new step and delete this
217
+ one. Enable/disable lives on the card header, not here. */}
218
+ <Accordion
219
+ type="single"
220
+ collapsible
221
+ defaultValue={hasAdvancedMeta ? "advanced" : undefined}
222
+ className="w-full"
223
+ >
224
+ <AccordionItem value="advanced" className="border-b-0">
225
+ <AccordionTrigger className="py-2 text-xs hover:no-underline">
226
+ <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
227
+ Advanced
228
+ </span>
229
+ </AccordionTrigger>
230
+ <AccordionContent className="pb-0">
231
+ <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
232
+ <div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
233
+ <MetaField label="Id" htmlFor={`${stableId}-id`}>
234
+ <Input
235
+ id={`${stableId}-id`}
236
+ value={value.id ?? ""}
237
+ onChange={(event) =>
238
+ onChange({
239
+ ...value,
240
+ id: event.target.value || undefined,
241
+ })
242
+ }
243
+ onBlur={() => {
244
+ // Never leave the id blank: re-fill a unique,
245
+ // log-friendly default so the action stays
246
+ // referenceable (artifacts.<id>.<name>) and parseable
247
+ // in run logs.
248
+ if (value.id) return;
249
+ const taken = collectActionIds(definition.actions);
250
+ onChange({
251
+ ...value,
252
+ id: defaultActionId(value, taken),
253
+ });
254
+ }}
255
+ placeholder="Generated on blur"
256
+ disabled={disabled}
257
+ className="h-8 text-xs"
258
+ />
259
+ </MetaField>
260
+
261
+ <MetaField label="Description" htmlFor={`${stableId}-desc`}>
262
+ <Input
263
+ id={`${stableId}-desc`}
264
+ value={value.description ?? ""}
265
+ onChange={(event) =>
266
+ onChange({
267
+ ...value,
268
+ description: event.target.value || undefined,
269
+ })
270
+ }
271
+ placeholder="Optional note"
272
+ disabled={disabled}
273
+ className="h-8 text-xs"
274
+ />
275
+ </MetaField>
276
+ </div>
277
+
278
+ <MetaField label="On failure">
279
+ <div
280
+ className={cn(
281
+ "flex h-8 items-center gap-2 select-none",
282
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
283
+ )}
284
+ onClick={() =>
285
+ !disabled &&
286
+ onChange({
287
+ ...value,
288
+ continue_on_error: value.continue_on_error !== true,
289
+ })
290
+ }
291
+ >
292
+ <Checkbox
293
+ checked={value.continue_on_error === true}
294
+ disabled={disabled}
295
+ />
296
+ <Label className="text-xs font-normal text-muted-foreground cursor-pointer">
297
+ Continue on error
298
+ </Label>
299
+ </div>
300
+ </MetaField>
301
+ </div>
302
+ </AccordionContent>
303
+ </AccordionItem>
304
+ </Accordion>
227
305
  </div>
228
- </ActionCard>
306
+ </ItemSheet>
307
+ </>
229
308
  );
230
309
  };
231
310
 
@@ -343,6 +422,17 @@ const ActionBody: React.FC<{
343
422
  />
344
423
  );
345
424
  }
425
+ case "wait_until": {
426
+ return (
427
+ <WaitUntilActionBody
428
+ value={value as WaitUntilInput}
429
+ onChange={(next) => onChange(next)}
430
+ variableNodes={variableNodes}
431
+ completionProvider={expressionCompletion}
432
+ disabled={disabled}
433
+ />
434
+ );
435
+ }
346
436
  case "sequence": {
347
437
  return (
348
438
  <SequenceActionBody
@@ -22,6 +22,7 @@ import type {
22
22
  import {
23
23
  assignDefaultIds,
24
24
  collectActionIds,
25
+ duplicateAction,
25
26
  makeEmptyAction,
26
27
  makeProviderAction,
27
28
  } from "./action-helpers";
@@ -128,6 +129,28 @@ export const ActionListEditor: React.FC<ActionListEditorProps> = ({
128
129
  setIds((current) => [...current, nextId()]);
129
130
  };
130
131
 
132
+ const duplicateStep = (index: number): void => {
133
+ const original = value[index];
134
+ if (!original) return;
135
+ // Clone with FRESH ids deduped against every id in the automation so the
136
+ // copy never collides with the original (or any sibling/nested step).
137
+ const taken = collectActionIds(definition.actions);
138
+ const clone = duplicateAction(original, taken);
139
+ // Insert directly after the original, keeping the parallel `ids` array in
140
+ // lockstep so each card's stable React key / drag id stays aligned.
141
+ const nextValue = [
142
+ ...value.slice(0, index + 1),
143
+ clone,
144
+ ...value.slice(index + 1),
145
+ ];
146
+ onChange(nextValue);
147
+ setIds((current) => [
148
+ ...current.slice(0, index + 1),
149
+ nextId(),
150
+ ...current.slice(index + 1),
151
+ ]);
152
+ };
153
+
131
154
  return (
132
155
  <div className="space-y-2">
133
156
  <DndContext
@@ -150,6 +173,7 @@ export const ActionListEditor: React.FC<ActionListEditorProps> = ({
150
173
  onChange(value.filter((_, i) => i !== index));
151
174
  setIds((current) => current.filter((_, i) => i !== index));
152
175
  }}
176
+ onDuplicate={disabled ? undefined : () => duplicateStep(index)}
153
177
  path={childPathAt(index)}
154
178
  definition={definition}
155
179
  disabled={disabled}
@@ -174,10 +198,11 @@ const SortableActionItem: React.FC<{
174
198
  value: ActionInput;
175
199
  onChange: (next: ActionInput) => void;
176
200
  onDelete: () => void;
201
+ onDuplicate?: () => void;
177
202
  path: ActionPath;
178
203
  definition: Parameters<typeof ActionEditor>[0]["definition"];
179
204
  disabled?: boolean;
180
- }> = ({ id, value, onChange, onDelete, path, definition, disabled }) => {
205
+ }> = ({ id, value, onChange, onDelete, onDuplicate, path, definition, disabled }) => {
181
206
  const sortable = useSortable({ id });
182
207
  const style: React.CSSProperties = {
183
208
  transform: CSS.Transform.toString(sortable.transform),
@@ -189,6 +214,7 @@ const SortableActionItem: React.FC<{
189
214
  value={value}
190
215
  onChange={onChange}
191
216
  onDelete={onDelete}
217
+ onDuplicate={onDuplicate}
192
218
  path={path}
193
219
  definition={definition}
194
220
  stableId={id}
@@ -1,17 +1,20 @@
1
1
  import React from "react";
2
- import { Plus, Search, Zap } from "lucide-react";
2
+ import { Zap } from "lucide-react";
3
3
  import {
4
- Button,
5
4
  Dialog,
6
5
  DialogContent,
7
6
  DialogHeader,
8
7
  DialogTitle,
9
8
  DynamicIcon,
10
- Input,
11
9
  TabPanel,
12
10
  Tabs,
13
11
  } from "@checkstack/ui";
14
12
  import { ACTION_KIND_META, type ActionKind } from "./action-helpers";
13
+ import {
14
+ PickerAddButton,
15
+ PickerRow,
16
+ PickerSearchInput,
17
+ } from "./picker-dialog";
15
18
  import { useAutomationRegistry } from "./registry-context";
16
19
 
17
20
  /**
@@ -30,6 +33,7 @@ const BLOCK_KINDS: ActionKind[] = [
30
33
  "stop",
31
34
  "variables",
32
35
  "wait_for_trigger",
36
+ "wait_until",
33
37
  ];
34
38
 
35
39
  const TAB_ITEMS = [
@@ -37,29 +41,6 @@ const TAB_ITEMS = [
37
41
  { id: "blocks", label: "Blocks" },
38
42
  ];
39
43
 
40
- /** Shared row used by both tabs — icon, name, description, and a `+` affordance. */
41
- const PickerRow: React.FC<{
42
- icon: React.ReactNode;
43
- title: string;
44
- description?: string;
45
- onClick: () => void;
46
- }> = ({ icon, title, description, onClick }) => (
47
- <button
48
- type="button"
49
- onClick={onClick}
50
- className="flex w-full items-start gap-3 rounded-md border border-border/60 bg-card px-3 py-2.5 text-left transition-colors hover:border-border hover:bg-accent/50"
51
- >
52
- <span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
53
- <div className="min-w-0 flex-1">
54
- <div className="text-sm font-medium">{title}</div>
55
- {description && (
56
- <div className="text-xs text-muted-foreground">{description}</div>
57
- )}
58
- </div>
59
- <Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
60
- </button>
61
- );
62
-
63
44
  export interface AddActionDialogProps {
64
45
  /** Insert a building-block step of the given kind. */
65
46
  onAddKind: (kind: ActionKind) => void;
@@ -128,17 +109,11 @@ export const AddActionDialog: React.FC<AddActionDialogProps> = ({
128
109
 
129
110
  return (
130
111
  <>
131
- <Button
132
- type="button"
133
- variant="outline"
134
- size="sm"
112
+ <PickerAddButton
113
+ label="Add step"
135
114
  disabled={disabled}
136
- className="h-7 text-xs"
137
115
  onClick={() => setOpen(true)}
138
- >
139
- <Plus className="mr-1 h-3 w-3" />
140
- Add step
141
- </Button>
116
+ />
142
117
  <Dialog open={open} onOpenChange={setOpen}>
143
118
  <DialogContent size="lg">
144
119
  <DialogHeader>
@@ -146,16 +121,11 @@ export const AddActionDialog: React.FC<AddActionDialogProps> = ({
146
121
  </DialogHeader>
147
122
 
148
123
  <div className="space-y-3">
149
- <div className="relative">
150
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
151
- <Input
152
- autoFocus
153
- value={query}
154
- onChange={(event) => setQuery(event.target.value)}
155
- placeholder="Search actions and blocks…"
156
- className="pl-9"
157
- />
158
- </div>
124
+ <PickerSearchInput
125
+ value={query}
126
+ onChange={setQuery}
127
+ placeholder="Search actions and blocks…"
128
+ />
159
129
 
160
130
  <Tabs items={TAB_ITEMS} activeTab={tab} onTabChange={setTab} />
161
131
 
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { DynamicIcon } from "@checkstack/ui";
3
+ import {
4
+ CONDITION_KIND_META,
5
+ type ConditionKind,
6
+ type ConditionKindGroup,
7
+ } from "./condition-kind";
8
+ import { PickerDialogShell, PickerRow } from "./picker-dialog";
9
+
10
+ export interface AddConditionDialogProps {
11
+ /** Append a condition of the picked kind (seeded via `defaultForKind`). */
12
+ onAdd: (kind: ConditionKind) => void;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ /** Picker group order — structured first (common case), advanced last. */
17
+ const GROUP_ORDER: ConditionKindGroup[] = ["Structured", "Logical", "Advanced"];
18
+
19
+ const ALL_KINDS = Object.values(CONDITION_KIND_META);
20
+
21
+ /**
22
+ * Home-Assistant-style condition type picker. The operator picks the condition
23
+ * kind up front (grouped by `CONDITION_KIND_META.group`, searchable on label /
24
+ * description); on pick the consumer (`ConditionsEditor`) appends a seeded
25
+ * default for that kind. Mirrors the Actions "Add step" picker but is
26
+ * single-list.
27
+ */
28
+ export const AddConditionDialog: React.FC<AddConditionDialogProps> = ({
29
+ onAdd,
30
+ disabled,
31
+ }) => (
32
+ <PickerDialogShell
33
+ addLabel="Add condition"
34
+ title="Add condition"
35
+ searchPlaceholder="Search conditions…"
36
+ disabled={disabled}
37
+ >
38
+ {({ query, close }) => {
39
+ const filtered = query
40
+ ? ALL_KINDS.filter(
41
+ (meta) =>
42
+ meta.label.toLowerCase().includes(query) ||
43
+ meta.description.toLowerCase().includes(query) ||
44
+ meta.kind.toLowerCase().includes(query),
45
+ )
46
+ : ALL_KINDS;
47
+
48
+ const grouped = GROUP_ORDER.map((group) => ({
49
+ group,
50
+ items: filtered.filter((meta) => meta.group === group),
51
+ })).filter(({ items }) => items.length > 0);
52
+
53
+ if (grouped.length === 0) {
54
+ return (
55
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
56
+ No matching conditions.
57
+ </p>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div className="space-y-3">
63
+ {grouped.map(({ group, items }) => (
64
+ <div key={group} className="space-y-1">
65
+ <p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
66
+ {group}
67
+ </p>
68
+ {items.map((meta) => (
69
+ <PickerRow
70
+ key={meta.kind}
71
+ icon={<DynamicIcon name={meta.icon} className="h-4 w-4" />}
72
+ title={meta.label}
73
+ description={meta.description}
74
+ onClick={() => {
75
+ onAdd(meta.kind);
76
+ close();
77
+ }}
78
+ />
79
+ ))}
80
+ </div>
81
+ ))}
82
+ </div>
83
+ );
84
+ }}
85
+ </PickerDialogShell>
86
+ );
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+ import { Zap } from "lucide-react";
3
+ import { useAutomationRegistry } from "./registry-context";
4
+ import { PickerDialogShell, PickerRow } from "./picker-dialog";
5
+
6
+ export interface AddTriggerDialogProps {
7
+ /** Append a trigger subscribed to the picked event's qualified id. */
8
+ onAdd: (event: string) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Home-Assistant-style trigger type picker. The operator picks a concrete
14
+ * trigger event (grouped by category, searchable) up front; on pick the
15
+ * consumer (`TriggersEditor`) creates the trigger with a unique default id and
16
+ * appends it. Mirrors the Actions "Add step" picker but is single-list.
17
+ */
18
+ export const AddTriggerDialog: React.FC<AddTriggerDialogProps> = ({
19
+ onAdd,
20
+ disabled,
21
+ }) => {
22
+ const { triggers } = useAutomationRegistry();
23
+
24
+ const items = React.useMemo(
25
+ () =>
26
+ triggers.map((t) => ({
27
+ id: t.qualifiedId,
28
+ label: t.displayName,
29
+ description: t.description,
30
+ category: t.category,
31
+ })),
32
+ [triggers],
33
+ );
34
+
35
+ return (
36
+ <PickerDialogShell
37
+ addLabel="Add trigger"
38
+ title="Add trigger"
39
+ searchPlaceholder="Search triggers…"
40
+ disabled={disabled}
41
+ >
42
+ {({ query, close }) => {
43
+ const filtered = query
44
+ ? items.filter(
45
+ (item) =>
46
+ item.label.toLowerCase().includes(query) ||
47
+ item.id.toLowerCase().includes(query) ||
48
+ item.description?.toLowerCase().includes(query) ||
49
+ item.category.toLowerCase().includes(query),
50
+ )
51
+ : items;
52
+
53
+ const groups = new Map<string, typeof filtered>();
54
+ for (const item of filtered) {
55
+ const list = groups.get(item.category) ?? [];
56
+ list.push(item);
57
+ groups.set(item.category, list);
58
+ }
59
+ const grouped = [...groups.entries()].toSorted(([a], [b]) =>
60
+ a.localeCompare(b),
61
+ );
62
+
63
+ if (grouped.length === 0) {
64
+ return (
65
+ <p className="px-1 py-6 text-center text-xs italic text-muted-foreground">
66
+ No matching triggers.
67
+ </p>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div className="space-y-3">
73
+ {grouped.map(([category, list]) => (
74
+ <div key={category} className="space-y-1">
75
+ <p className="px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
76
+ {category}
77
+ </p>
78
+ {list.map((item) => (
79
+ <PickerRow
80
+ key={item.id}
81
+ icon={<Zap className="h-4 w-4" />}
82
+ title={item.label}
83
+ description={item.description}
84
+ onClick={() => {
85
+ onAdd(item.id);
86
+ close();
87
+ }}
88
+ />
89
+ ))}
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ }}
95
+ </PickerDialogShell>
96
+ );
97
+ };