@arronqzy/react-blueprint 1.0.6 → 1.0.8

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.
@@ -1,4 +1,4 @@
1
- import { useCallback, useState, type ChangeEvent } from "react";
1
+ import { useCallback, useMemo, useState, type ChangeEvent } from "react";
2
2
  import {
3
3
  Button,
4
4
  Input,
@@ -10,11 +10,14 @@ import {
10
10
  cn,
11
11
  } from "@arronqzy/ui";
12
12
  import type {
13
+ ExecutionTraceEntry,
13
14
  FetchHttpMethod,
14
15
  FetchRequestConfig,
15
16
  FetchResponseType,
16
17
  } from "@arronqzy/blueprint-dsl";
17
18
  import {
19
+ applyFetchConfigScope,
20
+ draftFetchHeadersText,
18
21
  FETCH_CACHES,
19
22
  FETCH_CREDENTIALS,
20
23
  FETCH_HTTP_METHODS,
@@ -22,9 +25,17 @@ import {
22
25
  FETCH_NODE_TYPE,
23
26
  FETCH_REDIRECTS,
24
27
  FETCH_RESPONSE_TYPES,
28
+ fetchConfigHasScopeTemplate,
29
+ getFetchBodyValidationError,
30
+ getFetchHeadersValidationError,
31
+ latestTraceOutputsByNode,
32
+ parseFetchHeadersJson,
33
+ resolveFetchIncomingScope,
34
+ resolveFetchRequestUrl,
35
+ resolveFetchScopeAutocompleteRoot,
25
36
  } from "@arronqzy/blueprint-dsl";
26
37
 
27
- import type { BlueprintGraphNode } from "../graph/document";
38
+ import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
28
39
  import { resolveNodeFetchConfig } from "../graph/document";
29
40
  import {
30
41
  cancelSwaggerLoadTask,
@@ -36,7 +47,9 @@ import {
36
47
  } from "../fetch-config-task-store";
37
48
  import { useI18n } from "@arronqzy/i18n/react";
38
49
 
50
+ import { ConfigFieldLabel, ConfigHintIcon, ConfigSectionTitle } from "./ConfigHintIcon";
39
51
  import { FetchUrlAutocomplete } from "./FetchUrlAutocomplete";
52
+ import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
40
53
 
41
54
  function SendIcon({ className }: { className?: string }) {
42
55
  return (
@@ -231,6 +244,9 @@ function LoaderIcon({ className }: { className?: string }) {
231
244
 
232
245
  export type FetchNodeConfigPanelProps = {
233
246
  node: BlueprintGraphNode;
247
+ graphNodes?: BlueprintGraphNode[];
248
+ graphEdges?: BlueprintGraphEdge[];
249
+ traceEntries?: ExecutionTraceEntry[];
234
250
  onUpdateNode: (
235
251
  nodeId: string,
236
252
  patch: Partial<Pick<BlueprintGraphNode, "fetchConfig" | "configSource">>
@@ -251,6 +267,9 @@ function patchFetchConfig(
251
267
 
252
268
  export function FetchNodeConfigPanel({
253
269
  node,
270
+ graphNodes = [],
271
+ graphEdges = [],
272
+ traceEntries = [],
254
273
  onUpdateNode,
255
274
  }: FetchNodeConfigPanelProps) {
256
275
  const { t } = useI18n();
@@ -268,6 +287,69 @@ export function FetchNodeConfigPanel({
268
287
  const [validationError, setValidationError] = useState<string | null>(null);
269
288
  const [fetchValidationError, setFetchValidationError] = useState<string | null>(null);
270
289
  const swaggerError = validationError ?? swaggerTaskError;
290
+ const incomingScope = useMemo(() => {
291
+ const outputs = latestTraceOutputsByNode(traceEntries);
292
+ return resolveFetchIncomingScope({
293
+ fetchNodeId: node.id,
294
+ nodes: graphNodes,
295
+ edges: graphEdges,
296
+ getOutput: (sourceId, port) => outputs[sourceId]?.[port],
297
+ });
298
+ }, [graphEdges, graphNodes, node.id, traceEntries]);
299
+ const usesScopeTemplate = fetchConfigHasScopeTemplate(fetchConfig);
300
+ const resolvedFetchConfig = useMemo(
301
+ () => applyFetchConfigScope(fetchConfig, incomingScope),
302
+ [fetchConfig, incomingScope]
303
+ );
304
+ const resolvedUrlPreview = useMemo(() => {
305
+ if (!usesScopeTemplate) return "";
306
+ try {
307
+ return resolveFetchRequestUrl(resolvedFetchConfig);
308
+ } catch {
309
+ return resolvedFetchConfig.url?.trim() ?? "";
310
+ }
311
+ }, [resolvedFetchConfig, usesScopeTemplate]);
312
+ const incomingScopeJson = useMemo(() => {
313
+ if (incomingScope === undefined) return "";
314
+ try {
315
+ return JSON.stringify(incomingScope, null, 2);
316
+ } catch {
317
+ return String(incomingScope);
318
+ }
319
+ }, [incomingScope]);
320
+ const headersDraft = draftFetchHeadersText(fetchConfig);
321
+ const resolvedHeadersPreview = useMemo(() => {
322
+ if (!usesScopeTemplate) return "";
323
+ const headers = resolvedFetchConfig.headers;
324
+ if (!headers || Object.keys(headers).length === 0) return "";
325
+ try {
326
+ return JSON.stringify(headers, null, 2);
327
+ } catch {
328
+ return "";
329
+ }
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
+ );
343
+ const incomingHasPendingValue = useMemo(() => {
344
+ if (!incomingScope || typeof incomingScope !== "object") return false;
345
+ const entries =
346
+ "kind" in incomingScope
347
+ ? [incomingScope]
348
+ : Object.values(incomingScope as Record<string, { kind?: string }>);
349
+ return entries.some((entry) => entry && entry.kind === "pending");
350
+ }, [incomingScope]);
351
+ const autocompleteScope = resolveFetchScopeAutocompleteRoot(incomingScope);
352
+ const [formEl, setFormEl] = useState<HTMLDivElement | null>(null);
271
353
 
272
354
  const setUrlInputMode = useCallback(
273
355
  (mode: "swagger" | "manual") => {
@@ -307,30 +389,93 @@ export function FetchNodeConfigPanel({
307
389
  }, [node.id]);
308
390
 
309
391
  const handleSendFetchDebug = useCallback(() => {
310
- const url = fetchConfig.url?.trim();
392
+ const resolved = applyFetchConfigScope(fetchConfig, incomingScope);
393
+ const url = resolved.url?.trim();
311
394
  if (!url) {
312
- setFetchValidationError(t("blueprint.config.fillRequestUrlFirst"));
395
+ setFetchValidationError(
396
+ usesScopeTemplate && fetchConfig.url?.trim()
397
+ ? t("blueprint.config.fetchScopeUnresolved")
398
+ : t("blueprint.config.fillRequestUrlFirst")
399
+ );
400
+ return;
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
+ );
313
414
  return;
314
415
  }
315
416
 
316
417
  setFetchValidationError(null);
317
418
  startFetchDebugTask({
318
419
  nodeId: node.id,
319
- config: fetchConfig,
420
+ config: resolved,
320
421
  });
321
- }, [fetchConfig, node.id]);
422
+ }, [fetchConfig, incomingScope, node.id, t, usesScopeTemplate]);
322
423
 
323
424
  const handleAbortFetchDebug = useCallback(() => {
324
425
  cancelFetchDebugTask(node.id);
325
426
  setFetchValidationError(null);
326
427
  }, [node.id]);
327
428
 
429
+ const handleHeadersChange = useCallback(
430
+ (e: ChangeEvent<HTMLTextAreaElement>) => {
431
+ const headersJson = e.target.value;
432
+ const parsed = parseFetchHeadersJson(headersJson);
433
+ onUpdateNode(
434
+ node.id,
435
+ patchFetchConfig(node, {
436
+ headersJson,
437
+ ...(parsed ? { headers: parsed } : {}),
438
+ })
439
+ );
440
+ },
441
+ [node, onUpdateNode]
442
+ );
443
+
328
444
  return (
329
- <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
330
- <div className="font-medium text-foreground">{t("blueprint.config.fetchTitle")}</div>
331
- <p className="text-[11px] text-muted-foreground">
332
- {t("blueprint.config.fetchHint")}
333
- </p>
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
+ />
453
+ <ConfigSectionTitle
454
+ title={t("blueprint.config.fetchTitle")}
455
+ hint={t("blueprint.config.fetchHint")}
456
+ />
457
+ <div className="space-y-1 rounded-md border border-border/60 bg-background/70 p-2">
458
+ <div className="flex items-center gap-1.5 text-[11px] font-medium text-foreground">
459
+ {t("blueprint.config.fetchScopeTitle")}
460
+ <ConfigHintIcon label={t("blueprint.config.fetchScopeTitle")}>
461
+ <p>{t("blueprint.config.fetchScopeHint")}</p>
462
+ <p>{t("blueprint.config.fetchScopeEmpty")}</p>
463
+ <p>{t("blueprint.config.fetchScopePending")}</p>
464
+ </ConfigHintIcon>
465
+ </div>
466
+ {incomingScope === undefined ? null : (
467
+ <>
468
+ {incomingHasPendingValue ? (
469
+ <p className="text-[11px] text-amber-700 dark:text-amber-300">
470
+ {t("blueprint.config.fetchScopePending")}
471
+ </p>
472
+ ) : null}
473
+ <pre className="max-h-[180px] overflow-auto rounded border border-border/60 bg-muted/30 p-2 font-mono text-[10px] leading-relaxed text-foreground">
474
+ {incomingScopeJson}
475
+ </pre>
476
+ </>
477
+ )}
478
+ </div>
334
479
 
335
480
  <label className="block space-y-1">
336
481
  <span className="text-muted-foreground">{t("blueprint.config.swaggerUrlOptional")}</span>
@@ -338,6 +483,7 @@ export function FetchNodeConfigPanel({
338
483
  <Input
339
484
  value={fetchConfig.swaggerDocsUrl ?? ""}
340
485
  disabled={loadingSwagger}
486
+ data-scope-autocomplete="off"
341
487
  onChange={(e: ChangeEvent<HTMLInputElement>) =>
342
488
  onUpdateNode(
343
489
  node.id,
@@ -440,7 +586,7 @@ export function FetchNodeConfigPanel({
440
586
  patchFetchConfig(node, { apiBaseUrl: e.target.value })
441
587
  )
442
588
  }
443
- placeholder="https://api.example.com/v1"
589
+ placeholder="https://api.example.com/v1/{scope?.value?.tenant}"
444
590
  className="h-8 font-mono text-[11px]"
445
591
  />
446
592
  </label>
@@ -489,7 +635,7 @@ export function FetchNodeConfigPanel({
489
635
  onChange={(e: ChangeEvent<HTMLInputElement>) =>
490
636
  onUpdateNode(node.id, patchFetchConfig(node, { url: e.target.value }))
491
637
  }
492
- placeholder="https://api.example.com/data"
638
+ placeholder="https://api.example.com/users/{scope?.value?.id}"
493
639
  className="h-8 flex-1 font-mono text-[11px]"
494
640
  />
495
641
  <AsyncSendButton
@@ -504,6 +650,11 @@ export function FetchNodeConfigPanel({
504
650
  />
505
651
  </div>
506
652
  )}
653
+ {usesScopeTemplate && resolvedUrlPreview ? (
654
+ <p className="break-all font-mono text-[10px] text-muted-foreground">
655
+ {t("blueprint.config.fetchScopeResolvedUrl")}: {resolvedUrlPreview}
656
+ </p>
657
+ ) : null}
507
658
  {loadingFetchDebug ? (
508
659
  <p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
509
660
  <LoaderIcon className="h-3.5 w-3.5 shrink-0" />
@@ -541,38 +692,59 @@ export function FetchNodeConfigPanel({
541
692
  </label>
542
693
 
543
694
  <label className="block space-y-1">
544
- <span className="text-muted-foreground">{t("blueprint.config.requestHeadersJson")}</span>
695
+ <ConfigFieldLabel
696
+ label={t("blueprint.config.requestHeadersJson")}
697
+ hint={t("blueprint.config.fetchHeadersHint")}
698
+ />
545
699
  <textarea
546
- defaultValue={JSON.stringify(fetchConfig.headers ?? {}, null, 2)}
547
- key={`${node.id}-headers-${JSON.stringify(fetchConfig.headers)}`}
548
- onBlur={(e) => {
549
- try {
550
- const headers = JSON.parse(e.target.value || "{}") as Record<
551
- string,
552
- string
553
- >;
554
- onUpdateNode(node.id, patchFetchConfig(node, { headers }));
555
- } catch {
556
- /* 保留上次有效值 */
557
- }
558
- }}
700
+ value={headersDraft}
701
+ onChange={handleHeadersChange}
559
702
  rows={4}
560
- className="w-full rounded-md border border-input 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"
561
- placeholder='{"Content-Type":"application/json"}'
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
+ )}
707
+ placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
562
708
  />
709
+ {headersJsonError ? (
710
+ <p className="text-[11px] text-destructive">
711
+ {t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError })}
712
+ </p>
713
+ ) : null}
714
+ {usesScopeTemplate && resolvedHeadersPreview ? (
715
+ <p className="whitespace-pre-wrap break-all font-mono text-[10px] text-muted-foreground">
716
+ {t("blueprint.config.fetchScopeResolvedHeaders")}: {resolvedHeadersPreview}
717
+ </p>
718
+ ) : null}
563
719
  </label>
564
720
 
565
721
  <label className="block space-y-1">
566
- <span className="text-muted-foreground">{t("blueprint.config.requestBody")}</span>
722
+ <ConfigFieldLabel
723
+ label={t("blueprint.config.requestBody")}
724
+ hint={t("blueprint.config.fetchBodyHint")}
725
+ />
567
726
  <textarea
568
727
  value={fetchConfig.body ?? ""}
569
728
  onChange={(e) =>
570
729
  onUpdateNode(node.id, patchFetchConfig(node, { body: e.target.value }))
571
730
  }
572
731
  rows={4}
573
- className="w-full rounded-md border border-input 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"
574
- placeholder='{"key":"value"}'
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
+ )}
736
+ placeholder='{"id":"{scope?.value?.id}"}'
575
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}
576
748
  </label>
577
749
 
578
750
  <div className="grid grid-cols-2 gap-2">
@@ -103,6 +103,7 @@ export function FetchUrlAutocomplete({
103
103
  <input
104
104
  value={inputValue}
105
105
  readOnly={selectOnly && !open}
106
+ data-scope-autocomplete="off"
106
107
  placeholder={placeholder}
107
108
  onChange={(e) => {
108
109
  if (selectOnly) {
@@ -5,6 +5,7 @@ import { useI18n } from "@arronqzy/i18n/react";
5
5
 
6
6
  import type { BlueprintGraphNode } from "../graph/document";
7
7
  import { resolveNodeJsonConfig } from "../graph/document";
8
+ import { ConfigSectionTitle } from "./ConfigHintIcon";
8
9
 
9
10
  export type JsonNodeConfigPanelProps = {
10
11
  node: BlueprintGraphNode;
@@ -53,12 +54,10 @@ export function JsonNodeConfigPanel({
53
54
 
54
55
  return (
55
56
  <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
56
- <div className="font-medium text-foreground">
57
- {t("blueprint.config.jsonTitle")}
58
- </div>
59
- <p className="text-[11px] text-muted-foreground">
60
- {t("blueprint.config.jsonHint")}
61
- </p>
57
+ <ConfigSectionTitle
58
+ title={t("blueprint.config.jsonTitle")}
59
+ hint={t("blueprint.config.jsonHint")}
60
+ />
62
61
 
63
62
  <label className="block space-y-1">
64
63
  <span className="text-muted-foreground">
@@ -5,6 +5,7 @@ import { useI18n } from "@arronqzy/i18n/react";
5
5
 
6
6
  import type { BlueprintGraphNode } from "../graph/document";
7
7
  import { resolveNodeLogicConfig } from "../graph/document";
8
+ import { ConfigSectionTitle } from "./ConfigHintIcon";
8
9
 
9
10
  export type LogicNodeConfigPanelProps = {
10
11
  node: BlueprintGraphNode;
@@ -52,12 +53,10 @@ export function LogicNodeConfigPanel({
52
53
 
53
54
  return (
54
55
  <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
55
- <div className="font-medium text-foreground">
56
- {t("blueprint.config.logicTitle")}
57
- </div>
58
- <p className="text-[11px] text-muted-foreground">
59
- {t("blueprint.config.logicHint")}
60
- </p>
56
+ <ConfigSectionTitle
57
+ title={t("blueprint.config.logicTitle")}
58
+ hint={t("blueprint.config.logicHint")}
59
+ />
61
60
 
62
61
  <label className="block space-y-1">
63
62
  <span className="text-muted-foreground">
@@ -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),