@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.
- package/CHANGELOG.md +34 -0
- package/GET_STARTED.md +709 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/cli/index.js +83 -0
- package/helpers/args.js +22 -0
- package/helpers/config.js +142 -0
- package/helpers/generated.js +48 -0
- package/helpers/io.js +39 -0
- package/helpers/metadata.js +19 -0
- package/helpers/names.js +16 -0
- package/helpers/relations.js +101 -0
- package/helpers/shell.js +15 -0
- package/helpers/tables.js +79 -0
- package/helpers/ts.js +37 -0
- package/module.ts +151 -0
- package/nsdb.config.example.mjs +39 -0
- package/nsdb.config.example.ts +42 -0
- package/package.json +114 -0
- package/runtime/components/Form/NsdbRelationSelect.vue +258 -0
- package/runtime/components/NsdbForm.vue +865 -0
- package/runtime/components/NsdbList.vue +961 -0
- package/runtime/composables/useNsdbProfile.ts +119 -0
- package/runtime/composables/useNsdbSchemas.ts +176 -0
- package/runtime/composables/useSupabaseApi.ts +177 -0
- package/runtime/composables/useSupabaseApiStorage.ts +337 -0
- package/runtime/composables/useSupabaseModels.ts +412 -0
- package/runtime/query.ts +126 -0
- package/runtime/stores/createDbStore.ts +439 -0
- package/runtime/stores/createSingletonDbStore.ts +67 -0
- package/runtime/utils/dataFreshness.ts +47 -0
- package/runtime/utils/storage.ts +41 -0
- package/scripts/clear.js +64 -0
- package/scripts/generate-composables.js +100 -0
- package/scripts/generate-enums.js +106 -0
- package/scripts/generate-metadata.js +165 -0
- package/scripts/generate-models.js +164 -0
- package/scripts/generate-schemas.js +443 -0
- package/scripts/generate-stores.js +90 -0
- package/scripts/generate-types.js +196 -0
- package/scripts/init.js +225 -0
- package/templates/model.template.ts +48 -0
- package/templates/schema.template.ts +13 -0
- package/templates/useNsdbModel.template.ts +9 -0
- package/types/config.ts +50 -0
- package/types/entities.ts +66 -0
- package/types/index.ts +14 -0
- package/types/list.ts +78 -0
- 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>
|