@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.
- package/package.json +3 -3
- package/src/BlueprintContextMenu.tsx +1 -1
- package/src/BlueprintNodeConfigSidebar.tsx +115 -28
- package/src/blueprintNodeTypes.ts +2 -0
- package/src/components/ClockNodeConfigPanel.tsx +17 -24
- package/src/components/ConfigHintIcon.tsx +83 -0
- package/src/components/FetchNodeConfigPanel.tsx +204 -32
- package/src/components/FetchUrlAutocomplete.tsx +1 -0
- package/src/components/JsonNodeConfigPanel.tsx +5 -6
- package/src/components/LogicNodeConfigPanel.tsx +5 -6
- package/src/components/ScopeTemplateAutocompleteHost.tsx +245 -0
- package/src/graph/blueprint-graph.ts +8 -0
- package/src/graph/document.ts +136 -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 +3 -0
|
@@ -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
|
|
392
|
+
const resolved = applyFetchConfigScope(fetchConfig, incomingScope);
|
|
393
|
+
const url = resolved.url?.trim();
|
|
311
394
|
if (!url) {
|
|
312
|
-
setFetchValidationError(
|
|
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:
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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/
|
|
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
|
-
<
|
|
695
|
+
<ConfigFieldLabel
|
|
696
|
+
label={t("blueprint.config.requestHeadersJson")}
|
|
697
|
+
hint={t("blueprint.config.fetchHeadersHint")}
|
|
698
|
+
/>
|
|
545
699
|
<textarea
|
|
546
|
-
|
|
547
|
-
|
|
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=
|
|
561
|
-
|
|
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
|
-
<
|
|
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=
|
|
574
|
-
|
|
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">
|
|
@@ -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
|
-
<
|
|
57
|
-
{t("blueprint.config.jsonTitle")}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
<
|
|
56
|
-
{t("blueprint.config.logicTitle")}
|
|
57
|
-
|
|
58
|
-
|
|
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),
|