wip: dynamic search for component create

This commit is contained in:
Matthieu
2026-01-23 23:29:40 +01:00
parent 8af8374282
commit a8cb4d1ac0
3 changed files with 242 additions and 35 deletions

View File

@@ -17,12 +17,13 @@
<SearchSelect
:model-value="assignment.selectedComponentId || ''"
:options="componentOptions"
:loading="componentsLoading"
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
size="sm"
placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel"
:option-description="componentOptionDescription"
@search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/>
</div>
@@ -45,22 +46,23 @@
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describePieceRequirement(pieceAssignment.definition) }}
{{ describePieceRequirement(pieceAssignment) }}
</p>
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error">
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
Aucune pièce disponible pour cette famille.
</p>
</div>
<SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment.definition)"
:loading="piecesLoading"
:options="getPieceOptions(pieceAssignment)"
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
size="xs"
placeholder="Rechercher une pièce..."
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel"
:option-description="pieceOptionDescription"
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/>
</div>
@@ -83,22 +85,23 @@
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment.definition) }}
{{ describeProductRequirement(productAssignment) }}
</p>
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment.definition)"
:loading="productsLoading"
:options="getProductOptions(productAssignment)"
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
@search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
@@ -131,8 +134,9 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi';
import type {
ComponentModelPiece,
ComponentModelProduct,
@@ -206,6 +210,9 @@ const props = withDefaults(
componentsLoading?: boolean;
piecesLoading?: boolean;
productsLoading?: boolean;
pieceTypeLabelMap?: Record<string, string>;
productTypeLabelMap?: Record<string, string>;
componentTypeLabelMap?: Record<string, string>;
}>(),
{
depth: 0,
@@ -215,6 +222,9 @@ const props = withDefaults(
componentsLoading: false,
piecesLoading: false,
productsLoading: false,
pieceTypeLabelMap: () => ({}),
productTypeLabelMap: () => ({}),
componentTypeLabelMap: () => ({}),
},
);
@@ -225,10 +235,42 @@ const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
);
const { get } = useApi();
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
const pieceLoadingByPath = ref<Record<string, boolean>>({});
const productLoadingByPath = ref<Record<string, boolean>>({});
const componentLoadingByPath = ref<Record<string, boolean>>({});
const extractCollection = (payload: any): any[] => {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.member)) {
return payload.member;
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'];
}
if (Array.isArray(payload?.data)) {
return payload.data;
}
return [];
};
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value;
};
const componentOptions = computed(() => {
if (isRoot.value) {
return [];
}
const cached = componentOptionsByPath.value[props.assignment.path];
if (cached) {
return cached;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || null;
@@ -274,6 +316,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
return parts.join(' • ');
};
const typeIri = (id: string) => `/api/model_types/${id}`;
const primedPiecePaths = new Set<string>();
const primedProductPaths = new Set<string>();
const primedComponentPaths = new Set<string>();
const fetchComponentOptions = async (term = '') => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (componentLoadingByPath.value[key]) {
return;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId));
}
setLoading(componentLoadingByPath.value, key, true);
try {
const result = await get(`/composants?${params.toString()}`);
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(componentLoadingByPath.value, key, false);
}
};
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path;
if (pieceLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typePieceId || (definition as any).typePiece?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId));
}
setLoading(pieceLoadingByPath.value, key, true);
try {
const result = await get(`/pieces?${params.toString()}`);
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(pieceLoadingByPath.value, key, false);
}
};
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path;
if (productLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typeProductId || (definition as any).typeProduct?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId));
}
setLoading(productLoadingByPath.value, key, true);
try {
const result = await get(`/products?${params.toString()}`);
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(productLoadingByPath.value, key, false);
}
};
watch(
componentOptions,
(options) => {
@@ -290,7 +430,8 @@ watch(
{ immediate: true },
);
const describePieceRequirement = (definition: ComponentModelPiece) => {
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
@@ -299,16 +440,17 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
}
};
const options = getPieceOptions(definition);
const options = getPieceOptions(assignment);
const fallbackPiece = options[0] || null;
const fallbackType = fallbackPiece?.typePiece || null;
addPart(definition.role);
addPart(
const explicitLabel =
definition.typePieceLabel ||
(definition as any).typePiece?.name ||
fallbackType?.name,
);
(definition as any).typePiece?.name ||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
@@ -333,7 +475,12 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
};
const getProductOptions = (definition: ComponentModelProduct) => {
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typeProductId ||
(definition as any).typeProduct?.id ||
@@ -386,7 +533,8 @@ const productOptionDescription = (product?: ProductOption | null) => {
return parts.join(' • ');
};
const describeProductRequirement = (definition: ComponentModelProduct) => {
const describeProductRequirement = (assignment: StructureProductAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
@@ -395,16 +543,17 @@ const describeProductRequirement = (definition: ComponentModelProduct) => {
}
};
const options = getProductOptions(definition);
const options = getProductOptions(assignment);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
addPart(
const explicitLabel =
definition.typeProductLabel ||
(definition as any).typeProduct?.name ||
fallbackType?.name,
);
(definition as any).typeProduct?.name ||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
@@ -435,6 +584,9 @@ const requirementLabel = computed(() => {
if (alias) {
return alias;
}
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
return props.componentTypeLabelMap[definition.typeComposantId];
}
if (definition.typeComposant?.name) {
return definition.typeComposant.name;
}
@@ -448,6 +600,7 @@ const requirementDescription = computed(() => {
const definition = props.assignment.definition || {};
const family =
definition.typeComposantLabel ||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
definition.typeComposant?.name ||
definition.familyCode;
if (family) {
@@ -456,7 +609,12 @@ const requirementDescription = computed(() => {
return 'Sélectionnez un composant enfant conforme à cette position.';
});
const getPieceOptions = (definition: ComponentModelPiece) => {
const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typePieceId ||
(definition as any).typePiece?.id ||
@@ -526,13 +684,17 @@ watch(
() => [props.pieces, props.assignment.pieces],
() => {
for (const pieceAssignment of props.assignment.pieces) {
const options = getPieceOptions(pieceAssignment.definition);
const options = getPieceOptions(pieceAssignment);
if (
pieceAssignment.selectedPieceId &&
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) {
pieceAssignment.selectedPieceId = '';
}
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path);
fetchPieceOptions(pieceAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
@@ -542,15 +704,34 @@ watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment.definition);
const options = getProductOptions(productAssignment);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path);
fetchProductOptions(productAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => props.assignment.definition,
() => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key);
fetchComponentOptions().catch(() => {});
}
},
{ immediate: true },
);
</script>

View File

@@ -122,7 +122,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'search'])
const searchTerm = ref('')
const openDropdown = ref(false)
@@ -267,6 +267,7 @@ function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
emit('search', searchTerm.value)
}
function closeDropdown () {

View File

@@ -212,6 +212,9 @@
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
@@ -349,7 +352,9 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
@@ -372,20 +377,19 @@ const route = useRoute()
const router = useRouter()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const {
createComposant,
composants: componentCatalogRef,
loadComposants,
loading: componentsLoading,
} = useComposants()
const {
pieces: pieceCatalogRef,
loadPieces,
loading: piecesLoading,
} = usePieces()
const {
products: productCatalogRef,
loadProducts,
loading: productsLoading,
} = useProducts()
const toast = useToast()
@@ -414,6 +418,28 @@ const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
)
const pieceTypeLabelMap = computed(() =>
Object.fromEntries(
(pieceTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
)
const productTypeLabelMap = computed(() =>
Object.fromEntries(
(productTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
)
const componentTypeLabelMap = computed(() =>
Object.fromEntries(
(componentTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
)
watch(
() => route.query.typeId,
(value) => {
@@ -934,9 +960,8 @@ const submitCreation = async () => {
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieces(),
loadComposants(),
loadProducts(),
loadPieceTypes(),
loadProductTypes(),
])
})