17ca857cc3
Apres chaque save reussi de champs perso (machine via useMachineCustomFieldDefs, ModelType via useEntityTypes), on invalide le cache useCustomFieldNameSuggestions pour que les noms nouvellement crees apparaissent dans les futures autocomplete. Note : le plan mentionnait ModelTypeForm.vue, mais le save reel se fait dans useEntityTypes (le composant ne fait qu'emit 'submit'). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
331 lines
9.0 KiB
TypeScript
331 lines
9.0 KiB
TypeScript
import { reactive, ref } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
// --- Types ---
|
|
|
|
export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
|
|
|
|
export interface MachineCustomFieldEditorField {
|
|
uid: string
|
|
serverId?: string
|
|
name: string
|
|
type: MachineFieldType
|
|
required: boolean
|
|
optionsText: string
|
|
orderIndex: number
|
|
}
|
|
|
|
interface InitialDef {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
required: boolean
|
|
options?: string[]
|
|
orderIndex: number
|
|
defaultValue?: unknown
|
|
}
|
|
|
|
interface Deps {
|
|
machineId: string
|
|
initialDefs: InitialDef[]
|
|
onSaved: () => void | Promise<void>
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
let uidCounter = 0
|
|
const createUid = (): string => {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
uidCounter += 1
|
|
return `mcf-${Date.now().toString(36)}-${uidCounter}`
|
|
}
|
|
|
|
const normalizeLineEndings = (value: string): string =>
|
|
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
|
|
const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({
|
|
uid: createUid(),
|
|
serverId: def.id,
|
|
name: def.name || '',
|
|
type: (def.type || 'text') as MachineFieldType,
|
|
required: Boolean(def.required),
|
|
optionsText: normalizeLineEndings(
|
|
Array.isArray(def.options) ? def.options.join('\n') : '',
|
|
),
|
|
orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index,
|
|
})
|
|
|
|
const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] =>
|
|
defs
|
|
.map((def, index) => toEditorField(def, index))
|
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
|
.map((field, index) => ({ ...field, orderIndex: index }))
|
|
|
|
const buildSnapshot = (defs: InitialDef[]): Map<string, InitialDef> => {
|
|
const map = new Map<string, InitialDef>()
|
|
for (const def of defs) {
|
|
map.set(def.id, def)
|
|
}
|
|
return map
|
|
}
|
|
|
|
const applyOrderIndex = (
|
|
list: MachineCustomFieldEditorField[],
|
|
): MachineCustomFieldEditorField[] =>
|
|
list.map((field, index) => ({ ...field, orderIndex: index }))
|
|
|
|
const parseOptions = (optionsText: string): string[] =>
|
|
normalizeLineEndings(optionsText)
|
|
.split('\n')
|
|
.map(o => o.trim())
|
|
.filter(o => o.length > 0)
|
|
|
|
// --- Composable ---
|
|
|
|
export function useMachineCustomFieldDefs(deps: Deps) {
|
|
const { apiCall } = useApi()
|
|
const { showSuccess, showError } = useToast()
|
|
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
|
|
|
// --- State ---
|
|
|
|
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
|
|
const initialSnapshot = ref<Map<string, InitialDef>>(buildSnapshot(deps.initialDefs))
|
|
const saving = ref(false)
|
|
|
|
// --- CRUD ---
|
|
|
|
const addField = () => {
|
|
const next = fields.value.slice()
|
|
next.push({
|
|
uid: createUid(),
|
|
name: '',
|
|
type: 'text',
|
|
required: false,
|
|
optionsText: '',
|
|
orderIndex: next.length,
|
|
})
|
|
fields.value = applyOrderIndex(next)
|
|
}
|
|
|
|
const removeField = (index: number) => {
|
|
const next = fields.value.filter((_, i) => i !== index)
|
|
fields.value = applyOrderIndex(next)
|
|
}
|
|
|
|
// --- Drag & drop ---
|
|
|
|
const dragState = reactive({
|
|
draggingIndex: null as number | null,
|
|
dropTargetIndex: null as number | null,
|
|
})
|
|
|
|
const resetDragState = () => {
|
|
dragState.draggingIndex = null
|
|
dragState.dropTargetIndex = null
|
|
}
|
|
|
|
const onDragStart = (index: number, event: DragEvent) => {
|
|
dragState.draggingIndex = index
|
|
dragState.dropTargetIndex = index
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
}
|
|
|
|
const onDragEnter = (index: number) => {
|
|
if (dragState.draggingIndex === null) return
|
|
dragState.dropTargetIndex = index
|
|
}
|
|
|
|
const onDrop = (index: number) => {
|
|
const from = dragState.draggingIndex
|
|
if (from === null) {
|
|
resetDragState()
|
|
return
|
|
}
|
|
|
|
if (from === index) {
|
|
resetDragState()
|
|
return
|
|
}
|
|
|
|
const list = fields.value.slice()
|
|
if (from < 0 || index < 0 || from >= list.length || index >= list.length) {
|
|
resetDragState()
|
|
return
|
|
}
|
|
|
|
const [moved] = list.splice(from, 1)
|
|
if (!moved) {
|
|
resetDragState()
|
|
return
|
|
}
|
|
list.splice(index, 0, moved)
|
|
fields.value = applyOrderIndex(list)
|
|
resetDragState()
|
|
}
|
|
|
|
const onDragEnd = () => {
|
|
resetDragState()
|
|
}
|
|
|
|
const reorderClass = (index: number): string => {
|
|
if (dragState.draggingIndex === index) {
|
|
return 'border-dashed border-primary bg-primary/5'
|
|
}
|
|
if (
|
|
dragState.draggingIndex !== null
|
|
&& dragState.dropTargetIndex === index
|
|
&& dragState.draggingIndex !== index
|
|
) {
|
|
return 'border-primary border-dashed bg-primary/10'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// --- Save ---
|
|
|
|
const saveDefinitions = async () => {
|
|
if (saving.value) return
|
|
|
|
// Validate: remove empty-name fields before saving
|
|
const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId)
|
|
if (emptyNameFields.length > 0) {
|
|
fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId))
|
|
}
|
|
|
|
saving.value = true
|
|
|
|
try {
|
|
const snapshot = initialSnapshot.value
|
|
const currentServerIds = new Set(
|
|
fields.value.filter(f => f.serverId).map(f => f.serverId!),
|
|
)
|
|
|
|
// DELETE removed fields
|
|
const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id))
|
|
for (const id of deletedIds) {
|
|
const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' })
|
|
if (!result.success) {
|
|
showError('Erreur lors de la suppression d\'un champ personnalisé')
|
|
await deps.onSaved()
|
|
return
|
|
}
|
|
}
|
|
|
|
let hasNewFields = false
|
|
|
|
for (const field of fields.value) {
|
|
const name = field.name.trim()
|
|
if (!name) continue
|
|
|
|
const options = field.type === 'select' ? parseOptions(field.optionsText) : []
|
|
|
|
if (!field.serverId) {
|
|
// POST new field
|
|
hasNewFields = true
|
|
const body: Record<string, unknown> = {
|
|
name,
|
|
type: field.type,
|
|
required: field.required,
|
|
options,
|
|
orderIndex: field.orderIndex,
|
|
machine: `/api/machines/${deps.machineId}`,
|
|
}
|
|
|
|
const result = await apiCall('/custom_fields', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/ld+json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!result.success) {
|
|
showError('Erreur lors de la création d\'un champ personnalisé')
|
|
await deps.onSaved()
|
|
return
|
|
}
|
|
} else {
|
|
// PATCH modified field
|
|
const original = snapshot.get(field.serverId)
|
|
const originalOptions = Array.isArray(original?.options)
|
|
? original.options.join('\n')
|
|
: ''
|
|
const currentOptions = field.type === 'select' ? field.optionsText : ''
|
|
|
|
const changed
|
|
= original?.name !== name
|
|
|| original?.type !== field.type
|
|
|| original?.required !== field.required
|
|
|| normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions)
|
|
|| original?.orderIndex !== field.orderIndex
|
|
|
|
if (changed) {
|
|
const body: Record<string, unknown> = {
|
|
name,
|
|
type: field.type,
|
|
required: field.required,
|
|
options,
|
|
orderIndex: field.orderIndex,
|
|
}
|
|
|
|
const result = await apiCall(`/custom_fields/${field.serverId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/merge-patch+json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!result.success) {
|
|
showError('Erreur lors de la mise à jour d\'un champ personnalisé')
|
|
await deps.onSaved()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize missing custom field values if new fields were created
|
|
if (hasNewFields) {
|
|
await apiCall(`/machines/${deps.machineId}/add-custom-fields`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/ld+json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
}
|
|
|
|
showSuccess('Champs personnalisés sauvegardés avec succès')
|
|
invalidateCustomFieldNames()
|
|
await deps.onSaved()
|
|
} catch {
|
|
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
|
await deps.onSaved()
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// --- Reinit ---
|
|
|
|
const reinit = (newDefs: InitialDef[]) => {
|
|
fields.value = hydrateFields(newDefs)
|
|
initialSnapshot.value = buildSnapshot(newDefs)
|
|
}
|
|
|
|
return {
|
|
fields,
|
|
saving,
|
|
dragState,
|
|
addField,
|
|
removeField,
|
|
onDragStart,
|
|
onDragEnter,
|
|
onDrop,
|
|
onDragEnd,
|
|
reorderClass,
|
|
saveDefinitions,
|
|
reinit,
|
|
}
|
|
}
|