@checkstack/ui 1.10.0 → 1.12.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/.storybook/main.ts +43 -0
- package/CHANGELOG.md +565 -0
- package/package.json +15 -7
- package/scripts/generate-stdlib-types.ts +25 -2
- package/src/components/ActionCard.tsx +309 -0
- package/src/components/CodeEditor/CodeEditor.tsx +132 -9
- package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
- package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
- package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
- package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +26 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +41 -0
- package/src/components/CodeEditor/scriptContext.ts +76 -1
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/templateValidation.ts +51 -0
- package/src/components/CodeEditor/types.ts +168 -0
- package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
- package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
- package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
- package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
- package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +27 -1
- package/src/components/DynamicForm/FormField.tsx +138 -10
- package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +83 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TemplateInput.tsx +104 -0
- package/src/components/TemplateInputToggle.tsx +111 -0
- package/src/components/TemplateValueInput.test.ts +98 -0
- package/src/components/TemplateValueInput.tsx +470 -0
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/VariablePicker.tsx +271 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/hooks/useInitOnceForKey.test.ts +27 -0
- package/src/hooks/useInitOnceForKey.ts +21 -18
- package/src/index.ts +9 -0
- package/stories/ActionCard.stories.tsx +122 -0
- package/stories/Alert.stories.tsx +5 -5
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TemplateInputToggle.stories.tsx +77 -0
- package/stories/TemplateValueInput.stories.tsx +65 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/stories/VariablePicker.stories.tsx +109 -0
- package/tsconfig.json +1 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
- package/src/components/CodeEditor/monacoStdlib.ts +0 -62
- package/src/components/CodeEditor/monacoWorkers.ts +0 -118
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ChevronRight, Code2, Search } from "lucide-react";
|
|
3
|
+
import { Input } from "./Input";
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One node in the variable scope tree.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `VariableEntry` from `@checkstack/automation-common` — but this
|
|
10
|
+
* package can't depend on automation-common (UI primitives live below the
|
|
11
|
+
* automation layer), so the shape is duplicated here. The automation
|
|
12
|
+
* editor adapts `VariableScope.entries` → `VariableNode[]` at the call
|
|
13
|
+
* site.
|
|
14
|
+
*/
|
|
15
|
+
export interface VariableNode {
|
|
16
|
+
/** Canonical dot-separated path, e.g. `artifact.integration-jira.issue.key`. */
|
|
17
|
+
path: string;
|
|
18
|
+
/**
|
|
19
|
+
* Runtime-parseable `{{ }}` insertion form, e.g.
|
|
20
|
+
* `artifacts["integration-jira.issue"].key`. Inserted in preference to
|
|
21
|
+
* `path`; callers that don't supply it fall back to `path`.
|
|
22
|
+
*/
|
|
23
|
+
templateRef?: string;
|
|
24
|
+
/** Human-readable type label rendered next to the name. */
|
|
25
|
+
type: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
/** Nested entries for object types. */
|
|
28
|
+
children?: VariableNode[];
|
|
29
|
+
/**
|
|
30
|
+
* When set, the entry only exists when one of these triggers fired —
|
|
31
|
+
* picker renders an "Only when …" hint so the operator knows it might
|
|
32
|
+
* be undefined at runtime.
|
|
33
|
+
*/
|
|
34
|
+
conditionalOnTriggers?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VariablePickerProps {
|
|
38
|
+
/** Tree of in-scope variables. */
|
|
39
|
+
scope: VariableNode[];
|
|
40
|
+
/**
|
|
41
|
+
* Called with the runtime-parseable text to insert (the node's
|
|
42
|
+
* `templateRef`, falling back to `path`), e.g.
|
|
43
|
+
* `trigger.payload.systemId` or `artifacts["integration-jira.issue"].key`.
|
|
44
|
+
*/
|
|
45
|
+
onSelect: (templateRef: string) => void;
|
|
46
|
+
/** Optional element rendered as the popover trigger. Defaults to a small "Insert variable" button. */
|
|
47
|
+
trigger?: React.ReactNode;
|
|
48
|
+
/** Controls open state; defaults to uncontrolled. */
|
|
49
|
+
open?: boolean;
|
|
50
|
+
onOpenChange?: (open: boolean) => void;
|
|
51
|
+
/** Optional class on the popover content. */
|
|
52
|
+
contentClassName?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hierarchical variable picker. Click the trigger button → a popover
|
|
57
|
+
* opens with the tree of in-scope paths grouped under their parents
|
|
58
|
+
* (`trigger`, `var`, `artifact`, `repeat`). Each row inserts
|
|
59
|
+
* `{{ path }}` when clicked. A search input at the top filters across
|
|
60
|
+
* the whole tree (matches against the dot-path), and matched ancestors
|
|
61
|
+
* stay expanded so context is preserved.
|
|
62
|
+
*
|
|
63
|
+
* Designed for the explicit "fx" / "Insert variable" workflow inside the
|
|
64
|
+
* automation editor. For inline `{{` autocomplete inside a text field,
|
|
65
|
+
* use `TemplateValueInput`; for code editors, the `CodeEditor` already
|
|
66
|
+
* pops the same dropdown through Monaco's completion provider.
|
|
67
|
+
*/
|
|
68
|
+
export const VariablePicker: React.FC<VariablePickerProps> = ({
|
|
69
|
+
scope,
|
|
70
|
+
onSelect,
|
|
71
|
+
trigger,
|
|
72
|
+
open,
|
|
73
|
+
onOpenChange,
|
|
74
|
+
contentClassName,
|
|
75
|
+
}) => {
|
|
76
|
+
const [query, setQuery] = React.useState("");
|
|
77
|
+
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
78
|
+
const isOpen = open ?? internalOpen;
|
|
79
|
+
const setOpen = (next: boolean) => {
|
|
80
|
+
if (open === undefined) setInternalOpen(next);
|
|
81
|
+
onOpenChange?.(next);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const filtered = React.useMemo(
|
|
85
|
+
() => (query.trim() ? filterTree(scope, query.trim().toLowerCase()) : scope),
|
|
86
|
+
[scope, query],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const handleSelect = (path: string) => {
|
|
90
|
+
onSelect(path);
|
|
91
|
+
setOpen(false);
|
|
92
|
+
setQuery("");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleKeyDownOnInput = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
96
|
+
if (e.key === "Escape") {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
setOpen(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Popover open={isOpen} onOpenChange={setOpen}>
|
|
104
|
+
<PopoverTrigger asChild>
|
|
105
|
+
{trigger ?? (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className="inline-flex items-center gap-1 rounded border border-border bg-card px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
109
|
+
aria-label="Insert variable"
|
|
110
|
+
>
|
|
111
|
+
<Code2 className="h-3 w-3" />
|
|
112
|
+
<span>fx</span>
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
</PopoverTrigger>
|
|
116
|
+
<PopoverContent
|
|
117
|
+
className={`w-80 p-0 ${contentClassName ?? ""}`.trim()}
|
|
118
|
+
align="start"
|
|
119
|
+
>
|
|
120
|
+
<div className="border-b border-border px-2 py-2">
|
|
121
|
+
<div className="relative">
|
|
122
|
+
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
|
123
|
+
<Input
|
|
124
|
+
autoFocus
|
|
125
|
+
value={query}
|
|
126
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
127
|
+
onKeyDown={handleKeyDownOnInput}
|
|
128
|
+
placeholder="Filter variables…"
|
|
129
|
+
className="h-7 pl-7 font-mono text-xs"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="max-h-80 overflow-y-auto py-1">
|
|
134
|
+
{filtered.length === 0 ? (
|
|
135
|
+
<p className="px-3 py-4 text-center text-xs text-muted-foreground italic">
|
|
136
|
+
No matching variables.
|
|
137
|
+
</p>
|
|
138
|
+
) : (
|
|
139
|
+
filtered.map((node) => (
|
|
140
|
+
<VariableTreeNode
|
|
141
|
+
key={node.path}
|
|
142
|
+
node={node}
|
|
143
|
+
depth={0}
|
|
144
|
+
onSelect={handleSelect}
|
|
145
|
+
forceExpand={query.trim().length > 0}
|
|
146
|
+
/>
|
|
147
|
+
))
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</PopoverContent>
|
|
151
|
+
</Popover>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
interface VariableTreeNodeProps {
|
|
156
|
+
node: VariableNode;
|
|
157
|
+
depth: number;
|
|
158
|
+
onSelect: (path: string) => void;
|
|
159
|
+
forceExpand: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const VariableTreeNode: React.FC<VariableTreeNodeProps> = ({
|
|
163
|
+
node,
|
|
164
|
+
depth,
|
|
165
|
+
onSelect,
|
|
166
|
+
forceExpand,
|
|
167
|
+
}) => {
|
|
168
|
+
const [expanded, setExpanded] = React.useState(depth === 0);
|
|
169
|
+
const isExpanded = forceExpand || expanded;
|
|
170
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div>
|
|
174
|
+
<div
|
|
175
|
+
className="group flex w-full items-center gap-1 text-xs hover:bg-accent/50"
|
|
176
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
177
|
+
>
|
|
178
|
+
{hasChildren ? (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={(event) => {
|
|
182
|
+
event.stopPropagation();
|
|
183
|
+
setExpanded((v) => !v);
|
|
184
|
+
}}
|
|
185
|
+
className="flex h-6 w-4 shrink-0 items-center justify-center rounded hover:bg-accent hover:text-accent-foreground"
|
|
186
|
+
aria-label={isExpanded ? "Collapse" : "Expand"}
|
|
187
|
+
aria-expanded={isExpanded}
|
|
188
|
+
>
|
|
189
|
+
<ChevronRight
|
|
190
|
+
className={`h-3 w-3 transition-transform ${
|
|
191
|
+
isExpanded ? "rotate-90" : ""
|
|
192
|
+
}`}
|
|
193
|
+
/>
|
|
194
|
+
</button>
|
|
195
|
+
) : (
|
|
196
|
+
<span className="h-6 w-4 shrink-0" />
|
|
197
|
+
)}
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => onSelect(node.templateRef ?? node.path)}
|
|
201
|
+
className="flex flex-1 items-center gap-2 py-1 pr-2 text-left hover:bg-accent hover:text-accent-foreground"
|
|
202
|
+
title={`Insert {{${node.templateRef ?? node.path}}}`}
|
|
203
|
+
>
|
|
204
|
+
<code className="flex-1 truncate font-mono">{leafName(node.path)}</code>
|
|
205
|
+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">
|
|
206
|
+
{node.type}
|
|
207
|
+
</span>
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
{node.conditionalOnTriggers && node.conditionalOnTriggers.length > 0 && (
|
|
211
|
+
<p
|
|
212
|
+
className="px-2 text-[10px] italic text-muted-foreground"
|
|
213
|
+
style={{ paddingLeft: `${depth * 12 + 24}px` }}
|
|
214
|
+
>
|
|
215
|
+
Only when {node.conditionalOnTriggers.join(", ")}
|
|
216
|
+
</p>
|
|
217
|
+
)}
|
|
218
|
+
{node.description && (
|
|
219
|
+
<p
|
|
220
|
+
className="px-2 pb-1 text-[10px] text-muted-foreground"
|
|
221
|
+
style={{ paddingLeft: `${depth * 12 + 24}px` }}
|
|
222
|
+
>
|
|
223
|
+
{node.description}
|
|
224
|
+
</p>
|
|
225
|
+
)}
|
|
226
|
+
{hasChildren && isExpanded && (
|
|
227
|
+
<div>
|
|
228
|
+
{node.children!.map((child) => (
|
|
229
|
+
<VariableTreeNode
|
|
230
|
+
key={child.path}
|
|
231
|
+
node={child}
|
|
232
|
+
depth={depth + 1}
|
|
233
|
+
onSelect={onSelect}
|
|
234
|
+
forceExpand={forceExpand}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function leafName(path: string): string {
|
|
244
|
+
const idx = path.lastIndexOf(".");
|
|
245
|
+
return idx === -1 ? path : path.slice(idx + 1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Walk the tree and return a copy that only includes nodes whose path
|
|
250
|
+
* (or any descendant's path) contains the query. Matched ancestors are
|
|
251
|
+
* kept so the tree structure is preserved.
|
|
252
|
+
*/
|
|
253
|
+
function filterTree(
|
|
254
|
+
nodes: VariableNode[],
|
|
255
|
+
query: string,
|
|
256
|
+
): VariableNode[] {
|
|
257
|
+
const out: VariableNode[] = [];
|
|
258
|
+
for (const node of nodes) {
|
|
259
|
+
const selfMatch = node.path.toLowerCase().includes(query);
|
|
260
|
+
const childMatches = node.children
|
|
261
|
+
? filterTree(node.children, query)
|
|
262
|
+
: undefined;
|
|
263
|
+
if (selfMatch || (childMatches && childMatches.length > 0)) {
|
|
264
|
+
out.push({
|
|
265
|
+
...node,
|
|
266
|
+
children: childMatches,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker attribute placed on a combobox's anchor `<input>` so a Radix
|
|
3
|
+
* `PopoverContent`'s dismissable layer can recognize an interaction that
|
|
4
|
+
* originated on the anchor itself.
|
|
5
|
+
*
|
|
6
|
+
* Radix treats the anchor as "outside" the floating content, so the very
|
|
7
|
+
* click/focus that opens the list also fires `onPointerDownOutside` /
|
|
8
|
+
* `onFocusOutside` and dismisses it on the same frame (the "opens then
|
|
9
|
+
* immediately closes" flicker). Guarding those handlers against anchor-origin
|
|
10
|
+
* events keeps the list open.
|
|
11
|
+
*
|
|
12
|
+
* Usage (see `PackageNameCombobox` / `SecretEnvEditor`):
|
|
13
|
+
* 1. Spread {@link comboboxAnchorProps} onto the anchor `<input>`.
|
|
14
|
+
* 2. On `PopoverContent`, in both `onPointerDownOutside` and
|
|
15
|
+
* `onFocusOutside`, call `event.preventDefault()` when
|
|
16
|
+
* {@link isAnchorInteraction} matches the event target.
|
|
17
|
+
* 3. Prevent `onCloseAutoFocus` default.
|
|
18
|
+
* 4. Option buttons use `onMouseDown` + `preventDefault` so a click selects
|
|
19
|
+
* before the input blurs.
|
|
20
|
+
*
|
|
21
|
+
* Lives in `@checkstack/ui` so every combobox (across plugins) shares one
|
|
22
|
+
* implementation.
|
|
23
|
+
*/
|
|
24
|
+
export const COMBOBOX_ANCHOR_ATTR = "data-combobox-anchor";
|
|
25
|
+
|
|
26
|
+
/** Props to spread onto the anchor `<input>` so {@link isAnchorInteraction} can match it. */
|
|
27
|
+
export const comboboxAnchorProps: Record<string, string> = {
|
|
28
|
+
[COMBOBOX_ANCHOR_ATTR]: "",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether an event target is (or is inside) a combobox anchor input. Pure +
|
|
33
|
+
* DOM-only so it is unit-testable without a React render. Tolerates a null
|
|
34
|
+
* target and non-Element targets (returns false).
|
|
35
|
+
*/
|
|
36
|
+
export function isAnchorInteraction(target: EventTarget | null): boolean {
|
|
37
|
+
if (!target || !(target instanceof Element)) return false;
|
|
38
|
+
return target.closest(`[${COMBOBOX_ANCHOR_ATTR}]`) !== null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The DOM element a floating layer (e.g. a {@link PopoverContent}) should
|
|
5
|
+
* portal INTO when it is rendered inside a modal Sheet/Dialog.
|
|
6
|
+
*
|
|
7
|
+
* Radix modal dialogs lock body scroll via `react-remove-scroll`, which
|
|
8
|
+
* prevents wheel/touch scrolling on anything portaled to `document.body`
|
|
9
|
+
* (i.e. outside the dialog's DOM subtree). A Popover whose list overflows
|
|
10
|
+
* therefore can't scroll while a Sheet/Dialog is open. Portaling the Popover
|
|
11
|
+
* INTO the dialog content keeps it inside the allowed-scroll subtree, so its
|
|
12
|
+
* inner `overflow-y-auto` works again. Radix positions popovers with
|
|
13
|
+
* `position: fixed`, so the container has no effect on placement or clipping.
|
|
14
|
+
*
|
|
15
|
+
* `null` (the default, outside any Sheet/Dialog) means portal to `body` as
|
|
16
|
+
* usual.
|
|
17
|
+
*/
|
|
18
|
+
export const PortalContainerContext = React.createContext<HTMLElement | null>(
|
|
19
|
+
null,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** Read the nearest Sheet/Dialog content element to portal a popover into. */
|
|
23
|
+
export const usePortalContainer = (): HTMLElement | null =>
|
|
24
|
+
React.useContext(PortalContainerContext);
|
|
@@ -124,4 +124,31 @@ describe("shouldInitForKey", () => {
|
|
|
124
124
|
shouldInitForKey({ value: "any", key: "1", initialisedKey: 1 }),
|
|
125
125
|
).toBe(true);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
it("does not seed from a stale cache entry served on mount, only from a post-mount fetch", () => {
|
|
129
|
+
// Regression for the automation editor: another page observes the same
|
|
130
|
+
// `getAutomation` cache key without `gcTime: 0`, so a stale (pre-edit)
|
|
131
|
+
// entry can be served instantly on reopen. The call site gates `value` on
|
|
132
|
+
// `isFetchedAfterMount`, so until a fresh fetch lands it passes `undefined`
|
|
133
|
+
// and we must NOT seed the stale value (which would revert edits such as a
|
|
134
|
+
// renamed trigger id back to its auto-generated default).
|
|
135
|
+
const staleCacheBeforeFreshFetch = undefined; // isFetchedAfterMount === false
|
|
136
|
+
expect(
|
|
137
|
+
shouldInitForKey({
|
|
138
|
+
value: staleCacheBeforeFreshFetch,
|
|
139
|
+
key: "auto-1",
|
|
140
|
+
initialisedKey: undefined,
|
|
141
|
+
}),
|
|
142
|
+
).toBe(false);
|
|
143
|
+
|
|
144
|
+
// Once the post-mount fetch resolves, the caller passes the fresh value and
|
|
145
|
+
// the one-shot init fires with server-truth data.
|
|
146
|
+
expect(
|
|
147
|
+
shouldInitForKey({
|
|
148
|
+
value: { name: "A", definition: { triggers: [{ id: "majors" }] } },
|
|
149
|
+
key: "auto-1",
|
|
150
|
+
initialisedKey: undefined,
|
|
151
|
+
}),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
127
154
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Pure decision function powering {@link useInitOnceForKey}. Extracted so
|
|
@@ -52,8 +52,15 @@ export function shouldInitForKey({
|
|
|
52
52
|
* - Skips initialisation entirely while either `value` or `key` is
|
|
53
53
|
* `undefined`/`null`.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
55
|
+
* Seeding runs **during render** (not in a `useEffect`). This is deliberate:
|
|
56
|
+
* the app is wrapped in `<StrictMode>`, which double-mounts components and
|
|
57
|
+
* **discards a `setState` scheduled from an effect** when the source query
|
|
58
|
+
* resolves synchronously — e.g. a warm react-query cache on reopen. That made
|
|
59
|
+
* the one-shot init silently no-op, reverting seeded form state (a renamed id
|
|
60
|
+
* snapping back to its default, etc.) while a cold-cache first open worked.
|
|
61
|
+
* Seeding during render with a state guard is React's recommended
|
|
62
|
+
* "adjust state when data changes" pattern and is immune to that race. `onInit`
|
|
63
|
+
* must therefore be pure aside from calling this component's state setters.
|
|
57
64
|
*
|
|
58
65
|
* @example
|
|
59
66
|
* useInitOnceForKey(existingConfig, existingConfig?.id, (config) => {
|
|
@@ -68,20 +75,16 @@ export function useInitOnceForKey<T>(
|
|
|
68
75
|
key: string | number | null | undefined,
|
|
69
76
|
onInit: (value: T) => void,
|
|
70
77
|
): void {
|
|
71
|
-
const
|
|
72
|
-
|
|
78
|
+
const [initialisedKey, setInitialisedKey] =
|
|
79
|
+
useState<string | number | null | undefined>();
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
initialisedKeyRef.current = key;
|
|
85
|
-
onInitRef.current(value as T);
|
|
86
|
-
}, [value, key]);
|
|
81
|
+
if (shouldInitForKey({ value, key, initialisedKey })) {
|
|
82
|
+
// Setting state + invoking `onInit` (which sets this component's state)
|
|
83
|
+
// during render is the supported "store info from previous renders"
|
|
84
|
+
// pattern: React restarts this component's render with the new state
|
|
85
|
+
// before committing, and the `initialisedKey` guard makes it idempotent
|
|
86
|
+
// (no loop; background refetches of the same key are ignored).
|
|
87
|
+
setInitialisedKey(key);
|
|
88
|
+
onInit(value as T);
|
|
89
|
+
}
|
|
87
90
|
}
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ export * from "./components/Pagination";
|
|
|
41
41
|
export * from "./components/PaginatedList";
|
|
42
42
|
export * from "./hooks/usePagination";
|
|
43
43
|
export * from "./components/DateTimePicker";
|
|
44
|
+
export * from "./components/DurationInput";
|
|
45
|
+
export * from "./components/TimeOfDayInput";
|
|
44
46
|
export * from "./components/DateRangeFilter";
|
|
45
47
|
export * from "./components/BackLink";
|
|
46
48
|
export * from "./components/StatusUpdateTimeline";
|
|
@@ -56,12 +58,19 @@ export * from "./components/TerminalFeed";
|
|
|
56
58
|
export * from "./components/PerformanceProvider";
|
|
57
59
|
export * from "./components/AmbientBackground";
|
|
58
60
|
export * from "./components/CodeEditor";
|
|
61
|
+
export * from "./components/TemplateValueInput";
|
|
62
|
+
export * from "./components/VariablePicker";
|
|
63
|
+
export * from "./components/TemplateInput";
|
|
64
|
+
export * from "./components/TemplateInputToggle";
|
|
65
|
+
export * from "./components/ActionCard";
|
|
66
|
+
export * from "./components/ScriptTestPanel";
|
|
59
67
|
export * from "./components/AnimatedNumber";
|
|
60
68
|
export * from "./hooks/useAnimatedNumber";
|
|
61
69
|
export * from "./components/IDELayout";
|
|
62
70
|
export * from "./components/MetricTile";
|
|
63
71
|
export * from "./components/Sheet";
|
|
64
72
|
export * from "./components/Popover";
|
|
73
|
+
export * from "./components/comboboxInteraction";
|
|
65
74
|
export * from "./hooks/useIsMobile";
|
|
66
75
|
export * from "./hooks/useInitOnceForKey";
|
|
67
76
|
export * from "./components/ListEmptyState";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { ActionCard } from "../src/components/ActionCard";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ActionCard> = {
|
|
6
|
+
title: "Components/Automation/ActionCard",
|
|
7
|
+
component: ActionCard,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"Collapsible card that hosts a single action in the visual automation editor.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof ActionCard>;
|
|
21
|
+
|
|
22
|
+
const FullFeaturedDemo = () => {
|
|
23
|
+
const [enabled, setEnabled] = useState(true);
|
|
24
|
+
return (
|
|
25
|
+
<div className="w-[680px] p-4">
|
|
26
|
+
<ActionCard
|
|
27
|
+
id="notify-1"
|
|
28
|
+
title="Notify User"
|
|
29
|
+
description="Send a transactional notification to a specific operator."
|
|
30
|
+
category="Notification"
|
|
31
|
+
icon="Bell"
|
|
32
|
+
enabled={enabled}
|
|
33
|
+
onEnabledChange={setEnabled}
|
|
34
|
+
onDelete={() => alert("Delete clicked")}
|
|
35
|
+
badges={[{ label: "produces: notify_user_result", variant: "secondary" }]}
|
|
36
|
+
>
|
|
37
|
+
<div className="space-y-2 text-sm">
|
|
38
|
+
<p className="text-muted-foreground">
|
|
39
|
+
(DynamicForm or per-action config UI goes here.)
|
|
40
|
+
</p>
|
|
41
|
+
<code className="block font-mono text-xs">userId, title, body</code>
|
|
42
|
+
</div>
|
|
43
|
+
</ActionCard>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const FullFeatured: Story = {
|
|
49
|
+
render: () => <FullFeaturedDemo />,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const MinimalNoToggle: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<div className="w-[680px] p-4">
|
|
55
|
+
<ActionCard id="log-1" title="Log" icon="FileText">
|
|
56
|
+
<p className="text-sm text-muted-foreground">
|
|
57
|
+
Write a single line to the run logger.
|
|
58
|
+
</p>
|
|
59
|
+
</ActionCard>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Home-Assistant-style collapsed summary row. Passing `onOpenSheet` makes the
|
|
66
|
+
* card a non-expanding summary: clicking the header opens the item's full
|
|
67
|
+
* config in a side sheet (wired up by the host) instead of expanding inline.
|
|
68
|
+
* `summary` renders a compact one-line hint under the title, and `actions`
|
|
69
|
+
* adds a three-dot overflow menu (Duplicate / Disable / Delete).
|
|
70
|
+
*/
|
|
71
|
+
const CollapsedSummaryDemo = () => {
|
|
72
|
+
const [enabled, setEnabled] = useState(true);
|
|
73
|
+
return (
|
|
74
|
+
<div className="w-[680px] space-y-2 p-4">
|
|
75
|
+
<ActionCard
|
|
76
|
+
id="notify-1"
|
|
77
|
+
title="Notify User"
|
|
78
|
+
category="Notification"
|
|
79
|
+
icon="Bell"
|
|
80
|
+
summary="notify_user → on-call engineer"
|
|
81
|
+
enabled={enabled}
|
|
82
|
+
onOpenSheet={() => alert("Open config sheet")}
|
|
83
|
+
actions={[
|
|
84
|
+
{
|
|
85
|
+
label: enabled ? "Disable" : "Enable",
|
|
86
|
+
icon: enabled ? "PowerOff" : "Power",
|
|
87
|
+
onClick: () => setEnabled((value) => !value),
|
|
88
|
+
},
|
|
89
|
+
{ label: "Duplicate", icon: "Copy", onClick: () => alert("Duplicate") },
|
|
90
|
+
{
|
|
91
|
+
label: "Delete",
|
|
92
|
+
icon: "Trash2",
|
|
93
|
+
onClick: () => alert("Delete"),
|
|
94
|
+
variant: "destructive",
|
|
95
|
+
},
|
|
96
|
+
]}
|
|
97
|
+
/>
|
|
98
|
+
<ActionCard
|
|
99
|
+
id="choose-1"
|
|
100
|
+
title="Choose (if / else)"
|
|
101
|
+
category="Choose"
|
|
102
|
+
icon="GitBranch"
|
|
103
|
+
summary="2 branches + else"
|
|
104
|
+
onOpenSheet={() => alert("Open config sheet")}
|
|
105
|
+
errors={["choose.0.when: condition is required"]}
|
|
106
|
+
actions={[
|
|
107
|
+
{ label: "Duplicate", icon: "Copy", onClick: () => alert("Duplicate") },
|
|
108
|
+
{
|
|
109
|
+
label: "Delete",
|
|
110
|
+
icon: "Trash2",
|
|
111
|
+
onClick: () => alert("Delete"),
|
|
112
|
+
variant: "destructive",
|
|
113
|
+
},
|
|
114
|
+
]}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const CollapsedSummary: Story = {
|
|
121
|
+
render: () => <CollapsedSummaryDemo />,
|
|
122
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { CircleAlert, CircleCheck, Info,
|
|
2
|
+
import { CircleAlert, CircleCheck, Info, AlertTriangle } from "lucide-react";
|
|
3
3
|
import {
|
|
4
4
|
Alert,
|
|
5
5
|
AlertContent,
|
|
@@ -28,7 +28,7 @@ export const Default: Story = {
|
|
|
28
28
|
render: (args) => (
|
|
29
29
|
<Alert {...args}>
|
|
30
30
|
<AlertIcon>
|
|
31
|
-
<Info className="
|
|
31
|
+
<Info className="w-4 h-4" />
|
|
32
32
|
</AlertIcon>
|
|
33
33
|
<AlertContent>
|
|
34
34
|
<AlertTitle>Heads up</AlertTitle>
|
|
@@ -45,7 +45,7 @@ export const Success: Story = {
|
|
|
45
45
|
render: (args) => (
|
|
46
46
|
<Alert {...args}>
|
|
47
47
|
<AlertIcon>
|
|
48
|
-
<CircleCheck className="
|
|
48
|
+
<CircleCheck className="w-4 h-4" />
|
|
49
49
|
</AlertIcon>
|
|
50
50
|
<AlertContent>
|
|
51
51
|
<AlertTitle>Saved</AlertTitle>
|
|
@@ -60,7 +60,7 @@ export const Warning: Story = {
|
|
|
60
60
|
render: (args) => (
|
|
61
61
|
<Alert {...args}>
|
|
62
62
|
<AlertIcon>
|
|
63
|
-
<
|
|
63
|
+
<AlertTriangle className="w-4 h-4" />
|
|
64
64
|
</AlertIcon>
|
|
65
65
|
<AlertContent>
|
|
66
66
|
<AlertTitle>Heads up</AlertTitle>
|
|
@@ -77,7 +77,7 @@ export const Error: Story = {
|
|
|
77
77
|
render: (args) => (
|
|
78
78
|
<Alert {...args}>
|
|
79
79
|
<AlertIcon>
|
|
80
|
-
<CircleAlert className="
|
|
80
|
+
<CircleAlert className="w-4 h-4" />
|
|
81
81
|
</AlertIcon>
|
|
82
82
|
<AlertContent>
|
|
83
83
|
<AlertTitle>Something went wrong</AlertTitle>
|
|
@@ -14,9 +14,11 @@ type Story = StoryObj<typeof CodeEditor>;
|
|
|
14
14
|
const Demo = ({
|
|
15
15
|
language,
|
|
16
16
|
initial,
|
|
17
|
+
minHeight = "280px",
|
|
17
18
|
}: {
|
|
18
|
-
language: "json" | "yaml" | "javascript";
|
|
19
|
+
language: "json" | "yaml" | "javascript" | "typescript" | "shell";
|
|
19
20
|
initial: string;
|
|
21
|
+
minHeight?: string;
|
|
20
22
|
}) => {
|
|
21
23
|
const [value, setValue] = useState(initial);
|
|
22
24
|
return (
|
|
@@ -24,7 +26,7 @@ const Demo = ({
|
|
|
24
26
|
value={value}
|
|
25
27
|
onChange={setValue}
|
|
26
28
|
language={language}
|
|
27
|
-
minHeight=
|
|
29
|
+
minHeight={minHeight}
|
|
28
30
|
/>
|
|
29
31
|
);
|
|
30
32
|
};
|
|
@@ -65,3 +67,46 @@ export default async function check({ fetch }) {
|
|
|
65
67
|
/>
|
|
66
68
|
),
|
|
67
69
|
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The top-right "Expand editor" affordance opens the same editor in a large
|
|
73
|
+
* full-screen overlay. The inline editor here is deliberately short so the
|
|
74
|
+
* value of expanding a long script is obvious; both editors share the same
|
|
75
|
+
* value, so edits made in the overlay persist after closing it.
|
|
76
|
+
*/
|
|
77
|
+
export const Popout: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<Demo
|
|
80
|
+
language="typescript"
|
|
81
|
+
minHeight="120px"
|
|
82
|
+
initial={`// A longer script - click the expand icon (top-right) to edit it in a
|
|
83
|
+
// comfortable full-screen overlay. Edits sync back to the inline editor.
|
|
84
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
85
|
+
|
|
86
|
+
export default async function run({ context }) {
|
|
87
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
88
|
+
const res = await fetch(context.url);
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
return { ok: true, attempt };
|
|
91
|
+
}
|
|
92
|
+
await sleep(1000 * attempt);
|
|
93
|
+
}
|
|
94
|
+
return { ok: false };
|
|
95
|
+
}`}
|
|
96
|
+
/>
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Shell scripts get a "Edit script - Shell" overlay title. */
|
|
101
|
+
export const Shell: Story = {
|
|
102
|
+
render: () => (
|
|
103
|
+
<Demo
|
|
104
|
+
language="shell"
|
|
105
|
+
minHeight="120px"
|
|
106
|
+
initial={`#!/usr/bin/env bash
|
|
107
|
+
set -euo pipefail
|
|
108
|
+
|
|
109
|
+
curl -fsS "$TARGET_URL" > /dev/null && echo "up" || echo "down"`}
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
};
|