197 lines
5.6 KiB
Vue
197 lines
5.6 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<section class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold">Champs personnalisés</h3>
|
|
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
|
Ajouter
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="!localFields.length" class="text-xs text-gray-500">
|
|
Aucun champ personnalisé n'a encore été défini.
|
|
</p>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="(field, index) in localFields"
|
|
:key="`custom-field-${index}`"
|
|
class="border border-base-200 rounded-md p-3 space-y-2"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex-1 space-y-2">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<input
|
|
v-model="field.name"
|
|
type="text"
|
|
class="input input-bordered input-xs"
|
|
placeholder="Nom du champ"
|
|
/>
|
|
<select v-model="field.type" class="select select-bordered select-xs">
|
|
<option value="text">Texte</option>
|
|
<option value="number">Nombre</option>
|
|
<option value="select">Liste</option>
|
|
<option value="boolean">Oui/Non</option>
|
|
<option value="date">Date</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
|
Obligatoire
|
|
</div>
|
|
|
|
<textarea
|
|
v-if="field.type === 'select'"
|
|
v-model="field.optionsText"
|
|
class="textarea textarea-bordered textarea-xs h-20"
|
|
placeholder="Option 1 Option 2"
|
|
></textarea>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-error btn-xs btn-square"
|
|
@click="removeField(index)"
|
|
>
|
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, reactive, watch } from 'vue'
|
|
import IconLucidePlus from '~icons/lucide/plus'
|
|
import IconLucideTrash from '~icons/lucide/trash'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Object,
|
|
default: () => ({ customFields: [] }),
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const ensureArray = (value) => (Array.isArray(value) ? value : [])
|
|
|
|
const clone = (input, fallback = {}) => {
|
|
try {
|
|
return JSON.parse(JSON.stringify(input ?? fallback))
|
|
} catch (error) {
|
|
return JSON.parse(JSON.stringify(fallback))
|
|
}
|
|
}
|
|
|
|
const extractRest = (structure = {}) => {
|
|
if (!structure || typeof structure !== 'object') {
|
|
return {}
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(structure).filter(([key]) => key !== 'customFields')
|
|
)
|
|
}
|
|
|
|
const toEditorField = (input = {}) => ({
|
|
name: typeof input.name === 'string' ? input.name : '',
|
|
type: typeof input.type === 'string' && input.type ? input.type : 'text',
|
|
required: Boolean(input.required),
|
|
optionsText: Array.isArray(input.options)
|
|
? input.options.join('\n')
|
|
: typeof input.optionsText === 'string'
|
|
? input.optionsText
|
|
: '',
|
|
})
|
|
|
|
const hydrateFields = (structure = {}) => ensureArray(structure.customFields).map(toEditorField)
|
|
|
|
const localState = reactive({
|
|
fields: hydrateFields(props.modelValue),
|
|
})
|
|
|
|
const extraState = reactive({
|
|
rest: clone(extractRest(props.modelValue)),
|
|
})
|
|
|
|
const localFields = computed({
|
|
get: () => localState.fields,
|
|
set: (value) => {
|
|
localState.fields = ensureArray(value).map(toEditorField)
|
|
},
|
|
})
|
|
|
|
const normalizeFields = (fields) => {
|
|
return ensureArray(fields)
|
|
.map((field) => {
|
|
const name = typeof field.name === 'string' ? field.name.trim() : ''
|
|
if (!name) {
|
|
return null
|
|
}
|
|
|
|
const type = field.type || 'text'
|
|
const required = Boolean(field.required)
|
|
let options
|
|
if (type === 'select') {
|
|
const raw = typeof field.optionsText === 'string' ? field.optionsText : ''
|
|
const parsed = raw
|
|
.split(/\r?\n/)
|
|
.map((option) => option.trim())
|
|
.filter((option) => option.length > 0)
|
|
options = parsed.length > 0 ? parsed : undefined
|
|
}
|
|
|
|
const normalized = { name, type, required }
|
|
if (options) {
|
|
normalized.options = options
|
|
}
|
|
return normalized
|
|
})
|
|
.filter(Boolean)
|
|
}
|
|
|
|
let lastEmitted = JSON.stringify({
|
|
...clone(extraState.rest, {}),
|
|
customFields: normalizeFields(props.modelValue?.customFields),
|
|
})
|
|
|
|
const emitUpdate = () => {
|
|
const customFields = normalizeFields(localFields.value)
|
|
const payload = {
|
|
...clone(extraState.rest, {}),
|
|
customFields,
|
|
}
|
|
const serialized = JSON.stringify(payload)
|
|
if (serialized !== lastEmitted) {
|
|
lastEmitted = serialized
|
|
emit('update:modelValue', payload)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(value) => {
|
|
localFields.value = hydrateFields(value)
|
|
extraState.rest = clone(extractRest(value), {})
|
|
lastEmitted = JSON.stringify({
|
|
...clone(extraState.rest, {}),
|
|
customFields: normalizeFields(value?.customFields),
|
|
})
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(localFields, emitUpdate, { deep: true })
|
|
|
|
const addField = () => {
|
|
localFields.value = [...localFields.value, toEditorField()]
|
|
}
|
|
|
|
const removeField = (index) => {
|
|
localFields.value = localFields.value.filter((_, i) => i !== index)
|
|
}
|
|
</script>
|