@arronqzy/react-blueprint 1.0.7 → 1.0.9
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/package.json +7 -7
- package/src/BlueprintContextMenu.tsx +1 -1
- package/src/BlueprintNodeConfigSidebar.tsx +73 -6
- package/src/blueprintNodeTypes.ts +2 -0
- package/src/components/FetchNodeConfigPanel.tsx +71 -8
- package/src/components/FetchUrlAutocomplete.tsx +1 -0
- package/src/components/ScopeTemplateAutocompleteHost.tsx +245 -0
- package/src/graph/blueprint-graph.ts +8 -0
- package/src/graph/document.ts +135 -8
- package/src/graph/node-summary.ts +20 -0
- package/src/graph/node-types.ts +1 -0
- package/src/graph/react-flow-adapter.ts +4 -0
- package/src/graph/sync-nodes.ts +2 -1
- package/src/hooks/useBlueprintFlowState.ts +40 -7
- package/src/hooks/useBlueprintPageLifecycle.ts +106 -14
- package/src/index.tsx +2 -0
- package/src/nodes/BlueprintNodeCard.tsx +12 -2
- package/src/nodes/BlueprintNodeShell.tsx +6 -2
- package/src/nodes/EventFlowNode.tsx +31 -0
- package/src/runtime/document-to-runnable-graph.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arronqzy/react-blueprint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
"dayjs": "^1.11.13",
|
|
13
13
|
"react-dom": "^19",
|
|
14
14
|
"@xyflow/react": "^12.9.3",
|
|
15
|
-
"@arronqzy/blueprint-dsl": "1.0.
|
|
16
|
-
"@arronqzy/
|
|
17
|
-
"@arronqzy/
|
|
18
|
-
"@arronqzy/
|
|
15
|
+
"@arronqzy/blueprint-dsl": "1.0.8",
|
|
16
|
+
"@arronqzy/i18n": "0.1.7",
|
|
17
|
+
"@arronqzy/ui": "1.0.6",
|
|
18
|
+
"@arronqzy/react-rx-store": "1.1.5"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/react": "^19.0.1",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"eslint": "^8.57.0",
|
|
29
29
|
"typescript": "^5.5.4",
|
|
30
30
|
"vite": "^5.1.4",
|
|
31
|
-
"@arronqzy/tailwind": "1.0.2",
|
|
32
31
|
"@arronqzy/eslint-config": "0.0.0",
|
|
33
|
-
"@arronqzy/typescript-config": "1.0.0"
|
|
32
|
+
"@arronqzy/typescript-config": "1.0.0",
|
|
33
|
+
"@arronqzy/tailwind": "1.0.2"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [],
|
|
36
36
|
"author": "",
|
|
@@ -12,7 +12,7 @@ export type BlueprintContextMenuState =
|
|
|
12
12
|
clientX: number;
|
|
13
13
|
clientY: number;
|
|
14
14
|
nodeId: string;
|
|
15
|
-
role: "blueprint" | "logic" | "and" | "lifecycle" | "fetch" | "json" | "clock";
|
|
15
|
+
role: "blueprint" | "logic" | "and" | "lifecycle" | "event" | "fetch" | "json" | "clock";
|
|
16
16
|
}
|
|
17
17
|
| {
|
|
18
18
|
kind: "edge";
|
|
@@ -6,12 +6,15 @@ import {
|
|
|
6
6
|
SelectItem,
|
|
7
7
|
SelectTrigger,
|
|
8
8
|
SelectValue,
|
|
9
|
+
Checkbox,
|
|
9
10
|
} from "@arronqzy/ui";
|
|
10
11
|
import {
|
|
11
12
|
LIFECYCLE_NODE_TYPE,
|
|
12
13
|
PAGE_LIFECYCLE_PHASES,
|
|
14
|
+
VIEW_EVENT_TYPES,
|
|
13
15
|
type ExecutionTraceEntry,
|
|
14
16
|
type PageLifecyclePhase,
|
|
17
|
+
type ViewEventType,
|
|
15
18
|
} from "@arronqzy/blueprint-dsl";
|
|
16
19
|
import { useI18n } from "@arronqzy/i18n/react";
|
|
17
20
|
|
|
@@ -23,9 +26,12 @@ import { ViewElementMultiSelect } from "./components/ViewElementMultiSelect";
|
|
|
23
26
|
import { ConfigFieldLabel, ConfigHintIcon, ConfigSectionTitle } from "./components/ConfigHintIcon";
|
|
24
27
|
import {
|
|
25
28
|
getLifecyclePhaseLabel,
|
|
29
|
+
getViewEventTypeLabel,
|
|
30
|
+
inspectEventNodeInputs,
|
|
26
31
|
patchNodeConfigSource,
|
|
27
32
|
pruneViewElementIds,
|
|
28
33
|
resolveBlueprintConfigSource,
|
|
34
|
+
resolveNodeEventConfig,
|
|
29
35
|
resolveViewElementIds,
|
|
30
36
|
type BlueprintConfigSource,
|
|
31
37
|
type BlueprintGraphEdge,
|
|
@@ -70,6 +76,7 @@ export type BlueprintNodeConfigSidebarProps = {
|
|
|
70
76
|
| "jsonConfig"
|
|
71
77
|
| "logicConfig"
|
|
72
78
|
| "clockConfig"
|
|
79
|
+
| "eventConfig"
|
|
73
80
|
>
|
|
74
81
|
>
|
|
75
82
|
) => void;
|
|
@@ -83,6 +90,7 @@ const ROLE_LABEL_KEYS: Record<BlueprintNodeRole, string> = {
|
|
|
83
90
|
json: "blueprint.config.roleJson",
|
|
84
91
|
logic: "blueprint.config.roleLogic",
|
|
85
92
|
clock: "blueprint.config.roleClock",
|
|
93
|
+
event: "blueprint.config.roleEvent",
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
export function BlueprintNodeConfigSidebar({
|
|
@@ -99,6 +107,7 @@ export function BlueprintNodeConfigSidebar({
|
|
|
99
107
|
const { t } = useI18n();
|
|
100
108
|
const configSource = resolveBlueprintConfigSource(node);
|
|
101
109
|
const linkedViewElementIds = resolveViewElementIds(node);
|
|
110
|
+
const selectedEventTypes = resolveNodeEventConfig(node).eventTypes;
|
|
102
111
|
const existingViewElementIdSet = useMemo(
|
|
103
112
|
() => new Set(viewElementOptions.map((opt) => opt.id)),
|
|
104
113
|
[viewElementOptions]
|
|
@@ -108,7 +117,7 @@ export function BlueprintNodeConfigSidebar({
|
|
|
108
117
|
);
|
|
109
118
|
|
|
110
119
|
useEffect(() => {
|
|
111
|
-
if (configSource !== "view") return;
|
|
120
|
+
if (configSource !== "view" && configSource !== "event") return;
|
|
112
121
|
// 视图画布尚未就绪时不清理,避免误删有效绑定导致绑定节点「执行了但不写 scope」
|
|
113
122
|
if (viewElementOptions.length === 0) return;
|
|
114
123
|
const linked = resolveViewElementIds(node);
|
|
@@ -118,7 +127,7 @@ export function BlueprintNodeConfigSidebar({
|
|
|
118
127
|
onUpdateNode(node.id, {
|
|
119
128
|
viewElementIds: pruned.length > 0 ? pruned : undefined,
|
|
120
129
|
viewElementId: undefined,
|
|
121
|
-
configSource
|
|
130
|
+
configSource,
|
|
122
131
|
});
|
|
123
132
|
}, [
|
|
124
133
|
configSource,
|
|
@@ -131,6 +140,10 @@ export function BlueprintNodeConfigSidebar({
|
|
|
131
140
|
]);
|
|
132
141
|
|
|
133
142
|
const roleLabel = t(ROLE_LABEL_KEYS[node.role] ?? "blueprint.config.roleLogic");
|
|
143
|
+
const eventInputs = useMemo(
|
|
144
|
+
() => inspectEventNodeInputs(node.id, graphNodes, graphEdges),
|
|
145
|
+
[graphEdges, graphNodes, node.id]
|
|
146
|
+
);
|
|
134
147
|
|
|
135
148
|
return (
|
|
136
149
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background text-foreground">
|
|
@@ -160,7 +173,7 @@ export function BlueprintNodeConfigSidebar({
|
|
|
160
173
|
const nextSource = value as BlueprintConfigSource;
|
|
161
174
|
onUpdateNode(node.id, {
|
|
162
175
|
...patchNodeConfigSource(node, nextSource),
|
|
163
|
-
...(nextSource === "view"
|
|
176
|
+
...(nextSource === "view" || nextSource === "event"
|
|
164
177
|
? {
|
|
165
178
|
viewElementIds: linkedViewElementIds,
|
|
166
179
|
viewElementId: undefined,
|
|
@@ -183,16 +196,21 @@ export function BlueprintNodeConfigSidebar({
|
|
|
183
196
|
<SelectItem value="fetch">{t("blueprint.config.configFetch")}</SelectItem>
|
|
184
197
|
<SelectItem value="json">{t("blueprint.config.configJson")}</SelectItem>
|
|
185
198
|
<SelectItem value="clock">{t("blueprint.config.configClock")}</SelectItem>
|
|
199
|
+
<SelectItem value="event">{t("blueprint.config.configEvent")}</SelectItem>
|
|
186
200
|
<SelectItem value="view">{t("blueprint.config.configView")}</SelectItem>
|
|
187
201
|
</SelectContent>
|
|
188
202
|
</Select>
|
|
189
203
|
</label>
|
|
190
204
|
|
|
191
|
-
{configSource === "view" ? (
|
|
205
|
+
{configSource === "view" || configSource === "event" ? (
|
|
192
206
|
<div className="block space-y-1">
|
|
193
207
|
<ConfigFieldLabel
|
|
194
208
|
label={t("blueprint.config.linkedViewNodes")}
|
|
195
|
-
hint={
|
|
209
|
+
hint={
|
|
210
|
+
configSource === "event"
|
|
211
|
+
? t("blueprint.config.eventViewHint")
|
|
212
|
+
: t("blueprint.config.viewMultiHint")
|
|
213
|
+
}
|
|
196
214
|
/>
|
|
197
215
|
<ViewElementMultiSelect
|
|
198
216
|
options={viewElementOptions}
|
|
@@ -202,7 +220,7 @@ export function BlueprintNodeConfigSidebar({
|
|
|
202
220
|
onUpdateNode(node.id, {
|
|
203
221
|
viewElementIds: next,
|
|
204
222
|
viewElementId: undefined,
|
|
205
|
-
configSource
|
|
223
|
+
configSource,
|
|
206
224
|
})
|
|
207
225
|
}
|
|
208
226
|
/>
|
|
@@ -270,6 +288,55 @@ export function BlueprintNodeConfigSidebar({
|
|
|
270
288
|
</div>
|
|
271
289
|
) : null}
|
|
272
290
|
|
|
291
|
+
{configSource === "event" ? (
|
|
292
|
+
<div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
|
|
293
|
+
<div className="flex items-center gap-1.5">
|
|
294
|
+
<div className="font-medium text-foreground">
|
|
295
|
+
{t("blueprint.config.eventListen")}
|
|
296
|
+
</div>
|
|
297
|
+
<ConfigHintIcon label={t("blueprint.config.eventListen")}>
|
|
298
|
+
<p>{t("blueprint.config.eventNoInput")}</p>
|
|
299
|
+
<p>{t("blueprint.config.eventHint")}</p>
|
|
300
|
+
</ConfigHintIcon>
|
|
301
|
+
</div>
|
|
302
|
+
{eventInputs.nonLifecycleCount > 0 ? (
|
|
303
|
+
<p className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[11px] leading-relaxed text-destructive">
|
|
304
|
+
{t("blueprint.config.eventInputNotLifecycle")}
|
|
305
|
+
</p>
|
|
306
|
+
) : eventInputs.lifecycleCount === 0 ? (
|
|
307
|
+
<p className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
|
|
308
|
+
{t("blueprint.config.eventInputMissing")}
|
|
309
|
+
</p>
|
|
310
|
+
) : null}
|
|
311
|
+
<div className="space-y-1.5">
|
|
312
|
+
{VIEW_EVENT_TYPES.map((eventType) => {
|
|
313
|
+
const checked = selectedEventTypes.includes(eventType);
|
|
314
|
+
return (
|
|
315
|
+
<label key={eventType} className="flex items-center gap-2">
|
|
316
|
+
<Checkbox
|
|
317
|
+
checked={checked}
|
|
318
|
+
className="h-3.5 w-3.5 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
|
|
319
|
+
onCheckedChange={(nextChecked: boolean | "indeterminate") => {
|
|
320
|
+
const nextTypes: ViewEventType[] =
|
|
321
|
+
nextChecked === true
|
|
322
|
+
? [...new Set([...selectedEventTypes, eventType])]
|
|
323
|
+
: selectedEventTypes.filter((type) => type !== eventType);
|
|
324
|
+
onUpdateNode(node.id, {
|
|
325
|
+
eventConfig: {
|
|
326
|
+
eventTypes: nextTypes.length > 0 ? nextTypes : ["click"],
|
|
327
|
+
},
|
|
328
|
+
configSource: "event",
|
|
329
|
+
});
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
<span>{getViewEventTypeLabel(t, eventType)}</span>
|
|
333
|
+
</label>
|
|
334
|
+
);
|
|
335
|
+
})}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
) : null}
|
|
339
|
+
|
|
273
340
|
{configSource === "lifecycle" ? (
|
|
274
341
|
<div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
|
|
275
342
|
<div className="flex items-center gap-1.5">
|
|
@@ -6,6 +6,7 @@ import { ClockFlowNode } from "./nodes/ClockFlowNode";
|
|
|
6
6
|
import { FetchFlowNode } from "./nodes/FetchFlowNode";
|
|
7
7
|
import { JsonFlowNode } from "./nodes/JsonFlowNode";
|
|
8
8
|
import { LifecycleFlowNode } from "./nodes/LifecycleFlowNode";
|
|
9
|
+
import { EventFlowNode } from "./nodes/EventFlowNode";
|
|
9
10
|
import { LogicFlowNode } from "./nodes/LogicFlowNode";
|
|
10
11
|
|
|
11
12
|
/** 稳定引用,避免 React Flow 因 nodeTypes 变化反复卸载节点 */
|
|
@@ -17,4 +18,5 @@ export const blueprintNodeTypes = {
|
|
|
17
18
|
json: JsonFlowNode,
|
|
18
19
|
logic: LogicFlowNode,
|
|
19
20
|
lifecycle: LifecycleFlowNode,
|
|
21
|
+
event: EventFlowNode,
|
|
20
22
|
} satisfies NodeTypes;
|
|
@@ -26,10 +26,13 @@ import {
|
|
|
26
26
|
FETCH_REDIRECTS,
|
|
27
27
|
FETCH_RESPONSE_TYPES,
|
|
28
28
|
fetchConfigHasScopeTemplate,
|
|
29
|
+
getFetchBodyValidationError,
|
|
30
|
+
getFetchHeadersValidationError,
|
|
29
31
|
latestTraceOutputsByNode,
|
|
30
32
|
parseFetchHeadersJson,
|
|
31
33
|
resolveFetchIncomingScope,
|
|
32
34
|
resolveFetchRequestUrl,
|
|
35
|
+
resolveFetchScopeAutocompleteRoot,
|
|
33
36
|
} from "@arronqzy/blueprint-dsl";
|
|
34
37
|
|
|
35
38
|
import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
|
|
@@ -46,6 +49,7 @@ import { useI18n } from "@arronqzy/i18n/react";
|
|
|
46
49
|
|
|
47
50
|
import { ConfigFieldLabel, ConfigHintIcon, ConfigSectionTitle } from "./ConfigHintIcon";
|
|
48
51
|
import { FetchUrlAutocomplete } from "./FetchUrlAutocomplete";
|
|
52
|
+
import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
|
|
49
53
|
|
|
50
54
|
function SendIcon({ className }: { className?: string }) {
|
|
51
55
|
return (
|
|
@@ -324,6 +328,18 @@ export function FetchNodeConfigPanel({
|
|
|
324
328
|
return "";
|
|
325
329
|
}
|
|
326
330
|
}, [resolvedFetchConfig.headers, usesScopeTemplate]);
|
|
331
|
+
const resolvedBodyPreview = useMemo(() => {
|
|
332
|
+
if (!usesScopeTemplate) return "";
|
|
333
|
+
return resolvedFetchConfig.body?.trim() ?? "";
|
|
334
|
+
}, [resolvedFetchConfig.body, usesScopeTemplate]);
|
|
335
|
+
const headersJsonError = useMemo(
|
|
336
|
+
() => getFetchHeadersValidationError(fetchConfig, incomingScope),
|
|
337
|
+
[fetchConfig, incomingScope]
|
|
338
|
+
);
|
|
339
|
+
const bodyJsonError = useMemo(
|
|
340
|
+
() => getFetchBodyValidationError(fetchConfig.body, resolvedFetchConfig.body),
|
|
341
|
+
[fetchConfig.body, resolvedFetchConfig.body]
|
|
342
|
+
);
|
|
327
343
|
const incomingHasPendingValue = useMemo(() => {
|
|
328
344
|
if (!incomingScope || typeof incomingScope !== "object") return false;
|
|
329
345
|
const entries =
|
|
@@ -332,6 +348,8 @@ export function FetchNodeConfigPanel({
|
|
|
332
348
|
: Object.values(incomingScope as Record<string, { kind?: string }>);
|
|
333
349
|
return entries.some((entry) => entry && entry.kind === "pending");
|
|
334
350
|
}, [incomingScope]);
|
|
351
|
+
const autocompleteScope = resolveFetchScopeAutocompleteRoot(incomingScope);
|
|
352
|
+
const [formEl, setFormEl] = useState<HTMLDivElement | null>(null);
|
|
335
353
|
|
|
336
354
|
const setUrlInputMode = useCallback(
|
|
337
355
|
(mode: "swagger" | "manual") => {
|
|
@@ -381,6 +399,20 @@ export function FetchNodeConfigPanel({
|
|
|
381
399
|
);
|
|
382
400
|
return;
|
|
383
401
|
}
|
|
402
|
+
const headersError = getFetchHeadersValidationError(fetchConfig, incomingScope);
|
|
403
|
+
if (headersError) {
|
|
404
|
+
setFetchValidationError(
|
|
405
|
+
t("blueprint.config.fetchHeadersInvalidJson", { error: headersError })
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const bodyError = getFetchBodyValidationError(fetchConfig.body, resolved.body);
|
|
410
|
+
if (bodyError) {
|
|
411
|
+
setFetchValidationError(
|
|
412
|
+
t("blueprint.config.fetchBodyInvalidJson", { error: bodyError })
|
|
413
|
+
);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
384
416
|
|
|
385
417
|
setFetchValidationError(null);
|
|
386
418
|
startFetchDebugTask({
|
|
@@ -394,7 +426,7 @@ export function FetchNodeConfigPanel({
|
|
|
394
426
|
setFetchValidationError(null);
|
|
395
427
|
}, [node.id]);
|
|
396
428
|
|
|
397
|
-
const
|
|
429
|
+
const handleHeadersChange = useCallback(
|
|
398
430
|
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
399
431
|
const headersJson = e.target.value;
|
|
400
432
|
const parsed = parseFetchHeadersJson(headersJson);
|
|
@@ -410,7 +442,14 @@ export function FetchNodeConfigPanel({
|
|
|
410
442
|
);
|
|
411
443
|
|
|
412
444
|
return (
|
|
413
|
-
<div
|
|
445
|
+
<div
|
|
446
|
+
ref={setFormEl}
|
|
447
|
+
className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
|
|
448
|
+
>
|
|
449
|
+
<ScopeTemplateAutocompleteHost
|
|
450
|
+
scope={autocompleteScope}
|
|
451
|
+
container={formEl}
|
|
452
|
+
/>
|
|
414
453
|
<ConfigSectionTitle
|
|
415
454
|
title={t("blueprint.config.fetchTitle")}
|
|
416
455
|
hint={t("blueprint.config.fetchHint")}
|
|
@@ -444,6 +483,7 @@ export function FetchNodeConfigPanel({
|
|
|
444
483
|
<Input
|
|
445
484
|
value={fetchConfig.swaggerDocsUrl ?? ""}
|
|
446
485
|
disabled={loadingSwagger}
|
|
486
|
+
data-scope-autocomplete="off"
|
|
447
487
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
448
488
|
onUpdateNode(
|
|
449
489
|
node.id,
|
|
@@ -657,13 +697,20 @@ export function FetchNodeConfigPanel({
|
|
|
657
697
|
hint={t("blueprint.config.fetchHeadersHint")}
|
|
658
698
|
/>
|
|
659
699
|
<textarea
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
onBlur={handleHeadersBlur}
|
|
700
|
+
value={headersDraft}
|
|
701
|
+
onChange={handleHeadersChange}
|
|
663
702
|
rows={4}
|
|
664
|
-
className=
|
|
703
|
+
className={cn(
|
|
704
|
+
"w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary",
|
|
705
|
+
headersJsonError ? "border-destructive" : "border-input"
|
|
706
|
+
)}
|
|
665
707
|
placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
|
|
666
708
|
/>
|
|
709
|
+
{headersJsonError ? (
|
|
710
|
+
<p className="text-[11px] text-destructive">
|
|
711
|
+
{t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError })}
|
|
712
|
+
</p>
|
|
713
|
+
) : null}
|
|
667
714
|
{usesScopeTemplate && resolvedHeadersPreview ? (
|
|
668
715
|
<p className="whitespace-pre-wrap break-all font-mono text-[10px] text-muted-foreground">
|
|
669
716
|
{t("blueprint.config.fetchScopeResolvedHeaders")}: {resolvedHeadersPreview}
|
|
@@ -672,16 +719,32 @@ export function FetchNodeConfigPanel({
|
|
|
672
719
|
</label>
|
|
673
720
|
|
|
674
721
|
<label className="block space-y-1">
|
|
675
|
-
<
|
|
722
|
+
<ConfigFieldLabel
|
|
723
|
+
label={t("blueprint.config.requestBody")}
|
|
724
|
+
hint={t("blueprint.config.fetchBodyHint")}
|
|
725
|
+
/>
|
|
676
726
|
<textarea
|
|
677
727
|
value={fetchConfig.body ?? ""}
|
|
678
728
|
onChange={(e) =>
|
|
679
729
|
onUpdateNode(node.id, patchFetchConfig(node, { body: e.target.value }))
|
|
680
730
|
}
|
|
681
731
|
rows={4}
|
|
682
|
-
className=
|
|
732
|
+
className={cn(
|
|
733
|
+
"w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary",
|
|
734
|
+
bodyJsonError ? "border-destructive" : "border-input"
|
|
735
|
+
)}
|
|
683
736
|
placeholder='{"id":"{scope?.value?.id}"}'
|
|
684
737
|
/>
|
|
738
|
+
{bodyJsonError ? (
|
|
739
|
+
<p className="text-[11px] text-destructive">
|
|
740
|
+
{t("blueprint.config.fetchBodyInvalidJson", { error: bodyJsonError })}
|
|
741
|
+
</p>
|
|
742
|
+
) : null}
|
|
743
|
+
{usesScopeTemplate && resolvedBodyPreview ? (
|
|
744
|
+
<p className="whitespace-pre-wrap break-all font-mono text-[10px] text-muted-foreground">
|
|
745
|
+
{t("blueprint.config.fetchScopeResolvedBody")}: {resolvedBodyPreview}
|
|
746
|
+
</p>
|
|
747
|
+
) : null}
|
|
685
748
|
</label>
|
|
686
749
|
|
|
687
750
|
<div className="grid grid-cols-2 gap-2">
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type RefObject,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { createPortal } from "react-dom";
|
|
9
|
+
import { cn } from "@arronqzy/ui";
|
|
10
|
+
import {
|
|
11
|
+
applyScopeAutocompleteSelection,
|
|
12
|
+
buildScopeExpression,
|
|
13
|
+
getScopeAutocompleteSuggestions,
|
|
14
|
+
parseScopeAutocomplete,
|
|
15
|
+
type ScopeAutocompleteState,
|
|
16
|
+
} from "@arronqzy/blueprint-dsl";
|
|
17
|
+
|
|
18
|
+
function isAutocompleteTarget(
|
|
19
|
+
target: EventTarget | null
|
|
20
|
+
): target is HTMLInputElement | HTMLTextAreaElement {
|
|
21
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
22
|
+
return target.dataset.scopeAutocomplete !== "off";
|
|
23
|
+
}
|
|
24
|
+
if (target instanceof HTMLInputElement) {
|
|
25
|
+
if (target.dataset.scopeAutocomplete === "off") return false;
|
|
26
|
+
const type = target.type;
|
|
27
|
+
return (
|
|
28
|
+
!type ||
|
|
29
|
+
type === "text" ||
|
|
30
|
+
type === "search" ||
|
|
31
|
+
type === "url" ||
|
|
32
|
+
type === "password"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setNativeInputValue(
|
|
39
|
+
element: HTMLInputElement | HTMLTextAreaElement,
|
|
40
|
+
value: string
|
|
41
|
+
) {
|
|
42
|
+
const proto =
|
|
43
|
+
element instanceof HTMLTextAreaElement
|
|
44
|
+
? HTMLTextAreaElement.prototype
|
|
45
|
+
: HTMLInputElement.prototype;
|
|
46
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
|
|
47
|
+
descriptor?.set?.call(element, value);
|
|
48
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type DropdownState = {
|
|
52
|
+
input: HTMLInputElement | HTMLTextAreaElement;
|
|
53
|
+
state: ScopeAutocompleteState;
|
|
54
|
+
suggestions: string[];
|
|
55
|
+
activeIndex: number;
|
|
56
|
+
rect: DOMRect;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function ScopeTemplateAutocompleteHost({
|
|
60
|
+
scope,
|
|
61
|
+
container,
|
|
62
|
+
containerRef,
|
|
63
|
+
}: {
|
|
64
|
+
scope: unknown;
|
|
65
|
+
container?: HTMLElement | null;
|
|
66
|
+
containerRef?: RefObject<HTMLElement | null>;
|
|
67
|
+
}) {
|
|
68
|
+
const [dropdown, setDropdown] = useState<DropdownState | null>(null);
|
|
69
|
+
const dropdownRef = useRef<DropdownState | null>(null);
|
|
70
|
+
dropdownRef.current = dropdown;
|
|
71
|
+
const root = container ?? containerRef?.current ?? null;
|
|
72
|
+
|
|
73
|
+
const closeDropdown = useCallback(() => {
|
|
74
|
+
setDropdown(null);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const refreshForInput = useCallback(
|
|
78
|
+
(input: HTMLInputElement | HTMLTextAreaElement) => {
|
|
79
|
+
if (scope === undefined) {
|
|
80
|
+
closeDropdown();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const cursor = input.selectionStart ?? input.value.length;
|
|
84
|
+
const parsed = parseScopeAutocomplete(input.value, cursor, scope);
|
|
85
|
+
if (!parsed) {
|
|
86
|
+
closeDropdown();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const suggestions = getScopeAutocompleteSuggestions(scope, parsed);
|
|
90
|
+
if (suggestions.length === 0) {
|
|
91
|
+
closeDropdown();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setDropdown({
|
|
95
|
+
input,
|
|
96
|
+
state: parsed,
|
|
97
|
+
suggestions,
|
|
98
|
+
activeIndex: 0,
|
|
99
|
+
rect: input.getBoundingClientRect(),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
[closeDropdown, scope]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const applySuggestion = useCallback(
|
|
106
|
+
(index: number) => {
|
|
107
|
+
const current = dropdownRef.current;
|
|
108
|
+
if (!current) return;
|
|
109
|
+
const selected = current.suggestions[index];
|
|
110
|
+
if (!selected) return;
|
|
111
|
+
|
|
112
|
+
const { value, cursor } = applyScopeAutocompleteSelection(
|
|
113
|
+
current.input.value,
|
|
114
|
+
current.state,
|
|
115
|
+
selected
|
|
116
|
+
);
|
|
117
|
+
setNativeInputValue(current.input, value);
|
|
118
|
+
current.input.focus();
|
|
119
|
+
current.input.setSelectionRange(cursor, cursor);
|
|
120
|
+
closeDropdown();
|
|
121
|
+
window.requestAnimationFrame(() => {
|
|
122
|
+
refreshForInput(current.input);
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
[closeDropdown, refreshForInput]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!root || scope === undefined) return;
|
|
130
|
+
|
|
131
|
+
const onFocusIn = (event: FocusEvent) => {
|
|
132
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
133
|
+
refreshForInput(event.target);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const onInput = (event: Event) => {
|
|
137
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
138
|
+
refreshForInput(event.target);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
142
|
+
const current = dropdownRef.current;
|
|
143
|
+
if (!current || event.target !== current.input) return;
|
|
144
|
+
|
|
145
|
+
if (event.key === "ArrowDown") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
setDropdown((prev) =>
|
|
148
|
+
prev
|
|
149
|
+
? {
|
|
150
|
+
...prev,
|
|
151
|
+
activeIndex: Math.min(
|
|
152
|
+
prev.activeIndex + 1,
|
|
153
|
+
prev.suggestions.length - 1
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
: prev
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (event.key === "ArrowUp") {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
setDropdown((prev) =>
|
|
163
|
+
prev
|
|
164
|
+
? { ...prev, activeIndex: Math.max(prev.activeIndex - 1, 0) }
|
|
165
|
+
: prev
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
170
|
+
if (current.suggestions.length > 0) {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
applySuggestion(current.activeIndex);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (event.key === "Escape") {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
closeDropdown();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
183
|
+
const target = event.target as Node | null;
|
|
184
|
+
if (
|
|
185
|
+
dropdownRef.current?.input.contains(target ?? null) ||
|
|
186
|
+
(target instanceof Element && target.closest("[data-scope-ac-list]"))
|
|
187
|
+
) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!root.contains(target)) {
|
|
191
|
+
closeDropdown();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
root.addEventListener("focusin", onFocusIn);
|
|
196
|
+
root.addEventListener("input", onInput, true);
|
|
197
|
+
root.addEventListener("keydown", onKeyDown, true);
|
|
198
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
199
|
+
window.addEventListener("scroll", closeDropdown, true);
|
|
200
|
+
window.addEventListener("resize", closeDropdown);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
root.removeEventListener("focusin", onFocusIn);
|
|
204
|
+
root.removeEventListener("input", onInput, true);
|
|
205
|
+
root.removeEventListener("keydown", onKeyDown, true);
|
|
206
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
207
|
+
window.removeEventListener("scroll", closeDropdown, true);
|
|
208
|
+
window.removeEventListener("resize", closeDropdown);
|
|
209
|
+
};
|
|
210
|
+
}, [applySuggestion, closeDropdown, refreshForInput, root, scope]);
|
|
211
|
+
|
|
212
|
+
if (!dropdown || scope === undefined) return null;
|
|
213
|
+
|
|
214
|
+
return createPortal(
|
|
215
|
+
<div
|
|
216
|
+
data-scope-ac-list
|
|
217
|
+
className="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
|
|
218
|
+
style={{
|
|
219
|
+
left: dropdown.rect.left,
|
|
220
|
+
top: dropdown.rect.bottom + 4,
|
|
221
|
+
width: Math.max(dropdown.rect.width, 160),
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{dropdown.suggestions.map((key, index) => {
|
|
225
|
+
const preview = buildScopeExpression(dropdown.state.path, key);
|
|
226
|
+
return (
|
|
227
|
+
<button
|
|
228
|
+
key={key}
|
|
229
|
+
type="button"
|
|
230
|
+
className={cn(
|
|
231
|
+
"flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-accent",
|
|
232
|
+
index === dropdown.activeIndex && "bg-accent"
|
|
233
|
+
)}
|
|
234
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
235
|
+
onClick={() => applySuggestion(index)}
|
|
236
|
+
>
|
|
237
|
+
<span className="font-medium text-foreground">{key}</span>
|
|
238
|
+
<span className="font-mono text-[10px] text-muted-foreground">{`{${preview}}`}</span>
|
|
239
|
+
</button>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
</div>,
|
|
243
|
+
document.body
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
filterInvalidBlueprintEdges,
|
|
6
6
|
patchNodeConfigSource,
|
|
7
7
|
resolveNodeClockConfig,
|
|
8
|
+
resolveNodeEventConfig,
|
|
8
9
|
resolveNodeFetchConfig,
|
|
9
10
|
resolveNodeJsonConfig,
|
|
10
11
|
resolveNodeLogicConfig,
|
|
@@ -198,6 +199,7 @@ export class BlueprintGraph {
|
|
|
198
199
|
| "jsonConfig"
|
|
199
200
|
| "logicConfig"
|
|
200
201
|
| "clockConfig"
|
|
202
|
+
| "eventConfig"
|
|
201
203
|
>
|
|
202
204
|
>
|
|
203
205
|
) {
|
|
@@ -251,6 +253,12 @@ export class BlueprintGraph {
|
|
|
251
253
|
...patch.clockConfig,
|
|
252
254
|
};
|
|
253
255
|
}
|
|
256
|
+
if (patch.eventConfig) {
|
|
257
|
+
nextNode.eventConfig = {
|
|
258
|
+
...resolveNodeEventConfig(n),
|
|
259
|
+
...patch.eventConfig,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
254
262
|
return {
|
|
255
263
|
...nextNode,
|
|
256
264
|
nodeType: resolveRunnableNodeType(nextNode),
|