Add new dropdown search

This commit is contained in:
Matthieu
2025-10-16 08:51:18 +02:00
parent e297d1bb39
commit 62b5c9b297
10 changed files with 197 additions and 80 deletions

View File

@@ -7,6 +7,7 @@
:piece-types="availablePieceTypes"
:lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
is-root
/>
</div>
@@ -43,6 +44,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowSubcomponents: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -55,6 +60,7 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const fallbackRootTypeLabel = computed(() => {
if (!props.rootTypeId) {
@@ -156,4 +162,14 @@ onMounted(async () => {
}
syncRootType()
})
watch(
allowSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(localStructure.subcomponents) && localStructure.subcomponents.length) {
localStructure.subcomponents = []
}
},
{ immediate: true }
)
</script>

View File

@@ -14,21 +14,17 @@
<label class="label">
<span class="label-text text-xs">Sélectionner un composant</span>
</label>
<select
v-model="assignment.selectedComponentId"
class="select select-bordered select-sm"
>
<option value="">
{{ componentOptions.length ? 'Choisir un composant compatible' : 'Aucun composant disponible' }}
</option>
<option
v-for="component in componentOptions"
:key="component.id"
:value="component.id"
>
{{ formatComponentOption(component) }}
</option>
</select>
<SearchSelect
:model-value="assignment.selectedComponentId || ''"
:options="componentOptions"
:loading="componentsLoading"
size="sm"
placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel"
:option-description="componentOptionDescription"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/>
</div>
</section>
@@ -56,21 +52,17 @@
</p>
</div>
<select
v-model="pieceAssignment.selectedPieceId"
class="select select-bordered select-xs"
>
<option value="">
{{ getPieceOptions(pieceAssignment.definition).length ? 'Choisir une pièce' : 'Sélection impossible' }}
</option>
<option
v-for="piece in getPieceOptions(pieceAssignment.definition)"
:key="piece.id"
:value="piece.id"
>
{{ formatPieceOption(piece) }}
</option>
</select>
<SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment.definition)"
:loading="piecesLoading"
size="xs"
placeholder="Rechercher une pièce..."
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel"
:option-description="pieceOptionDescription"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/>
</div>
</section>
@@ -90,6 +82,8 @@
:assignment="subAssignment"
:pieces="pieces"
:components="components"
:components-loading="componentsLoading"
:pieces-loading="piecesLoading"
:depth="depth + 1"
/>
</section>
@@ -98,6 +92,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import type {
ComponentModelPiece,
ComponentModelStructureNode,
@@ -147,11 +142,15 @@ const props = withDefaults(
pieces: PieceOption[] | null;
components: ComponentOption[] | null;
depth?: number;
componentsLoading?: boolean;
piecesLoading?: boolean;
}>(),
{
depth: 0,
pieces: () => [],
components: () => [],
componentsLoading: false,
piecesLoading: false,
},
);
@@ -188,6 +187,29 @@ const componentOptions = computed(() => {
});
});
const componentOptionLabel = (component?: ComponentOption | null) => {
if (!component) {
return 'Composant sans nom';
}
return component.name || 'Composant sans nom';
};
const componentOptionDescription = (component?: ComponentOption | null) => {
if (!component) {
return '';
}
const parts: string[] = [];
const typeLabel =
component.typeComposant?.name || component.typeComposant?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (component.reference) {
parts.push(`Ref. ${component.reference}`);
}
return parts.join(' • ');
};
watch(
componentOptions,
(options) => {
@@ -204,18 +226,6 @@ watch(
{ immediate: true },
);
const formatComponentOption = (component: ComponentOption) => {
const name = component.name || 'Composant sans nom';
const reference = component.reference ? ` • Ref. ${component.reference}` : '';
const typeLabel =
component.typeComposant?.name || component.typeComposant?.code || '';
const typed =
typeLabel && component.typeComposant?.name !== name
? ` (${typeLabel})`
: '';
return `${name}${typed}${reference}`;
};
const describePieceRequirement = (definition: ComponentModelPiece) => {
const parts: string[] = [];
if (definition.role) {
@@ -288,10 +298,40 @@ const getPieceOptions = (definition: ComponentModelPiece) => {
});
};
const formatPieceOption = (piece: PieceOption) => {
const name = piece.name || 'Pièce';
const reference = piece.reference ? ` • Ref. ${piece.reference}` : '';
return `${name}${reference}`;
const pieceOptionLabel = (piece?: PieceOption | null) => {
if (!piece) {
return 'Pièce';
}
return piece.name || 'Pièce';
};
const pieceOptionDescription = (piece?: PieceOption | null) => {
if (!piece) {
return '';
}
const parts: string[] = [];
const typeLabel =
piece.typePiece?.name || piece.typePiece?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (piece.reference) {
parts.push(`Ref. ${piece.reference}`);
}
return parts.join(' • ');
};
const normalizeSelectionValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return '';
};
watch(

View File

@@ -175,7 +175,7 @@
</div>
</section>
<section class="space-y-3">
<section v-if="allowSubcomponents" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">Sous-composants</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addSubComponent">
@@ -197,6 +197,7 @@
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:allow-subcomponents="allowSubcomponents"
@remove="removeSubComponent(index)"
/>
</div>
@@ -233,6 +234,7 @@ const props = withDefaults(defineProps<{
isRoot?: boolean
lockType?: boolean
lockedTypeLabel?: string
allowSubcomponents?: boolean
}>(), {
depth: 0,
componentTypes: () => [],
@@ -240,12 +242,14 @@ const props = withDefaults(defineProps<{
isRoot: false,
lockType: false,
lockedTypeLabel: '',
allowSubcomponents: true,
})
const emit = defineEmits(['remove'])
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
const containerClass = computed(() => {
@@ -454,6 +458,9 @@ const removePiece = (index: number) => {
}
const addSubComponent = () => {
if (!allowSubcomponents.value) {
return
}
ensureArray('subcomponents')
props.node.subcomponents.push({
typeComposantId: '',
@@ -470,6 +477,16 @@ const removeSubComponent = (index: number) => {
props.node.subcomponents.splice(index, 1)
}
watch(
allowSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
props.node.subcomponents.splice(0, props.node.subcomponents.length)
}
},
{ immediate: true }
)
watch(componentTypes, () => {
syncComponentType(props.node)
}, { deep: true, immediate: true })

View File

@@ -7,6 +7,7 @@
:default-requirement="createDefaultRequirement"
:required-fallback="true"
:min-fallback="1"
:type-loading="loadingComponentTypes"
/>
</template>
@@ -51,7 +52,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const requirements = computed({
get: () => props.modelValue,

View File

@@ -7,6 +7,7 @@
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingPieceTypes"
/>
</template>
@@ -51,7 +52,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const requirements = computed({
get: () => props.modelValue,

View File

@@ -29,21 +29,17 @@
<span class="label-text">{{ labels.typeSelectLabel }}</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement[typeField] ?? ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { [typeField]: normalizeTypeValue($event.target.value) })"
>
<option value="">{{ labels.typePlaceholder }}</option>
<option
v-for="type in typeOptions"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<SearchSelect
:model-value="normalizeTypeModel(requirement[typeField])"
:options="typeOptions"
:loading="typeLoading"
size="sm"
:placeholder="labels.typePlaceholder"
:empty-text="typeOptions.length ? 'Aucun résultat' : 'Aucune option disponible'"
:option-label="optionLabel"
:option-description="optionDescription"
@update:modelValue="(value) => updateRequirement(index, { [typeField]: normalizeTypeValue(value) })"
/>
</div>
<div class="form-control">
@@ -128,10 +124,12 @@ import { computed } from 'vue'
import type { PropType } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import SearchSelect from '~/components/common/SearchSelect.vue'
type Option = {
id: string | number
name: string
description?: string | null
}
type Requirement = Record<string, unknown> & {
@@ -193,6 +191,10 @@ const props = defineProps({
type: Number,
default: 0,
},
typeLoading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -202,6 +204,23 @@ const requirements = computed({
set: (value) => emit('update:modelValue', value),
})
const optionLabel = (option: Option) => {
if (!option) {
return ''
}
return option.name || ''
}
const optionDescription = (option: Option) => {
if (!option) {
return ''
}
if (typeof option.description === 'string' && option.description.trim()) {
return option.description.trim()
}
return ''
}
const addRequirement = () => {
requirements.value = [
...requirements.value,
@@ -232,8 +251,18 @@ const parseOptionalNumber = (value: string) => {
return Number.isFinite(parsed) ? parsed : null
}
const normalizeTypeValue = (value: string) => {
if (!value) {
const normalizeTypeModel = (value: unknown) => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string' || typeof value === 'number') {
return value
}
return ''
}
const normalizeTypeValue = (value: string | number | null | undefined) => {
if (value === '' || value === null || value === undefined) {
return null
}
return value

View File

@@ -96,7 +96,10 @@
Aperçu :
<span class="font-medium text-base-content">{{ componentStructurePreview }}</span>
</p>
<ComponentModelStructureEditor v-model="componentStructure" />
<ComponentModelStructureEditor
v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents"
/>
</div>
<div
@@ -147,11 +150,13 @@ const props = withDefaults(defineProps<{
saving?: boolean
lockCategory?: boolean
structureLoading?: boolean
allowComponentSubcomponents?: boolean
}>(), {
initialData: null,
saving: false,
lockCategory: false,
structureLoading: false,
allowComponentSubcomponents: true,
})
const emit = defineEmits<{
@@ -162,6 +167,7 @@ const emit = defineEmits<{
const lockCategory = computed(() => props.lockCategory ?? false)
const structureLoading = computed(() => props.structureLoading ?? false)
const saving = computed(() => props.saving ?? false)
const allowComponentSubcomponents = computed(() => props.allowComponentSubcomponents !== false)
const form = reactive<ModelTypePayload>({
name: '',

View File

@@ -25,6 +25,7 @@
initial-category="COMPONENT"
:initial-data="initialData"
:lock-category="true"
:allow-component-subcomponents="false"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"

View File

@@ -19,6 +19,7 @@
mode="create"
initial-category="COMPONENT"
:lock-category="true"
:allow-component-subcomponents="false"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"

View File

@@ -19,21 +19,17 @@
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<select
<SearchSelect
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
required
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
@@ -189,6 +185,8 @@
:assignment="structureAssignments"
:pieces="availablePieces"
:components="availableComponents"
:pieces-loading="piecesLoading"
:components-loading="componentsLoading"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
@@ -297,6 +295,7 @@ import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ComponentStructureAssignmentNode, {
type StructureAssignmentNode,
} from '~/components/ComponentStructureAssignmentNode.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
@@ -381,6 +380,12 @@ const componentTypeList = computed<ComponentCatalogType[]>(() =>
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const typeOptionLabel = (type?: ComponentCatalogType) =>
type?.name || 'Catégorie'
const typeOptionDescription = (type?: ComponentCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null