feat(frontend): add reusable search select and wire it into machine creation

fix(frontend): guard custom field persistence against non-string values
This commit is contained in:
Matthieu
2025-10-13 17:03:06 +02:00
parent 469bcb82d1
commit e297d1bb39
8 changed files with 544 additions and 73 deletions

View File

@@ -50,12 +50,22 @@
<td>{{ component.typeComposant?.name || '—' }}</td>
<td>{{ component.reference || '—' }}</td>
<td>
<NuxtLink
:to="`/component/${component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/component/${component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(component)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
@@ -70,9 +80,33 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useComposants } from '~/composables/useComposants'
const { composants, loadComposants, loading: loadingComposantsRef } = useComposants()
import { useToast } from '~/composables/useToast'
const { showError } = useToast()
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const loadingComposants = computed(() => loadingComposantsRef.value)
const composantsList = computed(() => composants.value || [])
const handleDeleteComponent = async (component: Record<string, any>) => {
const hasLinkedElements =
(component?.machineLinks?.length ?? 0) > 0 ||
(component?.documents?.length ?? 0) > 0 ||
(component?.customFieldValues?.length ?? 0) > 0
if (hasLinkedElements) {
showError('Impossible de supprimer ce composant car il possède des éléments liés.')
return
}
const componentName = component?.name || 'ce composant'
const confirmed = window.confirm(`Voulez-vous vraiment supprimer ${componentName} ?`)
if (!confirmed) {
return
}
await deleteComposant(component.id)
}
onMounted(async () => {
await loadComposants()
})

View File

@@ -347,7 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value !== ''
return toFieldString(field.value).trim() !== ''
}),
)
@@ -358,6 +358,19 @@ const canSubmit = computed(() => Boolean(
!saving.value,
))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const fetchComponent = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
@@ -636,14 +649,14 @@ const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim() !== ''
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return field.value.trim()
return toFieldString(field.value).trim()
}
const saveCustomFieldValues = async (updatedComponent: any) => {

View File

@@ -597,7 +597,7 @@ const requiredCustomFieldsFilled = computed(() =>
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value !== ''
return toFieldString(field.value).trim() !== ''
}),
)
@@ -609,6 +609,19 @@ const canSubmit = computed(() => Boolean(
!submitting.value,
))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.customFields) ? structure.customFields : []
}
@@ -917,13 +930,13 @@ const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim() !== ''
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return field.value.trim()
return toFieldString(field.value).trim()
}
</script>

View File

@@ -53,14 +53,15 @@
<label class="label">
<span class="label-text">Type de machine</span>
</label>
<select v-model="newMachine.typeMachineId" class="select select-bordered" required>
<option value="">
Sélectionner un type
</option>
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
{{ type.name }} ({{ type.category }})
</option>
</select>
<SearchSelect
v-model="newMachine.typeMachineId"
:options="machineTypes"
:loading="machineTypesLoading"
placeholder="Rechercher un type…"
empty-text="Aucun type trouvé"
:option-label="machineTypeLabel"
:option-description="machineTypeDescription"
/>
</div>
<div class="form-control">
@@ -164,22 +165,17 @@
<label class="label">
<span class="label-text text-xs">Composant existant</span>
</label>
<select
class="select select-bordered select-sm"
:value="entry.composantId || ''"
@change="setComponentRequirementComponent(requirement, entryIndex, ($event.target && $event.target.value) || '')"
>
<option value="">
Sélectionner un composant disponible
</option>
<option
v-for="component in getComponentOptions(requirement, entry)"
:key="component.id"
:value="component.id"
>
{{ formatComponentOption(component) }}
</option>
</select>
<SearchSelect
:model-value="entry.composantId || ''"
:options="getComponentOptions(requirement, entry)"
:loading="composantsLoading"
size="sm"
placeholder="Rechercher un composant…"
empty-text="Aucun composant disponible"
:option-label="componentOptionLabel"
:option-description="componentOptionDescription"
@update:modelValue="setComponentRequirementComponent(requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getComponentOptions(requirement, entry).length === 0"
@@ -271,22 +267,17 @@
<label class="label">
<span class="label-text text-xs">Pièce existante</span>
</label>
<select
class="select select-bordered select-sm"
:value="entry.pieceId || ''"
@change="setPieceRequirementPiece(requirement, entryIndex, ($event.target && $event.target.value) || '')"
>
<option value="">
Sélectionner une pièce disponible
</option>
<option
v-for="pieceOption in getPieceOptions(requirement, entry)"
:key="pieceOption.id"
:value="pieceOption.id"
>
{{ formatPieceOption(pieceOption) }}
</option>
</select>
<SearchSelect
:model-value="entry.pieceId || ''"
:options="getPieceOptions(requirement, entry)"
:loading="piecesLoading"
size="sm"
placeholder="Rechercher une pièce…"
empty-text="Aucune pièce disponible"
:option-label="pieceOptionLabel"
:option-description="pieceOptionDescription"
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getPieceOptions(requirement, entry).length === 0"
@@ -562,6 +553,7 @@ import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
import IconLucideEye from '~icons/lucide/eye'
@@ -571,9 +563,9 @@ import IconLucideCircle from '~icons/lucide/circle'
const { createMachine, createMachineFromType } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { composants, loadComposants } = useComposants()
const { pieces, loadPieces } = usePieces()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants()
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
const toast = useToast()
const submitting = ref(false)
@@ -595,6 +587,27 @@ const selectedMachineType = computed(() => {
return machineTypes.value.find(type => type.id === newMachine.typeMachineId) || null
})
const machineTypeLabel = (type) => {
if (!type) {
return ''
}
return type.name || 'Type de machine'
}
const machineTypeDescription = (type) => {
if (!type) {
return ''
}
const parts = []
if (type.category) {
parts.push(`Catégorie : ${type.category}`)
}
const componentCount = type.componentRequirements?.length ?? 0
const pieceCount = type.pieceRequirements?.length ?? 0
parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`)
return parts.join(' • ')
}
const componentById = computed(() => {
const map = new Map()
;(composants.value || []).forEach((component) => {
@@ -891,11 +904,13 @@ const getPieceOptions = (requirement, currentEntry) => {
})
}
const formatComponentOption = (component) => {
const componentOptionLabel = (component) => component?.name || 'Composant'
const componentOptionDescription = (component) => {
if (!component) {
return ''
}
const parts = [component.name || 'Composant']
const parts = []
if (component.reference) {
parts.push(`Réf. ${component.reference}`)
}
@@ -910,11 +925,13 @@ const formatComponentOption = (component) => {
return parts.join(' • ')
}
const formatPieceOption = (piece) => {
const pieceOptionLabel = (piece) => piece?.name || 'Pièce'
const pieceOptionDescription = (piece) => {
if (!piece) {
return ''
}
const parts = [piece.name || 'Pièce']
const parts = []
if (piece.reference) {
parts.push(`Réf. ${piece.reference}`)
}

View File

@@ -49,12 +49,22 @@
<td>{{ piece.typePiece?.name || '—' }}</td>
<td>{{ piece.reference || '—' }}</td>
<td>
<NuxtLink
:to="`/pieces/${piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/pieces/${piece.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@click="handleDeletePiece(piece)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
@@ -69,10 +79,33 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { usePieces } from '~/composables/usePieces'
const { pieces, loadPieces, loading: loadingPiecesRef } = usePieces()
import { useToast } from '~/composables/useToast'
const { showError } = useToast()
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const loadingPieces = computed(() => loadingPiecesRef.value)
const piecesList = computed(() => pieces.value || [])
const handleDeletePiece = async (piece: Record<string, any>) => {
const hasLinkedElements =
(piece?.machineLinks?.length ?? 0) > 0 ||
(piece?.documents?.length ?? 0) > 0 ||
(piece?.customFieldValues?.length ?? 0) > 0
if (hasLinkedElements) {
showError('Impossible de supprimer cette pièce car elle possède des éléments liés.')
return
}
const pieceName = piece?.name || 'cette pièce'
const confirmed = window.confirm(`Voulez-vous vraiment supprimer ${pieceName} ?`)
if (!confirmed) {
return
}
await deletePiece(piece.id)
}
onMounted(async () => {
await loadPieces()
})

View File

@@ -314,7 +314,7 @@ const requiredCustomFieldsFilled = computed(() =>
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value !== ''
return toFieldString(field.value).trim() !== ''
}),
)
@@ -325,6 +325,19 @@ const canSubmit = computed(() => Boolean(
!saving.value,
))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const fetchPiece = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
@@ -547,14 +560,14 @@ const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim() !== ''
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return field.value.trim()
return toFieldString(field.value).trim()
}
const saveCustomFieldValues = async (updatedPiece: any) => {

View File

@@ -320,7 +320,7 @@ const requiredCustomFieldsFilled = computed(() =>
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value !== ''
return toFieldString(field.value).trim() !== ''
}),
)
@@ -331,6 +331,19 @@ const canSubmit = computed(() => Boolean(
!submitting.value,
))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
const clearCreationForm = () => {
@@ -565,13 +578,13 @@ const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim() !== ''
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return field.value.trim()
return toFieldString(field.value).trim()
}
</script>