@checkstack/ui 1.11.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.
Files changed (45) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +181 -0
  3. package/package.json +4 -4
  4. package/scripts/generate-stdlib-types.ts +23 -0
  5. package/src/components/ActionCard.tsx +96 -8
  6. package/src/components/CodeEditor/CodeEditor.tsx +95 -14
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
  8. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  9. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  10. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  11. package/src/components/CodeEditor/index.ts +24 -0
  12. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  13. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  14. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  15. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  16. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  17. package/src/components/CodeEditor/types.ts +59 -0
  18. package/src/components/CodeEditor/validateScripts.ts +132 -0
  19. package/src/components/Dialog.tsx +32 -11
  20. package/src/components/DurationInput.tsx +121 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +25 -1
  22. package/src/components/DynamicForm/FormField.tsx +109 -1
  23. package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
  24. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  25. package/src/components/DynamicForm/index.ts +6 -0
  26. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  27. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  28. package/src/components/DynamicForm/types.ts +72 -1
  29. package/src/components/DynamicForm/utils.ts +32 -0
  30. package/src/components/Popover.tsx +6 -1
  31. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  32. package/src/components/ScriptTestPanel.logic.ts +137 -0
  33. package/src/components/ScriptTestPanel.tsx +394 -0
  34. package/src/components/Sheet.tsx +21 -6
  35. package/src/components/TimeOfDayInput.tsx +116 -0
  36. package/src/components/comboboxInteraction.ts +39 -0
  37. package/src/components/portalContainer.ts +24 -0
  38. package/src/index.ts +4 -0
  39. package/stories/ActionCard.stories.tsx +60 -0
  40. package/stories/CodeEditor.stories.tsx +47 -2
  41. package/stories/DurationInput.stories.tsx +59 -0
  42. package/stories/ScriptTestPanel.stories.tsx +106 -0
  43. package/stories/SecretEnvEditor.stories.tsx +80 -0
  44. package/stories/TimeOfDayInput.stories.tsx +34 -0
  45. package/tsconfig.json +1 -0
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { X } from "lucide-react";
5
5
  import { cn } from "../utils";
6
6
  import { usePerformance } from "./PerformanceProvider";
7
+ import { PortalContainerContext } from "./portalContainer";
7
8
 
8
9
  const Sheet = DialogPrimitive.Root;
9
10
  const SheetTrigger = DialogPrimitive.Trigger;
@@ -56,11 +57,23 @@ const SheetContent = React.forwardRef<
56
57
  SheetContentProps
57
58
  >(({ className, children, size, ...props }, ref) => {
58
59
  const { isLowPower } = usePerformance();
60
+ // Expose the content element so popovers/comboboxes rendered inside the
61
+ // sheet portal INTO it — otherwise the modal scroll-lock blocks their
62
+ // internal scrolling (a popover portaled to body is outside the dialog).
63
+ const [content, setContent] = React.useState<HTMLDivElement | null>(null);
64
+ const setRefs = React.useCallback(
65
+ (node: HTMLDivElement | null) => {
66
+ setContent(node);
67
+ if (typeof ref === "function") ref(node);
68
+ else if (ref) ref.current = node;
69
+ },
70
+ [ref],
71
+ );
59
72
  return (
60
73
  <SheetPortal>
61
74
  <SheetOverlay />
62
75
  <DialogPrimitive.Content
63
- ref={ref}
76
+ ref={setRefs}
64
77
  className={cn(
65
78
  sheetContentVariants({ size }),
66
79
  "inset-y-0 right-0 h-full",
@@ -70,11 +83,13 @@ const SheetContent = React.forwardRef<
70
83
  )}
71
84
  {...props}
72
85
  >
73
- {children}
74
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
75
- <X className="h-4 w-4" />
76
- <span className="sr-only">Close</span>
77
- </DialogPrimitive.Close>
86
+ <PortalContainerContext.Provider value={content}>
87
+ {children}
88
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
89
+ <X className="h-4 w-4" />
90
+ <span className="sr-only">Close</span>
91
+ </DialogPrimitive.Close>
92
+ </PortalContainerContext.Provider>
78
93
  </DialogPrimitive.Content>
79
94
  </SheetPortal>
80
95
  );
@@ -0,0 +1,116 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Clock } from "lucide-react";
3
+
4
+ const padZero = (value: number): string => String(value).padStart(2, "0");
5
+ const filterNumeric = (value: string): string => value.replaceAll(/[^0-9]/g, "");
6
+
7
+ export interface TimeOfDayInputProps {
8
+ /** `"HH:mm"` (24h) or undefined when unset. */
9
+ value: string | undefined;
10
+ onChange: (next: string | undefined) => void;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ className?: string;
14
+ }
15
+
16
+ /** Split a `"HH:mm"` string into its fields, tolerating partial input. */
17
+ function split(value: string | undefined): { hour: string; minute: string } {
18
+ if (!value) return { hour: "", minute: "" };
19
+ const [h = "", m = ""] = value.split(":");
20
+ return { hour: h, minute: m };
21
+ }
22
+
23
+ /**
24
+ * 24h time-of-day (HH:MM) input. Two numeric fields mirroring the time
25
+ * section of {@link DateTimePicker}. Emits a `"HH:mm"` string (or
26
+ * undefined when either field is empty), the exact shape the automation
27
+ * `time` condition's `after` / `before` accept - so it round-trips
28
+ * losslessly through YAML.
29
+ *
30
+ * Plain inputs, no animations - no `usePerformance` gating needed.
31
+ */
32
+ export const TimeOfDayInput: React.FC<TimeOfDayInputProps> = ({
33
+ value,
34
+ onChange,
35
+ disabled,
36
+ id,
37
+ className,
38
+ }) => {
39
+ const [fields, setFields] = useState(() => split(value));
40
+ const isInternalChange = React.useRef(false);
41
+
42
+ // Sync from external value changes (not our own edits).
43
+ useEffect(() => {
44
+ if (isInternalChange.current) {
45
+ isInternalChange.current = false;
46
+ return;
47
+ }
48
+ setFields(split(value));
49
+ }, [value]);
50
+
51
+ const emit = (next: { hour: string; minute: string }) => {
52
+ isInternalChange.current = true;
53
+ const cleared: string | undefined = undefined;
54
+ if (next.hour === "" || next.minute === "") {
55
+ onChange(cleared);
56
+ return;
57
+ }
58
+ onChange(`${padZero(Number(next.hour))}:${padZero(Number(next.minute))}`);
59
+ };
60
+
61
+ const handleChange = (field: "hour" | "minute", raw: string) => {
62
+ const filtered = filterNumeric(raw).slice(0, 2);
63
+ const next = { ...fields, [field]: filtered };
64
+ setFields(next);
65
+ emit(next);
66
+ };
67
+
68
+ const handleBlur = (field: "hour" | "minute") => {
69
+ const raw = fields[field];
70
+ if (raw === "") return;
71
+ const n = Number(raw);
72
+ const max = field === "hour" ? 23 : 59;
73
+ const clamped = padZero(Math.min(Math.max(n, 0), max));
74
+ if (clamped !== raw) {
75
+ const next = { ...fields, [field]: clamped };
76
+ setFields(next);
77
+ emit(next);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div
83
+ className={`inline-flex items-center border rounded-lg bg-background px-2 py-1 ${
84
+ className ?? ""
85
+ }`}
86
+ >
87
+ <Clock className="h-4 w-4 text-muted-foreground mr-2" />
88
+ <input
89
+ id={id}
90
+ type="text"
91
+ inputMode="numeric"
92
+ value={fields.hour}
93
+ onChange={(event) => handleChange("hour", event.target.value)}
94
+ onBlur={() => handleBlur("hour")}
95
+ placeholder="HH"
96
+ aria-label="Hour"
97
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
98
+ maxLength={2}
99
+ disabled={disabled}
100
+ />
101
+ <span className="text-muted-foreground">:</span>
102
+ <input
103
+ type="text"
104
+ inputMode="numeric"
105
+ value={fields.minute}
106
+ onChange={(event) => handleChange("minute", event.target.value)}
107
+ onBlur={() => handleBlur("minute")}
108
+ placeholder="MM"
109
+ aria-label="Minute"
110
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
111
+ maxLength={2}
112
+ disabled={disabled}
113
+ />
114
+ </div>
115
+ );
116
+ };
@@ -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);
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";
@@ -61,12 +63,14 @@ export * from "./components/VariablePicker";
61
63
  export * from "./components/TemplateInput";
62
64
  export * from "./components/TemplateInputToggle";
63
65
  export * from "./components/ActionCard";
66
+ export * from "./components/ScriptTestPanel";
64
67
  export * from "./components/AnimatedNumber";
65
68
  export * from "./hooks/useAnimatedNumber";
66
69
  export * from "./components/IDELayout";
67
70
  export * from "./components/MetricTile";
68
71
  export * from "./components/Sheet";
69
72
  export * from "./components/Popover";
73
+ export * from "./components/comboboxInteraction";
70
74
  export * from "./hooks/useIsMobile";
71
75
  export * from "./hooks/useInitOnceForKey";
72
76
  export * from "./components/ListEmptyState";
@@ -60,3 +60,63 @@ export const MinimalNoToggle: Story = {
60
60
  </div>
61
61
  ),
62
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
+ };
@@ -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="280px"
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
+ };
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import {
4
+ DurationInput,
5
+ type DurationValue,
6
+ } from "../src/components/DurationInput";
7
+ import { Label } from "../src/components/Label";
8
+
9
+ const meta: Meta<typeof DurationInput> = {
10
+ title: "Components/Inputs/DurationInput",
11
+ component: DurationInput,
12
+ tags: ["autodocs"],
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof DurationInput>;
17
+
18
+ const Demo = ({
19
+ initial,
20
+ defaultUnit,
21
+ }: {
22
+ initial?: DurationValue;
23
+ defaultUnit?: "seconds" | "minutes" | "hours";
24
+ }) => {
25
+ const [value, setValue] = useState<DurationValue | undefined>(initial);
26
+ return (
27
+ <div className="max-w-sm space-y-2">
28
+ <Label>Fire only if state holds for</Label>
29
+ <DurationInput
30
+ value={value}
31
+ onChange={setValue}
32
+ defaultUnit={defaultUnit}
33
+ />
34
+ <pre className="text-xs text-muted-foreground">
35
+ {JSON.stringify(value ?? null)}
36
+ </pre>
37
+ </div>
38
+ );
39
+ };
40
+
41
+ export const Empty: Story = { render: () => <Demo /> };
42
+
43
+ export const Minutes: Story = {
44
+ render: () => <Demo initial={{ minutes: 30 }} />,
45
+ };
46
+
47
+ export const Hours: Story = {
48
+ render: () => <Demo initial={{ hours: 2 }} />,
49
+ };
50
+
51
+ export const SecondsDefault: Story = {
52
+ render: () => <Demo defaultUnit="seconds" />,
53
+ };
54
+
55
+ export const Disabled: Story = {
56
+ render: () => (
57
+ <DurationInput value={{ minutes: 10 }} onChange={() => {}} disabled />
58
+ ),
59
+ };
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import React from "react";
3
+ import {
4
+ ScriptTestPanel,
5
+ ContextSampleEditor,
6
+ type ScriptTestPanelResult,
7
+ } from "../src/components/ScriptTestPanel";
8
+
9
+ const meta: Meta<typeof ScriptTestPanel> = {
10
+ title: "Components/Editors/ScriptTestPanel",
11
+ component: ScriptTestPanel,
12
+ tags: ["autodocs"],
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof ScriptTestPanel>;
17
+
18
+ function delay<T>(value: T, ms = 400): Promise<T> {
19
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
20
+ }
21
+
22
+ const successResult: ScriptTestPanelResult = {
23
+ result: { id: "INC-42", ok: true },
24
+ stdout: "Resolved incident INC-42\nDone.",
25
+ stderr: "",
26
+ durationMs: 128,
27
+ timedOut: false,
28
+ };
29
+
30
+ const shellSuccessResult: ScriptTestPanelResult = {
31
+ stdout: "INC-42",
32
+ stderr: "",
33
+ exitCode: 0,
34
+ durationMs: 42,
35
+ timedOut: false,
36
+ };
37
+
38
+ const failureResult: ScriptTestPanelResult = {
39
+ stdout: "",
40
+ stderr: "TypeError: Cannot read properties of undefined (reading 'id')",
41
+ durationMs: 31,
42
+ timedOut: false,
43
+ error: "Cannot read properties of undefined (reading 'id')",
44
+ };
45
+
46
+ export const CollapsedByDefault: Story = {
47
+ render: () => (
48
+ <div className="max-w-xl">
49
+ <ScriptTestPanel onRun={() => delay(successResult)} />
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ export const TypeScriptSuccess: Story = {
55
+ render: () => (
56
+ <div className="max-w-xl">
57
+ <ScriptTestPanel onRun={() => delay(successResult)} defaultOpen />
58
+ </div>
59
+ ),
60
+ };
61
+
62
+ export const ShellSuccess: Story = {
63
+ render: () => (
64
+ <div className="max-w-xl">
65
+ <ScriptTestPanel onRun={() => delay(shellSuccessResult)} defaultOpen />
66
+ </div>
67
+ ),
68
+ };
69
+
70
+ export const Failure: Story = {
71
+ render: () => (
72
+ <div className="max-w-xl">
73
+ <ScriptTestPanel onRun={() => delay(failureResult)} defaultOpen />
74
+ </div>
75
+ ),
76
+ };
77
+
78
+ export const WithSampleContextEditor: Story = {
79
+ render: () => {
80
+ const Wrapper: React.FC = () => {
81
+ const [context, setContext] = React.useState(
82
+ '{\n "trigger": {\n "event": "incident.created",\n "payload": { "id": "INC-42" }\n }\n}',
83
+ );
84
+ return (
85
+ <div className="max-w-xl">
86
+ <ScriptTestPanel
87
+ onRun={() => delay(successResult)}
88
+ defaultOpen
89
+ contextEditor={
90
+ <ContextSampleEditor value={context} onChange={setContext} />
91
+ }
92
+ />
93
+ </div>
94
+ );
95
+ };
96
+ return <Wrapper />;
97
+ },
98
+ };
99
+
100
+ export const Disabled: Story = {
101
+ render: () => (
102
+ <div className="max-w-xl">
103
+ <ScriptTestPanel onRun={() => delay(successResult)} defaultOpen disabled />
104
+ </div>
105
+ ),
106
+ };
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { SecretEnvEditor } from "../src/components/DynamicForm/SecretEnvEditor";
4
+ import { Label } from "../src/components/Label";
5
+
6
+ const meta: Meta<typeof SecretEnvEditor> = {
7
+ title: "Components/Inputs/SecretEnvEditor",
8
+ component: SecretEnvEditor,
9
+ tags: ["autodocs"],
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof SecretEnvEditor>;
14
+
15
+ const Demo = ({
16
+ initial,
17
+ secretNames,
18
+ }: {
19
+ initial?: Record<string, string>;
20
+ secretNames?: string[];
21
+ }) => {
22
+ const [value, setValue] = useState<Record<string, string>>(initial ?? {});
23
+ return (
24
+ <div className="max-w-xl space-y-2">
25
+ <Label>Secret environment variables</Label>
26
+ <SecretEnvEditor
27
+ id="demo-secret-env"
28
+ value={value}
29
+ onChange={setValue}
30
+ secretNames={secretNames}
31
+ />
32
+ <pre className="text-xs text-muted-foreground">
33
+ {JSON.stringify(value, null, 2)}
34
+ </pre>
35
+ </div>
36
+ );
37
+ };
38
+
39
+ /** Empty editor with secret-name suggestions available. */
40
+ export const Empty: Story = {
41
+ render: () => (
42
+ <Demo secretNames={["jira_token", "db_password", "smtp_api_key"]} />
43
+ ),
44
+ };
45
+
46
+ /** Pre-populated mapping; the stored value is the `${{ secrets.NAME }}` template. */
47
+ export const WithValues: Story = {
48
+ render: () => (
49
+ <Demo
50
+ initial={{
51
+ API_TOKEN: "${{ secrets.jira_token }}",
52
+ DB_PASSWORD: "${{ secrets.db_password }}",
53
+ }}
54
+ secretNames={["jira_token", "db_password", "smtp_api_key"]}
55
+ />
56
+ ),
57
+ };
58
+
59
+ /** No name suggestions available — the secret field is a plain free-text
60
+ * combobox and no existence warning is shown (the list is unknown). */
61
+ export const NoSuggestions: Story = {
62
+ render: () => <Demo initial={{ TOKEN: "${{ secrets.some_secret }}" }} />,
63
+ };
64
+
65
+ /**
66
+ * A row references a secret that the loaded list doesn't contain — the row
67
+ * shows a non-blocking warning (red border + message). The secret may have
68
+ * been deleted/renamed or be created later, so the value still round-trips.
69
+ */
70
+ export const UnknownSecret: Story = {
71
+ render: () => (
72
+ <Demo
73
+ initial={{
74
+ API_TOKEN: "${{ secrets.jira_token }}",
75
+ GHOST: "${{ secrets.deleted_secret }}",
76
+ }}
77
+ secretNames={["jira_token", "db_password"]}
78
+ />
79
+ ),
80
+ };
@@ -0,0 +1,34 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { TimeOfDayInput } from "../src/components/TimeOfDayInput";
4
+ import { Label } from "../src/components/Label";
5
+
6
+ const meta: Meta<typeof TimeOfDayInput> = {
7
+ title: "Components/Inputs/TimeOfDayInput",
8
+ component: TimeOfDayInput,
9
+ tags: ["autodocs"],
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof TimeOfDayInput>;
14
+
15
+ const Demo = ({ initial }: { initial?: string }) => {
16
+ const [value, setValue] = useState<string | undefined>(initial);
17
+ return (
18
+ <div className="max-w-sm space-y-2">
19
+ <Label>On-call window starts at</Label>
20
+ <TimeOfDayInput value={value} onChange={setValue} />
21
+ <pre className="text-xs text-muted-foreground">
22
+ {JSON.stringify(value ?? null)}
23
+ </pre>
24
+ </div>
25
+ );
26
+ };
27
+
28
+ export const Empty: Story = { render: () => <Demo /> };
29
+
30
+ export const Set: Story = { render: () => <Demo initial="22:00" /> };
31
+
32
+ export const Disabled: Story = {
33
+ render: () => <TimeOfDayInput value="09:30" onChange={() => {}} disabled />,
34
+ };
package/tsconfig.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "include": [
4
4
  "src",
5
5
  "src/components/CodeEditor/generated/stdlib-types.json",
6
+ "src/components/CodeEditor/generated/builtin-modules.json",
6
7
  "scripts"
7
8
  ],
8
9
  "references": [