@checkstack/ui 1.8.2 → 1.9.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/CHANGELOG.md +69 -0
- package/package.json +6 -2
- package/scripts/generate-stdlib-types.ts +90 -0
- package/src/components/CodeEditor/CodeEditor.tsx +7 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +203 -117
- package/src/components/CodeEditor/generateTypeDefinitions.ts +19 -26
- package/src/components/CodeEditor/generated/stdlib-types.json +1 -0
- package/src/components/CodeEditor/index.ts +7 -0
- package/src/components/CodeEditor/monacoStdlib.ts +62 -0
- package/src/components/CodeEditor/monacoWorkers.ts +118 -0
- package/src/components/CodeEditor/scriptContext.test.ts +280 -0
- package/src/components/CodeEditor/scriptContext.ts +467 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.test.ts +95 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.ts +70 -0
- package/src/components/DynamicForm/DynamicForm.tsx +6 -0
- package/src/components/DynamicForm/FormField.tsx +15 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +111 -6
- package/src/components/DynamicForm/index.ts +2 -0
- package/src/components/DynamicForm/starterTemplateSelector.test.ts +96 -0
- package/src/components/DynamicForm/starterTemplateSelector.ts +32 -0
- package/src/components/DynamicForm/types.ts +34 -1
- package/src/components/LinksEditor.tsx +69 -34
- package/src/hooks/useInitOnceForKey.test.ts +127 -0
- package/src/hooks/useInitOnceForKey.ts +87 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +3 -1
|
@@ -33,6 +33,9 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
33
33
|
formValues,
|
|
34
34
|
optionsResolvers,
|
|
35
35
|
templateProperties,
|
|
36
|
+
typeDefinitions,
|
|
37
|
+
shellEnvVars,
|
|
38
|
+
starterTemplates,
|
|
36
39
|
onChange,
|
|
37
40
|
}) => {
|
|
38
41
|
const description = propSchema.description || "";
|
|
@@ -129,6 +132,9 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
129
132
|
isRequired={isRequired}
|
|
130
133
|
editorTypes={editorTypes}
|
|
131
134
|
templateProperties={templateProperties}
|
|
135
|
+
typeDefinitions={typeDefinitions}
|
|
136
|
+
shellEnvVars={shellEnvVars}
|
|
137
|
+
starterTemplates={starterTemplates}
|
|
132
138
|
onChange={onChange as (val: string | undefined) => void}
|
|
133
139
|
/>
|
|
134
140
|
);
|
|
@@ -346,6 +352,9 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
346
352
|
formValues={formValues}
|
|
347
353
|
optionsResolvers={optionsResolvers}
|
|
348
354
|
templateProperties={templateProperties}
|
|
355
|
+
typeDefinitions={typeDefinitions}
|
|
356
|
+
shellEnvVars={shellEnvVars}
|
|
357
|
+
starterTemplates={starterTemplates}
|
|
349
358
|
onChange={(val) =>
|
|
350
359
|
onChange({ ...(value as Record<string, unknown>), [key]: val })
|
|
351
360
|
}
|
|
@@ -454,6 +463,9 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
454
463
|
formValues={formValues}
|
|
455
464
|
optionsResolvers={optionsResolvers}
|
|
456
465
|
templateProperties={templateProperties}
|
|
466
|
+
typeDefinitions={typeDefinitions}
|
|
467
|
+
shellEnvVars={shellEnvVars}
|
|
468
|
+
starterTemplates={starterTemplates}
|
|
457
469
|
onChange={(val) => {
|
|
458
470
|
const next = [...(items as unknown[])];
|
|
459
471
|
next[index] = val;
|
|
@@ -582,6 +594,9 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
582
594
|
formValues={formValues}
|
|
583
595
|
optionsResolvers={optionsResolvers}
|
|
584
596
|
templateProperties={templateProperties}
|
|
597
|
+
typeDefinitions={typeDefinitions}
|
|
598
|
+
shellEnvVars={shellEnvVars}
|
|
599
|
+
starterTemplates={starterTemplates}
|
|
585
600
|
onChange={(val) => onChange({ ...currentValue, [key]: val })}
|
|
586
601
|
/>
|
|
587
602
|
))}
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
parseFormData,
|
|
18
18
|
EDITOR_TYPE_LABELS,
|
|
19
19
|
} from "./utils";
|
|
20
|
+
import type { EditorStarterTemplates, ShellEnvVar } from "./types";
|
|
21
|
+
import { pickStarterForEmptyField } from "./starterTemplateSelector";
|
|
20
22
|
|
|
21
23
|
export interface MultiTypeEditorFieldProps {
|
|
22
24
|
/** Unique identifier for the field */
|
|
@@ -33,6 +35,25 @@ export interface MultiTypeEditorFieldProps {
|
|
|
33
35
|
editorTypes: EditorType[];
|
|
34
36
|
/** Optional template properties for autocomplete */
|
|
35
37
|
templateProperties?: TemplateProperty[];
|
|
38
|
+
/**
|
|
39
|
+
* Optional TypeScript declarations injected into Monaco for TS/JS modes —
|
|
40
|
+
* powers IntelliSense + return-shape error reporting against the runtime
|
|
41
|
+
* context the user will see (`context.config`, `context.event`, ...).
|
|
42
|
+
*/
|
|
43
|
+
typeDefinitions?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Optional shell env-var hints surfaced as completion items after `$`
|
|
46
|
+
* (and `${`) in shell mode. Typically populated by the platform with
|
|
47
|
+
* names like `EVENT_ID` / `PAYLOAD_*` so users don't have to memorise.
|
|
48
|
+
*/
|
|
49
|
+
shellEnvVars?: ShellEnvVar[];
|
|
50
|
+
/**
|
|
51
|
+
* Optional starter templates per editor language. When the field is
|
|
52
|
+
* empty (and the user hasn't typed yet), switching to an editor with a
|
|
53
|
+
* starter for that language pre-populates the editor so users see a
|
|
54
|
+
* working example instead of a blank canvas.
|
|
55
|
+
*/
|
|
56
|
+
starterTemplates?: EditorStarterTemplates;
|
|
36
57
|
/** Callback when value changes */
|
|
37
58
|
onChange: (value: string | undefined) => void;
|
|
38
59
|
}
|
|
@@ -50,6 +71,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
50
71
|
isRequired,
|
|
51
72
|
editorTypes,
|
|
52
73
|
templateProperties,
|
|
74
|
+
typeDefinitions,
|
|
75
|
+
shellEnvVars,
|
|
76
|
+
starterTemplates,
|
|
53
77
|
onChange,
|
|
54
78
|
}) => {
|
|
55
79
|
// Detect initial editor type from value
|
|
@@ -57,16 +81,81 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
57
81
|
detectEditorType(value, editorTypes),
|
|
58
82
|
);
|
|
59
83
|
|
|
60
|
-
//
|
|
61
|
-
|
|
84
|
+
// Latched once the field has had non-empty content at least once for
|
|
85
|
+
// this mount. Used by the seed effects below to decide whether to
|
|
86
|
+
// (re-)install the starter template.
|
|
87
|
+
const hasSeededRef = React.useRef(false);
|
|
88
|
+
|
|
89
|
+
// React reuses this `MultiTypeEditorField` instance across collector
|
|
90
|
+
// switches when its parent `FormField` keeps the same key (e.g. both
|
|
91
|
+
// collectors have a property called `script`). The `useState`
|
|
92
|
+
// initializer above only runs on the first mount, so without this
|
|
93
|
+
// effect a switch from a shell-only collector to a typescript-only
|
|
94
|
+
// collector leaves `selectedType` stuck on "shell" — Monaco then
|
|
95
|
+
// tokenises the new collector's TS content as shell (no
|
|
96
|
+
// highlighting), and the reverse direction tokenises shell content as
|
|
97
|
+
// TS (nonsense "Cannot find name" errors).
|
|
98
|
+
//
|
|
99
|
+
// Whenever `editorTypes` changes and the current selection is no
|
|
100
|
+
// longer offered, re-detect from the new value. We deliberately do
|
|
101
|
+
// NOT reset on every `value` change — that would clobber the user's
|
|
102
|
+
// manual dropdown choice mid-edit.
|
|
62
103
|
React.useEffect(() => {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
if (!editorTypes.includes(selectedType)) {
|
|
105
|
+
const next = detectEditorType(value, editorTypes);
|
|
106
|
+
setSelectedType(next);
|
|
107
|
+
// Reset the seed latch too — this is effectively a fresh field
|
|
108
|
+
// for a different collector, and the new language's starter
|
|
109
|
+
// template should be eligible again.
|
|
110
|
+
hasSeededRef.current = false;
|
|
111
|
+
}
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only re-derive when the offered types change
|
|
113
|
+
}, [editorTypes]);
|
|
114
|
+
|
|
115
|
+
// Seed an empty field with the starter template for its detected
|
|
116
|
+
// language so users see a working example instead of a blank canvas.
|
|
117
|
+
//
|
|
118
|
+
// Why this isn't just `useEffect(..., [])`:
|
|
119
|
+
// React fires CHILD effects before PARENT effects. `DynamicForm`'s
|
|
120
|
+
// own init effect (which applies schema defaults) runs AFTER this one
|
|
121
|
+
// and, on initial mount, calls `onChange(defaults)` — which clobbers
|
|
122
|
+
// a freshly-seeded `script: starter` back to `script: ""` because the
|
|
123
|
+
// defaults effect's closure-captured `value` was the original `{}`.
|
|
124
|
+
// A naive `[]`-dep seed would therefore appear to do nothing.
|
|
125
|
+
//
|
|
126
|
+
// Two-effect design fixes this without depending on cross-component
|
|
127
|
+
// effect ordering:
|
|
128
|
+
// 1. The "observed" effect (declared FIRST) flips `hasSeededRef` to
|
|
129
|
+
// true the moment we see non-empty content in `value` — whether
|
|
130
|
+
// that came from our seed surviving, a parent default landing
|
|
131
|
+
// with content, or the user typing.
|
|
132
|
+
// 2. The "seed" effect re-runs on every value/starter change and
|
|
133
|
+
// installs the starter while `hasSeededRef` is still false. If
|
|
134
|
+
// the parent clobbers us back to "", we just re-seed on the
|
|
135
|
+
// next pass — until the value sticks and effect (1) latches.
|
|
136
|
+
//
|
|
137
|
+
// The latch also means we DON'T re-seed if the user deliberately
|
|
138
|
+
// deletes their content later, or on any subsequent refetch / parent
|
|
139
|
+
// re-render: once non-empty has been observed at least once, the
|
|
140
|
+
// field is the user's territory.
|
|
141
|
+
React.useEffect(() => {
|
|
142
|
+
if (!hasSeededRef.current && (value ?? "").trim().length > 0) {
|
|
143
|
+
hasSeededRef.current = true;
|
|
66
144
|
}
|
|
67
|
-
// Don't re-detect type when value changes after initial mount
|
|
68
145
|
}, [value]);
|
|
69
146
|
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
if (hasSeededRef.current) return;
|
|
149
|
+
const starter = pickStarterForEmptyField({
|
|
150
|
+
value,
|
|
151
|
+
selectedType,
|
|
152
|
+
starterTemplates,
|
|
153
|
+
});
|
|
154
|
+
if (starter !== undefined) {
|
|
155
|
+
onChange(starter);
|
|
156
|
+
}
|
|
157
|
+
}, [value, selectedType, starterTemplates, onChange]);
|
|
158
|
+
|
|
70
159
|
const handleTypeChange = (newType: EditorType) => {
|
|
71
160
|
setSelectedType(newType);
|
|
72
161
|
|
|
@@ -76,6 +165,19 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
76
165
|
return;
|
|
77
166
|
}
|
|
78
167
|
|
|
168
|
+
// If the field is empty after the switch and we have a starter template
|
|
169
|
+
// for the new language, seed it. (Switching languages on a non-empty
|
|
170
|
+
// field preserves the existing content, just like before.)
|
|
171
|
+
const starter = pickStarterForEmptyField({
|
|
172
|
+
value,
|
|
173
|
+
selectedType: newType,
|
|
174
|
+
starterTemplates,
|
|
175
|
+
});
|
|
176
|
+
if (starter !== undefined) {
|
|
177
|
+
onChange(starter);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
79
181
|
// When switching to formdata
|
|
80
182
|
if (newType === "formdata") {
|
|
81
183
|
if (!value || value.trim() === "") {
|
|
@@ -242,6 +344,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
242
344
|
language="javascript"
|
|
243
345
|
minHeight="150px"
|
|
244
346
|
templateProperties={templateProperties}
|
|
347
|
+
typeDefinitions={typeDefinitions}
|
|
245
348
|
/>
|
|
246
349
|
)}
|
|
247
350
|
|
|
@@ -253,6 +356,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
253
356
|
language="typescript"
|
|
254
357
|
minHeight="150px"
|
|
255
358
|
templateProperties={templateProperties}
|
|
359
|
+
typeDefinitions={typeDefinitions}
|
|
256
360
|
/>
|
|
257
361
|
)}
|
|
258
362
|
|
|
@@ -264,6 +368,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
|
|
|
264
368
|
language="shell"
|
|
265
369
|
minHeight="150px"
|
|
266
370
|
templateProperties={templateProperties}
|
|
371
|
+
shellEnvVars={shellEnvVars}
|
|
267
372
|
/>
|
|
268
373
|
)}
|
|
269
374
|
</div>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { pickStarterForEmptyField } from "./starterTemplateSelector";
|
|
3
|
+
import type { EditorStarterTemplates } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression suite for the starter-template seeding behaviour in
|
|
7
|
+
* `MultiTypeEditorField`.
|
|
8
|
+
*
|
|
9
|
+
* The previous implementation gated seeding behind an `isInitialMount`
|
|
10
|
+
* ref that a sibling effect with `[value]` deps flipped to false BEFORE
|
|
11
|
+
* the seed effect ran — so seeding silently no-op'd on every mount and
|
|
12
|
+
* users always saw a blank canvas. The refactored seed effect delegates
|
|
13
|
+
* to this pure function; testing it here proves the decision is correct
|
|
14
|
+
* regardless of the React effect ordering trap.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const STARTERS: EditorStarterTemplates = {
|
|
18
|
+
typescript: "// starter for TypeScript",
|
|
19
|
+
shell: "#!/bin/sh\necho starter for shell",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("pickStarterForEmptyField", () => {
|
|
23
|
+
it("returns the starter for the selected language when the field is empty", () => {
|
|
24
|
+
expect(
|
|
25
|
+
pickStarterForEmptyField({
|
|
26
|
+
value: "",
|
|
27
|
+
selectedType: "typescript",
|
|
28
|
+
starterTemplates: STARTERS,
|
|
29
|
+
}),
|
|
30
|
+
).toBe(STARTERS.typescript);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the starter when the value is whitespace-only", () => {
|
|
34
|
+
// The original bug let trailing whitespace block the seed; explicit
|
|
35
|
+
// coverage so we don't regress.
|
|
36
|
+
expect(
|
|
37
|
+
pickStarterForEmptyField({
|
|
38
|
+
value: " \n\t ",
|
|
39
|
+
selectedType: "typescript",
|
|
40
|
+
starterTemplates: STARTERS,
|
|
41
|
+
}),
|
|
42
|
+
).toBe(STARTERS.typescript);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns undefined when the field already has content (don't clobber)", () => {
|
|
46
|
+
expect(
|
|
47
|
+
pickStarterForEmptyField({
|
|
48
|
+
value: "// user's existing code",
|
|
49
|
+
selectedType: "typescript",
|
|
50
|
+
starterTemplates: STARTERS,
|
|
51
|
+
}),
|
|
52
|
+
).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns undefined when value is undefined and no starter is defined for the selected language", () => {
|
|
56
|
+
expect(
|
|
57
|
+
pickStarterForEmptyField({
|
|
58
|
+
value: undefined,
|
|
59
|
+
selectedType: "json",
|
|
60
|
+
starterTemplates: STARTERS,
|
|
61
|
+
}),
|
|
62
|
+
).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns undefined when no `starterTemplates` are provided at all", () => {
|
|
66
|
+
expect(
|
|
67
|
+
pickStarterForEmptyField({
|
|
68
|
+
value: "",
|
|
69
|
+
selectedType: "typescript",
|
|
70
|
+
}),
|
|
71
|
+
).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns the shell starter when shell is selected", () => {
|
|
75
|
+
expect(
|
|
76
|
+
pickStarterForEmptyField({
|
|
77
|
+
value: undefined,
|
|
78
|
+
selectedType: "shell",
|
|
79
|
+
starterTemplates: STARTERS,
|
|
80
|
+
}),
|
|
81
|
+
).toBe(STARTERS.shell);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats an empty-string starter as 'no starter available'", () => {
|
|
85
|
+
// We never want to install a literal empty string on top of an
|
|
86
|
+
// already-empty field; that would just look like the seed silently
|
|
87
|
+
// did nothing.
|
|
88
|
+
expect(
|
|
89
|
+
pickStarterForEmptyField({
|
|
90
|
+
value: "",
|
|
91
|
+
selectedType: "typescript",
|
|
92
|
+
starterTemplates: { typescript: "" },
|
|
93
|
+
}),
|
|
94
|
+
).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EditorStarterTemplates, EditorType } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure decision function powering the "seed empty editor with a starter
|
|
5
|
+
* template" behaviour in `MultiTypeEditorField`. Returns the starter
|
|
6
|
+
* content to install, or `undefined` if the field shouldn't be touched.
|
|
7
|
+
*
|
|
8
|
+
* Used both on first mount and when the user switches editor language on
|
|
9
|
+
* a still-empty field, so a single source of truth governs both paths
|
|
10
|
+
* (and a single test suite covers them).
|
|
11
|
+
*
|
|
12
|
+
* Returns `undefined` when:
|
|
13
|
+
* - the field already has non-whitespace content (don't clobber user input)
|
|
14
|
+
* - no `starterTemplates` were provided
|
|
15
|
+
* - no starter exists for the currently-selected language
|
|
16
|
+
* - the matching starter is the empty string
|
|
17
|
+
*/
|
|
18
|
+
export function pickStarterForEmptyField({
|
|
19
|
+
value,
|
|
20
|
+
selectedType,
|
|
21
|
+
starterTemplates,
|
|
22
|
+
}: {
|
|
23
|
+
value: string | undefined;
|
|
24
|
+
selectedType: EditorType;
|
|
25
|
+
starterTemplates?: EditorStarterTemplates;
|
|
26
|
+
}): string | undefined {
|
|
27
|
+
const trimmed = (value ?? "").trim();
|
|
28
|
+
if (trimmed.length > 0) return undefined;
|
|
29
|
+
const starter = starterTemplates?.[selectedType];
|
|
30
|
+
if (!starter || starter.length === 0) return undefined;
|
|
31
|
+
return starter;
|
|
32
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { TemplateProperty } from "../CodeEditor";
|
|
1
|
+
import type { TemplateProperty, ShellEnvVar } from "../CodeEditor";
|
|
2
2
|
import type { EditorType } from "@checkstack/common";
|
|
3
3
|
|
|
4
4
|
// Re-export types used by multi-type editor
|
|
5
5
|
export type { EditorType } from "./utils";
|
|
6
|
+
// Re-export `ShellEnvVar` so DynamicForm consumers don't have to import
|
|
7
|
+
// from two paths. The canonical definition lives in `../CodeEditor`.
|
|
8
|
+
export type { ShellEnvVar } from "../CodeEditor";
|
|
6
9
|
import type {
|
|
7
10
|
JsonSchemaPropertyCore,
|
|
8
11
|
JsonSchemaBase,
|
|
@@ -40,6 +43,13 @@ export type OptionsResolver = (
|
|
|
40
43
|
*/
|
|
41
44
|
export type JsonSchema = JsonSchemaBase<JsonSchemaProperty>;
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Default starter templates per editor language. Used to populate empty
|
|
48
|
+
* multi-type editor fields so users see a working example instead of a
|
|
49
|
+
* blank canvas. Keyed by `EditorType` (e.g. "typescript", "shell").
|
|
50
|
+
*/
|
|
51
|
+
export type EditorStarterTemplates = Partial<Record<EditorType, string>>;
|
|
52
|
+
|
|
43
53
|
export interface DynamicFormProps {
|
|
44
54
|
schema: JsonSchema;
|
|
45
55
|
value: Record<string, unknown>;
|
|
@@ -59,6 +69,25 @@ export interface DynamicFormProps {
|
|
|
59
69
|
* When provided, fields with x-editor-types get {{ autocomplete suggestions.
|
|
60
70
|
*/
|
|
61
71
|
templateProperties?: TemplateProperty[];
|
|
72
|
+
/**
|
|
73
|
+
* Optional TypeScript declarations to inject into Monaco for `typescript`
|
|
74
|
+
* or `javascript` editor-type fields. Typically built from a schema via
|
|
75
|
+
* `generateTypeDefinitions()` so users get autocomplete + type errors
|
|
76
|
+
* against the runtime context they'll see.
|
|
77
|
+
*/
|
|
78
|
+
typeDefinitions?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Optional list of environment-variable names that are available to
|
|
81
|
+
* `shell` editor-type fields. When provided, Monaco autocompletes them
|
|
82
|
+
* after `$` and `${`. Use this to surface platform-injected vars like
|
|
83
|
+
* `EVENT_ID`, `PAYLOAD_*` etc. so users don't have to remember the names.
|
|
84
|
+
*/
|
|
85
|
+
shellEnvVars?: ShellEnvVar[];
|
|
86
|
+
/**
|
|
87
|
+
* Optional initial content per editor language, used to populate empty
|
|
88
|
+
* fields with a working example. Keyed by `EditorType`.
|
|
89
|
+
*/
|
|
90
|
+
starterTemplates?: EditorStarterTemplates;
|
|
62
91
|
}
|
|
63
92
|
|
|
64
93
|
/** Props for the FormField component */
|
|
@@ -71,10 +100,14 @@ export interface FormFieldProps {
|
|
|
71
100
|
formValues: Record<string, unknown>;
|
|
72
101
|
optionsResolvers?: Record<string, OptionsResolver>;
|
|
73
102
|
templateProperties?: TemplateProperty[];
|
|
103
|
+
typeDefinitions?: string;
|
|
104
|
+
shellEnvVars?: ShellEnvVar[];
|
|
105
|
+
starterTemplates?: EditorStarterTemplates;
|
|
74
106
|
/** Callback when value changes. Omit val to clear the field. */
|
|
75
107
|
onChange: (val?: unknown) => void;
|
|
76
108
|
}
|
|
77
109
|
|
|
110
|
+
|
|
78
111
|
/** Props for the DynamicOptionsField component */
|
|
79
112
|
export interface DynamicOptionsFieldProps {
|
|
80
113
|
id: string;
|
|
@@ -10,6 +10,24 @@ export interface HotLink {
|
|
|
10
10
|
url: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Parse a user-supplied URL and return its canonicalized form ONLY when the
|
|
14
|
+
// scheme is `http:` / `https:`. Returns `undefined` for any other scheme
|
|
15
|
+
// (`javascript:`, `data:`, `vbscript:`, etc.) so callers can refuse to
|
|
16
|
+
// render an anchor at all. The returned string is `URL.toString()` — i.e.
|
|
17
|
+
// a re-serialized URL — so taint analysis sees a fresh, sanitized value
|
|
18
|
+
// rather than the raw input flowing through. CodeQL js/xss-through-dom.
|
|
19
|
+
function safeHref(raw: string): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(raw);
|
|
22
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
23
|
+
return parsed.toString();
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
export interface LinksEditorProps<T extends HotLink> {
|
|
14
32
|
/** Currently attached links. */
|
|
15
33
|
links: T[];
|
|
@@ -60,6 +78,11 @@ export function LinksEditor<T extends HotLink>({
|
|
|
60
78
|
setError("Must be a valid URL (include http:// or https://)");
|
|
61
79
|
return;
|
|
62
80
|
}
|
|
81
|
+
const parsed = new URL(trimmedUrl);
|
|
82
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
83
|
+
setError("Only http:// and https:// URLs are allowed");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
63
86
|
setError(undefined);
|
|
64
87
|
await onAdd({ label: label.trim() || undefined, url: trimmedUrl });
|
|
65
88
|
setLabel("");
|
|
@@ -77,42 +100,54 @@ export function LinksEditor<T extends HotLink>({
|
|
|
77
100
|
|
|
78
101
|
{links.length > 0 ? (
|
|
79
102
|
<div className="border rounded-lg divide-y">
|
|
80
|
-
{links.map((link) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<div className="min-w-0">
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
{links.map((link) => {
|
|
104
|
+
const href = safeHref(link.url);
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
key={link.id}
|
|
108
|
+
className="flex items-center justify-between p-3 gap-2"
|
|
109
|
+
>
|
|
110
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
111
|
+
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
112
|
+
<div className="min-w-0">
|
|
113
|
+
{href ? (
|
|
114
|
+
<a
|
|
115
|
+
href={href}
|
|
116
|
+
target="_blank"
|
|
117
|
+
rel="noopener noreferrer"
|
|
118
|
+
className="text-sm text-primary hover:underline truncate block"
|
|
119
|
+
>
|
|
120
|
+
{link.label ?? link.url}
|
|
121
|
+
</a>
|
|
122
|
+
) : (
|
|
123
|
+
<span
|
|
124
|
+
className="text-sm text-muted-foreground truncate block"
|
|
125
|
+
title="Unsafe URL scheme — link disabled"
|
|
126
|
+
>
|
|
127
|
+
{link.label ?? link.url}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
{link.label && (
|
|
131
|
+
<span className="text-xs text-muted-foreground truncate block">
|
|
132
|
+
{link.url}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
101
136
|
</div>
|
|
137
|
+
{canManage && (
|
|
138
|
+
<Button
|
|
139
|
+
variant="ghost"
|
|
140
|
+
size="sm"
|
|
141
|
+
onClick={() => void onRemove(link)}
|
|
142
|
+
disabled={busy}
|
|
143
|
+
aria-label="Remove link"
|
|
144
|
+
>
|
|
145
|
+
<Trash2 className="h-4 w-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
)}
|
|
102
148
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
variant="ghost"
|
|
106
|
-
size="sm"
|
|
107
|
-
onClick={() => void onRemove(link)}
|
|
108
|
-
disabled={busy}
|
|
109
|
-
aria-label="Remove link"
|
|
110
|
-
>
|
|
111
|
-
<Trash2 className="h-4 w-4" />
|
|
112
|
-
</Button>
|
|
113
|
-
)}
|
|
114
|
-
</div>
|
|
115
|
-
))}
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
116
151
|
</div>
|
|
117
152
|
) : (
|
|
118
153
|
<p className="text-sm text-muted-foreground">No links attached</p>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { shouldInitForKey } from "./useInitOnceForKey";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression suite for the form-preservation bug in the healthcheck editor.
|
|
6
|
+
*
|
|
7
|
+
* Before this hook existed, the page had:
|
|
8
|
+
*
|
|
9
|
+
* useEffect(() => {
|
|
10
|
+
* if (existingConfig) setFormState({ … });
|
|
11
|
+
* }, [existingConfig]);
|
|
12
|
+
*
|
|
13
|
+
* That fires on every refetch — including the refetches triggered by the
|
|
14
|
+
* realtime `HEALTH_CHECK_RUN_COMPLETED` signal — so the user's in-progress
|
|
15
|
+
* edits got wiped every time a healthcheck run completed anywhere on the
|
|
16
|
+
* platform.
|
|
17
|
+
*
|
|
18
|
+
* The hook delegates its decision to the pure `shouldInitForKey` function
|
|
19
|
+
* tested below. The hook is a thin React wrapper around two refs and a
|
|
20
|
+
* `useEffect` — testing the decision function gives full coverage of the
|
|
21
|
+
* logic without needing a DOM.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
describe("shouldInitForKey", () => {
|
|
25
|
+
it("returns true the first time value+key are defined and no key has been initialised", () => {
|
|
26
|
+
expect(
|
|
27
|
+
shouldInitForKey({
|
|
28
|
+
value: { name: "A" },
|
|
29
|
+
key: "id-1",
|
|
30
|
+
initialisedKey: undefined,
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false when value is undefined (query still loading)", () => {
|
|
36
|
+
expect(
|
|
37
|
+
shouldInitForKey({
|
|
38
|
+
value: undefined,
|
|
39
|
+
key: "id-1",
|
|
40
|
+
initialisedKey: undefined,
|
|
41
|
+
}),
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false when value is null", () => {
|
|
46
|
+
expect(
|
|
47
|
+
shouldInitForKey({
|
|
48
|
+
value: null,
|
|
49
|
+
key: "id-1",
|
|
50
|
+
initialisedKey: undefined,
|
|
51
|
+
}),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns false when key is undefined (no discriminator yet)", () => {
|
|
56
|
+
expect(
|
|
57
|
+
shouldInitForKey({
|
|
58
|
+
value: { name: "A" },
|
|
59
|
+
key: undefined,
|
|
60
|
+
initialisedKey: undefined,
|
|
61
|
+
}),
|
|
62
|
+
).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns false when key is null (record-not-found path)", () => {
|
|
66
|
+
expect(
|
|
67
|
+
shouldInitForKey({
|
|
68
|
+
value: { name: "A" },
|
|
69
|
+
key: null,
|
|
70
|
+
initialisedKey: undefined,
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false on a background refetch (same key already initialised)", () => {
|
|
76
|
+
// THE regression case: react-query refetched the same record after an
|
|
77
|
+
// invalidation, handing us a new `value` object reference. The
|
|
78
|
+
// `initialisedKey` already matches the new key, so we must not re-fire.
|
|
79
|
+
expect(
|
|
80
|
+
shouldInitForKey({
|
|
81
|
+
value: { name: "A (refetched)" },
|
|
82
|
+
key: "id-1",
|
|
83
|
+
initialisedKey: "id-1",
|
|
84
|
+
}),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true when the key changes (user navigated to a different record)", () => {
|
|
89
|
+
expect(
|
|
90
|
+
shouldInitForKey({
|
|
91
|
+
value: { name: "B" },
|
|
92
|
+
key: "id-2",
|
|
93
|
+
initialisedKey: "id-1",
|
|
94
|
+
}),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns true when we're returning to a previously-seen key from a different one", () => {
|
|
99
|
+
// The hook only stores the LAST initialised key, so flipping between
|
|
100
|
+
// two records re-initialises each time. That's the correct behaviour:
|
|
101
|
+
// each visit shows fresh server data.
|
|
102
|
+
expect(
|
|
103
|
+
shouldInitForKey({
|
|
104
|
+
value: { name: "A" },
|
|
105
|
+
key: "id-1",
|
|
106
|
+
initialisedKey: "id-2",
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("treats numeric keys correctly (different number → re-init)", () => {
|
|
112
|
+
expect(
|
|
113
|
+
shouldInitForKey({ value: "any", key: 1, initialisedKey: 1 }),
|
|
114
|
+
).toBe(false);
|
|
115
|
+
expect(
|
|
116
|
+
shouldInitForKey({ value: "any", key: 2, initialisedKey: 1 }),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("treats string vs number keys as different (no implicit coercion)", () => {
|
|
121
|
+
// If a caller starts passing IDs as numbers after passing strings (or
|
|
122
|
+
// vice versa) we'd rather re-init than miss an update; strict !== handles it.
|
|
123
|
+
expect(
|
|
124
|
+
shouldInitForKey({ value: "any", key: "1", initialisedKey: 1 }),
|
|
125
|
+
).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|