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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,21 +19,17 @@
<label class="label"> <label class="label">
<span class="label-text">Catégorie de composant</span> <span class="label-text">Catégorie de composant</span>
</label> </label>
<select <SearchSelect
v-model="selectedTypeId" 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" :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"> <p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories Chargement des catégories
</p> </p>
@@ -189,6 +185,8 @@
:assignment="structureAssignments" :assignment="structureAssignments"
:pieces="availablePieces" :pieces="availablePieces"
:components="availableComponents" :components="availableComponents"
:pieces-loading="piecesLoading"
:components-loading="componentsLoading"
/> />
<p v-else class="text-xs text-error"> <p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette. Impossible de générer les emplacements définis par le squelette.
@@ -297,6 +295,7 @@ import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ComponentStructureAssignmentNode, { import ComponentStructureAssignmentNode, {
type StructureAssignmentNode, type StructureAssignmentNode,
} from '~/components/ComponentStructureAssignmentNode.vue' } from '~/components/ComponentStructureAssignmentNode.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
@@ -381,6 +380,12 @@ const componentTypeList = computed<ComponentCatalogType[]>(() =>
.filter((item: any) => item?.category === 'COMPONENT') as 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(() => { const selectedType = computed(() => {
if (!selectedTypeId.value) { if (!selectedTypeId.value) {
return null return null