qdadm 1.11.0 → 1.13.0
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 +1 -1
- package/src/components/edit/LookupField.vue +406 -0
- package/src/components/edit/LookupPickerDialog.vue +227 -0
- package/src/components/editors/JsonStructuredField.vue +52 -3
- package/src/components/index.ts +2 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useEntityItemFormPage.ts +16 -16
- package/src/composables/useEntityItemFormPage.types.ts +14 -14
- package/src/composables/useEntityItemPage.ts +13 -13
- package/src/composables/useEntityItemShowPage.ts +37 -36
- package/src/composables/useListPage.ts +10 -8
- package/src/composables/useListPage.types.ts +10 -10
- package/src/composables/useOptionsLookup.ts +319 -0
- package/src/entity/EntityManager.cache.ts +22 -0
- package/src/entity/EntityManager.crud.ts +1 -0
- package/src/entity/EntityManager.interface.ts +19 -16
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +4 -0
- package/src/gen/generateManagers.test.js +156 -13
- package/src/gen/generateManagers.ts +74 -14
- package/src/gen/index.ts +2 -0
- package/src/generated/managers/testManager.ts +46 -0
- package/src/index.ts +7 -0
- package/src/modules/debug/EntitiesCollector.ts +2 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +2 -1
package/package.json
CHANGED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LookupField - Unified lookup input with two UI modes
|
|
4
|
+
*
|
|
5
|
+
* Wraps useOptionsLookup into a ready-to-use form field component.
|
|
6
|
+
*
|
|
7
|
+
* Modes (pickerMode):
|
|
8
|
+
* - **inline** (default): PrimeVue AutoComplete with dropdown button.
|
|
9
|
+
* - **picker**: Readonly input + search button → modal DataTable.
|
|
10
|
+
*
|
|
11
|
+
* Supports both single and multiple selection (multiple prop).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```vue
|
|
15
|
+
* <!-- Single inline -->
|
|
16
|
+
* <LookupField v-model="form.data.value.bookId" :lookup="bookLookup" />
|
|
17
|
+
*
|
|
18
|
+
* <!-- Single picker -->
|
|
19
|
+
* <LookupField v-model="bookId" :lookup="bookLookup" pickerMode="picker"
|
|
20
|
+
* :pickerColumns="['title', 'author']" pickerTitle="Select a Book" />
|
|
21
|
+
*
|
|
22
|
+
* <!-- Multi inline (chips) -->
|
|
23
|
+
* <LookupField v-model="conditions.tags" :lookup="tagLookup" multiple />
|
|
24
|
+
*
|
|
25
|
+
* <!-- Multi picker (checkboxes) -->
|
|
26
|
+
* <LookupField v-model="conditions.geoCountry" :lookup="geoLookup"
|
|
27
|
+
* multiple pickerMode="picker" :pickerColumns="['code', 'name']" />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
import { ref, computed, watch, type PropType } from 'vue'
|
|
31
|
+
import AutoComplete from 'primevue/autocomplete'
|
|
32
|
+
import InputText from 'primevue/inputtext'
|
|
33
|
+
import InputGroup from 'primevue/inputgroup'
|
|
34
|
+
import InputGroupAddon from 'primevue/inputgroupaddon'
|
|
35
|
+
import Chip from 'primevue/chip'
|
|
36
|
+
import LookupPickerDialog from './LookupPickerDialog.vue'
|
|
37
|
+
import type { UseOptionsLookupReturn } from '../../composables/useOptionsLookup'
|
|
38
|
+
import type { LookupColumn } from './LookupPickerDialog.vue'
|
|
39
|
+
|
|
40
|
+
type PickerMode = 'inline' | 'picker'
|
|
41
|
+
|
|
42
|
+
const props = defineProps({
|
|
43
|
+
/** v-model: raw value (single) or array of raw values (multiple) */
|
|
44
|
+
modelValue: { type: [String, Number, Array, null] as PropType<unknown>, default: null },
|
|
45
|
+
/** useOptionsLookup return object */
|
|
46
|
+
lookup: { type: Object as PropType<UseOptionsLookupReturn>, required: true },
|
|
47
|
+
/** UI mode */
|
|
48
|
+
pickerMode: { type: String as PropType<PickerMode>, default: 'inline' },
|
|
49
|
+
/** Allow multiple selection */
|
|
50
|
+
multiple: { type: Boolean, default: false },
|
|
51
|
+
/** Columns shown in picker dialog (picker mode only) */
|
|
52
|
+
pickerColumns: { type: Array as PropType<(string | LookupColumn)[]>, default: () => [] },
|
|
53
|
+
/** Dialog title (picker mode only) */
|
|
54
|
+
pickerTitle: { type: String, default: 'Select item' },
|
|
55
|
+
/** Dialog width (picker mode only) */
|
|
56
|
+
pickerWidth: { type: String, default: '700px' },
|
|
57
|
+
/** Placeholder text */
|
|
58
|
+
placeholder: { type: String, default: '' },
|
|
59
|
+
/** Disabled state */
|
|
60
|
+
disabled: { type: Boolean, default: false },
|
|
61
|
+
/** CSS class for the root element */
|
|
62
|
+
class: { type: String, default: '' },
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
'update:modelValue': [value: unknown]
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
// ═══════════════════════════════════════════════════════
|
|
70
|
+
// SINGLE MODE
|
|
71
|
+
// ═══════════════════════════════════════════════════════
|
|
72
|
+
|
|
73
|
+
// Display string for single inline autocomplete
|
|
74
|
+
const displayValue = ref('')
|
|
75
|
+
|
|
76
|
+
watch(() => props.modelValue, (val) => {
|
|
77
|
+
if (props.multiple) return
|
|
78
|
+
if (val != null && val !== '') {
|
|
79
|
+
displayValue.value = props.lookup.resolve(val)
|
|
80
|
+
} else {
|
|
81
|
+
displayValue.value = ''
|
|
82
|
+
}
|
|
83
|
+
}, { immediate: true })
|
|
84
|
+
|
|
85
|
+
watch(() => props.lookup.options.value.length, () => {
|
|
86
|
+
if (props.multiple) return
|
|
87
|
+
if (props.modelValue != null && props.modelValue !== '') {
|
|
88
|
+
displayValue.value = props.lookup.resolve(props.modelValue)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
function onInlineSelect(_event: { value: string }): void {
|
|
93
|
+
const raw = props.lookup.decode(displayValue.value)
|
|
94
|
+
emit('update:modelValue', raw)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onInlineBlur(): void {
|
|
98
|
+
if (displayValue.value) {
|
|
99
|
+
emit('update:modelValue', props.lookup.decode(displayValue.value))
|
|
100
|
+
} else {
|
|
101
|
+
emit('update:modelValue', null)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Single picker display
|
|
106
|
+
const pickerDisplayLabel = computed(() => {
|
|
107
|
+
if (props.multiple) return ''
|
|
108
|
+
if (props.modelValue == null || props.modelValue === '') return ''
|
|
109
|
+
return props.lookup.resolve(props.modelValue)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
function onPickerSelect(item: Record<string, unknown>): void {
|
|
113
|
+
const vf = guessValueField()
|
|
114
|
+
emit('update:modelValue', item[vf] ?? item.value ?? item.id)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══════════════════════════════════════════════════════
|
|
118
|
+
// MULTI MODE
|
|
119
|
+
// ═══════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
const multiValues = computed(() => {
|
|
122
|
+
if (!props.multiple) return []
|
|
123
|
+
return Array.isArray(props.modelValue) ? props.modelValue : []
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Inline multi: chips as encoded strings
|
|
127
|
+
const multiChips = ref<string[]>([])
|
|
128
|
+
|
|
129
|
+
watch(() => props.modelValue, (val) => {
|
|
130
|
+
if (!props.multiple) return
|
|
131
|
+
const arr = Array.isArray(val) ? val : []
|
|
132
|
+
multiChips.value = arr.map((v) => props.lookup.resolve(v))
|
|
133
|
+
}, { immediate: true })
|
|
134
|
+
|
|
135
|
+
watch(() => props.lookup.options.value.length, () => {
|
|
136
|
+
if (!props.multiple) return
|
|
137
|
+
const arr = Array.isArray(props.modelValue) ? props.modelValue : []
|
|
138
|
+
if (arr.length > 0) {
|
|
139
|
+
multiChips.value = arr.map((v) => props.lookup.resolve(v))
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
function onMultiChipsUpdate(chips: string[]): void {
|
|
144
|
+
emit('update:modelValue', chips.map((c) => props.lookup.decode(c)))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onMultiInlineSelect(): void {
|
|
148
|
+
emit('update:modelValue', multiChips.value.map((c) => props.lookup.decode(c)))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Picker multi: chips display
|
|
152
|
+
const multiPickerChips = computed(() =>
|
|
153
|
+
multiValues.value.map((v) => ({ raw: v, label: props.lookup.resolve(v) })),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
function removeMultiChip(rawValue: unknown): void {
|
|
157
|
+
const updated = multiValues.value.filter((v) => String(v) !== String(rawValue))
|
|
158
|
+
emit('update:modelValue', updated)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onPickerSelectMultiple(items: Record<string, unknown>[]): void {
|
|
162
|
+
const vf = guessValueField()
|
|
163
|
+
emit('update:modelValue', items.map((item) => item[vf] ?? item.value ?? item.id))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ═══════════════════════════════════════════════════════
|
|
167
|
+
// SHARED
|
|
168
|
+
// ═══════════════════════════════════════════════════════
|
|
169
|
+
|
|
170
|
+
const pickerVisible = ref(false)
|
|
171
|
+
|
|
172
|
+
const resolvedPickerColumns = computed(() => {
|
|
173
|
+
if (props.pickerColumns.length > 0) return props.pickerColumns
|
|
174
|
+
const firstRaw = props.lookup.raw.value[0]
|
|
175
|
+
if (firstRaw && typeof firstRaw === 'object') {
|
|
176
|
+
return Object.keys(firstRaw as Record<string, unknown>).slice(0, 4)
|
|
177
|
+
}
|
|
178
|
+
return ['name', 'id']
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
function guessValueField(): string {
|
|
182
|
+
const firstOpt = props.lookup.options.value[0]
|
|
183
|
+
if (!firstOpt) return 'id'
|
|
184
|
+
const firstRaw = props.lookup.raw.value[0] as Record<string, unknown> | undefined
|
|
185
|
+
if (firstRaw) {
|
|
186
|
+
for (const [key, val] of Object.entries(firstRaw)) {
|
|
187
|
+
if (val === firstOpt.value) return key
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return 'id'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function openPicker(): void {
|
|
194
|
+
if (!props.disabled) pickerVisible.value = true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const hasValue = computed(() => {
|
|
198
|
+
if (props.multiple) return multiValues.value.length > 0
|
|
199
|
+
return props.modelValue != null && props.modelValue !== ''
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
function clearValue(): void {
|
|
203
|
+
emit('update:modelValue', props.multiple ? [] : null)
|
|
204
|
+
displayValue.value = ''
|
|
205
|
+
multiChips.value = []
|
|
206
|
+
}
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<template>
|
|
210
|
+
<!-- ════════════ SINGLE INLINE ════════════ -->
|
|
211
|
+
<span v-if="!multiple && pickerMode === 'inline'" class="lookup-field-inline" :class="props.class || 'w-full'">
|
|
212
|
+
<AutoComplete
|
|
213
|
+
v-model="displayValue"
|
|
214
|
+
:suggestions="lookup.suggestions.value"
|
|
215
|
+
@complete="lookup.search($event.query)"
|
|
216
|
+
@item-select="onInlineSelect"
|
|
217
|
+
@blur="onInlineBlur"
|
|
218
|
+
:loading="lookup.loading.value"
|
|
219
|
+
:disabled="disabled"
|
|
220
|
+
:placeholder="placeholder"
|
|
221
|
+
dropdown
|
|
222
|
+
class="w-full"
|
|
223
|
+
/>
|
|
224
|
+
<button
|
|
225
|
+
v-if="hasValue && !disabled"
|
|
226
|
+
type="button"
|
|
227
|
+
class="lookup-clear-btn lookup-clear-btn--inline"
|
|
228
|
+
@click="clearValue"
|
|
229
|
+
tabindex="-1"
|
|
230
|
+
>
|
|
231
|
+
<i class="pi pi-times" />
|
|
232
|
+
</button>
|
|
233
|
+
</span>
|
|
234
|
+
|
|
235
|
+
<!-- ════════════ SINGLE PICKER ════════════ -->
|
|
236
|
+
<span v-else-if="!multiple && pickerMode === 'picker'" :class="props.class || 'w-full'" class="lookup-field-picker">
|
|
237
|
+
<InputGroup>
|
|
238
|
+
<InputText
|
|
239
|
+
:modelValue="pickerDisplayLabel"
|
|
240
|
+
readonly
|
|
241
|
+
:placeholder="placeholder || 'Click to select...'"
|
|
242
|
+
:disabled="disabled"
|
|
243
|
+
@click="openPicker"
|
|
244
|
+
style="cursor: pointer"
|
|
245
|
+
/>
|
|
246
|
+
<InputGroupAddon class="lookup-search-addon" @click="openPicker">
|
|
247
|
+
<i v-if="lookup.loading.value" class="pi pi-spinner pi-spin" />
|
|
248
|
+
<i v-else class="pi pi-search" />
|
|
249
|
+
</InputGroupAddon>
|
|
250
|
+
</InputGroup>
|
|
251
|
+
<button
|
|
252
|
+
v-if="hasValue && !disabled"
|
|
253
|
+
type="button"
|
|
254
|
+
class="lookup-clear-btn lookup-clear-btn--picker"
|
|
255
|
+
@click.stop="clearValue"
|
|
256
|
+
tabindex="-1"
|
|
257
|
+
>
|
|
258
|
+
<i class="pi pi-times" />
|
|
259
|
+
</button>
|
|
260
|
+
|
|
261
|
+
<LookupPickerDialog
|
|
262
|
+
v-model:visible="pickerVisible"
|
|
263
|
+
:title="pickerTitle"
|
|
264
|
+
:items="(lookup.raw.value as Record<string, unknown>[])"
|
|
265
|
+
:columns="resolvedPickerColumns"
|
|
266
|
+
:loading="lookup.loading.value"
|
|
267
|
+
:currentValue="modelValue"
|
|
268
|
+
:valueField="guessValueField()"
|
|
269
|
+
:width="pickerWidth"
|
|
270
|
+
@select="onPickerSelect"
|
|
271
|
+
/>
|
|
272
|
+
</span>
|
|
273
|
+
|
|
274
|
+
<!-- ════════════ MULTI INLINE (chips autocomplete) ════════════ -->
|
|
275
|
+
<span v-else-if="multiple && pickerMode === 'inline'" class="lookup-field-inline" :class="props.class || 'w-full'">
|
|
276
|
+
<AutoComplete
|
|
277
|
+
v-model="multiChips"
|
|
278
|
+
:suggestions="lookup.suggestions.value"
|
|
279
|
+
@complete="lookup.search($event.query)"
|
|
280
|
+
@item-select="onMultiInlineSelect"
|
|
281
|
+
@update:modelValue="onMultiChipsUpdate"
|
|
282
|
+
:loading="lookup.loading.value"
|
|
283
|
+
:disabled="disabled"
|
|
284
|
+
:placeholder="!hasValue ? placeholder : ''"
|
|
285
|
+
multiple
|
|
286
|
+
class="w-full"
|
|
287
|
+
/>
|
|
288
|
+
<button
|
|
289
|
+
v-if="hasValue && !disabled"
|
|
290
|
+
type="button"
|
|
291
|
+
class="lookup-clear-btn lookup-clear-btn--multi-inline"
|
|
292
|
+
@click="clearValue"
|
|
293
|
+
tabindex="-1"
|
|
294
|
+
>
|
|
295
|
+
<i class="pi pi-times" />
|
|
296
|
+
</button>
|
|
297
|
+
</span>
|
|
298
|
+
|
|
299
|
+
<!-- ════════════ MULTI PICKER (chips + search dialog) ════════════ -->
|
|
300
|
+
<span v-else :class="props.class || 'w-full'" class="lookup-field-picker">
|
|
301
|
+
<InputGroup>
|
|
302
|
+
<div class="lookup-multi-chips" @click="openPicker">
|
|
303
|
+
<template v-if="multiPickerChips.length > 0">
|
|
304
|
+
<Chip
|
|
305
|
+
v-for="chip in multiPickerChips"
|
|
306
|
+
:key="String(chip.raw)"
|
|
307
|
+
:label="chip.label"
|
|
308
|
+
removable
|
|
309
|
+
@remove="removeMultiChip(chip.raw)"
|
|
310
|
+
@click.stop
|
|
311
|
+
/>
|
|
312
|
+
</template>
|
|
313
|
+
<span v-else class="lookup-multi-placeholder">
|
|
314
|
+
{{ placeholder || 'Click search to select...' }}
|
|
315
|
+
</span>
|
|
316
|
+
</div>
|
|
317
|
+
<InputGroupAddon class="lookup-search-addon" @click="openPicker">
|
|
318
|
+
<i v-if="lookup.loading.value" class="pi pi-spinner pi-spin" />
|
|
319
|
+
<i v-else class="pi pi-search" />
|
|
320
|
+
</InputGroupAddon>
|
|
321
|
+
</InputGroup>
|
|
322
|
+
|
|
323
|
+
<LookupPickerDialog
|
|
324
|
+
v-model:visible="pickerVisible"
|
|
325
|
+
:title="pickerTitle"
|
|
326
|
+
:items="(lookup.raw.value as Record<string, unknown>[])"
|
|
327
|
+
:columns="resolvedPickerColumns"
|
|
328
|
+
:loading="lookup.loading.value"
|
|
329
|
+
:currentValue="modelValue"
|
|
330
|
+
:valueField="guessValueField()"
|
|
331
|
+
:width="pickerWidth"
|
|
332
|
+
multiple
|
|
333
|
+
@select-multiple="onPickerSelectMultiple"
|
|
334
|
+
/>
|
|
335
|
+
</span>
|
|
336
|
+
</template>
|
|
337
|
+
|
|
338
|
+
<style scoped>
|
|
339
|
+
.lookup-field-inline,
|
|
340
|
+
.lookup-field-picker {
|
|
341
|
+
position: relative;
|
|
342
|
+
display: inline-flex;
|
|
343
|
+
}
|
|
344
|
+
.lookup-clear-btn {
|
|
345
|
+
position: absolute;
|
|
346
|
+
top: 50%;
|
|
347
|
+
transform: translateY(-50%);
|
|
348
|
+
z-index: 1;
|
|
349
|
+
background: none;
|
|
350
|
+
border: none;
|
|
351
|
+
cursor: pointer;
|
|
352
|
+
color: var(--p-text-muted-color);
|
|
353
|
+
padding: 0.25rem;
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
opacity: 0.6;
|
|
357
|
+
transition: opacity 0.15s;
|
|
358
|
+
}
|
|
359
|
+
.lookup-clear-btn:hover {
|
|
360
|
+
opacity: 1;
|
|
361
|
+
color: var(--p-text-color);
|
|
362
|
+
}
|
|
363
|
+
/* Single inline: before the dropdown arrow button */
|
|
364
|
+
.lookup-clear-btn--inline {
|
|
365
|
+
right: 2.5rem;
|
|
366
|
+
}
|
|
367
|
+
/* Single picker: before the search addon */
|
|
368
|
+
.lookup-clear-btn--picker {
|
|
369
|
+
right: 2.75rem;
|
|
370
|
+
}
|
|
371
|
+
/* Multi inline: top-right corner */
|
|
372
|
+
.lookup-clear-btn--multi-inline {
|
|
373
|
+
right: 0.5rem;
|
|
374
|
+
top: 0.25rem;
|
|
375
|
+
transform: none;
|
|
376
|
+
}
|
|
377
|
+
/* Chips container for multi picker */
|
|
378
|
+
.lookup-multi-chips {
|
|
379
|
+
flex: 1 1 auto;
|
|
380
|
+
display: flex;
|
|
381
|
+
flex-wrap: wrap;
|
|
382
|
+
gap: 0.25rem;
|
|
383
|
+
align-items: center;
|
|
384
|
+
padding: 0.375rem 0.5rem;
|
|
385
|
+
min-height: 2.5rem;
|
|
386
|
+
cursor: pointer;
|
|
387
|
+
border: 1px solid var(--p-inputtext-border-color);
|
|
388
|
+
border-right: none;
|
|
389
|
+
border-radius: var(--p-inputtext-border-radius) 0 0 var(--p-inputtext-border-radius);
|
|
390
|
+
background: var(--p-inputtext-background);
|
|
391
|
+
transition: border-color 0.15s;
|
|
392
|
+
}
|
|
393
|
+
.lookup-multi-chips:hover {
|
|
394
|
+
border-color: var(--p-inputtext-hover-border-color);
|
|
395
|
+
}
|
|
396
|
+
.lookup-multi-placeholder {
|
|
397
|
+
color: var(--p-inputtext-placeholder-color);
|
|
398
|
+
font-size: 0.875rem;
|
|
399
|
+
}
|
|
400
|
+
.lookup-search-addon {
|
|
401
|
+
cursor: pointer;
|
|
402
|
+
}
|
|
403
|
+
.lookup-search-addon:hover {
|
|
404
|
+
background-color: var(--p-surface-100);
|
|
405
|
+
}
|
|
406
|
+
</style>
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LookupPickerDialog - Modal search dialog for entity lookup
|
|
4
|
+
*
|
|
5
|
+
* Opens a DataTable with search to select item(s) from a large dataset.
|
|
6
|
+
* Supports single selection (click row) and multiple selection (checkboxes).
|
|
7
|
+
*
|
|
8
|
+
* Used internally by LookupField in 'picker' mode.
|
|
9
|
+
*/
|
|
10
|
+
import { ref, computed, watch } from 'vue'
|
|
11
|
+
import Dialog from 'primevue/dialog'
|
|
12
|
+
import DataTable from 'primevue/datatable'
|
|
13
|
+
import Column from 'primevue/column'
|
|
14
|
+
import InputText from 'primevue/inputtext'
|
|
15
|
+
import Button from 'primevue/button'
|
|
16
|
+
|
|
17
|
+
export interface LookupColumn {
|
|
18
|
+
field: string
|
|
19
|
+
header?: string
|
|
20
|
+
sortable?: boolean
|
|
21
|
+
style?: string | Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
visible: boolean
|
|
26
|
+
title?: string
|
|
27
|
+
items: Record<string, unknown>[]
|
|
28
|
+
columns: (string | LookupColumn)[]
|
|
29
|
+
loading?: boolean
|
|
30
|
+
valueField?: string
|
|
31
|
+
/** Current value(s) — single value or array for multiple mode */
|
|
32
|
+
currentValue?: unknown
|
|
33
|
+
width?: string
|
|
34
|
+
/** Enable checkbox multi-selection */
|
|
35
|
+
multiple?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
39
|
+
title: 'Select item',
|
|
40
|
+
loading: false,
|
|
41
|
+
valueField: 'id',
|
|
42
|
+
currentValue: undefined,
|
|
43
|
+
width: '700px',
|
|
44
|
+
multiple: false,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{
|
|
48
|
+
'update:visible': [value: boolean]
|
|
49
|
+
/** Single selection (multiple=false) */
|
|
50
|
+
'select': [item: Record<string, unknown>]
|
|
51
|
+
/** Multi selection (multiple=true) */
|
|
52
|
+
'select-multiple': [items: Record<string, unknown>[]]
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
const searchQuery = ref('')
|
|
56
|
+
// Single mode
|
|
57
|
+
const selectedRow = ref<Record<string, unknown> | null>(null)
|
|
58
|
+
// Multi mode
|
|
59
|
+
const selectedRows = ref<Record<string, unknown>[]>([])
|
|
60
|
+
|
|
61
|
+
// Reset state + pre-select current values when dialog opens
|
|
62
|
+
watch(() => props.visible, (open) => {
|
|
63
|
+
if (open) {
|
|
64
|
+
searchQuery.value = ''
|
|
65
|
+
selectedRow.value = null
|
|
66
|
+
if (props.multiple && props.currentValue != null) {
|
|
67
|
+
// Pre-select rows matching currentValue array
|
|
68
|
+
const vals = Array.isArray(props.currentValue) ? props.currentValue : [props.currentValue]
|
|
69
|
+
const valStrings = new Set(vals.map(String))
|
|
70
|
+
selectedRows.value = props.items.filter((item) =>
|
|
71
|
+
valStrings.has(String(item[props.valueField])),
|
|
72
|
+
)
|
|
73
|
+
} else {
|
|
74
|
+
selectedRows.value = []
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Resolve columns config
|
|
80
|
+
const resolvedColumns = computed((): LookupColumn[] => {
|
|
81
|
+
return props.columns.map((col) => {
|
|
82
|
+
if (typeof col === 'string') {
|
|
83
|
+
return { field: col, header: col.charAt(0).toUpperCase() + col.slice(1), sortable: true }
|
|
84
|
+
}
|
|
85
|
+
return { header: col.field.charAt(0).toUpperCase() + col.field.slice(1), sortable: true, ...col }
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Filter items by search query across all visible columns
|
|
90
|
+
const filteredItems = computed(() => {
|
|
91
|
+
if (!searchQuery.value) return props.items
|
|
92
|
+
const q = searchQuery.value.toLowerCase()
|
|
93
|
+
return props.items.filter((item) =>
|
|
94
|
+
resolvedColumns.value.some((col) => {
|
|
95
|
+
const val = item[col.field]
|
|
96
|
+
return val != null && String(val).toLowerCase().includes(q)
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Highlight the row(s) matching currentValue (single mode only)
|
|
102
|
+
const rowClass = (data: Record<string, unknown>): string | undefined => {
|
|
103
|
+
if (props.multiple) return undefined
|
|
104
|
+
const val = data[props.valueField]
|
|
105
|
+
if (val != null && props.currentValue != null && String(val) === String(props.currentValue)) {
|
|
106
|
+
return 'lookup-current-row'
|
|
107
|
+
}
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onRowSelect(event: { data: Record<string, unknown> }): void {
|
|
112
|
+
if (!props.multiple) {
|
|
113
|
+
selectedRow.value = event.data
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onRowDblClick(event: { data: Record<string, unknown> }): void {
|
|
118
|
+
if (!props.multiple) {
|
|
119
|
+
emit('select', event.data)
|
|
120
|
+
emit('update:visible', false)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onConfirm(): void {
|
|
125
|
+
if (props.multiple) {
|
|
126
|
+
emit('select-multiple', selectedRows.value)
|
|
127
|
+
} else if (selectedRow.value) {
|
|
128
|
+
emit('select', selectedRow.value)
|
|
129
|
+
}
|
|
130
|
+
emit('update:visible', false)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onCancel(): void {
|
|
134
|
+
emit('update:visible', false)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const confirmDisabled = computed(() => {
|
|
138
|
+
if (props.multiple) return false // allow confirming empty selection (clear all)
|
|
139
|
+
return !selectedRow.value
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const confirmLabel = computed(() => {
|
|
143
|
+
if (props.multiple && selectedRows.value.length > 0) {
|
|
144
|
+
return `Select (${selectedRows.value.length})`
|
|
145
|
+
}
|
|
146
|
+
return 'Select'
|
|
147
|
+
})
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<Dialog
|
|
152
|
+
:visible="visible"
|
|
153
|
+
:header="title"
|
|
154
|
+
:modal="true"
|
|
155
|
+
:style="{ width }"
|
|
156
|
+
:closable="true"
|
|
157
|
+
:dismissableMask="true"
|
|
158
|
+
@update:visible="$emit('update:visible', $event)"
|
|
159
|
+
>
|
|
160
|
+
<!-- Search bar -->
|
|
161
|
+
<div class="flex gap-2 mb-3">
|
|
162
|
+
<span class="p-input-icon-left flex-1">
|
|
163
|
+
<i class="pi pi-search" />
|
|
164
|
+
<InputText
|
|
165
|
+
v-model="searchQuery"
|
|
166
|
+
placeholder="Search..."
|
|
167
|
+
class="w-full"
|
|
168
|
+
autofocus
|
|
169
|
+
/>
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- Results table -->
|
|
174
|
+
<DataTable
|
|
175
|
+
:value="filteredItems"
|
|
176
|
+
:loading="loading"
|
|
177
|
+
v-model:selection="selectedRows"
|
|
178
|
+
:selectionMode="multiple ? undefined : 'single'"
|
|
179
|
+
@row-select="onRowSelect"
|
|
180
|
+
@row-dblclick="onRowDblClick"
|
|
181
|
+
:rowClass="rowClass"
|
|
182
|
+
:paginator="filteredItems.length > 10"
|
|
183
|
+
:rows="10"
|
|
184
|
+
:dataKey="valueField"
|
|
185
|
+
scrollable
|
|
186
|
+
scrollHeight="400px"
|
|
187
|
+
stripedRows
|
|
188
|
+
class="lookup-picker-table"
|
|
189
|
+
>
|
|
190
|
+
<!-- Checkbox column for multi mode -->
|
|
191
|
+
<Column v-if="multiple" selectionMode="multiple" headerStyle="width: 3rem" />
|
|
192
|
+
<Column
|
|
193
|
+
v-for="col in resolvedColumns"
|
|
194
|
+
:key="col.field"
|
|
195
|
+
:field="col.field"
|
|
196
|
+
:header="col.header"
|
|
197
|
+
:sortable="col.sortable"
|
|
198
|
+
:style="col.style"
|
|
199
|
+
/>
|
|
200
|
+
<template #empty>
|
|
201
|
+
<div class="text-center text-color-secondary py-4">
|
|
202
|
+
{{ searchQuery ? 'No matching items' : 'No items available' }}
|
|
203
|
+
</div>
|
|
204
|
+
</template>
|
|
205
|
+
</DataTable>
|
|
206
|
+
|
|
207
|
+
<template #footer>
|
|
208
|
+
<Button
|
|
209
|
+
label="Cancel"
|
|
210
|
+
severity="secondary"
|
|
211
|
+
@click="onCancel"
|
|
212
|
+
/>
|
|
213
|
+
<Button
|
|
214
|
+
:label="confirmLabel"
|
|
215
|
+
icon="pi pi-check"
|
|
216
|
+
:disabled="confirmDisabled"
|
|
217
|
+
@click="onConfirm"
|
|
218
|
+
/>
|
|
219
|
+
</template>
|
|
220
|
+
</Dialog>
|
|
221
|
+
</template>
|
|
222
|
+
|
|
223
|
+
<style scoped>
|
|
224
|
+
.lookup-picker-table :deep(.lookup-current-row) {
|
|
225
|
+
background-color: var(--p-highlight-background) !important;
|
|
226
|
+
}
|
|
227
|
+
</style>
|