@arronqzy/vue-blueprint 0.1.4 → 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 +3 -3
- package/src/BlueprintNodeConfigSidebar.vue +68 -6
- package/src/blueprintNodeTypes.ts +2 -0
- package/src/components/BlueprintCanvas.vue +2 -0
- package/src/components/BlueprintContextMenu.vue +1 -1
- package/src/components/ConfigHintIcon.vue +9 -9
- package/src/components/FetchNodeConfigPanel.vue +65 -7
- package/src/components/FetchUrlAutocomplete.vue +1 -0
- 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 +135 -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 +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arronqzy/vue-blueprint",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Vue 3 blueprint editor for Abuilder (Vue Flow + Ant Design Vue)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"ant-design-vue": "^4.2.6",
|
|
20
20
|
"dayjs": "^1.11.13",
|
|
21
21
|
"vue": "^3.5.13",
|
|
22
|
-
"@arronqzy/blueprint-dsl": "1.0.
|
|
22
|
+
"@arronqzy/blueprint-dsl": "1.0.7",
|
|
23
23
|
"@arronqzy/vue-rx-store": "0.1.2",
|
|
24
|
-
"@arronqzy/i18n": "0.1.
|
|
24
|
+
"@arronqzy/i18n": "0.1.6"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"vue": "^3.4.0"
|
|
@@ -5,8 +5,10 @@ import { Checkbox, Input, Select } from "ant-design-vue";
|
|
|
5
5
|
import {
|
|
6
6
|
LIFECYCLE_NODE_TYPE,
|
|
7
7
|
PAGE_LIFECYCLE_PHASES,
|
|
8
|
+
VIEW_EVENT_TYPES,
|
|
8
9
|
type ExecutionTraceEntry,
|
|
9
10
|
type PageLifecyclePhase,
|
|
11
|
+
type ViewEventType,
|
|
10
12
|
} from "@arronqzy/blueprint-dsl";
|
|
11
13
|
|
|
12
14
|
import FetchNodeConfigPanel from "./components/FetchNodeConfigPanel.vue";
|
|
@@ -17,9 +19,12 @@ import ViewElementMultiSelect from "./components/ViewElementMultiSelect.vue";
|
|
|
17
19
|
import ConfigHintIcon from "./components/ConfigHintIcon.vue";
|
|
18
20
|
import {
|
|
19
21
|
getLifecyclePhaseLabel,
|
|
22
|
+
getViewEventTypeLabel,
|
|
23
|
+
inspectEventNodeInputs,
|
|
20
24
|
patchNodeConfigSource,
|
|
21
25
|
pruneViewElementIds,
|
|
22
26
|
resolveBlueprintConfigSource,
|
|
27
|
+
resolveNodeEventConfig,
|
|
23
28
|
resolveViewElementIds,
|
|
24
29
|
type BlueprintConfigSource,
|
|
25
30
|
type BlueprintGraphEdge,
|
|
@@ -66,6 +71,7 @@ export type BlueprintNodeConfigSidebarProps = {
|
|
|
66
71
|
| "jsonConfig"
|
|
67
72
|
| "logicConfig"
|
|
68
73
|
| "clockConfig"
|
|
74
|
+
| "eventConfig"
|
|
69
75
|
>
|
|
70
76
|
>
|
|
71
77
|
) => void;
|
|
@@ -82,6 +88,7 @@ const props = withDefaults(defineProps<BlueprintNodeConfigSidebarProps>(), {
|
|
|
82
88
|
|
|
83
89
|
const configSource = computed(() => resolveBlueprintConfigSource(props.node));
|
|
84
90
|
const linkedViewElementIds = computed(() => resolveViewElementIds(props.node));
|
|
91
|
+
const selectedEventTypes = computed(() => resolveNodeEventConfig(props.node).eventTypes);
|
|
85
92
|
const existingViewElementIdSet = computed(
|
|
86
93
|
() => new Set(props.viewElementOptions.map((opt) => opt.id))
|
|
87
94
|
);
|
|
@@ -97,12 +104,17 @@ const ROLE_LABEL_KEYS: Record<BlueprintNodeRole, string> = {
|
|
|
97
104
|
json: "blueprint.config.roleJson",
|
|
98
105
|
logic: "blueprint.config.roleLogic",
|
|
99
106
|
clock: "blueprint.config.roleClock",
|
|
107
|
+
event: "blueprint.config.roleEvent",
|
|
100
108
|
};
|
|
101
109
|
|
|
102
110
|
const roleLabel = computed(() =>
|
|
103
111
|
t(ROLE_LABEL_KEYS[props.node.role] ?? "blueprint.config.roleLogic")
|
|
104
112
|
);
|
|
105
113
|
|
|
114
|
+
const eventInputs = computed(() =>
|
|
115
|
+
inspectEventNodeInputs(props.node.id, props.graphNodes, props.graphEdges)
|
|
116
|
+
);
|
|
117
|
+
|
|
106
118
|
watch(
|
|
107
119
|
[
|
|
108
120
|
configSource,
|
|
@@ -113,7 +125,7 @@ watch(
|
|
|
113
125
|
() => props.viewElementOptions.length,
|
|
114
126
|
],
|
|
115
127
|
() => {
|
|
116
|
-
if (configSource.value !== "view") return;
|
|
128
|
+
if (configSource.value !== "view" && configSource.value !== "event") return;
|
|
117
129
|
if (props.viewElementOptions.length === 0) return;
|
|
118
130
|
const linked = resolveViewElementIds(props.node);
|
|
119
131
|
if (linked.length === 0) return;
|
|
@@ -122,7 +134,7 @@ watch(
|
|
|
122
134
|
props.onUpdateNode(props.node.id, {
|
|
123
135
|
viewElementIds: pruned.length > 0 ? pruned : undefined,
|
|
124
136
|
viewElementId: undefined,
|
|
125
|
-
configSource:
|
|
137
|
+
configSource: configSource.value,
|
|
126
138
|
});
|
|
127
139
|
}
|
|
128
140
|
);
|
|
@@ -131,7 +143,7 @@ function handleConfigSourceChange(value: string) {
|
|
|
131
143
|
const nextSource = value as BlueprintConfigSource;
|
|
132
144
|
props.onUpdateNode(props.node.id, {
|
|
133
145
|
...patchNodeConfigSource(props.node, nextSource),
|
|
134
|
-
...(nextSource === "view"
|
|
146
|
+
...(nextSource === "view" || nextSource === "event"
|
|
135
147
|
? {
|
|
136
148
|
viewElementIds: linkedViewElementIds.value,
|
|
137
149
|
viewElementId: undefined,
|
|
@@ -142,6 +154,19 @@ function handleConfigSourceChange(value: string) {
|
|
|
142
154
|
}),
|
|
143
155
|
});
|
|
144
156
|
}
|
|
157
|
+
|
|
158
|
+
function toggleEventType(eventType: ViewEventType, checked: boolean) {
|
|
159
|
+
const current = selectedEventTypes.value;
|
|
160
|
+
const nextTypes: ViewEventType[] = checked
|
|
161
|
+
? [...new Set([...current, eventType])]
|
|
162
|
+
: current.filter((type) => type !== eventType);
|
|
163
|
+
props.onUpdateNode(props.node.id, {
|
|
164
|
+
eventConfig: {
|
|
165
|
+
eventTypes: nextTypes.length > 0 ? nextTypes : ["click"],
|
|
166
|
+
},
|
|
167
|
+
configSource: "event",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
145
170
|
</script>
|
|
146
171
|
|
|
147
172
|
<template>
|
|
@@ -177,15 +202,16 @@ function handleConfigSourceChange(value: string) {
|
|
|
177
202
|
<Select.Option value="fetch">{{ t("blueprint.config.configFetch") }}</Select.Option>
|
|
178
203
|
<Select.Option value="json">{{ t("blueprint.config.configJson") }}</Select.Option>
|
|
179
204
|
<Select.Option value="clock">{{ t("blueprint.config.configClock") }}</Select.Option>
|
|
205
|
+
<Select.Option value="event">{{ t("blueprint.config.configEvent") }}</Select.Option>
|
|
180
206
|
<Select.Option value="view">{{ t("blueprint.config.configView") }}</Select.Option>
|
|
181
207
|
</Select>
|
|
182
208
|
</label>
|
|
183
209
|
|
|
184
|
-
<div v-if="configSource === 'view'" class="block space-y-1">
|
|
210
|
+
<div v-if="configSource === 'view' || configSource === 'event'" class="block space-y-1">
|
|
185
211
|
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
|
186
212
|
{{ t("blueprint.config.linkedViewNodes") }}
|
|
187
213
|
<ConfigHintIcon :label="t('blueprint.config.linkedViewNodes')">
|
|
188
|
-
{{ t("blueprint.config.viewMultiHint") }}
|
|
214
|
+
{{ configSource === 'event' ? t("blueprint.config.eventViewHint") : t("blueprint.config.viewMultiHint") }}
|
|
189
215
|
</ConfigHintIcon>
|
|
190
216
|
</span>
|
|
191
217
|
<ViewElementMultiSelect
|
|
@@ -197,7 +223,7 @@ function handleConfigSourceChange(value: string) {
|
|
|
197
223
|
onUpdateNode(node.id, {
|
|
198
224
|
viewElementIds: next,
|
|
199
225
|
viewElementId: undefined,
|
|
200
|
-
configSource
|
|
226
|
+
configSource,
|
|
201
227
|
})
|
|
202
228
|
"
|
|
203
229
|
/>
|
|
@@ -263,6 +289,42 @@ function handleConfigSourceChange(value: string) {
|
|
|
263
289
|
</label>
|
|
264
290
|
</div>
|
|
265
291
|
|
|
292
|
+
<div
|
|
293
|
+
v-if="configSource === 'event'"
|
|
294
|
+
class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
|
|
295
|
+
>
|
|
296
|
+
<div class="flex items-center gap-1.5">
|
|
297
|
+
<div class="font-medium text-foreground">{{ t("blueprint.config.eventListen") }}</div>
|
|
298
|
+
<ConfigHintIcon :label="t('blueprint.config.eventListen')">
|
|
299
|
+
<p>{{ t("blueprint.config.eventNoInput") }}</p>
|
|
300
|
+
<p>{{ t("blueprint.config.eventHint") }}</p>
|
|
301
|
+
</ConfigHintIcon>
|
|
302
|
+
</div>
|
|
303
|
+
<p
|
|
304
|
+
v-if="eventInputs.nonLifecycleCount > 0"
|
|
305
|
+
class="rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[11px] leading-relaxed text-destructive"
|
|
306
|
+
>
|
|
307
|
+
{{ t("blueprint.config.eventInputNotLifecycle") }}
|
|
308
|
+
</p>
|
|
309
|
+
<p
|
|
310
|
+
v-else-if="eventInputs.lifecycleCount === 0"
|
|
311
|
+
class="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] leading-relaxed text-amber-700"
|
|
312
|
+
>
|
|
313
|
+
{{ t("blueprint.config.eventInputMissing") }}
|
|
314
|
+
</p>
|
|
315
|
+
<label
|
|
316
|
+
v-for="eventType in VIEW_EVENT_TYPES"
|
|
317
|
+
:key="eventType"
|
|
318
|
+
class="flex items-center gap-2"
|
|
319
|
+
>
|
|
320
|
+
<Checkbox
|
|
321
|
+
:checked="selectedEventTypes.includes(eventType)"
|
|
322
|
+
@update:checked="(v) => toggleEventType(eventType, Boolean(v))"
|
|
323
|
+
/>
|
|
324
|
+
<span>{{ getViewEventTypeLabel(t, eventType) }}</span>
|
|
325
|
+
</label>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
266
328
|
<div
|
|
267
329
|
v-if="configSource === 'lifecycle'"
|
|
268
330
|
class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
|
|
@@ -6,6 +6,7 @@ import ClockFlowNode from "./nodes/ClockFlowNode.vue";
|
|
|
6
6
|
import FetchFlowNode from "./nodes/FetchFlowNode.vue";
|
|
7
7
|
import JsonFlowNode from "./nodes/JsonFlowNode.vue";
|
|
8
8
|
import LifecycleFlowNode from "./nodes/LifecycleFlowNode.vue";
|
|
9
|
+
import EventFlowNode from "./nodes/EventFlowNode.vue";
|
|
9
10
|
import LogicFlowNode from "./nodes/LogicFlowNode.vue";
|
|
10
11
|
|
|
11
12
|
/** 稳定引用,避免 Vue Flow 因 nodeTypes 变化反复卸载节点 */
|
|
@@ -17,4 +18,5 @@ export const blueprintNodeTypes: Record<string, Component> = {
|
|
|
17
18
|
json: markRaw(JsonFlowNode),
|
|
18
19
|
logic: markRaw(LogicFlowNode),
|
|
19
20
|
lifecycle: markRaw(LifecycleFlowNode),
|
|
21
|
+
event: markRaw(EventFlowNode),
|
|
20
22
|
};
|
|
@@ -105,6 +105,7 @@ const {
|
|
|
105
105
|
onEdgesChange,
|
|
106
106
|
onNodeDragStop,
|
|
107
107
|
onConnect,
|
|
108
|
+
onConnectEnd,
|
|
108
109
|
isValidConnection,
|
|
109
110
|
} = flowState;
|
|
110
111
|
|
|
@@ -201,6 +202,7 @@ function handleDeleteEdge(edgeId: string) {
|
|
|
201
202
|
@nodes-change="onNodesChange"
|
|
202
203
|
@edges-change="onEdgesChange"
|
|
203
204
|
@connect="onConnect"
|
|
205
|
+
@connect-end="onConnectEnd"
|
|
204
206
|
:is-valid-connection="isValidConnection"
|
|
205
207
|
@node-drag-stop="onNodeDragStop"
|
|
206
208
|
@pane-context-menu="onPaneContextMenu"
|
|
@@ -14,7 +14,7 @@ export type BlueprintContextMenuState =
|
|
|
14
14
|
clientX: number;
|
|
15
15
|
clientY: number;
|
|
16
16
|
nodeId: string;
|
|
17
|
-
role: "blueprint" | "logic" | "and" | "lifecycle" | "fetch" | "json" | "clock";
|
|
17
|
+
role: "blueprint" | "logic" | "and" | "lifecycle" | "event" | "fetch" | "json" | "clock";
|
|
18
18
|
}
|
|
19
19
|
| {
|
|
20
20
|
kind: "edge";
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
2
3
|
import { useI18n } from "@arronqzy/i18n/vue";
|
|
3
4
|
import { Tooltip } from "ant-design-vue";
|
|
4
5
|
|
|
5
6
|
const { t } = useI18n();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
);
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
label?: string;
|
|
9
|
+
contentClass?: string;
|
|
10
|
+
buttonClass?: string;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const resolvedLabel = computed(() => props.label || t("common.hint"));
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<template>
|
|
@@ -30,7 +30,7 @@ withDefaults(
|
|
|
30
30
|
buttonClass ??
|
|
31
31
|
'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] leading-none text-muted-foreground hover:bg-accent/50'
|
|
32
32
|
"
|
|
33
|
-
:aria-label="t('common.hintAria', { label })"
|
|
33
|
+
:aria-label="t('common.hintAria', { label: resolvedLabel })"
|
|
34
34
|
>
|
|
35
35
|
?
|
|
36
36
|
</button>
|
|
@@ -19,10 +19,13 @@ import {
|
|
|
19
19
|
FETCH_REDIRECTS,
|
|
20
20
|
FETCH_RESPONSE_TYPES,
|
|
21
21
|
fetchConfigHasScopeTemplate,
|
|
22
|
+
getFetchBodyValidationError,
|
|
23
|
+
getFetchHeadersValidationError,
|
|
22
24
|
latestTraceOutputsByNode,
|
|
23
25
|
parseFetchHeadersJson,
|
|
24
26
|
resolveFetchIncomingScope,
|
|
25
27
|
resolveFetchRequestUrl,
|
|
28
|
+
resolveFetchScopeAutocompleteRoot,
|
|
26
29
|
} from "@arronqzy/blueprint-dsl";
|
|
27
30
|
|
|
28
31
|
import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
} from "../fetch-config-task-store";
|
|
38
41
|
import ConfigHintIcon from "./ConfigHintIcon.vue";
|
|
39
42
|
import FetchUrlAutocomplete from "./FetchUrlAutocomplete.vue";
|
|
43
|
+
import ScopeTemplateAutocompleteHost from "./ScopeTemplateAutocompleteHost.vue";
|
|
40
44
|
|
|
41
45
|
const { t } = useI18n();
|
|
42
46
|
|
|
@@ -102,6 +106,16 @@ const resolvedHeadersPreview = computed(() => {
|
|
|
102
106
|
return "";
|
|
103
107
|
}
|
|
104
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
|
+
);
|
|
105
119
|
const incomingHasPendingValue = computed(() => {
|
|
106
120
|
const scope = incomingScope.value;
|
|
107
121
|
if (!scope || typeof scope !== "object") return false;
|
|
@@ -109,6 +123,10 @@ const incomingHasPendingValue = computed(() => {
|
|
|
109
123
|
"kind" in scope ? [scope] : Object.values(scope as Record<string, { kind?: string }>);
|
|
110
124
|
return entries.some((entry) => entry && entry.kind === "pending");
|
|
111
125
|
});
|
|
126
|
+
const autocompleteScope = computed(() =>
|
|
127
|
+
resolveFetchScopeAutocompleteRoot(incomingScope.value)
|
|
128
|
+
);
|
|
129
|
+
const formRef = ref<HTMLElement | null>(null);
|
|
112
130
|
const endpoints = computed(() => fetchConfig.value.swaggerEndpoints ?? []);
|
|
113
131
|
const hasSwaggerEndpoints = computed(() => endpoints.value.length > 0);
|
|
114
132
|
const urlInputMode = computed(
|
|
@@ -177,6 +195,23 @@ function handleSendFetchDebug() {
|
|
|
177
195
|
: t("blueprint.config.fillRequestUrlFirst");
|
|
178
196
|
return;
|
|
179
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
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
180
215
|
|
|
181
216
|
fetchValidationError.value = null;
|
|
182
217
|
startFetchDebugTask({
|
|
@@ -215,7 +250,7 @@ const debugOk = computed(() => {
|
|
|
215
250
|
return result ? result.status >= 200 && result.status < 300 : false;
|
|
216
251
|
});
|
|
217
252
|
|
|
218
|
-
function
|
|
253
|
+
function handleHeadersInput(event: Event) {
|
|
219
254
|
const headersJson = (event.target as HTMLTextAreaElement).value;
|
|
220
255
|
const parsed = parseFetchHeadersJson(headersJson);
|
|
221
256
|
props.onUpdateNode(
|
|
@@ -229,7 +264,11 @@ function handleHeadersBlur(event: Event) {
|
|
|
229
264
|
</script>
|
|
230
265
|
|
|
231
266
|
<template>
|
|
232
|
-
<div
|
|
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" />
|
|
233
272
|
<div class="flex items-center gap-1.5">
|
|
234
273
|
<div class="font-medium text-foreground">{{ t("blueprint.config.fetchTitle") }}</div>
|
|
235
274
|
<ConfigHintIcon :label="t('blueprint.config.fetchTitle')">
|
|
@@ -261,6 +300,7 @@ function handleHeadersBlur(event: Event) {
|
|
|
261
300
|
:value="fetchConfig.swaggerDocsUrl ?? ''"
|
|
262
301
|
:disabled="loadingSwagger"
|
|
263
302
|
placeholder="https://example.com/v3/api-docs"
|
|
303
|
+
data-scope-autocomplete="off"
|
|
264
304
|
class="flex-1 font-mono text-[11px]"
|
|
265
305
|
@update:value="
|
|
266
306
|
(v) =>
|
|
@@ -516,13 +556,16 @@ function handleHeadersBlur(event: Event) {
|
|
|
516
556
|
</ConfigHintIcon>
|
|
517
557
|
</span>
|
|
518
558
|
<textarea
|
|
519
|
-
:key="`${node.id}-headers-${headersDraft}`"
|
|
520
559
|
:value="headersDraft"
|
|
521
560
|
rows="4"
|
|
522
|
-
class="w-full rounded-md border
|
|
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'"
|
|
523
563
|
placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
|
|
524
|
-
@
|
|
564
|
+
@input="handleHeadersInput"
|
|
525
565
|
/>
|
|
566
|
+
<p v-if="headersJsonError" class="text-[11px] text-destructive">
|
|
567
|
+
{{ t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError }) }}
|
|
568
|
+
</p>
|
|
526
569
|
<p
|
|
527
570
|
v-if="usesScopeTemplate && resolvedHeadersPreview"
|
|
528
571
|
class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
|
|
@@ -532,11 +575,17 @@ function handleHeadersBlur(event: Event) {
|
|
|
532
575
|
</label>
|
|
533
576
|
|
|
534
577
|
<label class="block space-y-1">
|
|
535
|
-
<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>
|
|
536
584
|
<textarea
|
|
537
585
|
:value="fetchConfig.body ?? ''"
|
|
538
586
|
rows="4"
|
|
539
|
-
class="w-full rounded-md border
|
|
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'"
|
|
540
589
|
placeholder='{"id":"{scope?.value?.id}"}'
|
|
541
590
|
@input="
|
|
542
591
|
(e) =>
|
|
@@ -546,6 +595,15 @@ function handleHeadersBlur(event: Event) {
|
|
|
546
595
|
)
|
|
547
596
|
"
|
|
548
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>
|
|
549
607
|
</label>
|
|
550
608
|
|
|
551
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'"
|
|
@@ -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>
|