@arronqzy/vue-blueprint 0.1.3 → 0.1.5
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 +4 -4
- package/src/BlueprintNodeConfigSidebar.vue +111 -23
- package/src/blueprintNodeTypes.ts +2 -0
- package/src/components/BlueprintCanvas.vue +2 -0
- package/src/components/BlueprintContextMenu.vue +1 -1
- package/src/components/ClockNodeConfigPanel.vue +25 -14
- package/src/components/ConfigHintIcon.vue +38 -0
- package/src/components/FetchNodeConfigPanel.vue +191 -31
- package/src/components/FetchUrlAutocomplete.vue +1 -0
- package/src/components/JsonNodeConfigPanel.vue +7 -2
- package/src/components/LogicNodeConfigPanel.vue +7 -2
- package/src/components/ScopeTemplateAutocompleteHost.vue +231 -0
- package/src/composables/useBlueprintFlowState.ts +51 -5
- package/src/composables/useBlueprintPageLifecycle.ts +99 -10
- 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/sync-nodes.ts +2 -1
- package/src/graph/vue-flow-adapter.ts +4 -0
- package/src/nodes/BlueprintNodeCard.vue +12 -2
- package/src/nodes/BlueprintNodeShell.vue +4 -2
- package/src/nodes/EventFlowNode.vue +31 -0
- package/src/runtime/document-to-runnable-graph.ts +3 -0
|
@@ -3,11 +3,14 @@ import { useI18n } from "@arronqzy/i18n/vue";
|
|
|
3
3
|
import { computed, ref } from "vue";
|
|
4
4
|
import { Button, Input, Select } from "ant-design-vue";
|
|
5
5
|
import type {
|
|
6
|
+
ExecutionTraceEntry,
|
|
6
7
|
FetchHttpMethod,
|
|
7
8
|
FetchRequestConfig,
|
|
8
9
|
FetchResponseType,
|
|
9
10
|
} from "@arronqzy/blueprint-dsl";
|
|
10
11
|
import {
|
|
12
|
+
applyFetchConfigScope,
|
|
13
|
+
draftFetchHeadersText,
|
|
11
14
|
FETCH_CACHES,
|
|
12
15
|
FETCH_CREDENTIALS,
|
|
13
16
|
FETCH_HTTP_METHODS,
|
|
@@ -15,9 +18,17 @@ import {
|
|
|
15
18
|
FETCH_NODE_TYPE,
|
|
16
19
|
FETCH_REDIRECTS,
|
|
17
20
|
FETCH_RESPONSE_TYPES,
|
|
21
|
+
fetchConfigHasScopeTemplate,
|
|
22
|
+
getFetchBodyValidationError,
|
|
23
|
+
getFetchHeadersValidationError,
|
|
24
|
+
latestTraceOutputsByNode,
|
|
25
|
+
parseFetchHeadersJson,
|
|
26
|
+
resolveFetchIncomingScope,
|
|
27
|
+
resolveFetchRequestUrl,
|
|
28
|
+
resolveFetchScopeAutocompleteRoot,
|
|
18
29
|
} from "@arronqzy/blueprint-dsl";
|
|
19
30
|
|
|
20
|
-
import type { BlueprintGraphNode } from "../graph/document";
|
|
31
|
+
import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
|
|
21
32
|
import { resolveNodeFetchConfig } from "../graph/document";
|
|
22
33
|
import {
|
|
23
34
|
cancelFetchDebugTask,
|
|
@@ -27,25 +38,95 @@ import {
|
|
|
27
38
|
useFetchDebugTask,
|
|
28
39
|
useSwaggerLoadTask,
|
|
29
40
|
} from "../fetch-config-task-store";
|
|
41
|
+
import ConfigHintIcon from "./ConfigHintIcon.vue";
|
|
30
42
|
import FetchUrlAutocomplete from "./FetchUrlAutocomplete.vue";
|
|
43
|
+
import ScopeTemplateAutocompleteHost from "./ScopeTemplateAutocompleteHost.vue";
|
|
31
44
|
|
|
32
45
|
const { t } = useI18n();
|
|
33
46
|
|
|
34
47
|
export type FetchNodeConfigPanelProps = {
|
|
35
48
|
node: BlueprintGraphNode;
|
|
49
|
+
graphNodes?: BlueprintGraphNode[];
|
|
50
|
+
graphEdges?: BlueprintGraphEdge[];
|
|
51
|
+
traceEntries?: ExecutionTraceEntry[];
|
|
36
52
|
onUpdateNode: (
|
|
37
53
|
nodeId: string,
|
|
38
54
|
patch: Partial<Pick<BlueprintGraphNode, "fetchConfig" | "configSource">>
|
|
39
55
|
) => void;
|
|
40
56
|
};
|
|
41
57
|
|
|
42
|
-
const props = defineProps<FetchNodeConfigPanelProps>()
|
|
58
|
+
const props = withDefaults(defineProps<FetchNodeConfigPanelProps>(), {
|
|
59
|
+
graphNodes: () => [],
|
|
60
|
+
graphEdges: () => [],
|
|
61
|
+
traceEntries: () => [],
|
|
62
|
+
});
|
|
43
63
|
|
|
44
64
|
const validationError = ref<string | null>(null);
|
|
45
65
|
const fetchValidationError = ref<string | null>(null);
|
|
46
66
|
const debugExpanded = ref(false);
|
|
47
67
|
|
|
48
68
|
const fetchConfig = computed(() => resolveNodeFetchConfig(props.node));
|
|
69
|
+
const incomingScope = computed(() => {
|
|
70
|
+
const outputs = latestTraceOutputsByNode(props.traceEntries);
|
|
71
|
+
return resolveFetchIncomingScope({
|
|
72
|
+
fetchNodeId: props.node.id,
|
|
73
|
+
nodes: props.graphNodes,
|
|
74
|
+
edges: props.graphEdges,
|
|
75
|
+
getOutput: (sourceId, port) => outputs[sourceId]?.[port],
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const usesScopeTemplate = computed(() => fetchConfigHasScopeTemplate(fetchConfig.value));
|
|
79
|
+
const resolvedFetchConfig = computed(() =>
|
|
80
|
+
applyFetchConfigScope(fetchConfig.value, incomingScope.value)
|
|
81
|
+
);
|
|
82
|
+
const resolvedUrlPreview = computed(() => {
|
|
83
|
+
if (!usesScopeTemplate.value) return "";
|
|
84
|
+
try {
|
|
85
|
+
return resolveFetchRequestUrl(resolvedFetchConfig.value);
|
|
86
|
+
} catch {
|
|
87
|
+
return resolvedFetchConfig.value.url?.trim() ?? "";
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const incomingScopeJson = computed(() => {
|
|
91
|
+
if (incomingScope.value === undefined) return "";
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(incomingScope.value, null, 2);
|
|
94
|
+
} catch {
|
|
95
|
+
return String(incomingScope.value);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const headersDraft = computed(() => draftFetchHeadersText(fetchConfig.value));
|
|
99
|
+
const resolvedHeadersPreview = computed(() => {
|
|
100
|
+
if (!usesScopeTemplate.value) return "";
|
|
101
|
+
const headers = resolvedFetchConfig.value.headers;
|
|
102
|
+
if (!headers || Object.keys(headers).length === 0) return "";
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(headers, null, 2);
|
|
105
|
+
} catch {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const resolvedBodyPreview = computed(() => {
|
|
110
|
+
if (!usesScopeTemplate.value) return "";
|
|
111
|
+
return resolvedFetchConfig.value.body?.trim() ?? "";
|
|
112
|
+
});
|
|
113
|
+
const headersJsonError = computed(() =>
|
|
114
|
+
getFetchHeadersValidationError(fetchConfig.value, incomingScope.value)
|
|
115
|
+
);
|
|
116
|
+
const bodyJsonError = computed(() =>
|
|
117
|
+
getFetchBodyValidationError(fetchConfig.value.body, resolvedFetchConfig.value.body)
|
|
118
|
+
);
|
|
119
|
+
const incomingHasPendingValue = computed(() => {
|
|
120
|
+
const scope = incomingScope.value;
|
|
121
|
+
if (!scope || typeof scope !== "object") return false;
|
|
122
|
+
const entries =
|
|
123
|
+
"kind" in scope ? [scope] : Object.values(scope as Record<string, { kind?: string }>);
|
|
124
|
+
return entries.some((entry) => entry && entry.kind === "pending");
|
|
125
|
+
});
|
|
126
|
+
const autocompleteScope = computed(() =>
|
|
127
|
+
resolveFetchScopeAutocompleteRoot(incomingScope.value)
|
|
128
|
+
);
|
|
129
|
+
const formRef = ref<HTMLElement | null>(null);
|
|
49
130
|
const endpoints = computed(() => fetchConfig.value.swaggerEndpoints ?? []);
|
|
50
131
|
const hasSwaggerEndpoints = computed(() => endpoints.value.length > 0);
|
|
51
132
|
const urlInputMode = computed(
|
|
@@ -105,16 +186,37 @@ function handleAbortSwagger() {
|
|
|
105
186
|
}
|
|
106
187
|
|
|
107
188
|
function handleSendFetchDebug() {
|
|
108
|
-
const
|
|
189
|
+
const resolved = applyFetchConfigScope(fetchConfig.value, incomingScope.value);
|
|
190
|
+
const url = resolved.url?.trim();
|
|
109
191
|
if (!url) {
|
|
110
|
-
fetchValidationError.value =
|
|
192
|
+
fetchValidationError.value =
|
|
193
|
+
usesScopeTemplate.value && fetchConfig.value.url?.trim()
|
|
194
|
+
? t("blueprint.config.fetchScopeUnresolved")
|
|
195
|
+
: t("blueprint.config.fillRequestUrlFirst");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const headersError = getFetchHeadersValidationError(
|
|
199
|
+
fetchConfig.value,
|
|
200
|
+
incomingScope.value
|
|
201
|
+
);
|
|
202
|
+
if (headersError) {
|
|
203
|
+
fetchValidationError.value = t("blueprint.config.fetchHeadersInvalidJson", {
|
|
204
|
+
error: headersError,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const bodyError = getFetchBodyValidationError(fetchConfig.value.body, resolved.body);
|
|
209
|
+
if (bodyError) {
|
|
210
|
+
fetchValidationError.value = t("blueprint.config.fetchBodyInvalidJson", {
|
|
211
|
+
error: bodyError,
|
|
212
|
+
});
|
|
111
213
|
return;
|
|
112
214
|
}
|
|
113
215
|
|
|
114
216
|
fetchValidationError.value = null;
|
|
115
217
|
startFetchDebugTask({
|
|
116
218
|
nodeId: props.node.id,
|
|
117
|
-
config:
|
|
219
|
+
config: resolved,
|
|
118
220
|
});
|
|
119
221
|
}
|
|
120
222
|
|
|
@@ -148,25 +250,47 @@ const debugOk = computed(() => {
|
|
|
148
250
|
return result ? result.status >= 200 && result.status < 300 : false;
|
|
149
251
|
});
|
|
150
252
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
253
|
+
function handleHeadersInput(event: Event) {
|
|
254
|
+
const headersJson = (event.target as HTMLTextAreaElement).value;
|
|
255
|
+
const parsed = parseFetchHeadersJson(headersJson);
|
|
256
|
+
props.onUpdateNode(
|
|
257
|
+
props.node.id,
|
|
258
|
+
patchFetchConfig(props.node, {
|
|
259
|
+
headersJson,
|
|
260
|
+
...(parsed ? { headers: parsed } : {}),
|
|
261
|
+
})
|
|
262
|
+
);
|
|
161
263
|
}
|
|
162
264
|
</script>
|
|
163
265
|
|
|
164
266
|
<template>
|
|
165
|
-
<div
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
267
|
+
<div
|
|
268
|
+
ref="formRef"
|
|
269
|
+
class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
|
|
270
|
+
>
|
|
271
|
+
<ScopeTemplateAutocompleteHost :scope="autocompleteScope" :container-ref="formRef" />
|
|
272
|
+
<div class="flex items-center gap-1.5">
|
|
273
|
+
<div class="font-medium text-foreground">{{ t("blueprint.config.fetchTitle") }}</div>
|
|
274
|
+
<ConfigHintIcon :label="t('blueprint.config.fetchTitle')">
|
|
275
|
+
{{ t("blueprint.config.fetchHint") }}
|
|
276
|
+
</ConfigHintIcon>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="space-y-1 rounded-md border border-gray-200/80 bg-white/80 p-2">
|
|
279
|
+
<div class="flex items-center gap-1.5 text-[11px] font-medium text-gray-800">
|
|
280
|
+
{{ t("blueprint.config.fetchScopeTitle") }}
|
|
281
|
+
<ConfigHintIcon :label="t('blueprint.config.fetchScopeTitle')">
|
|
282
|
+
<p>{{ t("blueprint.config.fetchScopeHint") }}</p>
|
|
283
|
+
<p>{{ t("blueprint.config.fetchScopeEmpty") }}</p>
|
|
284
|
+
<p>{{ t("blueprint.config.fetchScopePending") }}</p>
|
|
285
|
+
</ConfigHintIcon>
|
|
286
|
+
</div>
|
|
287
|
+
<template v-if="incomingScope !== undefined">
|
|
288
|
+
<p v-if="incomingHasPendingValue" class="text-[11px] text-amber-700">
|
|
289
|
+
{{ t("blueprint.config.fetchScopePending") }}
|
|
290
|
+
</p>
|
|
291
|
+
<pre class="max-h-[180px] overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-[10px] leading-relaxed text-gray-800">{{ incomingScopeJson }}</pre>
|
|
292
|
+
</template>
|
|
293
|
+
</div>
|
|
170
294
|
|
|
171
295
|
<label class="block space-y-1">
|
|
172
296
|
<span class="text-muted-foreground">{{ t("blueprint.config.swaggerUrlOptional") }}</span>
|
|
@@ -176,6 +300,7 @@ function handleHeadersBlur(event: Event) {
|
|
|
176
300
|
:value="fetchConfig.swaggerDocsUrl ?? ''"
|
|
177
301
|
:disabled="loadingSwagger"
|
|
178
302
|
placeholder="https://example.com/v3/api-docs"
|
|
303
|
+
data-scope-autocomplete="off"
|
|
179
304
|
class="flex-1 font-mono text-[11px]"
|
|
180
305
|
@update:value="
|
|
181
306
|
(v) =>
|
|
@@ -254,7 +379,7 @@ function handleHeadersBlur(event: Event) {
|
|
|
254
379
|
<Input
|
|
255
380
|
size="small"
|
|
256
381
|
:value="fetchConfig.apiBaseUrl ?? ''"
|
|
257
|
-
placeholder="https://api.example.com/v1"
|
|
382
|
+
placeholder="https://api.example.com/v1/{scope?.value?.tenant}"
|
|
258
383
|
class="font-mono text-[11px]"
|
|
259
384
|
@update:value="
|
|
260
385
|
(v) => onUpdateNode(node.id, patchFetchConfig(node, { apiBaseUrl: String(v) }))
|
|
@@ -313,7 +438,7 @@ function handleHeadersBlur(event: Event) {
|
|
|
313
438
|
size="small"
|
|
314
439
|
:value="fetchConfig.url"
|
|
315
440
|
:disabled="loadingFetchDebug"
|
|
316
|
-
placeholder="https://api.example.com/
|
|
441
|
+
placeholder="https://api.example.com/users/{scope?.value?.id}"
|
|
317
442
|
class="flex-1 font-mono text-[11px]"
|
|
318
443
|
@update:value="(v) => onUpdateNode(node.id, patchFetchConfig(node, { url: String(v) }))"
|
|
319
444
|
/>
|
|
@@ -338,6 +463,12 @@ function handleHeadersBlur(event: Event) {
|
|
|
338
463
|
</Button>
|
|
339
464
|
</div>
|
|
340
465
|
|
|
466
|
+
<p
|
|
467
|
+
v-if="usesScopeTemplate && resolvedUrlPreview"
|
|
468
|
+
class="break-all font-mono text-[10px] text-gray-500"
|
|
469
|
+
>
|
|
470
|
+
{{ t("blueprint.config.fetchScopeResolvedUrl") }}: {{ resolvedUrlPreview }}
|
|
471
|
+
</p>
|
|
341
472
|
<p v-if="loadingFetchDebug" class="text-[11px] text-muted-foreground">
|
|
342
473
|
{{ t("blueprint.config.sendingDebug") }}
|
|
343
474
|
</p>
|
|
@@ -418,24 +549,44 @@ function handleHeadersBlur(event: Event) {
|
|
|
418
549
|
</label>
|
|
419
550
|
|
|
420
551
|
<label class="block space-y-1">
|
|
421
|
-
<span class="text-muted-foreground">
|
|
552
|
+
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
|
553
|
+
{{ t("blueprint.config.requestHeadersJson") }}
|
|
554
|
+
<ConfigHintIcon :label="t('blueprint.config.requestHeadersJson')">
|
|
555
|
+
{{ t("blueprint.config.fetchHeadersHint") }}
|
|
556
|
+
</ConfigHintIcon>
|
|
557
|
+
</span>
|
|
422
558
|
<textarea
|
|
423
|
-
:
|
|
424
|
-
:value="JSON.stringify(fetchConfig.headers ?? {}, null, 2)"
|
|
559
|
+
:value="headersDraft"
|
|
425
560
|
rows="4"
|
|
426
|
-
class="w-full rounded-md border
|
|
427
|
-
|
|
428
|
-
|
|
561
|
+
class="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"
|
|
562
|
+
:class="headersJsonError ? 'border-destructive' : 'border-input'"
|
|
563
|
+
placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
|
|
564
|
+
@input="handleHeadersInput"
|
|
429
565
|
/>
|
|
566
|
+
<p v-if="headersJsonError" class="text-[11px] text-destructive">
|
|
567
|
+
{{ t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError }) }}
|
|
568
|
+
</p>
|
|
569
|
+
<p
|
|
570
|
+
v-if="usesScopeTemplate && resolvedHeadersPreview"
|
|
571
|
+
class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
|
|
572
|
+
>
|
|
573
|
+
{{ t("blueprint.config.fetchScopeResolvedHeaders") }}: {{ resolvedHeadersPreview }}
|
|
574
|
+
</p>
|
|
430
575
|
</label>
|
|
431
576
|
|
|
432
577
|
<label class="block space-y-1">
|
|
433
|
-
<span class="text-muted-foreground">
|
|
578
|
+
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
|
579
|
+
{{ t("blueprint.config.requestBody") }}
|
|
580
|
+
<ConfigHintIcon :label="t('blueprint.config.requestBody')">
|
|
581
|
+
{{ t("blueprint.config.fetchBodyHint") }}
|
|
582
|
+
</ConfigHintIcon>
|
|
583
|
+
</span>
|
|
434
584
|
<textarea
|
|
435
585
|
:value="fetchConfig.body ?? ''"
|
|
436
586
|
rows="4"
|
|
437
|
-
class="w-full rounded-md border
|
|
438
|
-
|
|
587
|
+
class="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"
|
|
588
|
+
:class="bodyJsonError ? 'border-destructive' : 'border-input'"
|
|
589
|
+
placeholder='{"id":"{scope?.value?.id}"}'
|
|
439
590
|
@input="
|
|
440
591
|
(e) =>
|
|
441
592
|
onUpdateNode(
|
|
@@ -444,6 +595,15 @@ function handleHeadersBlur(event: Event) {
|
|
|
444
595
|
)
|
|
445
596
|
"
|
|
446
597
|
/>
|
|
598
|
+
<p v-if="bodyJsonError" class="text-[11px] text-destructive">
|
|
599
|
+
{{ t("blueprint.config.fetchBodyInvalidJson", { error: bodyJsonError }) }}
|
|
600
|
+
</p>
|
|
601
|
+
<p
|
|
602
|
+
v-if="usesScopeTemplate && resolvedBodyPreview"
|
|
603
|
+
class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
|
|
604
|
+
>
|
|
605
|
+
{{ t("blueprint.config.fetchScopeResolvedBody") }}: {{ resolvedBodyPreview }}
|
|
606
|
+
</p>
|
|
447
607
|
</label>
|
|
448
608
|
|
|
449
609
|
<div class="grid grid-cols-2 gap-2">
|
|
@@ -142,6 +142,7 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
142
142
|
<input
|
|
143
143
|
:value="inputValue"
|
|
144
144
|
:readOnly="selectOnly && !open"
|
|
145
|
+
data-scope-autocomplete="off"
|
|
145
146
|
:placeholder="placeholder"
|
|
146
147
|
class="flex h-8 w-full rounded-md border border-input bg-background px-2 py-1 text-[11px] text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
|
147
148
|
:class="selectOnly && !open ? 'cursor-pointer font-medium' : 'font-mono'"
|
|
@@ -4,6 +4,7 @@ import { computed, ref } from "vue";
|
|
|
4
4
|
import { validateJsonString } from "@arronqzy/blueprint-dsl";
|
|
5
5
|
import type { JsonNodeConfig } from "@arronqzy/blueprint-dsl";
|
|
6
6
|
|
|
7
|
+
import ConfigHintIcon from "./ConfigHintIcon.vue";
|
|
7
8
|
import type { BlueprintGraphNode } from "../graph/document";
|
|
8
9
|
import { resolveNodeJsonConfig } from "../graph/document";
|
|
9
10
|
|
|
@@ -48,8 +49,12 @@ function handleChange(event: Event) {
|
|
|
48
49
|
|
|
49
50
|
<template>
|
|
50
51
|
<div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
|
|
51
|
-
<div class="
|
|
52
|
-
|
|
52
|
+
<div class="flex items-center gap-1.5">
|
|
53
|
+
<div class="font-medium text-foreground">{{ t("blueprint.config.jsonTitle") }}</div>
|
|
54
|
+
<ConfigHintIcon :label="t('blueprint.config.jsonTitle')">
|
|
55
|
+
{{ t("blueprint.config.jsonHint") }}
|
|
56
|
+
</ConfigHintIcon>
|
|
57
|
+
</div>
|
|
53
58
|
|
|
54
59
|
<label class="block space-y-1">
|
|
55
60
|
<span class="text-muted-foreground">{{ t("blueprint.config.jsonContent") }}</span>
|
|
@@ -4,6 +4,7 @@ import { computed, ref } from "vue";
|
|
|
4
4
|
import { validateLogicSourceCode } from "@arronqzy/blueprint-dsl";
|
|
5
5
|
import type { LogicNodeConfig } from "@arronqzy/blueprint-dsl";
|
|
6
6
|
|
|
7
|
+
import ConfigHintIcon from "./ConfigHintIcon.vue";
|
|
7
8
|
import type { BlueprintGraphNode } from "../graph/document";
|
|
8
9
|
import { resolveNodeLogicConfig } from "../graph/document";
|
|
9
10
|
|
|
@@ -48,8 +49,12 @@ function handleChange(event: Event) {
|
|
|
48
49
|
|
|
49
50
|
<template>
|
|
50
51
|
<div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
|
|
51
|
-
<div class="
|
|
52
|
-
|
|
52
|
+
<div class="flex items-center gap-1.5">
|
|
53
|
+
<div class="font-medium text-foreground">{{ t("blueprint.config.logicTitle") }}</div>
|
|
54
|
+
<ConfigHintIcon :label="t('blueprint.config.logicTitle')">
|
|
55
|
+
{{ t("blueprint.config.logicHint") }}
|
|
56
|
+
</ConfigHintIcon>
|
|
57
|
+
</div>
|
|
53
58
|
|
|
54
59
|
<label class="block space-y-1">
|
|
55
60
|
<span class="text-muted-foreground">{{ t("blueprint.config.jsCode") }}</span>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
toValue,
|
|
4
|
+
watch,
|
|
5
|
+
shallowRef,
|
|
6
|
+
type MaybeRef,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import {
|
|
9
|
+
applyScopeAutocompleteSelection,
|
|
10
|
+
buildScopeExpression,
|
|
11
|
+
getScopeAutocompleteSuggestions,
|
|
12
|
+
parseScopeAutocomplete,
|
|
13
|
+
type ScopeAutocompleteState,
|
|
14
|
+
} from "@arronqzy/blueprint-dsl";
|
|
15
|
+
|
|
16
|
+
type DropdownState = {
|
|
17
|
+
input: HTMLInputElement | HTMLTextAreaElement;
|
|
18
|
+
state: ScopeAutocompleteState;
|
|
19
|
+
suggestions: string[];
|
|
20
|
+
activeIndex: number;
|
|
21
|
+
rect: DOMRect;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
scope: unknown;
|
|
26
|
+
containerRef?: MaybeRef<HTMLElement | null | undefined>;
|
|
27
|
+
}>();
|
|
28
|
+
|
|
29
|
+
const dropdown = shallowRef<DropdownState | null>(null);
|
|
30
|
+
|
|
31
|
+
function isAutocompleteTarget(
|
|
32
|
+
target: EventTarget | null
|
|
33
|
+
): target is HTMLInputElement | HTMLTextAreaElement {
|
|
34
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
35
|
+
return target.dataset.scopeAutocomplete !== "off";
|
|
36
|
+
}
|
|
37
|
+
if (target instanceof HTMLInputElement) {
|
|
38
|
+
if (target.dataset.scopeAutocomplete === "off") return false;
|
|
39
|
+
const type = target.type;
|
|
40
|
+
return (
|
|
41
|
+
!type ||
|
|
42
|
+
type === "text" ||
|
|
43
|
+
type === "search" ||
|
|
44
|
+
type === "url" ||
|
|
45
|
+
type === "password"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setNativeInputValue(
|
|
52
|
+
element: HTMLInputElement | HTMLTextAreaElement,
|
|
53
|
+
value: string
|
|
54
|
+
) {
|
|
55
|
+
const proto =
|
|
56
|
+
element instanceof HTMLTextAreaElement
|
|
57
|
+
? HTMLTextAreaElement.prototype
|
|
58
|
+
: HTMLInputElement.prototype;
|
|
59
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
|
|
60
|
+
descriptor?.set?.call(element, value);
|
|
61
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function closeDropdown() {
|
|
65
|
+
dropdown.value = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function refreshForInput(input: HTMLInputElement | HTMLTextAreaElement) {
|
|
69
|
+
if (props.scope === undefined) {
|
|
70
|
+
closeDropdown();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const cursor = input.selectionStart ?? input.value.length;
|
|
74
|
+
const parsed = parseScopeAutocomplete(input.value, cursor, props.scope);
|
|
75
|
+
if (!parsed) {
|
|
76
|
+
closeDropdown();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const suggestions = getScopeAutocompleteSuggestions(props.scope, parsed);
|
|
80
|
+
if (suggestions.length === 0) {
|
|
81
|
+
closeDropdown();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
dropdown.value = {
|
|
85
|
+
input,
|
|
86
|
+
state: parsed,
|
|
87
|
+
suggestions,
|
|
88
|
+
activeIndex: 0,
|
|
89
|
+
rect: input.getBoundingClientRect(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applySuggestion(index: number) {
|
|
94
|
+
const current = dropdown.value;
|
|
95
|
+
if (!current) return;
|
|
96
|
+
const selected = current.suggestions[index];
|
|
97
|
+
if (!selected) return;
|
|
98
|
+
|
|
99
|
+
const { value, cursor } = applyScopeAutocompleteSelection(
|
|
100
|
+
current.input.value,
|
|
101
|
+
current.state,
|
|
102
|
+
selected
|
|
103
|
+
);
|
|
104
|
+
setNativeInputValue(current.input, value);
|
|
105
|
+
current.input.focus();
|
|
106
|
+
current.input.setSelectionRange(cursor, cursor);
|
|
107
|
+
closeDropdown();
|
|
108
|
+
window.requestAnimationFrame(() => {
|
|
109
|
+
refreshForInput(current.input);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onFocusIn(event: FocusEvent) {
|
|
114
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
115
|
+
refreshForInput(event.target);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onInput(event: Event) {
|
|
119
|
+
if (!isAutocompleteTarget(event.target)) return;
|
|
120
|
+
refreshForInput(event.target);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
124
|
+
const current = dropdown.value;
|
|
125
|
+
if (!current || event.target !== current.input) return;
|
|
126
|
+
|
|
127
|
+
if (event.key === "ArrowDown") {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
dropdown.value = {
|
|
130
|
+
...current,
|
|
131
|
+
activeIndex: Math.min(current.activeIndex + 1, current.suggestions.length - 1),
|
|
132
|
+
};
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (event.key === "ArrowUp") {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
dropdown.value = {
|
|
138
|
+
...current,
|
|
139
|
+
activeIndex: Math.max(current.activeIndex - 1, 0),
|
|
140
|
+
};
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
144
|
+
if (current.suggestions.length > 0) {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
applySuggestion(current.activeIndex);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (event.key === "Escape") {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
closeDropdown();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onPointerDown(event: MouseEvent) {
|
|
157
|
+
const target = event.target as Node | null;
|
|
158
|
+
if (
|
|
159
|
+
dropdown.value?.input.contains(target ?? null) ||
|
|
160
|
+
(target instanceof Element && target.closest("[data-scope-ac-list]"))
|
|
161
|
+
) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const container = toValue(props.containerRef);
|
|
165
|
+
if (container && !container.contains(target)) {
|
|
166
|
+
closeDropdown();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
watch(
|
|
171
|
+
() => toValue(props.containerRef),
|
|
172
|
+
(container, _prev, onCleanup) => {
|
|
173
|
+
if (!container || props.scope === undefined) return;
|
|
174
|
+
|
|
175
|
+
container.addEventListener("focusin", onFocusIn);
|
|
176
|
+
container.addEventListener("input", onInput, true);
|
|
177
|
+
container.addEventListener("keydown", onKeyDown, true);
|
|
178
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
179
|
+
window.addEventListener("scroll", closeDropdown, true);
|
|
180
|
+
window.addEventListener("resize", closeDropdown);
|
|
181
|
+
|
|
182
|
+
onCleanup(() => {
|
|
183
|
+
container.removeEventListener("focusin", onFocusIn);
|
|
184
|
+
container.removeEventListener("input", onInput, true);
|
|
185
|
+
container.removeEventListener("keydown", onKeyDown, true);
|
|
186
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
187
|
+
window.removeEventListener("scroll", closeDropdown, true);
|
|
188
|
+
window.removeEventListener("resize", closeDropdown);
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
{ immediate: true }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
watch(
|
|
195
|
+
() => props.scope,
|
|
196
|
+
() => {
|
|
197
|
+
if (props.scope === undefined) closeDropdown();
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
</script>
|
|
201
|
+
|
|
202
|
+
<template>
|
|
203
|
+
<Teleport v-if="dropdown && scope !== undefined" to="body">
|
|
204
|
+
<div
|
|
205
|
+
data-scope-ac-list
|
|
206
|
+
class="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-white py-1 text-gray-800 shadow-md"
|
|
207
|
+
:style="{
|
|
208
|
+
left: `${dropdown.rect.left}px`,
|
|
209
|
+
top: `${dropdown.rect.bottom + 4}px`,
|
|
210
|
+
width: `${Math.max(dropdown.rect.width, 160)}px`,
|
|
211
|
+
}"
|
|
212
|
+
>
|
|
213
|
+
<button
|
|
214
|
+
v-for="(key, index) in dropdown.suggestions"
|
|
215
|
+
:key="key"
|
|
216
|
+
type="button"
|
|
217
|
+
:class="[
|
|
218
|
+
'flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-gray-100',
|
|
219
|
+
index === dropdown.activeIndex && 'bg-gray-100',
|
|
220
|
+
]"
|
|
221
|
+
@mousedown.prevent
|
|
222
|
+
@click="applySuggestion(index)"
|
|
223
|
+
>
|
|
224
|
+
<span class="font-medium text-gray-800">{{ key }}</span>
|
|
225
|
+
<span class="font-mono text-[10px] text-gray-500">{{
|
|
226
|
+
`{${buildScopeExpression(dropdown.state.path, key)}}`
|
|
227
|
+
}}</span>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</Teleport>
|
|
231
|
+
</template>
|