|
@@ -1,31 +1,32 @@
|
|
|
<!--
|
|
<!--
|
|
|
-Liste déroulante avec autocompletion
|
|
|
|
|
|
|
+Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
|
|
|
|
|
|
|
|
@see https://vuetifyjs.com/en/components/autocompletes/#usage
|
|
@see https://vuetifyjs.com/en/components/autocompletes/#usage
|
|
|
-->
|
|
-->
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
|
<main>
|
|
<main>
|
|
|
|
|
+ <!--suppress TypeScriptValidateTypes -->
|
|
|
<v-autocomplete
|
|
<v-autocomplete
|
|
|
- autocomplete="search"
|
|
|
|
|
- :value="data"
|
|
|
|
|
- :items="itemsToDisplayed"
|
|
|
|
|
- :label="$t(fieldLabel)"
|
|
|
|
|
- item-text="itemTextDisplay"
|
|
|
|
|
- :item-value="itemValue"
|
|
|
|
|
- :no-data-text="$t('autocomplete_research')"
|
|
|
|
|
- :no-filter="noFilter"
|
|
|
|
|
- auto-select-first
|
|
|
|
|
- :multiple="multiple"
|
|
|
|
|
- :loading="isLoading"
|
|
|
|
|
- :return-object="returnObject"
|
|
|
|
|
- :search-input.sync="search"
|
|
|
|
|
- :prepend-icon="prependIcon"
|
|
|
|
|
- :error="error || !!violation"
|
|
|
|
|
- :error-messages="errorMessage || violation ? $t(violation) : ''"
|
|
|
|
|
- :rules="rules"
|
|
|
|
|
- :chips="chips"
|
|
|
|
|
- @input="onChange($event)"
|
|
|
|
|
|
|
+ :model-value="modelValue"
|
|
|
|
|
+ autocomplete="search"
|
|
|
|
|
+ :items="items"
|
|
|
|
|
+ :label="$t(fieldLabel)"
|
|
|
|
|
+ item-text="itemTextDisplay"
|
|
|
|
|
+ :item-value="itemValue"
|
|
|
|
|
+ :no-data-text="$t('autocomplete_research')"
|
|
|
|
|
+ :no-filter="noFilter"
|
|
|
|
|
+ auto-select-first
|
|
|
|
|
+ :multiple="multiple"
|
|
|
|
|
+ :loading="isLoading"
|
|
|
|
|
+ :return-object="returnObject"
|
|
|
|
|
+ :search-input.sync="search"
|
|
|
|
|
+ :prepend-icon="prependIcon"
|
|
|
|
|
+ :error="error || !!fieldViolations"
|
|
|
|
|
+ :error-messages="errorMessage || fieldViolations ? $t(fieldViolations) : ''"
|
|
|
|
|
+ :rules="rules"
|
|
|
|
|
+ :chips="chips"
|
|
|
|
|
+ @update:model-value="onUpdate"
|
|
|
>
|
|
>
|
|
|
<template v-if="slotText" #item="data">
|
|
<template v-if="slotText" #item="data">
|
|
|
<v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>
|
|
<v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>
|
|
@@ -35,91 +36,154 @@ Liste déroulante avec autocompletion
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import {useNuxtApp} from "#app";
|
|
|
|
|
import {computed, ComputedRef, Ref} from "@vue/reactivity";
|
|
import {computed, ComputedRef, Ref} from "@vue/reactivity";
|
|
|
import {useFieldViolation} from "~/composables/form/useFieldViolation";
|
|
import {useFieldViolation} from "~/composables/form/useFieldViolation";
|
|
|
-import {onUnmounted, watch} from "@vue/runtime-core";
|
|
|
|
|
import ObjectUtils from "~/services/utils/objectUtils";
|
|
import ObjectUtils from "~/services/utils/objectUtils";
|
|
|
|
|
+import {AnyJson} from "~/types/data";
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
const props = defineProps({
|
|
|
- label: {
|
|
|
|
|
- type: String,
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * v-model
|
|
|
|
|
+ */
|
|
|
|
|
+ modelValue: {
|
|
|
|
|
+ type: [String, Number, Object, Array],
|
|
|
required: false,
|
|
required: false,
|
|
|
default: null
|
|
default: null
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
|
|
|
|
|
+ * - Utilisé par la validation
|
|
|
|
|
+ * - Laisser null si le champ ne s'applique pas à une entité
|
|
|
|
|
+ */
|
|
|
field: {
|
|
field: {
|
|
|
type: String,
|
|
type: String,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: null
|
|
default: null
|
|
|
},
|
|
},
|
|
|
- data: {
|
|
|
|
|
- type: [String, Number, Object, Array],
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Label du champ
|
|
|
|
|
+ * Si non défini, c'est le nom de propriété qui est utilisé
|
|
|
|
|
+ */
|
|
|
|
|
+ label: {
|
|
|
|
|
+ type: String,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: null
|
|
default: null
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Liste des éléments de la liste
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-items
|
|
|
|
|
+ */
|
|
|
items: {
|
|
items: {
|
|
|
- type: Array,
|
|
|
|
|
|
|
+ type: Array<Object>,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: () => []
|
|
default: () => []
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Définit si le champ est en lecture seule
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
|
|
|
|
|
+ */
|
|
|
readonly: {
|
|
readonly: {
|
|
|
type: Boolean,
|
|
type: Boolean,
|
|
|
required: false
|
|
required: false
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Le model est l'objet lui-même, et non pas son id (ou la propriété définie avec itemValue)
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-return-object
|
|
|
|
|
+ */
|
|
|
|
|
+ returnObject: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: false
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Autorise la sélection multiple
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
|
|
|
|
|
+ */
|
|
|
|
|
+ multiple: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: false
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Propriété de l'objet à utiliser comme label
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-title
|
|
|
|
|
+ */
|
|
|
|
|
+ itemTitle: {
|
|
|
|
|
+ type: String,
|
|
|
|
|
+ required: true
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Propriété de l'objet à utiliser comme clé (et correspondant au v-model)
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-value
|
|
|
|
|
+ */
|
|
|
itemValue: {
|
|
itemValue: {
|
|
|
type: String,
|
|
type: String,
|
|
|
default: 'id'
|
|
default: 'id'
|
|
|
},
|
|
},
|
|
|
- itemText: {
|
|
|
|
|
- type: Array,
|
|
|
|
|
- required: true
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Icône de gauche
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-prepend-icon
|
|
|
|
|
+ */
|
|
|
|
|
+ prependIcon: {
|
|
|
|
|
+ type: String
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Rends les résultats sous forme de puces
|
|
|
|
|
+ * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
|
|
|
|
|
+ */
|
|
|
|
|
+ chips: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ default: false
|
|
|
|
|
+ },
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Le contenu de la liste est en cours de chargement
|
|
|
|
|
+ */
|
|
|
|
|
+ isLoading: {
|
|
|
|
|
+ type: Boolean,
|
|
|
|
|
+ required: false,
|
|
|
|
|
+ default: false
|
|
|
},
|
|
},
|
|
|
- group:{
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Propriété de l'objet utilisé pour grouper les items ; laisser null pour ne pas grouper
|
|
|
|
|
+ */
|
|
|
|
|
+ group: {
|
|
|
type: String,
|
|
type: String,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: null
|
|
default: null
|
|
|
},
|
|
},
|
|
|
|
|
+ // TODO: c'est quoi?
|
|
|
slotText: {
|
|
slotText: {
|
|
|
type: Array,
|
|
type: Array,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: null
|
|
default: null
|
|
|
},
|
|
},
|
|
|
- returnObject: {
|
|
|
|
|
- type: Boolean,
|
|
|
|
|
- default: false
|
|
|
|
|
- },
|
|
|
|
|
- multiple: {
|
|
|
|
|
- type: Boolean,
|
|
|
|
|
- default: false
|
|
|
|
|
- },
|
|
|
|
|
- isLoading: {
|
|
|
|
|
- type: Boolean,
|
|
|
|
|
- default: false
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ // TODO: c'est quoi?
|
|
|
noFilter: {
|
|
noFilter: {
|
|
|
type: Boolean,
|
|
type: Boolean,
|
|
|
default: false
|
|
default: false
|
|
|
},
|
|
},
|
|
|
- prependIcon: {
|
|
|
|
|
- type: String
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ // TODO: c'est quoi?
|
|
|
translate: {
|
|
translate: {
|
|
|
type: Boolean,
|
|
type: Boolean,
|
|
|
default: false
|
|
default: false
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Règles de validation
|
|
|
|
|
+ * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
|
|
|
|
|
+ */
|
|
|
rules: {
|
|
rules: {
|
|
|
type: Array,
|
|
type: Array,
|
|
|
required: false,
|
|
required: false,
|
|
|
default: () => []
|
|
default: () => []
|
|
|
},
|
|
},
|
|
|
- chips: {
|
|
|
|
|
- type: Boolean,
|
|
|
|
|
- default: false
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Le champ est-il actuellement en état d'erreur
|
|
|
|
|
+ */
|
|
|
error: {
|
|
error: {
|
|
|
type: Boolean,
|
|
type: Boolean,
|
|
|
required: false
|
|
required: false
|
|
|
},
|
|
},
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Si le champ est en état d'erreur, quel est le message d'erreur?
|
|
|
|
|
+ */
|
|
|
errorMessage: {
|
|
errorMessage: {
|
|
|
type: String,
|
|
type: String,
|
|
|
required: false,
|
|
required: false,
|
|
@@ -127,50 +191,59 @@ const props = defineProps({
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-const { emit } = useNuxtApp()
|
|
|
|
|
-
|
|
|
|
|
-const { i18n } = useNuxtApp()
|
|
|
|
|
|
|
+const i18n = useI18n()
|
|
|
|
|
|
|
|
const search: Ref<string|null> = ref(null)
|
|
const search: Ref<string|null> = ref(null)
|
|
|
|
|
|
|
|
-const fieldLabel = props.label ?? props.field
|
|
|
|
|
|
|
+const fieldLabel: string = props.label ?? props.field
|
|
|
|
|
|
|
|
-const { violation, onChange } = useFieldViolation(props.field, emit)
|
|
|
|
|
|
|
+const {fieldViolations, updateViolationState} = useFieldViolation(props.field)
|
|
|
|
|
|
|
|
-// On reconstruit les items à afficher...
|
|
|
|
|
-const itemsToDisplayed: ComputedRef<Array<AnyJson>> = computed(() => {
|
|
|
|
|
- const itemsByGroup:Array<Array<string>> = classItemsByGroup(props.items)
|
|
|
|
|
- return prepareItemsToDisplayed(itemsByGroup)
|
|
|
|
|
-})
|
|
|
|
|
|
|
+const emit = defineEmits(['update:model-value'])
|
|
|
|
|
|
|
|
-const unwatch = watch(
|
|
|
|
|
- search,
|
|
|
|
|
- useDebounce(async (newResearch, oldResearch) => {
|
|
|
|
|
- if(newResearch !== oldResearch && oldResearch !== null)
|
|
|
|
|
- emit('research', newResearch)
|
|
|
|
|
- }, 500)
|
|
|
|
|
-)
|
|
|
|
|
|
|
+const onUpdate = (event: string) => {
|
|
|
|
|
+ updateViolationState(event)
|
|
|
|
|
+ emit('update:model-value', props.modelValue)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-onUnmounted(() => {
|
|
|
|
|
- unwatch()
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Items à afficher
|
|
|
|
|
+ * TODO: à revoir
|
|
|
|
|
+ */
|
|
|
|
|
+const items: ComputedRef<Array<AnyJson>> = computed(() => {
|
|
|
|
|
+ let _items: Array<any> = props.items
|
|
|
|
|
+ return _items
|
|
|
|
|
+ // if (props.group !== null) {
|
|
|
|
|
+ // _items = groupItems(props.items)
|
|
|
|
|
+ // }
|
|
|
|
|
+ //
|
|
|
|
|
+ // return prepareGroups(_items)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * On construit l'Array à double entrée contenant les groups (headers) et les propositions
|
|
|
|
|
|
|
+ * On construit l'Array à double entrée contenant les groups (headers) et les items
|
|
|
|
|
+ * TODO: à revoir
|
|
|
*
|
|
*
|
|
|
* @param items
|
|
* @param items
|
|
|
*/
|
|
*/
|
|
|
-const classItemsByGroup = (items:Array<any>): Array<Array<string>> => {
|
|
|
|
|
- const group = props.group as string
|
|
|
|
|
|
|
+const groupItems = (items: Array<any>): Array<Array<string>> => {
|
|
|
|
|
+ const group = props.group as string | null
|
|
|
|
|
+ if (group === null) {
|
|
|
|
|
+ return items
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const itemsByGroup: Array<Array<string>> = []
|
|
const itemsByGroup: Array<Array<string>> = []
|
|
|
|
|
+ let groupValue = null
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
for (const item of items) {
|
|
|
if (item) {
|
|
if (item) {
|
|
|
- if (!itemsByGroup[item[group]]) {
|
|
|
|
|
- itemsByGroup[item[group]] = []
|
|
|
|
|
|
|
+ groupValue = item[group]
|
|
|
|
|
+
|
|
|
|
|
+ if (!itemsByGroup[groupValue]) {
|
|
|
|
|
+ itemsByGroup[groupValue] = []
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- itemsByGroup[item[group]].push(item)
|
|
|
|
|
|
|
+ itemsByGroup[groupValue].push(item)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -179,13 +252,14 @@ const classItemsByGroup = (items:Array<any>): Array<Array<string>> => {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
|
|
* Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
|
|
|
|
|
+ * TODO: à revoir
|
|
|
*
|
|
*
|
|
|
- * @param itemsByGroup
|
|
|
|
|
|
|
+ * @param groupedItems
|
|
|
*/
|
|
*/
|
|
|
-const prepareItemsToDisplayed = (itemsByGroup: Array<Array<string>>): Array<AnyJson> => {
|
|
|
|
|
|
|
+const prepareGroups = (groupedItems: Array<Array<string>>): Array<AnyJson> => {
|
|
|
let finalItems: Array<AnyJson> = []
|
|
let finalItems: Array<AnyJson> = []
|
|
|
|
|
|
|
|
- for (const group in itemsByGroup) {
|
|
|
|
|
|
|
+ for (const group in groupedItems) {
|
|
|
|
|
|
|
|
// Si un groupe est présent, alors on créé le groupe options header
|
|
// Si un groupe est présent, alors on créé le groupe options header
|
|
|
if (group !== 'undefined') {
|
|
if (group !== 'undefined') {
|
|
@@ -193,27 +267,38 @@ const prepareItemsToDisplayed = (itemsByGroup: Array<Array<string>>): Array<AnyJ
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// On parcourt les items pour préparer les texts / slotTexts à afficher
|
|
// On parcourt les items pour préparer les texts / slotTexts à afficher
|
|
|
- finalItems = finalItems.concat(itemsByGroup[group].map((item: any) => {
|
|
|
|
|
- const slotTextDisplay: Array<string> = []
|
|
|
|
|
- const itemTextDisplay: Array<string> = []
|
|
|
|
|
|
|
+ finalItems = finalItems.concat(groupedItems[group].map((item: any) => {
|
|
|
|
|
+ return prepareItem(item)
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+ return finalItems
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- item = ObjectUtils.cloneAndFlatten(item)
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Construction d'un item
|
|
|
|
|
+ * TODO: à revoir
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param item
|
|
|
|
|
+ */
|
|
|
|
|
+const prepareItem = (item: Object): AnyJson => {
|
|
|
|
|
+ const slotTextDisplay: Array<string> = []
|
|
|
|
|
+ const itemTextDisplay: Array<string> = []
|
|
|
|
|
|
|
|
- // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
|
|
|
|
|
- if (props.slotText) {
|
|
|
|
|
- for (const text of props.slotText) {
|
|
|
|
|
- slotTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ item = ObjectUtils.cloneAndFlatten(item)
|
|
|
|
|
|
|
|
- for (const text of props.itemText) {
|
|
|
|
|
- itemTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
|
|
|
|
|
+ if (props.slotText) {
|
|
|
|
|
+ for (const text of props.slotText) {
|
|
|
|
|
+ slotTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // On reconstruit l'objet
|
|
|
|
|
- return Object.assign({}, item, { itemTextDisplay: itemTextDisplay.join(' '), slotTextDisplay: slotTextDisplay.join(' ') })
|
|
|
|
|
- }))
|
|
|
|
|
|
|
+ for (const text of props.itemTitle) {
|
|
|
|
|
+ itemTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
|
|
|
}
|
|
}
|
|
|
- return finalItems
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // On reconstruit l'objet
|
|
|
|
|
+ return Object.assign({}, item, { itemTextDisplay: itemTextDisplay.join(' '), slotTextDisplay: slotTextDisplay.join(' ') })
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
</script>
|
|
</script>
|