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 <SearchSelect
:model-value="assignment.selectedComponentId || ''" :model-value="assignment.selectedComponentId || ''"
:options="componentOptions" :options="componentOptions"
:loading="componentsLoading" :loading="componentsLoading || componentLoadingByPath[assignment.path]"
size="sm" size="sm"
placeholder="Rechercher un composant..." placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'" :empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel" :option-label="componentOptionLabel"
:option-description="componentOptionDescription" :option-description="componentOptionDescription"
@search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/> />
</div> </div>
@@ -45,22 +46,23 @@
> >
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-medium text-base-content"> <p class="text-xs font-medium text-base-content">
{{ describePieceRequirement(pieceAssignment.definition) }} {{ describePieceRequirement(pieceAssignment) }}
</p> </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. Aucune pièce disponible pour cette famille.
</p> </p>
</div> </div>
<SearchSelect <SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''" :model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment.definition)" :options="getPieceOptions(pieceAssignment)"
:loading="piecesLoading" :loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
size="xs" size="xs"
placeholder="Rechercher une pièce..." 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-label="pieceOptionLabel"
:option-description="pieceOptionDescription" :option-description="pieceOptionDescription"
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/> />
</div> </div>
@@ -83,22 +85,23 @@
> >
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-medium text-base-content"> <p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment.definition) }} {{ describeProductRequirement(productAssignment) }}
</p> </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. Aucun produit disponible pour cette catégorie.
</p> </p>
</div> </div>
<SearchSelect <SearchSelect
:model-value="productAssignment.selectedProductId || ''" :model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment.definition)" :options="getProductOptions(productAssignment)"
:loading="productsLoading" :loading="productsLoading || productLoadingByPath[productAssignment.path]"
size="xs" size="xs"
placeholder="Rechercher un produit..." 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-label="productOptionLabel"
:option-description="productOptionDescription" :option-description="productOptionDescription"
@search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/> />
</div> </div>
@@ -131,8 +134,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi';
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct, ComponentModelProduct,
@@ -206,6 +210,9 @@ const props = withDefaults(
componentsLoading?: boolean; componentsLoading?: boolean;
piecesLoading?: boolean; piecesLoading?: boolean;
productsLoading?: boolean; productsLoading?: boolean;
pieceTypeLabelMap?: Record<string, string>;
productTypeLabelMap?: Record<string, string>;
componentTypeLabelMap?: Record<string, string>;
}>(), }>(),
{ {
depth: 0, depth: 0,
@@ -215,6 +222,9 @@ const props = withDefaults(
componentsLoading: false, componentsLoading: false,
piecesLoading: false, piecesLoading: false,
productsLoading: 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', 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(() => { const componentOptions = computed(() => {
if (isRoot.value) { if (isRoot.value) {
return []; return [];
} }
const cached = componentOptionsByPath.value[props.assignment.path];
if (cached) {
return cached;
}
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const requiredTypeId = const requiredTypeId =
definition.typeComposantId || definition.modelId || null; definition.typeComposantId || definition.modelId || null;
@@ -274,6 +316,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
return parts.join(' • '); 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( watch(
componentOptions, componentOptions,
(options) => { (options) => {
@@ -290,7 +430,8 @@ watch(
{ immediate: true }, { immediate: true },
); );
const describePieceRequirement = (definition: ComponentModelPiece) => { const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition;
const parts: string[] = []; const parts: string[] = [];
const addPart = (value?: string | null) => { const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : ''; 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 fallbackPiece = options[0] || null;
const fallbackType = fallbackPiece?.typePiece || null; const fallbackType = fallbackPiece?.typePiece || null;
addPart(definition.role); addPart(definition.role);
addPart( const explicitLabel =
definition.typePieceLabel || definition.typePieceLabel ||
(definition as any).typePiece?.name || (definition as any).typePiece?.name ||
fallbackType?.name, (definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
); fallbackType?.name;
addPart(explicitLabel);
const family = const family =
definition.familyCode || definition.familyCode ||
@@ -333,7 +475,12 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette'; 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 = const requiredTypeId =
definition.typeProductId || definition.typeProductId ||
(definition as any).typeProduct?.id || (definition as any).typeProduct?.id ||
@@ -386,7 +533,8 @@ const productOptionDescription = (product?: ProductOption | null) => {
return parts.join(' • '); return parts.join(' • ');
}; };
const describeProductRequirement = (definition: ComponentModelProduct) => { const describeProductRequirement = (assignment: StructureProductAssignment) => {
const definition = assignment.definition;
const parts: string[] = []; const parts: string[] = [];
const addPart = (value?: string | null) => { const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : ''; 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 fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null; const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role); addPart(definition.role);
addPart( const explicitLabel =
definition.typeProductLabel || definition.typeProductLabel ||
(definition as any).typeProduct?.name || (definition as any).typeProduct?.name ||
fallbackType?.name, (definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
); fallbackType?.name;
addPart(explicitLabel);
const family = const family =
definition.familyCode || definition.familyCode ||
@@ -435,6 +584,9 @@ const requirementLabel = computed(() => {
if (alias) { if (alias) {
return alias; return alias;
} }
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
return props.componentTypeLabelMap[definition.typeComposantId];
}
if (definition.typeComposant?.name) { if (definition.typeComposant?.name) {
return definition.typeComposant.name; return definition.typeComposant.name;
} }
@@ -448,6 +600,7 @@ const requirementDescription = computed(() => {
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const family = const family =
definition.typeComposantLabel || definition.typeComposantLabel ||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
definition.typeComposant?.name || definition.typeComposant?.name ||
definition.familyCode; definition.familyCode;
if (family) { if (family) {
@@ -456,7 +609,12 @@ const requirementDescription = computed(() => {
return 'Sélectionnez un composant enfant conforme à cette position.'; 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 = const requiredTypeId =
definition.typePieceId || definition.typePieceId ||
(definition as any).typePiece?.id || (definition as any).typePiece?.id ||
@@ -526,13 +684,17 @@ watch(
() => [props.pieces, props.assignment.pieces], () => [props.pieces, props.assignment.pieces],
() => { () => {
for (const pieceAssignment of props.assignment.pieces) { for (const pieceAssignment of props.assignment.pieces) {
const options = getPieceOptions(pieceAssignment.definition); const options = getPieceOptions(pieceAssignment);
if ( if (
pieceAssignment.selectedPieceId && pieceAssignment.selectedPieceId &&
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId) !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) { ) {
pieceAssignment.selectedPieceId = ''; pieceAssignment.selectedPieceId = '';
} }
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path);
fetchPieceOptions(pieceAssignment).catch(() => {});
}
} }
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
@@ -542,15 +704,34 @@ watch(
() => [props.products, props.assignment.products], () => [props.products, props.assignment.products],
() => { () => {
for (const productAssignment of props.assignment.products) { for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment.definition); const options = getProductOptions(productAssignment);
if ( if (
productAssignment.selectedProductId && productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId) !options.some((product) => product.id === productAssignment.selectedProductId)
) { ) {
productAssignment.selectedProductId = ''; productAssignment.selectedProductId = '';
} }
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path);
fetchProductOptions(productAssignment).catch(() => {});
}
} }
}, },
{ deep: true, immediate: true }, { 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> </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 searchTerm = ref('')
const openDropdown = ref(false) const openDropdown = ref(false)
@@ -267,6 +267,7 @@ function handleInput () {
if (!openDropdown.value) { if (!openDropdown.value) {
openDropdown.value = true openDropdown.value = true
} }
emit('search', searchTerm.value)
} }
function closeDropdown () { function closeDropdown () {

View File

@@ -212,6 +212,9 @@
:pieces-loading="piecesLoading" :pieces-loading="piecesLoading"
:products-loading="productsLoading" :products-loading="productsLoading"
:components-loading="componentsLoading" :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"> <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.
@@ -349,7 +352,9 @@ 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'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
@@ -372,20 +377,19 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const { const {
createComposant, createComposant,
composants: componentCatalogRef, composants: componentCatalogRef,
loadComposants,
loading: componentsLoading, loading: componentsLoading,
} = useComposants() } = useComposants()
const { const {
pieces: pieceCatalogRef, pieces: pieceCatalogRef,
loadPieces,
loading: piecesLoading, loading: piecesLoading,
} = usePieces() } = usePieces()
const { const {
products: productCatalogRef, products: productCatalogRef,
loadProducts,
loading: productsLoading, loading: productsLoading,
} = useProducts() } = useProducts()
const toast = useToast() const toast = useToast()
@@ -414,6 +418,28 @@ const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value || productsLoading.value, () => 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( watch(
() => route.query.typeId, () => route.query.typeId,
(value) => { (value) => {
@@ -934,9 +960,8 @@ const submitCreation = async () => {
onMounted(async () => { onMounted(async () => {
await Promise.allSettled([ await Promise.allSettled([
loadComponentTypes(), loadComponentTypes(),
loadPieces(), loadPieceTypes(),
loadComposants(), loadProductTypes(),
loadProducts(),
]) ])
}) })