@lucashw68/nsdb 1.0.0-rc.2

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 (49) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/GET_STARTED.md +709 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -0
  5. package/cli/index.js +83 -0
  6. package/helpers/args.js +22 -0
  7. package/helpers/config.js +142 -0
  8. package/helpers/generated.js +48 -0
  9. package/helpers/io.js +39 -0
  10. package/helpers/metadata.js +19 -0
  11. package/helpers/names.js +16 -0
  12. package/helpers/relations.js +101 -0
  13. package/helpers/shell.js +15 -0
  14. package/helpers/tables.js +79 -0
  15. package/helpers/ts.js +37 -0
  16. package/module.ts +151 -0
  17. package/nsdb.config.example.mjs +39 -0
  18. package/nsdb.config.example.ts +42 -0
  19. package/package.json +114 -0
  20. package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
  21. package/runtime/components/NsdbForm.vue +865 -0
  22. package/runtime/components/NsdbList.vue +961 -0
  23. package/runtime/composables/useNsdbProfile.ts +119 -0
  24. package/runtime/composables/useNsdbSchemas.ts +176 -0
  25. package/runtime/composables/useSupabaseApi.ts +177 -0
  26. package/runtime/composables/useSupabaseApiStorage.ts +337 -0
  27. package/runtime/composables/useSupabaseModels.ts +412 -0
  28. package/runtime/query.ts +126 -0
  29. package/runtime/stores/createDbStore.ts +439 -0
  30. package/runtime/stores/createSingletonDbStore.ts +67 -0
  31. package/runtime/utils/dataFreshness.ts +47 -0
  32. package/runtime/utils/storage.ts +41 -0
  33. package/scripts/clear.js +64 -0
  34. package/scripts/generate-composables.js +100 -0
  35. package/scripts/generate-enums.js +106 -0
  36. package/scripts/generate-metadata.js +165 -0
  37. package/scripts/generate-models.js +164 -0
  38. package/scripts/generate-schemas.js +443 -0
  39. package/scripts/generate-stores.js +90 -0
  40. package/scripts/generate-types.js +196 -0
  41. package/scripts/init.js +225 -0
  42. package/templates/model.template.ts +48 -0
  43. package/templates/schema.template.ts +13 -0
  44. package/templates/useNsdbModel.template.ts +9 -0
  45. package/types/config.ts +50 -0
  46. package/types/entities.ts +66 -0
  47. package/types/index.ts +14 -0
  48. package/types/list.ts +78 -0
  49. package/types/model.ts +57 -0
@@ -0,0 +1,961 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, computed, onBeforeUnmount, onMounted, useId } from 'vue'
3
+ import { useSupabaseUser } from '#imports'
4
+ import { useNsdbModel } from '#build/nsdb/registry'
5
+ import * as nsdbSchemas from '#build/nsdb/schemas'
6
+ import type { Column, NsdbTableClasses, OrderDirection, SortState, WhereClause } from '@lucashw68/nsdb/types/list'
7
+
8
+ const defaultClasses: NsdbTableClasses = {
9
+ wrapper: 'w-full space-y-3',
10
+ headerWrapper: 'flex items-center justify-between',
11
+ headerTitle: 'text-lg font-semibold capitalize mb-2',
12
+ headerSubtitle: 'text-sm opacity-70',
13
+ toolbar: 'flex flex-col md:flex-row md:items-center gap-2',
14
+ searchInput: 'border text-black rounded px-3 py-2 w-full md:max-w-xs text-sm',
15
+ error: 'text-sm text-red-600',
16
+ tableContainer: 'border w-full overflow-x-auto',
17
+ table: 'nsdb-table w-full text-sm',
18
+ thead: 'bg-gray-50',
19
+ theadRow: 'border-2 border-white',
20
+ th: 'text-left text-black px-4 py-2 font-bold hover:cursor-pointer hover:text-purple-500 hover:bg-gray-200 hover:rounded-lg',
21
+ actionsTh: 'text-black',
22
+ loadingCell: 'px-4 py-6 text-center',
23
+ emptyCell: 'px-4 py-6 text-center',
24
+ bodyRow: 'border-t hover:bg-gray-400 hover:cursor-pointer',
25
+ td: 'py-2 px-4 text-center border-2 border-white',
26
+ actionsTd: 'flex items-center gap-2 py-2 px-4 justify-center',
27
+ deleteButton: 'flex items-center rounded-full hover:bg-gray-600 px-2 py-1',
28
+ footer: 'w-full flex flex-col md:flex-row md:justify-between md:items-center gap-2 mt-2',
29
+ pagination: 'flex flex-wrap items-center gap-2',
30
+ pageButton: 'px-3 py-1 rounded border text-sm',
31
+ pageButtonActive: 'font-bold',
32
+ pageButtonDisabled: 'opacity-40 cursor-not-allowed',
33
+ }
34
+
35
+ const props = defineProps<{
36
+ model: string
37
+ columns?: Column[]
38
+ pageSize?: number
39
+ query?: {
40
+ select?: string
41
+ where?: WhereClause
42
+ orderBy?: string
43
+ orderDirection?: OrderDirection
44
+ orderForeignTable?: string
45
+ limit?: number
46
+ offset?: number
47
+ search?: string
48
+ searchColumns?: string[]
49
+ }
50
+ filters?: WhereClause
51
+ sortBy?: string
52
+ sortDirection?: OrderDirection
53
+ classes?: Partial<NsdbTableClasses>
54
+ unstyled?: boolean
55
+ variant?: 'table' | 'cards'
56
+ pageWindow?: number
57
+ showFirstLast?: boolean
58
+ showPageNumbers?: boolean
59
+ searchable?: boolean
60
+ search?: string
61
+ searchColumns?: string[]
62
+ searchPlaceholder?: string
63
+ searchDebounceMs?: number
64
+ store?: boolean
65
+ }>()
66
+
67
+ const loading = ref(false)
68
+ const error = ref<string | null>(null)
69
+
70
+ type NsdbListModel = {
71
+ primaryKey?: string
72
+ items: { value?: Array<Record<string, any>> }
73
+ totalCount?: { value?: number | null }
74
+ fetch: (query?: Record<string, any>) => Promise<Array<Record<string, any>>>
75
+ remove?: (id: string | number) => Promise<void> | void
76
+ }
77
+
78
+ const nsdbModel = computed(() =>
79
+ useNsdbModel(props.model, { store: props.store ?? false }) as unknown as NsdbListModel
80
+ )
81
+ const supabaseUser = useSupabaseUser()
82
+ const rows = computed(() => nsdbModel.value.items.value ?? [])
83
+ const totalCount = computed<number | null>(() => {
84
+ return (nsdbModel.value as any)?.totalCount?.value ?? null
85
+ })
86
+
87
+ const currentPage = ref(1)
88
+
89
+ const pageSize = computed<number | undefined>(() => {
90
+ if (props.pageSize != null) return props.pageSize
91
+ return undefined
92
+ })
93
+
94
+ const effectiveLimit = computed<number>(() => {
95
+ const baseQuery = props.query ?? {}
96
+ return pageSize.value ?? baseQuery.limit ?? 100
97
+ })
98
+
99
+ const effectiveOffset = computed<number>(() => {
100
+ const baseQuery = props.query ?? {}
101
+ return baseQuery.offset ?? (currentPage.value - 1) * effectiveLimit.value
102
+ })
103
+
104
+ const totalPages = computed<number | null>(() => {
105
+ if (!pageSize.value) return null
106
+ if (totalCount.value == null) return null
107
+ return Math.max(1, Math.ceil(totalCount.value / pageSize.value))
108
+ })
109
+
110
+ const classes = computed<NsdbTableClasses>(() => {
111
+ if (props.unstyled) {
112
+ return Object.keys(defaultClasses).reduce((acc, key) => {
113
+ acc[key as keyof NsdbTableClasses] = ''
114
+ return acc
115
+ }, {} as NsdbTableClasses)
116
+ }
117
+
118
+ return {
119
+ ...defaultClasses,
120
+ ...(props.classes ?? {}),
121
+ }
122
+ })
123
+
124
+ const canGoPrev = computed(() => currentPage.value > 1)
125
+
126
+ const canGoNext = computed(() => {
127
+ if (totalPages.value != null) return currentPage.value < totalPages.value
128
+ if (!pageSize.value) return rows.value.length > 0
129
+ return rows.value.length === pageSize.value
130
+ })
131
+
132
+ const pageWindow = computed(() => props.pageWindow ?? 2)
133
+ const showFirstLast = computed(() => props.showFirstLast ?? true)
134
+ const showPageNumbers = computed(() => props.showPageNumbers ?? true)
135
+ const searchTerm = ref(props.search ?? '')
136
+ const debouncedSearchTerm = ref(props.search ?? '')
137
+ let searchDebounceTimeout: ReturnType<typeof setTimeout> | null = null
138
+ let loadSequence = 0
139
+ let isMounted = false
140
+ const deletingRows = ref(new Set<string | number>())
141
+ const searchInputId = `nsdb-list-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-search`
142
+
143
+ const pageItems = computed<(number | '...')[]>(() => {
144
+ if (!showPageNumbers.value) return []
145
+ if (totalPages.value == null) return []
146
+
147
+ const tp = totalPages.value
148
+ const cp = currentPage.value
149
+ const w = Math.max(0, pageWindow.value)
150
+
151
+ if (tp <= 1) return [1]
152
+ if (tp <= 2 + 2 * w + 2) {
153
+ return Array.from({ length: tp }, (_, i) => i + 1)
154
+ }
155
+
156
+ const items: (number | '...')[] = []
157
+ const start = Math.max(2, cp - w)
158
+ const end = Math.min(tp - 1, cp + w)
159
+
160
+ items.push(1)
161
+
162
+ if (start > 2) items.push('...')
163
+ for (let p = start; p <= end; p++) items.push(p)
164
+ if (end < tp - 1) items.push('...')
165
+
166
+ items.push(tp)
167
+ return items
168
+ })
169
+
170
+ const effectiveColumns = computed<Column[]>(() => {
171
+ if (props.columns && props.columns.length > 0) {
172
+ props.columns.forEach(column => {
173
+ if (typeof column.key !== 'string') {
174
+ console.warn('[NsdbList] column.key should be a string, got:', column.key)
175
+ }
176
+ })
177
+ return props.columns
178
+ }
179
+ const schema = modelSchema.value
180
+ if (schema) {
181
+ return Object.entries(schema)
182
+ .filter(([, field]) => field?.selectable !== false && !field?.hidden && !field?.serverOnly)
183
+ .map(([key, field]) => ({ key, label: field?.label ?? key }))
184
+ }
185
+
186
+ const first = rows.value[0]
187
+ if (!first) return []
188
+ return Object.keys(first).map(key => ({ key, label: key }))
189
+ })
190
+
191
+ function toPascalCase(value: string) {
192
+ return value
193
+ .split(/[^a-zA-Z0-9]/)
194
+ .filter(Boolean)
195
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
196
+ .join('')
197
+ }
198
+
199
+ const modelSchema = computed<Record<string, any> | null>(() => {
200
+ const schemaExportName = `${toPascalCase(props.model)}Schema`
201
+ return (nsdbSchemas as Record<string, any>)[schemaExportName] ?? null
202
+ })
203
+
204
+ function isTextSearchColumn(columnKey: string, warn = false) {
205
+ const schema = modelSchema.value
206
+ if (!schema) return true
207
+ if (columnKey.includes('.')) return true
208
+
209
+ const field = schema[columnKey]
210
+ if (!field) return true
211
+
212
+ const isTextField = field.type === 'text' || field.type === 'textarea'
213
+ if (!isTextField && warn) {
214
+ console.warn(
215
+ `[NsdbList] "${columnKey}" ignored from searchColumns on "${props.model}" because its schema type is "${field.type}". Use filters for non-text columns.`
216
+ )
217
+ }
218
+
219
+ return isTextField
220
+ }
221
+
222
+ function isSortableColumn(columnKey: string) {
223
+ if (columnKey.includes('.')) {
224
+ console.warn(
225
+ `[NsdbList] "${columnKey}" cannot be sorted automatically because it is a relation path. Configure query.orderBy/orderForeignTable manually if needed.`
226
+ )
227
+ return false
228
+ }
229
+
230
+ return true
231
+ }
232
+
233
+ const effectiveSearchColumns = computed(() => {
234
+ if (props.searchColumns?.length) {
235
+ return props.searchColumns.filter(column => isTextSearchColumn(column, true))
236
+ }
237
+
238
+ if (props.query?.searchColumns?.length) {
239
+ return props.query.searchColumns.filter(column => isTextSearchColumn(column, true))
240
+ }
241
+
242
+ return effectiveColumns.value
243
+ .map(column => column.key)
244
+ .filter(columnKey => !columnKey.includes('.'))
245
+ .filter(column => isTextSearchColumn(column))
246
+ })
247
+
248
+ const effectiveSearch = computed(() => {
249
+ const localSearch = debouncedSearchTerm.value.trim()
250
+ if (localSearch) return localSearch
251
+ return props.query?.search ?? ''
252
+ })
253
+
254
+ function setSearchTerm(value: string) {
255
+ searchTerm.value = value
256
+ }
257
+
258
+ const sortState = ref<SortState>({
259
+ key: props.sortBy ?? null,
260
+ direction: props.sortBy ? props.sortDirection ?? 'asc' : null,
261
+ })
262
+
263
+ async function setSort(key: string | null, direction: OrderDirection | null = 'asc') {
264
+ if (key && !isSortableColumn(key)) return
265
+
266
+ sortState.value = { key, direction: key ? direction : null }
267
+ currentPage.value = 1
268
+ await load()
269
+ }
270
+
271
+ function sortAriaValue(column: Column) {
272
+ if (sortState.value.key !== column.key || !sortState.value.direction) return 'none'
273
+ return sortState.value.direction === 'asc' ? 'ascending' : 'descending'
274
+ }
275
+
276
+ async function toggleSort(column: Column) {
277
+ if (sortState.value.key !== column.key) {
278
+ await setSort(column.key, 'asc')
279
+ return
280
+ }
281
+
282
+ if (sortState.value.direction === 'asc') {
283
+ await setSort(column.key, 'desc')
284
+ return
285
+ }
286
+
287
+ await setSort(null, null)
288
+ }
289
+
290
+ function getDeep(row: any, path: unknown) {
291
+ if (!row || path == null) return null
292
+
293
+ if (Array.isArray(path)) {
294
+ let current: any = row
295
+ for (const part of path) {
296
+ if (current == null) return null
297
+ current = current[part as keyof typeof current]
298
+ }
299
+ return current
300
+ }
301
+
302
+ if (typeof path !== 'string') {
303
+ console.warn('[NsdbList.getDeep] path is not a string:', path, 'typeof =', typeof path)
304
+ return null
305
+ }
306
+
307
+ const segments = path.split('.')
308
+ let current: any = row
309
+ for (const segment of segments) {
310
+ if (current == null) return null
311
+ current = current[segment as keyof typeof current]
312
+ }
313
+ return current
314
+ }
315
+
316
+ const effectiveWhere = computed<WhereClause | undefined>(() => {
317
+ const mergedWhere = {
318
+ ...(props.query?.where ?? {}),
319
+ ...(props.filters ?? {}),
320
+ }
321
+
322
+ return Object.keys(mergedWhere).length > 0 ? mergedWhere : undefined
323
+ })
324
+
325
+ const serverQuery = computed(() => {
326
+ const baseQuery = props.query ?? {}
327
+
328
+ // Requête unique consommée par le modèle: recherche, filtres, tri et pagination restent côté Supabase.
329
+ return {
330
+ ...baseQuery,
331
+ where: effectiveWhere.value,
332
+ orderBy: sortState.value.key ?? props.sortBy ?? baseQuery.orderBy,
333
+ orderDirection: sortState.value.direction ?? props.sortDirection ?? baseQuery.orderDirection,
334
+ limit: effectiveLimit.value,
335
+ offset: effectiveOffset.value,
336
+ search: effectiveSearch.value || undefined,
337
+ searchColumns: effectiveSearchColumns.value,
338
+ }
339
+ })
340
+
341
+ const displayRows = computed(() => rows.value)
342
+ const hasRows = computed(() => displayRows.value.length > 0)
343
+ const isEmpty = computed(() => !loading.value && !error.value && !hasRows.value)
344
+ const emptyMessage = computed(() => effectiveSearch.value ? 'Aucun résultat pour cette recherche' : 'Aucune donnée')
345
+ const paginationSlotProps = computed(() => ({
346
+ currentPage: currentPage.value,
347
+ pageSize: effectiveLimit.value,
348
+ limit: effectiveLimit.value,
349
+ offset: effectiveOffset.value,
350
+ totalCount: totalCount.value,
351
+ totalPages: totalPages.value,
352
+ canGoPrev: canGoPrev.value,
353
+ canGoNext: canGoNext.value,
354
+ pageItems: pageItems.value,
355
+ showFirstLast: showFirstLast.value,
356
+ showPageNumbers: showPageNumbers.value,
357
+ goToPage,
358
+ firstPage: handleFirstPage,
359
+ lastPage: handleLastPage,
360
+ prevPage: handlePrevPage,
361
+ nextPage: handleNextPage,
362
+ }))
363
+
364
+ async function load() {
365
+ const requestId = ++loadSequence
366
+ loading.value = true
367
+ error.value = null
368
+
369
+ try {
370
+ if (typeof (nsdbModel.value as any).fetch === 'function') {
371
+ await (nsdbModel.value as any).fetch(serverQuery.value)
372
+ } else {
373
+ console.warn('[NsdbList] No fetch() found on nsdbModel for', props.model)
374
+ }
375
+ } catch (e: any) {
376
+ if (requestId !== loadSequence) return
377
+ error.value = e?.message ?? 'Erreur de chargement'
378
+ } finally {
379
+ if (requestId === loadSequence) loading.value = false
380
+ }
381
+ }
382
+
383
+ defineExpose({ refresh: load })
384
+
385
+ async function goToPage(page: number) {
386
+ const tp = totalPages.value
387
+ let target = page
388
+
389
+ if (target < 1) target = 1
390
+ if (tp != null && target > tp) target = tp
391
+
392
+ if (target === currentPage.value) return
393
+ currentPage.value = target
394
+ await load()
395
+ }
396
+
397
+ async function handlePrevPage() {
398
+ if (!canGoPrev.value) return
399
+ await goToPage(currentPage.value - 1)
400
+ }
401
+
402
+ async function handleNextPage() {
403
+ if (!canGoNext.value) return
404
+ await goToPage(currentPage.value + 1)
405
+ }
406
+
407
+ async function handleFirstPage() {
408
+ await goToPage(1)
409
+ }
410
+
411
+ async function handleLastPage() {
412
+ if (totalPages.value == null) return
413
+ await goToPage(totalPages.value)
414
+ }
415
+
416
+ watch(
417
+ () => [
418
+ props.model,
419
+ props.pageSize,
420
+ props.query,
421
+ props.filters,
422
+ props.searchColumns,
423
+ ],
424
+ () => {
425
+ currentPage.value = 1
426
+ if (isMounted) void load()
427
+ },
428
+ { deep: true }
429
+ )
430
+
431
+ watch(
432
+ () => [props.sortBy, props.sortDirection] as const,
433
+ ([sortBy, sortDirection]) => {
434
+ sortState.value = {
435
+ key: sortBy ?? null,
436
+ direction: sortBy ? sortDirection ?? 'asc' : null,
437
+ }
438
+ currentPage.value = 1
439
+ if (isMounted) void load()
440
+ }
441
+ )
442
+
443
+ watch(
444
+ () => props.search,
445
+ (searchValue) => {
446
+ searchTerm.value = searchValue ?? ''
447
+ }
448
+ )
449
+
450
+ watch(
451
+ () => supabaseUser.value?.id ?? null,
452
+ () => {
453
+ // Quarantine rendered rows synchronously across logout/account switches.
454
+ const modelItems = nsdbModel.value.items
455
+ if (modelItems && 'value' in modelItems) modelItems.value = []
456
+ currentPage.value = 1
457
+ if (isMounted) void load()
458
+ },
459
+ { flush: 'sync' },
460
+ )
461
+
462
+ watch(
463
+ searchTerm,
464
+ (searchValue) => {
465
+ if (searchDebounceTimeout) clearTimeout(searchDebounceTimeout)
466
+
467
+ searchDebounceTimeout = setTimeout(() => {
468
+ debouncedSearchTerm.value = searchValue
469
+ currentPage.value = 1
470
+ if (isMounted) void load()
471
+ }, props.searchDebounceMs ?? 300)
472
+ }
473
+ )
474
+
475
+ onMounted(() => {
476
+ isMounted = true
477
+ void load()
478
+ })
479
+
480
+ onBeforeUnmount(() => {
481
+ isMounted = false
482
+ if (searchDebounceTimeout) clearTimeout(searchDebounceTimeout)
483
+ loadSequence++
484
+ })
485
+
486
+ async function handleDelete(row: any) {
487
+ const id = row?.[nsdbModel.value.primaryKey ?? 'id']
488
+ if (id == null) return
489
+ if (deletingRows.value.has(id)) return
490
+
491
+ try {
492
+ deletingRows.value.add(id)
493
+ if (typeof (nsdbModel.value as any).remove === 'function') {
494
+ await (nsdbModel.value as any).remove(id)
495
+ } else {
496
+ console.warn('[NsdbList] No remove() method found on nsdbModel for', props.model)
497
+ return
498
+ }
499
+
500
+ if (totalPages.value != null && currentPage.value > totalPages.value) {
501
+ currentPage.value = totalPages.value
502
+ }
503
+
504
+ await load()
505
+ } catch (e: any) {
506
+ console.error('[NsdbList] Error while deleting row:', e)
507
+ error.value = e?.message ?? 'Erreur de suppression'
508
+ } finally {
509
+ deletingRows.value.delete(id)
510
+ }
511
+ }
512
+
513
+ function displayValue(value: unknown) {
514
+ if (value == null || value === '') return 'Inconnu'
515
+ if (Array.isArray(value)) return value.length === 0 ? '—' : `${value.length} élément${value.length > 1 ? 's' : ''}`
516
+ if (typeof value === 'object') {
517
+ const record = value as Record<string, unknown>
518
+ for (const key of ['label', 'name', 'title']) {
519
+ if (record[key] != null) return String(record[key])
520
+ }
521
+ return '1 élément'
522
+ }
523
+ if (typeof value === 'boolean') return value ? 'Oui' : 'Non'
524
+ return String(value)
525
+ }
526
+
527
+ function rowKey(row: Record<string, any>) {
528
+ return row?.[nsdbModel.value.primaryKey ?? 'id'] ?? JSON.stringify(row)
529
+ }
530
+ </script>
531
+
532
+ <template>
533
+ <slot
534
+ :model="props.model"
535
+ :rows="displayRows"
536
+ :raw-rows="rows"
537
+ :has-rows="hasRows"
538
+ :is-empty="isEmpty"
539
+ :columns="effectiveColumns"
540
+ :loading="loading"
541
+ :error="error"
542
+ :query="serverQuery"
543
+ :filters="effectiveWhere"
544
+ :sort-state="sortState"
545
+ :set-sort="setSort"
546
+ :current-page="currentPage"
547
+ :page-size="effectiveLimit"
548
+ :limit="effectiveLimit"
549
+ :offset="effectiveOffset"
550
+ :total-count="totalCount"
551
+ :total-pages="totalPages"
552
+ :can-go-prev="canGoPrev"
553
+ :can-go-next="canGoNext"
554
+ :page-items="pageItems"
555
+ :show-first-last="showFirstLast"
556
+ :show-page-numbers="showPageNumbers"
557
+ :pagination="paginationSlotProps"
558
+ :go-to-page="goToPage"
559
+ :first-page="handleFirstPage"
560
+ :last-page="handleLastPage"
561
+ :prev-page="handlePrevPage"
562
+ :next-page="handleNextPage"
563
+ :delete-row="handleDelete"
564
+ :search="searchTerm"
565
+ :search-columns="effectiveSearchColumns"
566
+ :set-search="setSearchTerm"
567
+ :refresh="load"
568
+ >
569
+ <div :class="classes.wrapper" :aria-busy="loading ? 'true' : 'false'">
570
+ <div :class="classes.headerWrapper">
571
+ <slot
572
+ name="header"
573
+ :model="props.model"
574
+ :rows="rows"
575
+ :has-rows="hasRows"
576
+ :is-empty="isEmpty"
577
+ :loading="loading"
578
+ :error="error"
579
+ :query="serverQuery"
580
+ :filters="effectiveWhere"
581
+ :sort-state="sortState"
582
+ :columns="effectiveColumns"
583
+ :current-page="currentPage"
584
+ :total-count="totalCount"
585
+ :total-pages="totalPages"
586
+ >
587
+ <div>
588
+ <h3 :class="classes.headerTitle">
589
+ {{ props.model }}
590
+ </h3>
591
+ <div v-if="totalCount != null" :class="classes.headerSubtitle">
592
+ {{ totalCount }} éléments
593
+ <span v-if="totalPages"> - page {{ currentPage }} / {{ totalPages }}</span>
594
+ </div>
595
+ <div v-else-if="rows.length" :class="classes.headerSubtitle">
596
+ {{ rows.length }} éléments (page {{ currentPage }})
597
+ </div>
598
+ </div>
599
+ </slot>
600
+ </div>
601
+
602
+ <slot
603
+ name="toolbar"
604
+ :search="searchTerm"
605
+ :search-columns="effectiveSearchColumns"
606
+ :set-search="setSearchTerm"
607
+ :query="serverQuery"
608
+ :filters="effectiveWhere"
609
+ :sort-state="sortState"
610
+ :set-sort="setSort"
611
+ :refresh="load"
612
+ >
613
+ <div v-if="props.searchable" :class="classes.toolbar">
614
+ <label :for="searchInputId" class="sr-only">Rechercher dans {{ props.model }}</label>
615
+ <input
616
+ :id="searchInputId"
617
+ v-model="searchTerm"
618
+ type="search"
619
+ :class="classes.searchInput"
620
+ :placeholder="props.searchPlaceholder ?? 'Rechercher...'"
621
+ :disabled="loading"
622
+ />
623
+ </div>
624
+ </slot>
625
+
626
+ <slot name="error" v-if="error" :error="error">
627
+ <div :class="classes.error" role="alert" aria-live="polite">
628
+ {{ error }}
629
+ </div>
630
+ </slot>
631
+
632
+ <div v-if="props.variant === 'cards'">
633
+ <template v-if="loading">
634
+ <slot name="loading" :columns="effectiveColumns">
635
+ <div :class="classes.loadingCell">Chargement...</div>
636
+ </slot>
637
+ </template>
638
+
639
+ <template v-else-if="!error && displayRows.length === 0">
640
+ <slot
641
+ name="empty"
642
+ :model="props.model"
643
+ :rows="displayRows"
644
+ :raw-rows="rows"
645
+ :columns="effectiveColumns"
646
+ :loading="loading"
647
+ :error="error"
648
+ :query="serverQuery"
649
+ :filters="effectiveWhere"
650
+ :search="searchTerm"
651
+ :search-columns="effectiveSearchColumns"
652
+ :refresh="load"
653
+ >
654
+ <div :class="classes.emptyCell">
655
+ {{ emptyMessage }}
656
+ </div>
657
+ </slot>
658
+ </template>
659
+
660
+ <template v-else>
661
+ <slot name="cards" :rows="displayRows" :columns="effectiveColumns" :query="serverQuery">
662
+ <div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
663
+ <div
664
+ v-for="row in displayRows"
665
+ :key="rowKey(row)"
666
+ class="border rounded-lg p-4 shadow-sm bg-white"
667
+ >
668
+ <slot name="card" :row="row" :columns="effectiveColumns">
669
+ <div
670
+ v-for="column in effectiveColumns"
671
+ :key="column.key"
672
+ class="text-sm text-black mb-1"
673
+ >
674
+ <span class="font-semibold mr-1">{{ column.label }}:</span>
675
+ <span>
676
+ {{
677
+ column.format
678
+ ? column.format(getDeep(row, column.key), row)
679
+ : displayValue(getDeep(row, column.key))
680
+ }}
681
+ </span>
682
+ </div>
683
+
684
+ <button
685
+ type="button"
686
+ class="mt-2 text-xs text-red-500 underline"
687
+ @click="handleDelete(row)"
688
+ >
689
+ Supprimer
690
+ </button>
691
+ </slot>
692
+ </div>
693
+ </div>
694
+ </slot>
695
+ </template>
696
+ </div>
697
+
698
+ <div v-else :class="classes.tableContainer">
699
+ <table :class="classes.table">
700
+ <thead :class="classes.thead">
701
+ <slot name="thead" :columns="effectiveColumns">
702
+ <tr :class="classes.theadRow">
703
+ <th
704
+ v-for="column in effectiveColumns"
705
+ :key="column.key"
706
+ :class="classes.th"
707
+ :aria-sort="sortAriaValue(column)"
708
+ >
709
+ <slot
710
+ name="th"
711
+ :column="column"
712
+ :sort-key="sortState.key"
713
+ :sort-direction="sortState.direction"
714
+ :sort-state="sortState"
715
+ :set-sort="setSort"
716
+ :toggle-sort="() => toggleSort(column)"
717
+ >
718
+ <button
719
+ type="button"
720
+ class="w-full text-left"
721
+ :aria-label="`Trier par ${column.label}`"
722
+ @click="toggleSort(column)"
723
+ >
724
+ {{ column.label }}
725
+ <span v-if="sortState.key === column.key" aria-hidden="true">
726
+ <span v-if="sortState.direction === 'asc'">▲</span>
727
+ <span v-else-if="sortState.direction === 'desc'">▼</span>
728
+ </span>
729
+ </button>
730
+ </slot>
731
+ </th>
732
+
733
+ <th :class="classes.actionsTh">Actions</th>
734
+ </tr>
735
+ </slot>
736
+ </thead>
737
+
738
+ <tbody>
739
+ <template v-if="loading">
740
+ <slot name="loading" :columns="effectiveColumns">
741
+ <tr>
742
+ <td :colspan="effectiveColumns.length + 1" :class="classes.loadingCell">
743
+ Chargement...
744
+ </td>
745
+ </tr>
746
+ </slot>
747
+ </template>
748
+
749
+ <template v-else-if="!error && displayRows.length === 0">
750
+ <slot
751
+ name="empty"
752
+ :model="props.model"
753
+ :rows="displayRows"
754
+ :raw-rows="rows"
755
+ :columns="effectiveColumns"
756
+ :loading="loading"
757
+ :error="error"
758
+ :query="serverQuery"
759
+ :filters="effectiveWhere"
760
+ :search="searchTerm"
761
+ :search-columns="effectiveSearchColumns"
762
+ :refresh="load"
763
+ >
764
+ <tr>
765
+ <td :colspan="effectiveColumns.length + 1" :class="classes.emptyCell">
766
+ {{ emptyMessage }}
767
+ </td>
768
+ </tr>
769
+ </slot>
770
+ </template>
771
+
772
+ <template v-else>
773
+ <slot name="body" :rows="displayRows" :columns="effectiveColumns" :query="serverQuery">
774
+ <tr
775
+ v-for="row in displayRows"
776
+ :key="rowKey(row)"
777
+ :class="classes.bodyRow"
778
+ >
779
+ <td
780
+ v-for="column in effectiveColumns"
781
+ :key="column.key"
782
+ :class="classes.td"
783
+ >
784
+ <slot
785
+ name="cell"
786
+ :row="row"
787
+ :column="column"
788
+ :value="
789
+ column.format
790
+ ? column.format(getDeep(row, column.key), row)
791
+ : displayValue(getDeep(row, column.key))
792
+ "
793
+ >
794
+ {{
795
+ column.format
796
+ ? column.format(getDeep(row, column.key), row)
797
+ : displayValue(getDeep(row, column.key))
798
+ }}
799
+ </slot>
800
+ </td>
801
+
802
+ <td :class="classes.actionsTd">
803
+ <button
804
+ type="button"
805
+ aria-label="Supprimer la ligne"
806
+ :disabled="deletingRows.has(row?.[nsdbModel.primaryKey ?? 'id'])"
807
+ @click="handleDelete(row)"
808
+ :class="classes.deleteButton"
809
+ >
810
+ <svg
811
+ aria-hidden="true"
812
+ class="w-4 h-4 text-red-500"
813
+ viewBox="0 0 24 24"
814
+ fill="none"
815
+ stroke="currentColor"
816
+ stroke-width="2"
817
+ >
818
+ <path d="M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5m4-5v5" />
819
+ </svg>
820
+ </button>
821
+ </td>
822
+ </tr>
823
+ </slot>
824
+ </template>
825
+ </tbody>
826
+ </table>
827
+ </div>
828
+
829
+ <div :class="classes.footer">
830
+ <slot
831
+ name="footer"
832
+ :rows="displayRows"
833
+ :columns="effectiveColumns"
834
+ :model="props.model"
835
+ :query="serverQuery"
836
+ :filters="effectiveWhere"
837
+ :sort-state="sortState"
838
+ :set-sort="setSort"
839
+ :current-page="currentPage"
840
+ :page-size="effectiveLimit"
841
+ :limit="effectiveLimit"
842
+ :offset="effectiveOffset"
843
+ :total-count="totalCount"
844
+ :total-pages="totalPages"
845
+ :can-go-prev="canGoPrev"
846
+ :can-go-next="canGoNext"
847
+ :go-to-page="goToPage"
848
+ :first-page="handleFirstPage"
849
+ :last-page="handleLastPage"
850
+ :prev-page="handlePrevPage"
851
+ :next-page="handleNextPage"
852
+ :page-items="pageItems"
853
+ :show-first-last="showFirstLast"
854
+ :show-page-numbers="showPageNumbers"
855
+ :pagination="paginationSlotProps"
856
+ >
857
+ <div class="text-sm opacity-70">
858
+ Page {{ currentPage }}
859
+ <span v-if="totalPages"> / {{ totalPages }}</span>
860
+ <span v-if="totalCount != null"> - {{ totalCount }} éléments</span>
861
+ </div>
862
+
863
+ <slot
864
+ name="pagination"
865
+ :rows="displayRows"
866
+ :model="props.model"
867
+ :loading="loading"
868
+ :current-page="currentPage"
869
+ :page-size="effectiveLimit"
870
+ :limit="effectiveLimit"
871
+ :offset="effectiveOffset"
872
+ :total-count="totalCount"
873
+ :total-pages="totalPages"
874
+ :can-go-prev="canGoPrev"
875
+ :can-go-next="canGoNext"
876
+ :page-items="pageItems"
877
+ :show-first-last="showFirstLast"
878
+ :show-page-numbers="showPageNumbers"
879
+ :go-to-page="goToPage"
880
+ :first-page="handleFirstPage"
881
+ :last-page="handleLastPage"
882
+ :prev-page="handlePrevPage"
883
+ :next-page="handleNextPage"
884
+ :pagination="paginationSlotProps"
885
+ >
886
+ <div :class="classes.pagination">
887
+ <button
888
+ v-if="showFirstLast && totalPages"
889
+ type="button"
890
+ :class="[classes.pageButton, (!canGoPrev || loading) && classes.pageButtonDisabled]"
891
+ :disabled="!canGoPrev || loading"
892
+ @click="handleFirstPage"
893
+ >
894
+ Première
895
+ </button>
896
+
897
+ <button
898
+ type="button"
899
+ :class="[classes.pageButton, (!canGoPrev || loading) && classes.pageButtonDisabled]"
900
+ :disabled="!canGoPrev || loading"
901
+ @click="handlePrevPage"
902
+ >
903
+ Précédent
904
+ </button>
905
+
906
+ <template v-if="showPageNumbers && totalPages">
907
+ <template v-for="it in pageItems" :key="String(it) + '-' + currentPage">
908
+ <span v-if="it === '...'" class="px-2 opacity-60">...</span>
909
+ <button
910
+ v-else
911
+ type="button"
912
+ :class="[
913
+ classes.pageButton,
914
+ it === currentPage && classes.pageButtonActive,
915
+ loading && classes.pageButtonDisabled
916
+ ]"
917
+ :disabled="loading"
918
+ @click="goToPage(it)"
919
+ >
920
+ {{ it }}
921
+ </button>
922
+ </template>
923
+ </template>
924
+
925
+ <button
926
+ type="button"
927
+ :class="[classes.pageButton, (!canGoNext || loading) && classes.pageButtonDisabled]"
928
+ :disabled="!canGoNext || loading"
929
+ @click="handleNextPage"
930
+ >
931
+ Suivant
932
+ </button>
933
+
934
+ <button
935
+ v-if="showFirstLast && totalPages"
936
+ type="button"
937
+ :class="[classes.pageButton, (!canGoNext || loading) && classes.pageButtonDisabled]"
938
+ :disabled="!canGoNext || loading"
939
+ @click="handleLastPage"
940
+ >
941
+ Dernière
942
+ </button>
943
+ </div>
944
+ </slot>
945
+ </slot>
946
+ </div>
947
+ </div>
948
+ </slot>
949
+ </template>
950
+
951
+ <style scoped>
952
+ .nsdb-table {
953
+ width: 100% !important;
954
+ max-width: 100%;
955
+ table-layout: auto;
956
+ width: -webkit-fill-available;
957
+ width: -moz-available;
958
+ width: stretch;
959
+ max-width: 100%;
960
+ }
961
+ </style>