@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,865 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, computed, nextTick, useId } from 'vue'
3
+ import { useSupabaseUser } from '#imports'
4
+ import { useNsdbModel } from '#build/nsdb/registry'
5
+ import NsdbRelationSelect from './Form/NsdbRelationSelect.vue'
6
+ import type { EntityRelation } from '@lucashw68/nsdb/types/entities'
7
+
8
+ type NsdbFormMode = 'create' | 'edit'
9
+
10
+ type Label = {
11
+ key: string
12
+ label: string
13
+ }
14
+
15
+ type NsdbFormModel = {
16
+ items?: unknown
17
+ schema: Record<string, any>
18
+ createDraft?: () => Record<string, any>
19
+ getById?: (id: string | number) => Promise<Record<string, any> | null>
20
+ create?: (payload: Record<string, any>) => Promise<any>
21
+ update?: (id: string | number, payload: Record<string, any>) => Promise<any>
22
+ relatedModels?: Record<string, NsdbFormModel>
23
+ }
24
+
25
+ const props = defineProps<{
26
+ model: string
27
+ id?: string | number | null
28
+ initialValues?: Record<string, any>
29
+ labels?: Label[]
30
+ hideFields?: string[]
31
+ store?: boolean
32
+ validate?: (
33
+ values: Readonly<Record<string, any>>,
34
+ context: { mode: NsdbFormMode; model: string },
35
+ ) => Record<string, string | string[]> | null | undefined | Promise<Record<string, string | string[]> | null | undefined>
36
+ }>()
37
+
38
+ const emit = defineEmits<{
39
+ (e: 'saved', payload: any): void
40
+ (e: 'created', payload: any): void
41
+ (e: 'updated', payload: any): void
42
+ (e: 'error', error: any): void
43
+ }>()
44
+
45
+ // DX handle générique (playlists, songs, etc.)
46
+ // The registry returns a table-specific Insert/Update contract. The generic form
47
+ // intentionally erases that table parameter only at this UI boundary.
48
+ const nsdbModel = computed(() =>
49
+ useNsdbModel(props.model, { store: props.store ?? false }) as unknown as NsdbFormModel
50
+ )
51
+ const nsdbSchema = computed<Record<string, any>>(() => nsdbModel.value.schema ?? {})
52
+ const supabaseUser = useSupabaseUser()
53
+
54
+ // ------------------------
55
+ // Modèles liés (belongsTo)
56
+ // ------------------------
57
+
58
+ const relatedModels = computed<Record<string, NsdbFormModel>>(() => {
59
+ const models: Record<string, NsdbFormModel> = {}
60
+ for (const def of Object.values(nsdbSchema.value)) {
61
+ if (def?.type === 'relation' && def.relation && def.relation.kind === 'belongsTo') {
62
+ const relation = def.relation as EntityRelation
63
+ const table = relation.referencedTable
64
+ if (!models[table]) {
65
+ try {
66
+ models[table] = useNsdbModel(table, { store: props.store ?? false }) as unknown as NsdbFormModel
67
+ } catch (e) {
68
+ console.warn('[NsdbForm] Impossible d’initialiser le modèle lié pour', table, e)
69
+ }
70
+ }
71
+ }
72
+ }
73
+ return models
74
+ })
75
+
76
+ function splitInitialValuesByRelation(
77
+ initialValues: Record<string, any> | undefined,
78
+ schema: Record<string, any> | undefined
79
+ ) {
80
+ const root: Record<string, any> = {}
81
+ const perRelation: Record<string, Record<string, any>> = {}
82
+
83
+ if (!initialValues || !schema) {
84
+ return { root, perRelation }
85
+ }
86
+
87
+ for (const [fullKey, value] of Object.entries(initialValues)) {
88
+ // pas de point → champ de l’entité principale
89
+ if (!fullKey.includes('.')) {
90
+ root[fullKey] = value
91
+ continue
92
+ }
93
+
94
+ const [prefix = '', childKey = ''] = fullKey.split('.', 2)
95
+ if (!childKey) continue
96
+
97
+ let relationFieldKey: string | null = null
98
+
99
+ // 1) le prefix correspond directement à un champ relation
100
+ if (schema[prefix]?.type === 'relation') {
101
+ relationFieldKey = prefix
102
+ } else {
103
+ // 2) sinon, on regarde si le prefix correspond à la table référencée
104
+ for (const [fieldKey, fieldDef] of Object.entries(schema)) {
105
+ if (
106
+ fieldDef?.type === 'relation' &&
107
+ fieldDef.relation?.referencedTable &&
108
+ fieldDef.relation.referencedTable.replace(/s$/i, '') === prefix.replace(/s$/i, '')
109
+ ) {
110
+ relationFieldKey = fieldKey
111
+ break
112
+ }
113
+ }
114
+ }
115
+
116
+ if (!relationFieldKey) {
117
+ console.warn(
118
+ '[NsdbForm] initialValues: impossible de résoudre la relation pour la clé',
119
+ fullKey
120
+ )
121
+ continue
122
+ }
123
+
124
+ const resolvedRelationKey = relationFieldKey
125
+
126
+ if (!perRelation[resolvedRelationKey]) {
127
+ perRelation[resolvedRelationKey] = {}
128
+ }
129
+ perRelation[resolvedRelationKey][childKey] = value
130
+ }
131
+
132
+ return { root, perRelation }
133
+ }
134
+
135
+ const parsedInitialValues = computed(() =>
136
+ splitInitialValuesByRelation(props.initialValues ?? {}, nsdbSchema.value)
137
+ )
138
+
139
+ const rootInitialValues = computed(
140
+ () => parsedInitialValues.value?.root ?? {}
141
+ )
142
+
143
+ const relationInitialValues = computed(
144
+ () => parsedInitialValues.value?.perRelation ?? {}
145
+ )
146
+
147
+ const mode = computed<NsdbFormMode>(() => (props.id != null ? 'edit' : 'create'))
148
+
149
+ const loading = ref(false)
150
+ const saving = ref(false)
151
+ const error = ref<string | null>(null)
152
+ const fieldErrors = ref<Record<string, string[]>>({})
153
+ const form = ref<Record<string, any>>({})
154
+ const formElement = ref<HTMLFormElement | null>(null)
155
+ const formUid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
156
+ const status = ref<'idle' | 'loading' | 'saving' | 'saved' | 'error'>('idle')
157
+ const baseline = ref('{}')
158
+ let loadSequence = 0
159
+
160
+ const dirty = computed(() => JSON.stringify(form.value) !== baseline.value)
161
+
162
+ const hiddenFieldsSet = computed(() => new Set([
163
+ ...(props.hideFields ?? []),
164
+ ...Object.entries(nsdbSchema.value)
165
+ .filter(([, definition]) => definition?.hidden || definition?.serverOnly)
166
+ .map(([key]) => key),
167
+ ]))
168
+
169
+ function initForm() {
170
+ const base = typeof nsdbModel.value.createDraft === 'function'
171
+ ? nsdbModel.value.createDraft()
172
+ : {}
173
+
174
+ form.value = {
175
+ ...base,
176
+ ...(rootInitialValues.value || {}),
177
+ }
178
+ baseline.value = JSON.stringify(form.value)
179
+ }
180
+
181
+ /**
182
+ * Champs cachés + required => doivent être présents dans initialValues
183
+ */
184
+ const missingRequiredHiddenFields = computed<string[]>(() => {
185
+ if (!hiddenFieldsSet.value.size) return []
186
+
187
+ return [...hiddenFieldsSet.value].filter((field: string) => {
188
+ const def = nsdbSchema.value[field]
189
+ if (!def || !def.required) return false
190
+
191
+ // doit exister dans initialValues
192
+ if (!props.initialValues) return true
193
+ return !(field in props.initialValues)
194
+ })
195
+ })
196
+
197
+ watch(
198
+ missingRequiredHiddenFields,
199
+ (missing: string[]) => {
200
+ if (missing.length) {
201
+ error.value = `Les champs requis cachés suivants doivent être présents dans initialValues : ${missing.join(
202
+ ', '
203
+ )}`
204
+ }
205
+ },
206
+ { immediate: true }
207
+ )
208
+
209
+ /**
210
+ * Chargement de l'entité existante en mode "edit"
211
+ */
212
+ async function load() {
213
+ const requestId = ++loadSequence
214
+ // Never render values or errors belonging to the previous model/item.
215
+ form.value = {}
216
+ fieldErrors.value = {}
217
+ error.value = null
218
+ loading.value = false
219
+ status.value = mode.value === 'edit' ? 'loading' : 'idle'
220
+ if (mode.value === 'create') {
221
+ initForm()
222
+ return
223
+ }
224
+
225
+ if (props.id == null) {
226
+ error.value = 'Aucun ID fourni pour le mode édition.'
227
+ return
228
+ }
229
+
230
+ loading.value = true
231
+
232
+ try {
233
+ let existing: any = null
234
+
235
+ if (typeof nsdbModel.value.getById === 'function') {
236
+ existing = await nsdbModel.value.getById(props.id)
237
+ }
238
+ if (requestId !== loadSequence) return
239
+
240
+ if (!existing) {
241
+ error.value = 'Élément introuvable'
242
+ form.value = {}
243
+ } else {
244
+ form.value = { ...existing }
245
+ baseline.value = JSON.stringify(form.value)
246
+ }
247
+ } catch (e: any) {
248
+ if (requestId !== loadSequence) return
249
+ error.value = e?.message ?? 'Erreur de chargement'
250
+ status.value = 'error'
251
+ emit('error', e)
252
+ } finally {
253
+ if (requestId === loadSequence) {
254
+ loading.value = false
255
+ if (!error.value) status.value = 'idle'
256
+ }
257
+ }
258
+ }
259
+
260
+ // (Re)init quand id / initialValues / schema changent
261
+ watch(
262
+ () => [props.model, props.id, props.initialValues, props.store],
263
+ () => {
264
+ load()
265
+ },
266
+ { immediate: true, deep: true, flush: 'sync' }
267
+ )
268
+
269
+ watch(
270
+ () => supabaseUser.value?.id ?? null,
271
+ () => { void load() },
272
+ { flush: 'sync' },
273
+ )
274
+
275
+ function setField(field: string, value: any) {
276
+ form.value = {
277
+ ...form.value,
278
+ [field]: value,
279
+ }
280
+ if (fieldErrors.value[field]) {
281
+ const nextErrors = { ...fieldErrors.value }
282
+ delete nextErrors[field]
283
+ fieldErrors.value = nextErrors
284
+ }
285
+ }
286
+
287
+ function isFieldVisibleForMode(key: string, definition: any) {
288
+ if (hiddenFieldsSet.value.has(key) || definition?.serverOnly) return false
289
+ if (mode.value === 'create') {
290
+ return definition?.insertable !== false && !definition?.readonly && definition?.editable !== false
291
+ }
292
+ // Existing readonly values stay visible in edit mode, but never enter payloads.
293
+ return key in form.value || definition?.updatable !== false
294
+ }
295
+
296
+ const visibleFieldKeys = computed(() =>
297
+ Object.keys(nsdbSchema.value).filter(key => isFieldVisibleForMode(key, nsdbSchema.value[key]))
298
+ )
299
+
300
+ function hasMissingHiddenRequiredFields(): boolean {
301
+ if (missingRequiredHiddenFields.value.length) {
302
+ error.value =
303
+ error.value ||
304
+ `Impossible d’enregistrer : champs requis cachés manquants (${missingRequiredHiddenFields.value.join(
305
+ ', ',
306
+ )})`
307
+ return true
308
+ }
309
+ return false
310
+ }
311
+
312
+ function resetErrors() {
313
+ error.value = null
314
+ fieldErrors.value = {}
315
+ }
316
+
317
+ function validateVisibleRequiredFields(): Record<string, string[]> {
318
+ const validationErrors: Record<string, string[]> = {}
319
+
320
+ for (const [key, def] of Object.entries(nsdbSchema.value)) {
321
+ // on ignore les champs non required
322
+ if (!def.required) continue
323
+
324
+ // on ignore les champs cachés (déjà gérés par missingRequiredHiddenFields)
325
+ if (hiddenFieldsSet.value.has(key)) continue
326
+
327
+ const value = form.value[key]
328
+
329
+ const isEmpty =
330
+ value === null ||
331
+ value === undefined ||
332
+ (typeof value === 'string' && value.trim() === '')
333
+
334
+ if (isEmpty) {
335
+ if (!validationErrors[key]) validationErrors[key] = []
336
+ validationErrors[key].push('Ce champ est obligatoire.')
337
+ }
338
+ }
339
+
340
+ return validationErrors
341
+ }
342
+
343
+ function normalizeFieldErrors(errors: Record<string, string | string[]> | null | undefined) {
344
+ return Object.fromEntries(
345
+ Object.entries(errors ?? {}).map(([key, messages]) => [key, Array.isArray(messages) ? messages : [messages]]),
346
+ )
347
+ }
348
+
349
+ function emptyValueForField(key: string) {
350
+ const definition = nsdbSchema.value[key]
351
+ if (definition?.nullable) return null
352
+ if (mode.value === 'create' && definition?.hasDefault) return undefined
353
+ return ''
354
+ }
355
+
356
+ function setTextField(key: string, rawValue: string) {
357
+ setField(key, rawValue === '' ? emptyValueForField(key) : rawValue)
358
+ }
359
+
360
+ function setNumberField(key: string, rawValue: string) {
361
+ if (rawValue === '') {
362
+ setField(key, emptyValueForField(key))
363
+ return
364
+ }
365
+ setField(key, Number(rawValue))
366
+ }
367
+
368
+ function structuredControlValue(value: any) {
369
+ if (value == null) return ''
370
+ if (typeof value === 'string') return value
371
+ return JSON.stringify(value, null, 2)
372
+ }
373
+
374
+ function serializeStructuredField(key: string, value: any) {
375
+ if (value === undefined) return undefined
376
+ if (value === null) return null
377
+ if (typeof value !== 'string') return value
378
+ if (value.trim() === '') return emptyValueForField(key)
379
+
380
+ try {
381
+ const parsed = JSON.parse(value)
382
+ if (nsdbSchema.value[key]?.type === 'array' && !Array.isArray(parsed)) {
383
+ throw new Error('Cette valeur doit être un tableau JSON valide.')
384
+ }
385
+ return parsed
386
+ } catch (cause: any) {
387
+ const message = cause?.message?.includes('tableau')
388
+ ? cause.message
389
+ : 'Saisissez une valeur JSON valide.'
390
+ throw Object.assign(new Error(message), { fieldErrors: { [key]: [message] } })
391
+ }
392
+ }
393
+
394
+ function normalizeRelationValue(key: string, value: string | number | null) {
395
+ if (value == null) return null
396
+ const databaseType = String(nsdbSchema.value[key]?.databaseType ?? '').toLowerCase()
397
+ if (typeof value === 'string' && /^(smallint|integer|bigint|numeric|decimal|real|double precision)/.test(databaseType)) {
398
+ return Number(value)
399
+ }
400
+ return value
401
+ }
402
+
403
+ function applyValidationErrors(validationErrors: Record<string, string[]>) {
404
+ fieldErrors.value = validationErrors
405
+ error.value = 'Certains champs obligatoires sont manquants.'
406
+ }
407
+
408
+ async function resolveRelationsAndBuildPayload(): Promise<Record<string, any>> {
409
+ const payload: Record<string, any> = {}
410
+ const relationCreationPromises: Promise<void>[] = []
411
+
412
+ for (const [key, value] of Object.entries(form.value)) {
413
+ const def = nsdbSchema.value[key]
414
+
415
+ // Database capabilities are mode-specific. Legacy schemas still use readonly/editable.
416
+ if (def?.serverOnly || def?.readonly || def?.editable === false) continue
417
+ if (mode.value === 'create' && def?.insertable === false) continue
418
+ if (mode.value === 'edit' && (def?.updatable === false || def?.primaryKey)) continue
419
+ if (value === undefined) continue
420
+
421
+ // ------------------------
422
+ // Champ relation
423
+ // ------------------------
424
+ if (def?.type === 'relation' && def.relation) {
425
+ const relation = def.relation
426
+ const table = relation.referencedTable
427
+
428
+ // Cas simple : valeur primitive = FK existante
429
+ if (
430
+ value === null ||
431
+ typeof value === 'string' ||
432
+ typeof value === 'number'
433
+ ) {
434
+ payload[key] = normalizeRelationValue(key, value)
435
+ continue
436
+ }
437
+
438
+ // Cas inline-create
439
+ if (value && typeof value === 'object' && (value as any).__nsdbInlineCreate) {
440
+ const inline = value as any
441
+ const model = relatedModels.value[table] ?? nsdbModel.value.relatedModels?.[table] ?? null
442
+
443
+ if (!model || typeof model.create !== 'function') {
444
+ console.warn(
445
+ '[NsdbForm] Aucun DX handle lié pour la table',
446
+ table,
447
+ '→ impossible de créer inline.'
448
+ )
449
+ continue
450
+ }
451
+ const createRelatedEntity = model.create
452
+
453
+ relationCreationPromises.push(
454
+ (async () => {
455
+ // point de départ : data remontée par le composant relation
456
+ const childData: Record<string, any> = {
457
+ ...(inline.data || {}),
458
+ }
459
+
460
+ // 1) defaults nested depuis initialValues : ex. "playlist.profile_id"
461
+ const childDefaults = relationInitialValues.value[key]
462
+ if (childDefaults) {
463
+ for (const [childKey, defaultValue] of Object.entries(childDefaults)) {
464
+ if (childData[childKey] == null) {
465
+ childData[childKey] = defaultValue
466
+ }
467
+ }
468
+ }
469
+
470
+ // 2) création de l’entité liée
471
+ const created = await createRelatedEntity(childData)
472
+ if (!created) {
473
+ throw new Error(
474
+ `[nsdb] Échec de la création liée pour ${table}`
475
+ )
476
+ }
477
+
478
+ // 3) récupération de la PK référencée
479
+ const pkColumn = relation.referencedColumns?.[0] ?? 'id'
480
+ const createdId = (created as any)?.[pkColumn]
481
+
482
+ if (!createdId) {
483
+ console.warn(
484
+ `[NsdbForm] Impossible de récupérer la PK (${pkColumn}) sur l’entité créée de ${table}.`
485
+ )
486
+ }
487
+
488
+ payload[key] = createdId
489
+ })()
490
+ )
491
+
492
+ continue
493
+ }
494
+
495
+ // Valeur non gérée → on la passe telle quelle
496
+ payload[key] = value
497
+ continue
498
+ }
499
+
500
+ // ------------------------
501
+ // Champ "normal"
502
+ // ------------------------
503
+ payload[key] = def?.type === 'json' || def?.type === 'array'
504
+ ? serializeStructuredField(key, value)
505
+ : value
506
+ }
507
+
508
+ if (relationCreationPromises.length) {
509
+ await Promise.all(relationCreationPromises)
510
+ }
511
+
512
+ return payload
513
+ }
514
+
515
+ async function submitToModel(payload: Record<string, any>): Promise<any> {
516
+ if (mode.value === 'create') {
517
+ if (typeof nsdbModel.value.create !== 'function') {
518
+ throw new Error('Le nsdb model ne définit pas de méthode create().')
519
+ }
520
+ const result = await nsdbModel.value.create(payload)
521
+ emit('created', result)
522
+ return result
523
+ }
524
+
525
+ // mode 'edit'
526
+ if (props.id == null) {
527
+ throw new Error('Impossible de mettre à jour : aucun id fourni.')
528
+ }
529
+ if (typeof nsdbModel.value.update !== 'function') {
530
+ throw new Error('Le DX handle ne définit pas de méthode update().')
531
+ }
532
+ const result = await nsdbModel.value.update(props.id, payload)
533
+ emit('updated', result)
534
+ return result
535
+ }
536
+
537
+ function handleSubmitError(e: any) {
538
+ error.value = e?.message ?? 'Erreur lors de l’enregistrement'
539
+ status.value = 'error'
540
+
541
+ if (e?.fieldErrors && typeof e.fieldErrors === 'object') {
542
+ fieldErrors.value = e.fieldErrors
543
+ }
544
+
545
+ emit('error', e)
546
+ void focusFirstInvalidField()
547
+ }
548
+
549
+ async function onSubmit() {
550
+ if (saving.value) return
551
+
552
+ // 1. Validation des champs requis cachés
553
+ if (hasMissingHiddenRequiredFields()) {
554
+ return
555
+ }
556
+
557
+ // 2. Reset des erreurs
558
+ resetErrors()
559
+
560
+ // 3. Validation des champs visibles requis
561
+ const validationErrors = validateVisibleRequiredFields()
562
+ if (Object.keys(validationErrors).length > 0) {
563
+ applyValidationErrors(validationErrors)
564
+ status.value = 'error'
565
+ await focusFirstInvalidField()
566
+ return
567
+ }
568
+ if (props.validate) {
569
+ const customErrors = normalizeFieldErrors(await props.validate(form.value, { mode: mode.value, model: props.model }))
570
+ if (Object.keys(customErrors).length) {
571
+ applyValidationErrors(customErrors)
572
+ status.value = 'error'
573
+ await focusFirstInvalidField()
574
+ return
575
+ }
576
+ }
577
+
578
+ saving.value = true
579
+ status.value = 'saving'
580
+
581
+ try {
582
+ // 4. Construction du payload + résolution des créations liées
583
+ const payload = await resolveRelationsAndBuildPayload()
584
+
585
+ // 5. Soumission au DX handle (add / patch)
586
+ const result = await submitToModel(payload)
587
+
588
+ // 6. Événement global
589
+ emit('saved', result)
590
+ baseline.value = JSON.stringify(form.value)
591
+ status.value = 'saved'
592
+ } catch (e: any) {
593
+ handleSubmitError(e)
594
+ } finally {
595
+ saving.value = false
596
+ }
597
+ }
598
+
599
+ function fieldId(key: string) {
600
+ return `nsdb-${formUid}-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`
601
+ }
602
+
603
+ function fieldErrorId(key: string) {
604
+ return `${fieldId(key)}-error`
605
+ }
606
+
607
+ function numberStep(key: string) {
608
+ const databaseType = String(nsdbSchema.value[key]?.databaseType ?? '').toLowerCase()
609
+ return /^(smallint|integer|bigint)/.test(databaseType) ? '1' : 'any'
610
+ }
611
+
612
+ async function focusFirstInvalidField() {
613
+ await nextTick()
614
+ const firstInvalid = formElement.value?.querySelector<HTMLElement>('[aria-invalid="true"]')
615
+ firstInvalid?.focus()
616
+ }
617
+ </script>
618
+
619
+ <template>
620
+ <form ref="formElement" class="space-y-4" :aria-busy="saving || loading" @submit.prevent="onSubmit">
621
+ <!-- HEADER -->
622
+ <slot
623
+ name="header"
624
+ :model="props.model"
625
+ :mode="mode"
626
+ :loading="loading"
627
+ :status="status"
628
+ :dirty="dirty"
629
+ >
630
+ <h3 class="text-lg font-semibold capitalize">
631
+ {{ mode === 'create' ? `Créer ${props.model}` : `Modifier ${props.model}` }}
632
+ </h3>
633
+ </slot>
634
+
635
+ <!-- ERREUR GLOBALE -->
636
+ <slot
637
+ name="error"
638
+ v-if="error"
639
+ :error="error"
640
+ >
641
+ <div class="text-sm text-red-600" role="alert" aria-live="polite">
642
+ {{ error }}
643
+ </div>
644
+ </slot>
645
+
646
+ <!-- CHAMPS -->
647
+ <slot
648
+ name="fields"
649
+ :form="form"
650
+ :set-field="setField"
651
+ :errors="fieldErrors"
652
+ :mode="mode"
653
+ :loading="loading"
654
+ :saving="saving"
655
+ :status="status"
656
+ :dirty="dirty"
657
+ :visible-field-keys="visibleFieldKeys"
658
+ :schema="nsdbSchema"
659
+ >
660
+ <!-- Fallback : rendu automatique des champs visibles -->
661
+ <div
662
+ v-for="key in visibleFieldKeys"
663
+ :key="key"
664
+ class="space-y-1"
665
+ >
666
+ <label :for="fieldId(key)" class="block text-sm font-medium capitalize">
667
+ {{ labels?.find(l => l.key === key)?.label || nsdbSchema[key]?.label || key }}
668
+ </label>
669
+
670
+ <slot
671
+ :name="`field-${key}`"
672
+ :field="nsdbSchema[key]"
673
+ :field-key="key"
674
+ :value="form[key]"
675
+ :update="(value: any) => setField(key, value)"
676
+ :error="fieldErrors[key]?.[0] ?? null"
677
+ :mode="mode"
678
+ :disabled="loading || saving"
679
+ >
680
+ <input
681
+ v-if="nsdbSchema[key]?.type === 'text'"
682
+ :id="fieldId(key)"
683
+ :name="key"
684
+ type="text"
685
+ class="border text-black rounded px-3 py-2 w-full text-sm"
686
+ :value="form[key] ?? ''"
687
+ :disabled="loading || saving"
688
+ :readonly="nsdbSchema[key]?.readonly"
689
+ :required="nsdbSchema[key]?.required"
690
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
691
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
692
+ placeholder="Entrez une valeur"
693
+ @input="setTextField(key, ($event.target as HTMLInputElement).value)"
694
+ />
695
+
696
+ <input
697
+ v-else-if="nsdbSchema[key]?.type === 'number'"
698
+ :id="fieldId(key)"
699
+ :name="key"
700
+ type="number"
701
+ :step="numberStep(key)"
702
+ class="border text-black rounded px-3 py-2 w-full text-sm"
703
+ :value="form[key] ?? ''"
704
+ :disabled="loading || saving"
705
+ :readonly="nsdbSchema[key]?.readonly"
706
+ :required="nsdbSchema[key]?.required"
707
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
708
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
709
+ placeholder="Entrez une valeur"
710
+ @input="setNumberField(key, ($event.target as HTMLInputElement).value)"
711
+ />
712
+
713
+ <textarea
714
+ v-else-if="['textarea', 'json', 'array'].includes(nsdbSchema[key]?.type)"
715
+ :id="fieldId(key)"
716
+ :name="key"
717
+ class="border text-black rounded px-3 py-2 w-full text-sm"
718
+ :value="nsdbSchema[key]?.type === 'textarea' ? (form[key] ?? '') : structuredControlValue(form[key])"
719
+ :disabled="loading || saving"
720
+ :readonly="nsdbSchema[key]?.readonly"
721
+ :required="nsdbSchema[key]?.required"
722
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
723
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
724
+ placeholder="Entrez une valeur"
725
+ @input="setTextField(key, ($event.target as HTMLTextAreaElement).value)"
726
+ />
727
+
728
+ <input
729
+ v-else-if="nsdbSchema[key]?.type === 'datetime' || nsdbSchema[key]?.type === 'date'"
730
+ :id="fieldId(key)"
731
+ :name="key"
732
+ :type="nsdbSchema[key]?.type === 'date' ? 'date' : 'datetime-local'"
733
+ class="border text-black rounded px-3 py-2 w-full text-sm"
734
+ :value="form[key] ?? ''"
735
+ :disabled="loading || saving"
736
+ :readonly="nsdbSchema[key]?.readonly"
737
+ :required="nsdbSchema[key]?.required"
738
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
739
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
740
+ @input="setTextField(key, ($event.target as HTMLInputElement).value)"
741
+ />
742
+
743
+ <input
744
+ v-else-if="nsdbSchema[key]?.type === 'file'"
745
+ :id="fieldId(key)"
746
+ :name="key"
747
+ type="file"
748
+ class="border text-black rounded px-3 py-2 w-full text-sm"
749
+ :disabled="loading || saving || nsdbSchema[key]?.readonly"
750
+ :required="nsdbSchema[key]?.required"
751
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
752
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
753
+ @change="setField(key, ($event.target as HTMLInputElement).files?.[0] ?? null)"
754
+ />
755
+
756
+ <select
757
+ v-else-if="nsdbSchema[key]?.type === 'select'"
758
+ :id="fieldId(key)"
759
+ :name="key"
760
+ class="border text-black rounded px-3 py-2 w-full text-sm"
761
+ :value="form[key] ?? ''"
762
+ :disabled="loading || saving || nsdbSchema[key]?.readonly"
763
+ :required="nsdbSchema[key]?.required"
764
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
765
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
766
+ @change="setTextField(key, ($event.target as HTMLSelectElement).value)"
767
+ >
768
+ <option value="" :disabled="nsdbSchema[key]?.required && !nsdbSchema[key]?.nullable">Sélectionnez une option</option>
769
+ <option
770
+ v-for="opt in nsdbSchema[key]?.options || []"
771
+ :key="opt.value"
772
+ :value="opt.value"
773
+ >
774
+ {{ opt.label }}
775
+ </option>
776
+ </select>
777
+
778
+ <div
779
+ v-else-if="nsdbSchema[key]?.type === 'checkbox'"
780
+ class="flex items-center gap-2"
781
+ >
782
+ <input
783
+ type="checkbox"
784
+ :id="fieldId(key)"
785
+ :name="key"
786
+ :checked="!!form[key]"
787
+ :disabled="loading || saving || nsdbSchema[key]?.readonly"
788
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
789
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
790
+ @change="setField(key, ($event.target as HTMLInputElement).checked)"
791
+ />
792
+ </div>
793
+
794
+ <!-- RELATION : utilise NsdbRelationSelect basé sur le schema -->
795
+ <NsdbRelationSelect
796
+ v-else-if="nsdbSchema?.[key]?.type === 'relation' && nsdbSchema[key]?.relation"
797
+ :input-id="fieldId(key)"
798
+ :name="key"
799
+ :relation="nsdbSchema[key].relation"
800
+ :value="form[key] ?? null"
801
+ :disabled="loading || saving || nsdbSchema[key]?.readonly"
802
+ :required="nsdbSchema[key]?.required"
803
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
804
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
805
+ :store="props.store"
806
+ @update:value="setField(key, $event)"
807
+ />
808
+
809
+ <input
810
+ v-else
811
+ :id="fieldId(key)"
812
+ :name="key"
813
+ type="text"
814
+ class="border text-black rounded px-3 py-2 w-full text-sm"
815
+ :value="form[key] ?? ''"
816
+ :disabled="loading || saving"
817
+ :readonly="nsdbSchema[key]?.readonly"
818
+ :required="nsdbSchema[key]?.required"
819
+ :aria-invalid="fieldErrors[key]?.length ? 'true' : undefined"
820
+ :aria-describedby="fieldErrors[key]?.length ? fieldErrorId(key) : undefined"
821
+ @input="setTextField(key, ($event.target as HTMLInputElement).value)"
822
+ />
823
+ </slot>
824
+
825
+ <p
826
+ v-if="fieldErrors[key]?.length"
827
+ :id="fieldErrorId(key)"
828
+ class="text-xs text-red-500"
829
+ role="alert"
830
+ >
831
+ {{ fieldErrors[key][0] }}
832
+ </p>
833
+ </div>
834
+
835
+ <div v-if="visibleFieldKeys.length === 0" class="text-xs text-gray-500">
836
+ Aucun champ à afficher. Fournis <code>initialValues</code> ou un slot <code>#fields</code>.
837
+ </div>
838
+ </slot>
839
+
840
+ <!-- ACTIONS -->
841
+ <slot
842
+ name="actions"
843
+ :mode="mode"
844
+ :saving="saving"
845
+ :status="status"
846
+ :dirty="dirty"
847
+ :can-submit="!saving && !loading && missingRequiredHiddenFields.length === 0"
848
+ >
849
+ <div class="flex justify-end gap-2">
850
+ <button
851
+ type="submit"
852
+ class="px-4 py-2 rounded bg-indigo-600 text-white text-sm disabled:opacity-50"
853
+ :disabled="saving || loading || missingRequiredHiddenFields.length > 0"
854
+ >
855
+ <span v-if="saving">
856
+ Enregistrement…
857
+ </span>
858
+ <span v-else>
859
+ {{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
860
+ </span>
861
+ </button>
862
+ </div>
863
+ </slot>
864
+ </form>
865
+ </template>