@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.
Files changed (36) hide show
  1. package/dist/components/Card.vue.d.ts +1 -1
  2. package/dist/components/Card.vue.d.ts.map +1 -1
  3. package/dist/components/calendar/CalendarPopover.vue.d.ts +6 -6
  4. package/dist/components/dataTable/DataTable.vue.d.ts +3 -1
  5. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  6. package/dist/components/form/inputs/ArrayInput.vue.d.ts.map +1 -1
  7. package/dist/form-flow/FlowDesigner.vue.d.ts +13 -0
  8. package/dist/form-flow/FlowDesigner.vue.d.ts.map +1 -0
  9. package/dist/form-flow/FlowEdge.vue.d.ts +21 -0
  10. package/dist/form-flow/FlowEdge.vue.d.ts.map +1 -0
  11. package/dist/form-flow/FlowNode.vue.d.ts +30 -0
  12. package/dist/form-flow/FlowNode.vue.d.ts.map +1 -0
  13. package/dist/form-flow/FormFlowPlayer.vue.d.ts +62 -0
  14. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -0
  15. package/dist/form-flow/structure.d.ts +66 -0
  16. package/dist/form-flow/structure.d.ts.map +1 -0
  17. package/dist/index.cjs +104 -104
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.mjs +10076 -9391
  21. package/dist/style.css +1 -1
  22. package/dist/types/TableSchema.d.ts +7 -1
  23. package/dist/types/TableSchema.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/Card.vue +1 -1
  26. package/src/components/dataTable/DataTable.vue +13 -0
  27. package/src/components/form/inputs/ArrayInput.vue +11 -2
  28. package/src/form-flow/FlowDesigner.vue +462 -0
  29. package/src/form-flow/FlowEdge.vue +117 -0
  30. package/src/form-flow/FlowNode.vue +188 -0
  31. package/src/form-flow/FormFlowPlayer.vue +243 -0
  32. package/src/form-flow/structure.ts +123 -0
  33. package/src/index.ts +6 -0
  34. package/src/styles/layout.css +4 -0
  35. package/src/styles/mobilLayout.css +4 -0
  36. 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;AACnD,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;CAC9B;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"}
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.178",
4
+ "version": "1.15.182",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -27,7 +27,7 @@ const props = defineProps<{
27
27
  thin?: boolean
28
28
  outline?: boolean
29
29
  h100?: boolean
30
- to?: string
30
+ to?: import('vue-router').RouteLocationRaw
31
31
  href?: string
32
32
  overflowX?: boolean
33
33
  overflowY?: boolean
@@ -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
- return item.trim()
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
- return val.trim()
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>