@bagelink/vue 1.15.178 → 1.15.182
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/dist/components/Card.vue.d.ts +1 -1
- package/dist/components/Card.vue.d.ts.map +1 -1
- package/dist/components/calendar/CalendarPopover.vue.d.ts +6 -6
- package/dist/components/dataTable/DataTable.vue.d.ts +3 -1
- package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/ArrayInput.vue.d.ts.map +1 -1
- package/dist/form-flow/FlowDesigner.vue.d.ts +13 -0
- package/dist/form-flow/FlowDesigner.vue.d.ts.map +1 -0
- package/dist/form-flow/FlowEdge.vue.d.ts +21 -0
- package/dist/form-flow/FlowEdge.vue.d.ts.map +1 -0
- package/dist/form-flow/FlowNode.vue.d.ts +30 -0
- package/dist/form-flow/FlowNode.vue.d.ts.map +1 -0
- package/dist/form-flow/FormFlowPlayer.vue.d.ts +62 -0
- package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -0
- package/dist/form-flow/structure.d.ts +66 -0
- package/dist/form-flow/structure.d.ts.map +1 -0
- package/dist/index.cjs +104 -104
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +10076 -9391
- package/dist/style.css +1 -1
- package/dist/types/TableSchema.d.ts +7 -1
- package/dist/types/TableSchema.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Card.vue +1 -1
- package/src/components/dataTable/DataTable.vue +13 -0
- package/src/components/form/inputs/ArrayInput.vue +11 -2
- package/src/form-flow/FlowDesigner.vue +462 -0
- package/src/form-flow/FlowEdge.vue +117 -0
- package/src/form-flow/FlowNode.vue +188 -0
- package/src/form-flow/FormFlowPlayer.vue +243 -0
- package/src/form-flow/structure.ts +123 -0
- package/src/index.ts +6 -0
- package/src/styles/layout.css +4 -0
- package/src/styles/mobilLayout.css +4 -0
- package/src/types/TableSchema.ts +7 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BglFormSchemaT } from '..';
|
|
1
|
+
import { BglFormSchemaT, IconType } from '..';
|
|
2
2
|
import { MaybeRefOrGetter, ComputedRef } from 'vue';
|
|
3
3
|
export type SortDirectionsT = 'ASC' | 'DESC';
|
|
4
4
|
export type EmitOrderT = `${string} ${SortDirectionsT}`;
|
|
@@ -16,6 +16,12 @@ export interface TableSchemaProps<T extends {
|
|
|
16
16
|
handle?: string;
|
|
17
17
|
};
|
|
18
18
|
onLastItemVisible?: () => void;
|
|
19
|
+
/** Icon for the built-in empty state (default: 'inbox'). Pass 'none' to hide. */
|
|
20
|
+
emptyIcon?: IconType | 'none';
|
|
21
|
+
/** Title for the built-in empty state (default: 'No results'). */
|
|
22
|
+
emptyTitle?: string;
|
|
23
|
+
/** Description shown under the empty-state title. */
|
|
24
|
+
emptyDescription?: string;
|
|
19
25
|
}
|
|
20
26
|
export interface SortingOptions {
|
|
21
27
|
onSort: (field: string, direction: SortDirectionsT) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TableSchema.d.ts","sourceRoot":"","sources":["../../src/types/TableSchema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"TableSchema.d.ts","sourceRoot":"","sources":["../../src/types/TableSchema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,KAAK,CAAA;AAExD,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,MAAM,CAAA;AAC5C,MAAM,MAAM,UAAU,GAAG,GAAG,MAAM,IAAI,eAAe,EAAE,CAAA;AAEvD,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE;IAC1F,IAAI,EAAE,CAAC,EAAE,CAAA;IACT,MAAM,CAAC,EAAE,gBAAgB,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAA;IAC5C,OAAO,CAAC,EAAE,gBAAgB,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,GAAG;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACxC,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAA;IAC9B,iFAAiF;IACjF,SAAS,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAA;IAC7B,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,cAAc;IAC9B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,eAAe,KAAK,IAAI,CAAA;CAC3D;AAED,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,aAAa,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAClC,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,0BAA0B,CAAC,CAAC;IAC5C,IAAI,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAA;CAC9B;AAED,MAAM,WAAW,gBAAgB,CAAC,CAAC;IAClC,IAAI,EAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAA;IAC3B,MAAM,CAAC,EAAE,gBAAgB,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAA;IAC5C,OAAO,CAAC,EAAE,gBAAgB,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,eAAe,CAAA;IAC9B,aAAa,CAAC,EAAE,OAAO,CAAA;CACvB"}
|
package/package.json
CHANGED
package/src/components/Card.vue
CHANGED
|
@@ -4,6 +4,7 @@ import type { SetupContext } from 'vue'
|
|
|
4
4
|
import type { TableSchemaProps } from '../../types/TableSchema'
|
|
5
5
|
import type { DraggableEvent } from '../draggable/useDraggable'
|
|
6
6
|
import {
|
|
7
|
+
EmptyState,
|
|
7
8
|
Icon,
|
|
8
9
|
keyToLabel,
|
|
9
10
|
Loading
|
|
@@ -146,6 +147,9 @@ watch(
|
|
|
146
147
|
)
|
|
147
148
|
|
|
148
149
|
const showLoading = computed(() => loading.value)
|
|
150
|
+
|
|
151
|
+
// Show the empty state only once loading has settled and there are no rows.
|
|
152
|
+
const isEmpty = computed(() => !showLoading.value && computedData.value.length === 0)
|
|
149
153
|
</script>
|
|
150
154
|
|
|
151
155
|
<template>
|
|
@@ -198,6 +202,15 @@ const showLoading = computed(() => loading.value)
|
|
|
198
202
|
<tr v-if="props.onLastItemVisible !== undefined" ref="lastItemEl" style="height: 1px;" />
|
|
199
203
|
</tbody>
|
|
200
204
|
</table>
|
|
205
|
+
<!-- Built-in empty state (overridable via the #empty slot) -->
|
|
206
|
+
<slot v-if="isEmpty" name="empty">
|
|
207
|
+
<EmptyState
|
|
208
|
+
:icon="props.emptyIcon ?? 'inbox'"
|
|
209
|
+
:title="props.emptyTitle ?? 'No results'"
|
|
210
|
+
:description="props.emptyDescription"
|
|
211
|
+
compact
|
|
212
|
+
/>
|
|
213
|
+
</slot>
|
|
201
214
|
</div>
|
|
202
215
|
</div>
|
|
203
216
|
</template>
|
|
@@ -51,15 +51,24 @@ const itemLabelPrefix = computed(() => {
|
|
|
51
51
|
const IDENTIFIER_FIELDS = ['title', 'name', 'label', 'displayName', 'display_name', 'heading', 'caption', 'text']
|
|
52
52
|
const IMAGE_FIELDS = ['image', 'thumbnail', 'avatar', 'photo', 'picture', 'profilePic', 'profile_pic', 'icon', 'logo']
|
|
53
53
|
|
|
54
|
+
/** Rich-text fields often store HTML; collapse to plain text for the collapsed row label. */
|
|
55
|
+
function toPlainText(value: string): string {
|
|
56
|
+
if (!value.includes('<') && !value.includes('&')) return value.trim()
|
|
57
|
+
const doc = new DOMParser().parseFromString(value, 'text/html')
|
|
58
|
+
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
function getItemIdentifier(item: T): string | null {
|
|
55
62
|
if (typeof item === 'string' && item.trim().length > 0) {
|
|
56
|
-
|
|
63
|
+
const plain = toPlainText(item)
|
|
64
|
+
return plain.length > 0 ? plain : null
|
|
57
65
|
}
|
|
58
66
|
if (item !== null && item !== undefined && typeof item === 'object') {
|
|
59
67
|
for (const field of IDENTIFIER_FIELDS) {
|
|
60
68
|
const val = (item as Record<string, unknown>)[field]
|
|
61
69
|
if (typeof val === 'string' && val.trim().length > 0) {
|
|
62
|
-
|
|
70
|
+
const plain = toPlainText(val)
|
|
71
|
+
if (plain.length > 0) return plain
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
}
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FlowDesigner — visual builder for a decision-tree multi-step FormStructure.
|
|
4
|
+
*
|
|
5
|
+
* Left: an SVG canvas of draggable step nodes connected by conditional edges.
|
|
6
|
+
* Right: an inspector for the selected step (title/key + field editor) or the
|
|
7
|
+
* selected edge (SCIM condition + priority via reordering).
|
|
8
|
+
*
|
|
9
|
+
* v-model is the FormStructure (see ./structure.ts). Single-node structures are
|
|
10
|
+
* valid and behave as single-step forms at runtime; deleting down to one node
|
|
11
|
+
* (and removing edges) collapses back automatically — there is no mode flag.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FormStep, FormStructure, StepEdge } from './structure'
|
|
15
|
+
import { computed, ref } from 'vue'
|
|
16
|
+
import { fieldsToSchema, schemaToFields, type FormField } from './schema-fields'
|
|
17
|
+
import FlowEdge, { type Point } from './FlowEdge.vue'
|
|
18
|
+
import FlowNode from './FlowNode.vue'
|
|
19
|
+
import { edgesFrom, entryStepKey } from './structure'
|
|
20
|
+
|
|
21
|
+
export interface Props {
|
|
22
|
+
modelValue: FormStructure
|
|
23
|
+
}
|
|
24
|
+
const props = defineProps<Props>()
|
|
25
|
+
const emit = defineEmits<{ 'update:modelValue': [value: FormStructure] }>()
|
|
26
|
+
|
|
27
|
+
// --- selection state ---
|
|
28
|
+
const selectedStepKey = ref<string | null>(null)
|
|
29
|
+
const selectedEdgeIdx = ref<number | null>(null)
|
|
30
|
+
|
|
31
|
+
// --- helpers to emit an updated structure immutably ---
|
|
32
|
+
function patch(next: Partial<FormStructure>) {
|
|
33
|
+
emit('update:modelValue', { ...props.modelValue, ...next })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NODE_W = 180
|
|
37
|
+
const NODE_H = 72
|
|
38
|
+
|
|
39
|
+
const structure = computed(() => props.modelValue)
|
|
40
|
+
const startKey = computed(() => entryStepKey(structure.value))
|
|
41
|
+
|
|
42
|
+
function stepByKey(key: string): FormStep | undefined {
|
|
43
|
+
return structure.value.steps.find(s => s.key === key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function outAnchor(step: FormStep): Point {
|
|
47
|
+
return { x: (step.x ?? 0) + NODE_W, y: (step.y ?? 0) + NODE_H / 2 }
|
|
48
|
+
}
|
|
49
|
+
function inAnchor(step: FormStep): Point {
|
|
50
|
+
return { x: step.x ?? 0, y: (step.y ?? 0) + NODE_H / 2 }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolved geometry for each edge (skips edges whose endpoints vanished). */
|
|
54
|
+
const edgeGeoms = computed(() => {
|
|
55
|
+
return structure.value.transitions.map((e, idx) => {
|
|
56
|
+
const from = stepByKey(e.from)
|
|
57
|
+
const to = stepByKey(e.to)
|
|
58
|
+
if (!from || !to) return null
|
|
59
|
+
// priority = 1-based position among edges sharing the same source.
|
|
60
|
+
const siblings = edgesFrom(structure.value, e.from)
|
|
61
|
+
const priority = siblings.indexOf(e) + 1
|
|
62
|
+
return { idx, edge: e, from: outAnchor(from), to: inAnchor(to), priority }
|
|
63
|
+
}).filter((x): x is NonNullable<typeof x> => x != null)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// --- step CRUD ---
|
|
67
|
+
|
|
68
|
+
function uniqueKey(base = 'step'): string {
|
|
69
|
+
const keys = new Set(structure.value.steps.map(s => s.key))
|
|
70
|
+
let i = structure.value.steps.length + 1
|
|
71
|
+
let key = `${base}_${i}`
|
|
72
|
+
while (keys.has(key)) key = `${base}_${++i}`
|
|
73
|
+
return key
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addStep(): void {
|
|
77
|
+
const key = uniqueKey()
|
|
78
|
+
const nth = structure.value.steps.length
|
|
79
|
+
const step: FormStep = {
|
|
80
|
+
key,
|
|
81
|
+
title: `Step ${nth + 1}`,
|
|
82
|
+
schema: { type: 'object', properties: {} },
|
|
83
|
+
x: 60 + (nth % 4) * 220,
|
|
84
|
+
y: 60 + Math.floor(nth / 4) * 160,
|
|
85
|
+
}
|
|
86
|
+
patch({ steps: [...structure.value.steps, step] })
|
|
87
|
+
selectStep(key)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function removeStep(key: string): void {
|
|
91
|
+
patch({
|
|
92
|
+
steps: structure.value.steps.filter(s => s.key !== key),
|
|
93
|
+
transitions: structure.value.transitions.filter(e => e.from !== key && e.to !== key),
|
|
94
|
+
startId: structure.value.startId === key ? null : structure.value.startId,
|
|
95
|
+
})
|
|
96
|
+
if (selectedStepKey.value === key) selectedStepKey.value = null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function moveStep(key: string, pos: { x: number, y: number }): void {
|
|
100
|
+
patch({
|
|
101
|
+
steps: structure.value.steps.map(s => (s.key === key ? { ...s, x: pos.x, y: pos.y } : s)),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function selectStep(key: string): void {
|
|
106
|
+
selectedStepKey.value = key
|
|
107
|
+
selectedEdgeIdx.value = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setStart(key: string): void {
|
|
111
|
+
patch({ startId: key })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateStepMeta(key: string, meta: Partial<Pick<FormStep, 'key' | 'title'>>): void {
|
|
115
|
+
// Renaming a key rewires edges + startId to keep the graph consistent.
|
|
116
|
+
const oldKey = key
|
|
117
|
+
const newKey = meta.key?.trim()
|
|
118
|
+
patch({
|
|
119
|
+
steps: structure.value.steps.map(s => (s.key === oldKey
|
|
120
|
+
? { ...s, ...meta, key: newKey || s.key }
|
|
121
|
+
: s)),
|
|
122
|
+
transitions: newKey && newKey !== oldKey
|
|
123
|
+
? structure.value.transitions.map(e => ({
|
|
124
|
+
...e,
|
|
125
|
+
from: e.from === oldKey ? newKey : e.from,
|
|
126
|
+
to: e.to === oldKey ? newKey : e.to,
|
|
127
|
+
}))
|
|
128
|
+
: structure.value.transitions,
|
|
129
|
+
startId: newKey && structure.value.startId === oldKey ? newKey : structure.value.startId,
|
|
130
|
+
})
|
|
131
|
+
if (newKey && newKey !== oldKey) selectedStepKey.value = newKey
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- edge drawing ---
|
|
135
|
+
|
|
136
|
+
const pendingEdge = ref<{ from: string, cursor: Point } | null>(null)
|
|
137
|
+
|
|
138
|
+
function beginEdge(key: string, _ev: PointerEvent): void {
|
|
139
|
+
const from = stepByKey(key)
|
|
140
|
+
if (!from) return
|
|
141
|
+
pendingEdge.value = { from: key, cursor: outAnchor(from) }
|
|
142
|
+
window.addEventListener('pointermove', onEdgeDragMove)
|
|
143
|
+
window.addEventListener('pointerup', onEdgeDragUp, { once: true })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function onEdgeDragMove(ev: PointerEvent): void {
|
|
147
|
+
if (!pendingEdge.value) return
|
|
148
|
+
const rect = canvasRef.value?.getBoundingClientRect()
|
|
149
|
+
if (!rect) return
|
|
150
|
+
pendingEdge.value = {
|
|
151
|
+
...pendingEdge.value,
|
|
152
|
+
cursor: { x: ev.clientX - rect.left, y: ev.clientY - rect.top },
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onEdgeDragUp(): void {
|
|
157
|
+
window.removeEventListener('pointermove', onEdgeDragMove)
|
|
158
|
+
// If released over a node, completeEdge() already ran via pointerenter.
|
|
159
|
+
pendingEdge.value = null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function completeEdge(targetKey: string): void {
|
|
163
|
+
const pending = pendingEdge.value
|
|
164
|
+
if (!pending || pending.from === targetKey) return
|
|
165
|
+
const exists = structure.value.transitions.some(
|
|
166
|
+
e => e.from === pending.from && e.to === targetKey,
|
|
167
|
+
)
|
|
168
|
+
if (!exists) {
|
|
169
|
+
const edge: StepEdge = { from: pending.from, to: targetKey, condition: null }
|
|
170
|
+
patch({ transitions: [...structure.value.transitions, edge] })
|
|
171
|
+
selectEdge(structure.value.transitions.length)
|
|
172
|
+
}
|
|
173
|
+
pendingEdge.value = null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cancelEdge(): void {
|
|
177
|
+
pendingEdge.value = null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- edge editing ---
|
|
181
|
+
|
|
182
|
+
function selectEdge(idx: number): void {
|
|
183
|
+
selectedEdgeIdx.value = idx
|
|
184
|
+
selectedStepKey.value = null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function removeEdge(idx: number): void {
|
|
188
|
+
patch({ transitions: structure.value.transitions.filter((_, i) => i !== idx) })
|
|
189
|
+
if (selectedEdgeIdx.value === idx) selectedEdgeIdx.value = null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function updateEdgeCondition(idx: number, c: string): void {
|
|
193
|
+
patch({
|
|
194
|
+
transitions: structure.value.transitions.map((e, i) => (i === idx ? { ...e, condition: c || null } : e)),
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Reorder an edge among its same-source siblings to change match priority. */
|
|
199
|
+
function reprioritizeEdge(idx: number, dir: -1 | 1): void {
|
|
200
|
+
const list = [...structure.value.transitions]
|
|
201
|
+
const edge = list[idx]
|
|
202
|
+
if (!edge) return
|
|
203
|
+
const siblingIdxs = list
|
|
204
|
+
.map((e, i) => (e.from === edge.from ? i : -1))
|
|
205
|
+
.filter(i => i >= 0)
|
|
206
|
+
const pos = siblingIdxs.indexOf(idx)
|
|
207
|
+
const swapWith = siblingIdxs[pos + dir]
|
|
208
|
+
if (swapWith == null) return
|
|
209
|
+
;[list[idx], list[swapWith]] = [list[swapWith], list[idx]]
|
|
210
|
+
patch({ transitions: list })
|
|
211
|
+
selectedEdgeIdx.value = swapWith
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- inspector: selected step field editor ---
|
|
215
|
+
|
|
216
|
+
const selectedStep = computed(() => (selectedStepKey.value ? stepByKey(selectedStepKey.value) : undefined))
|
|
217
|
+
const selectedEdge = computed(() => (selectedEdgeIdx.value != null ? structure.value.transitions[selectedEdgeIdx.value] : undefined))
|
|
218
|
+
|
|
219
|
+
const stepFields = computed<FormField[]>({
|
|
220
|
+
get() {
|
|
221
|
+
return selectedStep.value ? schemaToFields(selectedStep.value.schema as any) : []
|
|
222
|
+
},
|
|
223
|
+
set(fields) {
|
|
224
|
+
if (!selectedStepKey.value) return
|
|
225
|
+
const schema = fieldsToSchema(fields)
|
|
226
|
+
patch({
|
|
227
|
+
steps: structure.value.steps.map(s => (s.key === selectedStepKey.value ? { ...s, schema } : s)),
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
function addField(): void {
|
|
233
|
+
const fields = [...stepFields.value]
|
|
234
|
+
const n = fields.length + 1
|
|
235
|
+
fields.push({ id: `f${Date.now()}`, key: `field_${n}`, type: 'text', label: `Field ${n}` })
|
|
236
|
+
stepFields.value = fields
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function updateField(i: number, patchField: Partial<FormField>): void {
|
|
240
|
+
const fields = stepFields.value.map((f, idx) => (idx === i ? { ...f, ...patchField } : f))
|
|
241
|
+
stepFields.value = fields
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function removeField(i: number): void {
|
|
245
|
+
stepFields.value = stepFields.value.filter((_, idx) => idx !== i)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const canvasRef = ref<HTMLElement | null>(null)
|
|
249
|
+
|
|
250
|
+
const FIELD_TYPES: FormField['type'][] = [
|
|
251
|
+
'text', 'textarea', 'number', 'email', 'phone', 'url',
|
|
252
|
+
'select', 'radio', 'checkbox', 'toggle', 'date', 'file',
|
|
253
|
+
]
|
|
254
|
+
</script>
|
|
255
|
+
|
|
256
|
+
<template>
|
|
257
|
+
<div class="flow-designer">
|
|
258
|
+
<!-- Canvas -->
|
|
259
|
+
<div
|
|
260
|
+
ref="canvasRef"
|
|
261
|
+
class="fd-canvas"
|
|
262
|
+
@click="selectedStepKey = null; selectedEdgeIdx = null"
|
|
263
|
+
@pointerup="cancelEdge"
|
|
264
|
+
>
|
|
265
|
+
<div class="fd-toolbar">
|
|
266
|
+
<button type="button" class="fd-add" @click.stop="addStep">+ Add step</button>
|
|
267
|
+
<span class="fd-hint">Drag the blue handle between steps to branch</span>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<svg class="fd-edges" @click="selectedEdgeIdx = null">
|
|
271
|
+
<defs>
|
|
272
|
+
<marker
|
|
273
|
+
id="ffp-arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
|
274
|
+
markerWidth="7" markerHeight="7" orient="auto-start-reverse"
|
|
275
|
+
>
|
|
276
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8" />
|
|
277
|
+
</marker>
|
|
278
|
+
</defs>
|
|
279
|
+
|
|
280
|
+
<FlowEdge
|
|
281
|
+
v-for="g in edgeGeoms"
|
|
282
|
+
:key="g.idx"
|
|
283
|
+
:from="g.from"
|
|
284
|
+
:to="g.to"
|
|
285
|
+
:condition="g.edge.condition"
|
|
286
|
+
:priority="g.priority"
|
|
287
|
+
:selected="selectedEdgeIdx === g.idx"
|
|
288
|
+
@select="selectEdge(g.idx)"
|
|
289
|
+
@remove="removeEdge(g.idx)"
|
|
290
|
+
/>
|
|
291
|
+
|
|
292
|
+
<!-- Edge being drawn -->
|
|
293
|
+
<path
|
|
294
|
+
v-if="pendingEdge"
|
|
295
|
+
class="fd-pending"
|
|
296
|
+
:d="`M ${pendingEdge.cursor.x} ${pendingEdge.cursor.y} l 0 0`"
|
|
297
|
+
/>
|
|
298
|
+
</svg>
|
|
299
|
+
|
|
300
|
+
<FlowNode
|
|
301
|
+
v-for="s in structure.steps"
|
|
302
|
+
:key="s.key"
|
|
303
|
+
:step="s"
|
|
304
|
+
:selected="selectedStepKey === s.key"
|
|
305
|
+
:is-start="startKey === s.key"
|
|
306
|
+
@move="moveStep(s.key, $event)"
|
|
307
|
+
@select="selectStep(s.key)"
|
|
308
|
+
@set-start="setStart(s.key)"
|
|
309
|
+
@remove="removeStep(s.key)"
|
|
310
|
+
@start-edge="beginEdge(s.key, $event)"
|
|
311
|
+
@end-edge="completeEdge(s.key)"
|
|
312
|
+
/>
|
|
313
|
+
|
|
314
|
+
<div v-if="structure.steps.length === 0" class="fd-empty">
|
|
315
|
+
<p>No steps yet.</p>
|
|
316
|
+
<button type="button" class="fd-add" @click.stop="addStep">+ Add your first step</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<!-- Inspector -->
|
|
321
|
+
<aside class="fd-inspector">
|
|
322
|
+
<!-- Step inspector -->
|
|
323
|
+
<template v-if="selectedStep">
|
|
324
|
+
<h3 class="fd-insp-title">Step</h3>
|
|
325
|
+
<label class="fd-label">Title
|
|
326
|
+
<input
|
|
327
|
+
class="fd-input"
|
|
328
|
+
:value="selectedStep.title"
|
|
329
|
+
@input="updateStepMeta(selectedStep.key, { title: ($event.target as HTMLInputElement).value })"
|
|
330
|
+
>
|
|
331
|
+
</label>
|
|
332
|
+
<label class="fd-label">Key
|
|
333
|
+
<input
|
|
334
|
+
class="fd-input fd-mono"
|
|
335
|
+
:value="selectedStep.key"
|
|
336
|
+
@change="updateStepMeta(selectedStep.key, { key: ($event.target as HTMLInputElement).value })"
|
|
337
|
+
>
|
|
338
|
+
</label>
|
|
339
|
+
|
|
340
|
+
<div class="fd-fields-head">
|
|
341
|
+
<span>Fields</span>
|
|
342
|
+
<button type="button" class="fd-mini" @click="addField">+ Field</button>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div v-for="(f, i) in stepFields" :key="f.id" class="fd-field-row">
|
|
346
|
+
<input
|
|
347
|
+
class="fd-input fd-mono fd-field-key"
|
|
348
|
+
:value="f.key"
|
|
349
|
+
placeholder="key"
|
|
350
|
+
@input="updateField(i, { key: ($event.target as HTMLInputElement).value })"
|
|
351
|
+
>
|
|
352
|
+
<select
|
|
353
|
+
class="fd-input fd-field-type"
|
|
354
|
+
:value="f.type"
|
|
355
|
+
@change="updateField(i, { type: ($event.target as HTMLSelectElement).value as any })"
|
|
356
|
+
>
|
|
357
|
+
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
|
|
358
|
+
</select>
|
|
359
|
+
<label class="fd-req" title="Required">
|
|
360
|
+
<input
|
|
361
|
+
type="checkbox"
|
|
362
|
+
:checked="f.required"
|
|
363
|
+
@change="updateField(i, { required: ($event.target as HTMLInputElement).checked })"
|
|
364
|
+
>req
|
|
365
|
+
</label>
|
|
366
|
+
<button type="button" class="fd-del" @click="removeField(i)">×</button>
|
|
367
|
+
</div>
|
|
368
|
+
<p v-if="stepFields.length === 0" class="fd-muted">No fields on this step.</p>
|
|
369
|
+
</template>
|
|
370
|
+
|
|
371
|
+
<!-- Edge inspector -->
|
|
372
|
+
<template v-else-if="selectedEdge">
|
|
373
|
+
<h3 class="fd-insp-title">Transition</h3>
|
|
374
|
+
<p class="fd-muted fd-mono">{{ selectedEdge.from }} → {{ selectedEdge.to }}</p>
|
|
375
|
+
|
|
376
|
+
<label class="fd-label">Condition (SCIM)
|
|
377
|
+
<input
|
|
378
|
+
class="fd-input fd-mono"
|
|
379
|
+
:value="selectedEdge.condition ?? ''"
|
|
380
|
+
placeholder='e.g. plan eq "pro"'
|
|
381
|
+
@input="updateEdgeCondition(selectedEdgeIdx!, ($event.target as HTMLInputElement).value)"
|
|
382
|
+
>
|
|
383
|
+
</label>
|
|
384
|
+
<p class="fd-muted">
|
|
385
|
+
Empty = always taken (fallback). Edges are evaluated top-to-bottom;
|
|
386
|
+
first match wins.
|
|
387
|
+
</p>
|
|
388
|
+
|
|
389
|
+
<div class="fd-prio">
|
|
390
|
+
<button type="button" class="fd-mini" @click="reprioritizeEdge(selectedEdgeIdx!, -1)">▲ Higher priority</button>
|
|
391
|
+
<button type="button" class="fd-mini" @click="reprioritizeEdge(selectedEdgeIdx!, 1)">▼ Lower priority</button>
|
|
392
|
+
</div>
|
|
393
|
+
<button type="button" class="fd-del-btn" @click="removeEdge(selectedEdgeIdx!)">Delete transition</button>
|
|
394
|
+
</template>
|
|
395
|
+
|
|
396
|
+
<!-- Nothing selected -->
|
|
397
|
+
<template v-else>
|
|
398
|
+
<h3 class="fd-insp-title">Flow</h3>
|
|
399
|
+
<p class="fd-muted">
|
|
400
|
+
{{ structure.steps.length }} step(s), {{ structure.transitions.length }} transition(s).
|
|
401
|
+
</p>
|
|
402
|
+
<p class="fd-muted">Select a step or transition to edit it.</p>
|
|
403
|
+
</template>
|
|
404
|
+
</aside>
|
|
405
|
+
</div>
|
|
406
|
+
</template>
|
|
407
|
+
|
|
408
|
+
<style scoped>
|
|
409
|
+
.flow-designer { display: flex; height: 100%; min-height: 480px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
|
|
410
|
+
|
|
411
|
+
.fd-canvas {
|
|
412
|
+
position: relative;
|
|
413
|
+
flex: 1;
|
|
414
|
+
background:
|
|
415
|
+
linear-gradient(90deg, #f8fafc 1px, transparent 1px) 0 0 / 20px 20px,
|
|
416
|
+
linear-gradient(#f8fafc 1px, transparent 1px) 0 0 / 20px 20px,
|
|
417
|
+
#fff;
|
|
418
|
+
overflow: auto;
|
|
419
|
+
}
|
|
420
|
+
.fd-toolbar {
|
|
421
|
+
position: sticky; top: 0; left: 0; z-index: 3;
|
|
422
|
+
display: flex; align-items: center; gap: 12px;
|
|
423
|
+
padding: 8px 12px; background: rgba(255,255,255,0.9); border-bottom: 1px solid #f1f5f9;
|
|
424
|
+
}
|
|
425
|
+
.fd-hint { font-size: 11px; color: #94a3b8; }
|
|
426
|
+
.fd-edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
|
427
|
+
.fd-edges :deep(.flow-edge) { pointer-events: all; }
|
|
428
|
+
.fd-pending { stroke: var(--primary-color, #007bff); stroke-dasharray: 4 3; fill: none; }
|
|
429
|
+
|
|
430
|
+
.fd-empty {
|
|
431
|
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
432
|
+
align-items: center; justify-content: center; gap: 12px; color: #94a3b8;
|
|
433
|
+
}
|
|
434
|
+
.fd-add {
|
|
435
|
+
padding: 6px 12px; border: 1px solid var(--primary-color, #007bff);
|
|
436
|
+
background: var(--primary-color, #007bff); color: white;
|
|
437
|
+
border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.fd-inspector {
|
|
441
|
+
width: 280px; flex: none; padding: 16px; border-left: 1px solid #e2e8f0;
|
|
442
|
+
background: #fff; overflow-y: auto;
|
|
443
|
+
}
|
|
444
|
+
.fd-insp-title { margin: 0 0 12px; font-size: 14px; }
|
|
445
|
+
.fd-label { display: block; font-size: 12px; color: #475569; margin-bottom: 10px; }
|
|
446
|
+
.fd-input {
|
|
447
|
+
display: block; width: 100%; margin-top: 4px; padding: 6px 8px;
|
|
448
|
+
border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; box-sizing: border-box;
|
|
449
|
+
}
|
|
450
|
+
.fd-mono { font-family: ui-monospace, monospace; }
|
|
451
|
+
.fd-muted { font-size: 11px; color: #94a3b8; }
|
|
452
|
+
|
|
453
|
+
.fd-fields-head { display: flex; justify-content: space-between; align-items: center; margin: 12px 0 6px; font-size: 12px; font-weight: 600; }
|
|
454
|
+
.fd-field-row { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
|
|
455
|
+
.fd-field-key { flex: 1; }
|
|
456
|
+
.fd-field-type { flex: none; width: 84px; }
|
|
457
|
+
.fd-req { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; color: #64748b; }
|
|
458
|
+
.fd-mini { border: 1px solid #cbd5e1; background: #f8fafc; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer; }
|
|
459
|
+
.fd-del { border: none; background: transparent; color: #ef4444; cursor: pointer; font-size: 15px; line-height: 1; }
|
|
460
|
+
.fd-prio { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }
|
|
461
|
+
.fd-del-btn { width: 100%; padding: 6px; border: 1px solid #fecaca; background: #fef2f2; color: #ef4444; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
|
462
|
+
</style>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FlowEdge — an SVG connector between two nodes in the FlowDesigner, with an
|
|
4
|
+
* inline SCIM condition label. Rendered inside the designer's <svg> layer.
|
|
5
|
+
*
|
|
6
|
+
* Edges are directed (from -> to). The first edge (in declared order) whose
|
|
7
|
+
* condition passes wins at runtime, so an unconditional edge is the fallback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { computed } from 'vue'
|
|
11
|
+
|
|
12
|
+
export interface Point { x: number, y: number }
|
|
13
|
+
|
|
14
|
+
export interface Props {
|
|
15
|
+
from: Point
|
|
16
|
+
to: Point
|
|
17
|
+
condition?: string | null
|
|
18
|
+
/** Declared priority (1-based) shown as a small badge. */
|
|
19
|
+
priority?: number
|
|
20
|
+
selected?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = defineProps<Props>()
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
select: []
|
|
27
|
+
remove: []
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
// Cubic bezier with horizontal control handles for a clean left-to-right flow.
|
|
31
|
+
const path = computed(() => {
|
|
32
|
+
const { from, to } = props
|
|
33
|
+
const dx = Math.max(40, Math.abs(to.x - from.x) * 0.5)
|
|
34
|
+
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const mid = computed(() => ({
|
|
38
|
+
x: (props.from.x + props.to.x) / 2,
|
|
39
|
+
y: (props.from.y + props.to.y) / 2,
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const label = computed(() => {
|
|
43
|
+
const c = props.condition?.trim()
|
|
44
|
+
return c && c.length ? c : 'always'
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<g class="flow-edge" :class="{ selected }" @click.stop="emit('select')">
|
|
50
|
+
<!-- Wide invisible hit-area for easier selection. -->
|
|
51
|
+
<path :d="path" class="edge-hit" fill="none" stroke="transparent" stroke-width="14" />
|
|
52
|
+
<path
|
|
53
|
+
:d="path"
|
|
54
|
+
class="edge-line"
|
|
55
|
+
fill="none"
|
|
56
|
+
marker-end="url(#ffp-arrow)"
|
|
57
|
+
/>
|
|
58
|
+
<foreignObject :x="mid.x - 70" :y="mid.y - 14" width="140" height="28" class="edge-label-fo">
|
|
59
|
+
<div class="edge-label" :class="{ fallback: label === 'always' }" :title="label">
|
|
60
|
+
<span v-if="priority != null" class="edge-prio">{{ priority }}</span>
|
|
61
|
+
<span class="edge-cond">{{ label }}</span>
|
|
62
|
+
<button v-if="selected" type="button" class="edge-del" @click.stop="emit('remove')">×</button>
|
|
63
|
+
</div>
|
|
64
|
+
</foreignObject>
|
|
65
|
+
</g>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<style scoped>
|
|
69
|
+
.edge-line {
|
|
70
|
+
stroke: #94a3b8;
|
|
71
|
+
stroke-width: 2;
|
|
72
|
+
transition: stroke 0.15s;
|
|
73
|
+
}
|
|
74
|
+
.flow-edge:hover .edge-line,
|
|
75
|
+
.flow-edge.selected .edge-line {
|
|
76
|
+
stroke: var(--primary-color, #007bff);
|
|
77
|
+
}
|
|
78
|
+
.flow-edge { cursor: pointer; }
|
|
79
|
+
.edge-label-fo { overflow: visible; }
|
|
80
|
+
.edge-label {
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 4px;
|
|
84
|
+
max-width: 140px;
|
|
85
|
+
padding: 2px 6px;
|
|
86
|
+
font-size: 11px;
|
|
87
|
+
line-height: 1.4;
|
|
88
|
+
background: white;
|
|
89
|
+
border: 1px solid #cbd5e1;
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
color: #334155;
|
|
92
|
+
white-space: nowrap;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
}
|
|
95
|
+
.edge-label.fallback { color: #94a3b8; font-style: italic; }
|
|
96
|
+
.edge-cond { overflow: hidden; text-overflow: ellipsis; }
|
|
97
|
+
.edge-prio {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
width: 14px; height: 14px;
|
|
102
|
+
border-radius: 50%;
|
|
103
|
+
background: var(--primary-color, #007bff);
|
|
104
|
+
color: white;
|
|
105
|
+
font-size: 9px;
|
|
106
|
+
flex: none;
|
|
107
|
+
}
|
|
108
|
+
.edge-del {
|
|
109
|
+
border: none;
|
|
110
|
+
background: transparent;
|
|
111
|
+
color: #ef4444;
|
|
112
|
+
font-size: 14px;
|
|
113
|
+
line-height: 1;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
padding: 0 2px;
|
|
116
|
+
}
|
|
117
|
+
</style>
|