@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FlowNode — a draggable step card on the FlowDesigner canvas.
|
|
4
|
+
*
|
|
5
|
+
* Emits low-level pointer intents; all graph mutation lives in FlowDesigner:
|
|
6
|
+
* - `move` : node dragged to a new (x, y)
|
|
7
|
+
* - `select` : node clicked (opens the step's field editor)
|
|
8
|
+
* - `startEdge`: the out-handle was grabbed to draw a new edge
|
|
9
|
+
* - `endEdge` : pointer released over this node (edge target)
|
|
10
|
+
* - `remove` : delete this step
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FormStep } from './structure'
|
|
14
|
+
import { computed, ref } from 'vue'
|
|
15
|
+
|
|
16
|
+
export interface Props {
|
|
17
|
+
step: FormStep
|
|
18
|
+
selected?: boolean
|
|
19
|
+
isStart?: boolean
|
|
20
|
+
fieldCount?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = defineProps<Props>()
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
'move': [pos: { x: number, y: number }]
|
|
27
|
+
'select': []
|
|
28
|
+
'startEdge': [ev: PointerEvent]
|
|
29
|
+
'endEdge': []
|
|
30
|
+
'remove': []
|
|
31
|
+
'setStart': []
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const dragging = ref(false)
|
|
35
|
+
let startX = 0
|
|
36
|
+
let startY = 0
|
|
37
|
+
let originX = 0
|
|
38
|
+
let originY = 0
|
|
39
|
+
|
|
40
|
+
function onPointerDown(ev: PointerEvent) {
|
|
41
|
+
// Ignore drags that begin on the out-handle or action buttons.
|
|
42
|
+
if ((ev.target as HTMLElement).closest('.node-handle, .node-actions')) return
|
|
43
|
+
dragging.value = true
|
|
44
|
+
startX = ev.clientX
|
|
45
|
+
startY = ev.clientY
|
|
46
|
+
originX = props.step.x ?? 0
|
|
47
|
+
originY = props.step.y ?? 0
|
|
48
|
+
;(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function onPointerMove(ev: PointerEvent) {
|
|
52
|
+
if (!dragging.value) return
|
|
53
|
+
emit('move', {
|
|
54
|
+
x: originX + (ev.clientX - startX),
|
|
55
|
+
y: originY + (ev.clientY - startY),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onPointerUp(ev: PointerEvent) {
|
|
60
|
+
if (dragging.value) {
|
|
61
|
+
dragging.value = false
|
|
62
|
+
;(ev.currentTarget as HTMLElement).releasePointerCapture(ev.pointerId)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const title = computed(() => props.step.title?.trim() || props.step.key)
|
|
67
|
+
const fieldLabel = computed(() => {
|
|
68
|
+
const n = props.fieldCount ?? Object.keys(props.step.schema?.properties ?? {}).length
|
|
69
|
+
return `${n} field${n === 1 ? '' : 's'}`
|
|
70
|
+
})
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div
|
|
75
|
+
class="flow-node"
|
|
76
|
+
:class="{ selected, 'is-start': isStart }"
|
|
77
|
+
:style="{ left: `${step.x ?? 0}px`, top: `${step.y ?? 0}px` }"
|
|
78
|
+
@pointerdown="onPointerDown"
|
|
79
|
+
@pointermove="onPointerMove"
|
|
80
|
+
@pointerup="onPointerUp"
|
|
81
|
+
@click.stop="emit('select')"
|
|
82
|
+
@pointerenter="dragging || emit('endEdge')"
|
|
83
|
+
>
|
|
84
|
+
<div class="node-head">
|
|
85
|
+
<span v-if="isStart" class="node-start-badge" title="Start step">▶</span>
|
|
86
|
+
<span class="node-title" :title="title">{{ title }}</span>
|
|
87
|
+
<div class="node-actions">
|
|
88
|
+
<button
|
|
89
|
+
v-if="!isStart"
|
|
90
|
+
type="button"
|
|
91
|
+
class="node-btn"
|
|
92
|
+
title="Set as start step"
|
|
93
|
+
@click.stop="emit('setStart')"
|
|
94
|
+
>★</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
class="node-btn node-del"
|
|
98
|
+
title="Delete step"
|
|
99
|
+
@click.stop="emit('remove')"
|
|
100
|
+
>×</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="node-body">
|
|
105
|
+
<span class="node-key">{{ step.key }}</span>
|
|
106
|
+
<span class="node-fields">{{ fieldLabel }}</span>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- Out-handle: drag from here to another node to create an edge. -->
|
|
110
|
+
<div
|
|
111
|
+
class="node-handle"
|
|
112
|
+
title="Drag to connect"
|
|
113
|
+
@pointerdown.stop="emit('startEdge', $event)"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<style scoped>
|
|
119
|
+
.flow-node {
|
|
120
|
+
position: absolute;
|
|
121
|
+
width: 180px;
|
|
122
|
+
background: white;
|
|
123
|
+
border: 2px solid #e2e8f0;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
126
|
+
cursor: grab;
|
|
127
|
+
user-select: none;
|
|
128
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
129
|
+
}
|
|
130
|
+
.flow-node:active { cursor: grabbing; }
|
|
131
|
+
.flow-node.selected {
|
|
132
|
+
border-color: var(--primary-color, #007bff);
|
|
133
|
+
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.25);
|
|
134
|
+
}
|
|
135
|
+
.flow-node.is-start { border-color: var(--success-color, #28a745); }
|
|
136
|
+
|
|
137
|
+
.node-head {
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 6px;
|
|
141
|
+
padding: 8px 10px;
|
|
142
|
+
border-bottom: 1px solid #f1f5f9;
|
|
143
|
+
}
|
|
144
|
+
.node-title {
|
|
145
|
+
flex: 1;
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
font-size: 13px;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
text-overflow: ellipsis;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
}
|
|
152
|
+
.node-start-badge { color: var(--success-color, #28a745); font-size: 10px; }
|
|
153
|
+
.node-actions { display: flex; gap: 2px; }
|
|
154
|
+
.node-btn {
|
|
155
|
+
border: none;
|
|
156
|
+
background: transparent;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
line-height: 1;
|
|
160
|
+
color: #94a3b8;
|
|
161
|
+
padding: 2px 4px;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
}
|
|
164
|
+
.node-btn:hover { background: #f1f5f9; color: #475569; }
|
|
165
|
+
.node-del:hover { color: #ef4444; }
|
|
166
|
+
|
|
167
|
+
.node-body {
|
|
168
|
+
display: flex;
|
|
169
|
+
justify-content: space-between;
|
|
170
|
+
padding: 8px 10px;
|
|
171
|
+
font-size: 11px;
|
|
172
|
+
color: #64748b;
|
|
173
|
+
}
|
|
174
|
+
.node-key { font-family: ui-monospace, monospace; }
|
|
175
|
+
|
|
176
|
+
.node-handle {
|
|
177
|
+
position: absolute;
|
|
178
|
+
right: -8px;
|
|
179
|
+
top: 50%;
|
|
180
|
+
transform: translateY(-50%);
|
|
181
|
+
width: 14px;
|
|
182
|
+
height: 14px;
|
|
183
|
+
border-radius: 50%;
|
|
184
|
+
background: var(--primary-color, #007bff);
|
|
185
|
+
border: 2px solid white;
|
|
186
|
+
cursor: crosshair;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FormFlowPlayer — decision-tree multi-step form runtime.
|
|
4
|
+
*
|
|
5
|
+
* Renders a `FormStructure` (see ./structure.ts) one step at a time, reusing
|
|
6
|
+
* FormFlow.vue per node. The client owns navigation:
|
|
7
|
+
* - Answers accumulate into a single `formData` object across steps.
|
|
8
|
+
* - After validating the current step, the *next* step is resolved by
|
|
9
|
+
* first-matching SCIM edge (`resolveNext`). A `null` result means the
|
|
10
|
+
* current step is terminal, so the primary button reads "Submit" and
|
|
11
|
+
* clicking it emits `complete` with the full accumulated data.
|
|
12
|
+
* - A visited-step stack enables Back without re-resolving.
|
|
13
|
+
*
|
|
14
|
+
* A single-node structure behaves like a plain single-step form (the primary
|
|
15
|
+
* button is always "Submit").
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { FormStructure } from './structure'
|
|
19
|
+
import type { Slots } from 'vue'
|
|
20
|
+
import { computed, ref, useSlots, watch } from 'vue'
|
|
21
|
+
import { useI18n } from '../i18n'
|
|
22
|
+
import { fromJSONSchema } from './form-flow'
|
|
23
|
+
import FormFlow from './FormFlow.vue'
|
|
24
|
+
import { entryStepKey, resolveNext, stepByKey } from './structure'
|
|
25
|
+
|
|
26
|
+
export interface Props {
|
|
27
|
+
structure: FormStructure
|
|
28
|
+
modelValue?: Record<string, any>
|
|
29
|
+
components?: Record<string, any>
|
|
30
|
+
/** Optional async submit handler; awaited before `complete` is emitted. */
|
|
31
|
+
onComplete?: (data: Record<string, any>) => Promise<void> | void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const props = defineProps<Props>()
|
|
35
|
+
|
|
36
|
+
const emit = defineEmits<{
|
|
37
|
+
'update:modelValue': [value: Record<string, any>]
|
|
38
|
+
'stepChange': [stepKey: string]
|
|
39
|
+
'complete': [data: Record<string, any>]
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const { $t } = useI18n()
|
|
43
|
+
const slots: Slots = useSlots()
|
|
44
|
+
const slotNames = computed((): string[] => Object.keys(slots))
|
|
45
|
+
|
|
46
|
+
// Accumulated answers across every visited step.
|
|
47
|
+
const formData = ref<Record<string, any>>({ ...(props.modelValue || {}) })
|
|
48
|
+
|
|
49
|
+
watch(formData, val => emit('update:modelValue', val), { deep: true })
|
|
50
|
+
watch(() => props.modelValue, (val) => {
|
|
51
|
+
if (val && val !== formData.value) formData.value = { ...val }
|
|
52
|
+
}, { deep: true })
|
|
53
|
+
|
|
54
|
+
// Navigation state: the current step key plus the stack of previously visited
|
|
55
|
+
// keys (for Back). Initialised to the structure's entry step.
|
|
56
|
+
const currentKey = ref<string | undefined>(entryStepKey(props.structure))
|
|
57
|
+
const history = ref<string[]>([])
|
|
58
|
+
|
|
59
|
+
watch(() => props.structure, () => {
|
|
60
|
+
currentKey.value = entryStepKey(props.structure)
|
|
61
|
+
history.value = []
|
|
62
|
+
}, { deep: true })
|
|
63
|
+
|
|
64
|
+
watch(currentKey, (key) => {
|
|
65
|
+
if (key) emit('stepChange', key)
|
|
66
|
+
}, { immediate: true })
|
|
67
|
+
|
|
68
|
+
const currentStep = computed(() => stepByKey(props.structure, currentKey.value ?? null))
|
|
69
|
+
|
|
70
|
+
// Convert the current step's stored JSON Schema into a renderable schema.
|
|
71
|
+
const currentSchema = computed(() => {
|
|
72
|
+
const step = currentStep.value
|
|
73
|
+
return step ? fromJSONSchema(step.schema as any) : null
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// The *next* step given current answers (reactive: recomputes as fields change).
|
|
77
|
+
// `null` => the current step is terminal, so the primary action is Submit.
|
|
78
|
+
const nextKey = computed<string | null>(() => {
|
|
79
|
+
if (!currentKey.value) return null
|
|
80
|
+
return resolveNext(props.structure, currentKey.value, formData.value)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const isTerminal = computed(() => nextKey.value === null)
|
|
84
|
+
const canGoBack = computed(() => history.value.length > 0)
|
|
85
|
+
|
|
86
|
+
const formRef = ref<{ validate: () => boolean } | null>(null)
|
|
87
|
+
const submitting = ref(false)
|
|
88
|
+
|
|
89
|
+
/** Validate the visible current step via the underlying FormFlow. */
|
|
90
|
+
function validateStep(): boolean {
|
|
91
|
+
return formRef.value?.validate() ?? true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function onPrimary() {
|
|
95
|
+
if (!validateStep()) return
|
|
96
|
+
|
|
97
|
+
if (isTerminal.value) {
|
|
98
|
+
submitting.value = true
|
|
99
|
+
try {
|
|
100
|
+
await props.onComplete?.({ ...formData.value })
|
|
101
|
+
emit('complete', { ...formData.value })
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
submitting.value = false
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Advance to the resolved next step.
|
|
110
|
+
const target = nextKey.value
|
|
111
|
+
if (target && currentKey.value) {
|
|
112
|
+
history.value.push(currentKey.value)
|
|
113
|
+
currentKey.value = target
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onBack() {
|
|
118
|
+
const prev = history.value.pop()
|
|
119
|
+
if (prev) currentKey.value = prev
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Progress: position in the visited path (best-effort; branches make total
|
|
123
|
+
// step count indeterminate, so we show visited-count rather than a fixed total).
|
|
124
|
+
const stepPosition = computed(() => history.value.length + 1)
|
|
125
|
+
|
|
126
|
+
defineExpose({ formData, currentKey, nextKey, isTerminal, validateStep })
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<template>
|
|
130
|
+
<div class="form-flow-player">
|
|
131
|
+
<div v-if="currentStep" class="ffp-step">
|
|
132
|
+
<h2 v-if="currentStep.title" class="ffp-title">
|
|
133
|
+
{{ currentStep.title }}
|
|
134
|
+
</h2>
|
|
135
|
+
|
|
136
|
+
<slot
|
|
137
|
+
name="step"
|
|
138
|
+
:step="currentStep"
|
|
139
|
+
:form-data="formData"
|
|
140
|
+
:step-key="currentKey"
|
|
141
|
+
>
|
|
142
|
+
<FormFlow
|
|
143
|
+
v-if="currentSchema"
|
|
144
|
+
ref="formRef"
|
|
145
|
+
v-model="formData"
|
|
146
|
+
:schema="currentSchema"
|
|
147
|
+
:components="components"
|
|
148
|
+
>
|
|
149
|
+
<template v-for="slotName in slotNames" :key="slotName" #[slotName]="slotProps">
|
|
150
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
151
|
+
</template>
|
|
152
|
+
</FormFlow>
|
|
153
|
+
</slot>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div v-else class="ffp-empty">
|
|
157
|
+
<slot name="empty">
|
|
158
|
+
{{ $t?.('multiStep.empty') ?? 'No steps to display.' }}
|
|
159
|
+
</slot>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="ffp-nav">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="btn-secondary"
|
|
166
|
+
:disabled="!canGoBack"
|
|
167
|
+
@click="onBack"
|
|
168
|
+
>
|
|
169
|
+
{{ $t?.('multiStep.previous') ?? 'Back' }}
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<span class="ffp-counter">{{ stepPosition }}</span>
|
|
173
|
+
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="btn-primary"
|
|
177
|
+
:disabled="submitting"
|
|
178
|
+
@click="onPrimary"
|
|
179
|
+
>
|
|
180
|
+
<slot name="primary-label" :is-terminal="isTerminal">
|
|
181
|
+
{{ isTerminal
|
|
182
|
+
? ($t?.('multiStep.complete') ?? 'Submit')
|
|
183
|
+
: ($t?.('multiStep.next') ?? 'Next') }}
|
|
184
|
+
</slot>
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</template>
|
|
189
|
+
|
|
190
|
+
<style scoped>
|
|
191
|
+
.form-flow-player {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
gap: 1.5rem;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.ffp-title {
|
|
198
|
+
margin: 0 0 1rem 0;
|
|
199
|
+
font-size: 1.5rem;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.ffp-nav {
|
|
203
|
+
display: flex;
|
|
204
|
+
justify-content: space-between;
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: 1rem;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.ffp-counter {
|
|
210
|
+
font-size: 0.875rem;
|
|
211
|
+
color: #666;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
button {
|
|
215
|
+
padding: 0.75rem 1.5rem;
|
|
216
|
+
border: none;
|
|
217
|
+
border-radius: 4px;
|
|
218
|
+
font-weight: 600;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
transition: all 0.2s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.btn-primary {
|
|
224
|
+
background: var(--primary-color, #007bff);
|
|
225
|
+
color: white;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.btn-primary:disabled {
|
|
229
|
+
opacity: 0.5;
|
|
230
|
+
cursor: not-allowed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.btn-secondary {
|
|
234
|
+
background: white;
|
|
235
|
+
color: var(--primary-color, #007bff);
|
|
236
|
+
border: 1px solid var(--primary-color, #007bff);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.btn-secondary:disabled {
|
|
240
|
+
opacity: 0.5;
|
|
241
|
+
cursor: not-allowed;
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision-tree multi-step form structure (client mirror of the backend
|
|
3
|
+
* `common/form_submission/src/structure.py` model).
|
|
4
|
+
*
|
|
5
|
+
* A multi-step form is a graph of steps connected by directed edges carrying a
|
|
6
|
+
* SCIM 2.0 condition. From a step, edges are evaluated top-to-bottom and the
|
|
7
|
+
* first whose condition passes (against the accumulated form data) wins.
|
|
8
|
+
* `resolveNext` returning `null` means the current step is terminal (the form
|
|
9
|
+
* is complete) — this drives the reactive Next-vs-Submit button in the player.
|
|
10
|
+
*
|
|
11
|
+
* The client owns navigation; the server exposes the same logic via
|
|
12
|
+
* `POST /form/definitions/{id}/resolve-next` for non-authoritative checks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { evaluateQuery } from '../utils/queryFilter'
|
|
16
|
+
|
|
17
|
+
/** A single node in the form graph. Owns its own JSON Schema. */
|
|
18
|
+
export interface FormStep {
|
|
19
|
+
key: string
|
|
20
|
+
title?: string
|
|
21
|
+
/** JSON Schema for this step's fields (stored under `schema`). */
|
|
22
|
+
schema: Record<string, any>
|
|
23
|
+
/** Canvas position (designer-only; ignored by the server). */
|
|
24
|
+
x?: number
|
|
25
|
+
y?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A directed edge between two steps with an optional SCIM condition. */
|
|
29
|
+
export interface StepEdge {
|
|
30
|
+
from: string
|
|
31
|
+
to: string
|
|
32
|
+
/** SCIM 2.0 filter over accumulated form data. Empty/undefined => always taken. */
|
|
33
|
+
condition?: string | null
|
|
34
|
+
label?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The decision-tree graph for a multi-step form definition. */
|
|
38
|
+
export interface FormStructure {
|
|
39
|
+
/** Explicit start step key; falls back to the first step. */
|
|
40
|
+
startId?: string | null
|
|
41
|
+
steps: FormStep[]
|
|
42
|
+
transitions: StepEdge[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An empty structure (single-step / not-yet-built). */
|
|
46
|
+
export function emptyStructure(): FormStructure {
|
|
47
|
+
return { steps: [], transitions: [] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A structure is multi-step iff it has more than one node. */
|
|
51
|
+
export function isMultiStep(structure: FormStructure | null | undefined): boolean {
|
|
52
|
+
return !!structure && structure.steps.length > 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Look up a step by key. */
|
|
56
|
+
export function stepByKey(structure: FormStructure, key: string | null | undefined): FormStep | undefined {
|
|
57
|
+
if (key == null) return undefined
|
|
58
|
+
return structure.steps.find(s => s.key === key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Outgoing edges for a step, in declared (priority) order. */
|
|
62
|
+
export function edgesFrom(structure: FormStructure, key: string): StepEdge[] {
|
|
63
|
+
return structure.transitions.filter(e => e.from === key)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The entry step key: explicit `startId` if valid, else the first step. */
|
|
67
|
+
export function entryStepKey(structure: FormStructure): string | undefined {
|
|
68
|
+
if (structure.startId && stepByKey(structure, structure.startId)) {
|
|
69
|
+
return structure.startId
|
|
70
|
+
}
|
|
71
|
+
return structure.steps[0]?.key
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the next step key from `currentKey` given the accumulated answers.
|
|
76
|
+
*
|
|
77
|
+
* First outgoing edge whose SCIM `condition` passes wins (unconditional edges
|
|
78
|
+
* always pass, so declare them last as fallbacks). Returns `null` when no edge
|
|
79
|
+
* matches — the current step is terminal and the form is complete.
|
|
80
|
+
*
|
|
81
|
+
* Mirrors `FormStructure.resolve_next` on the backend.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveNext(
|
|
84
|
+
structure: FormStructure,
|
|
85
|
+
currentKey: string,
|
|
86
|
+
formData: Record<string, any>
|
|
87
|
+
): string | null {
|
|
88
|
+
for (const edge of edgesFrom(structure, currentKey)) {
|
|
89
|
+
if (!edge.condition || edge.condition.trim() === '' || evaluateQuery(edge.condition, formData)) {
|
|
90
|
+
return edge.to
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a step is terminal (a leaf) given the current answers. */
|
|
97
|
+
export function isTerminal(
|
|
98
|
+
structure: FormStructure,
|
|
99
|
+
currentKey: string,
|
|
100
|
+
formData: Record<string, any>
|
|
101
|
+
): boolean {
|
|
102
|
+
return resolveNext(structure, currentKey, formData) === null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Union all step schemas into one flat JSON Schema (later steps win on key
|
|
107
|
+
* collisions; `required` is the union). Mirrors `FormStructure.merged_schema`.
|
|
108
|
+
*/
|
|
109
|
+
export function mergedSchema(structure: FormStructure): Record<string, any> {
|
|
110
|
+
const properties: Record<string, any> = {}
|
|
111
|
+
const required: string[] = []
|
|
112
|
+
for (const step of structure.steps) {
|
|
113
|
+
const props = step.schema?.properties
|
|
114
|
+
if (props && typeof props === 'object') Object.assign(properties, props)
|
|
115
|
+
const req = step.schema?.required
|
|
116
|
+
if (Array.isArray(req)) {
|
|
117
|
+
for (const k of req) if (!required.includes(k)) required.push(k)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const merged: Record<string, any> = { type: 'object', properties }
|
|
121
|
+
if (required.length) merged.required = required
|
|
122
|
+
return merged
|
|
123
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,12 @@ export * from './directives'
|
|
|
9
9
|
export * from './form-flow/form-flow'
|
|
10
10
|
export { default as FormFlow } from './form-flow/FormFlow.vue'
|
|
11
11
|
export { default as MultiStepForm } from './form-flow/MultiStepForm.vue'
|
|
12
|
+
// Decision-tree multi-step forms
|
|
13
|
+
export * from './form-flow/structure'
|
|
14
|
+
export { default as FormFlowPlayer } from './form-flow/FormFlowPlayer.vue'
|
|
15
|
+
export { default as FlowDesigner } from './form-flow/FlowDesigner.vue'
|
|
16
|
+
export { default as FlowNode } from './form-flow/FlowNode.vue'
|
|
17
|
+
export { default as FlowEdge } from './form-flow/FlowEdge.vue'
|
|
12
18
|
export type { FormFieldType, FormFieldValidation, FormField, JSONSchemaProperty, JSONSchemaObject } from './form-flow/schema-fields'
|
|
13
19
|
export { defaultRowSpan, inferFieldType, schemaToFields, fieldsToSchema, initFormData, validateFormData, useSchemaToFields } from './form-flow/schema-fields'
|
|
14
20
|
// i18n exports
|
package/src/styles/layout.css
CHANGED
package/src/types/TableSchema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BglFormSchemaT } from '@bagelink/vue'
|
|
1
|
+
import type { BglFormSchemaT, IconType } from '@bagelink/vue'
|
|
2
2
|
import type { MaybeRefOrGetter, ComputedRef } from 'vue'
|
|
3
3
|
|
|
4
4
|
export type SortDirectionsT = 'ASC' | 'DESC'
|
|
@@ -12,6 +12,12 @@ export interface TableSchemaProps<T extends { [key: string]: any } = { [key: str
|
|
|
12
12
|
selectable?: boolean
|
|
13
13
|
sortable?: boolean | { handle?: string }
|
|
14
14
|
onLastItemVisible?: () => void
|
|
15
|
+
/** Icon for the built-in empty state (default: 'inbox'). Pass 'none' to hide. */
|
|
16
|
+
emptyIcon?: IconType | 'none'
|
|
17
|
+
/** Title for the built-in empty state (default: 'No results'). */
|
|
18
|
+
emptyTitle?: string
|
|
19
|
+
/** Description shown under the empty-state title. */
|
|
20
|
+
emptyDescription?: string
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export interface SortingOptions {
|