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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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>