Autocomplete.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. <!--
  2. Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
  3. @see https://vuetifyjs.com/en/components/autocompletes/#usage
  4. -->
  5. <template>
  6. <main>
  7. <!--suppress TypeScriptValidateTypes -->
  8. <v-autocomplete
  9. v-model:search-input="search"
  10. :model-value="modelValue"
  11. autocomplete="search"
  12. :items="items"
  13. :label="$t(fieldLabel)"
  14. :item-title="itemTitle"
  15. :item-value="itemValue"
  16. :no-filter="noFilter"
  17. :auto-select-first="autoSelectFirst"
  18. :multiple="multiple"
  19. :loading="isLoading"
  20. :return-object="returnObject"
  21. :prepend-inner-icon="prependInnerIcon"
  22. :error="error || !!fieldViolations"
  23. :error-messages="
  24. errorMessage || fieldViolations ? $t(fieldViolations) : ''
  25. "
  26. :rules="rules"
  27. :chips="chips"
  28. :closable-chips="closableChips"
  29. :hide-no-data="hideNoData"
  30. :no-data-text="
  31. isLoading ? $t('please_wait') : $t('no_result_matching_your_request')
  32. "
  33. :variant="variant"
  34. density="compact"
  35. class="mb-3"
  36. @update:model-value="onUpdate"
  37. @update:search="emit('update:search', $event)"
  38. @update:menu="emit('update:menu', $event)"
  39. @update:focused="emit('update:focused', $event)"
  40. >
  41. <template v-if="slotText" #item="{ item }">
  42. <div>{{ item.slotTextDisplay }}</div>
  43. </template>
  44. </v-autocomplete>
  45. </main>
  46. </template>
  47. <script setup lang="ts">
  48. import type { Ref, PropType } from 'vue'
  49. import { useFieldViolation } from '~/composables/form/useFieldViolation'
  50. import ObjectUtils from '~/services/utils/objectUtils'
  51. import type { AnyJson } from '~/types/data'
  52. const props = defineProps({
  53. /**
  54. * v-model
  55. */
  56. modelValue: {
  57. type: [String, Number, Object, Array] as PropType<
  58. string | number | object | Array<unknown>
  59. >,
  60. required: false,
  61. default: null,
  62. },
  63. /**
  64. * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
  65. * - Utilisé par la validation
  66. * - Laisser null si le champ ne s'applique pas à une entité
  67. */
  68. field: {
  69. type: String,
  70. required: false,
  71. default: null,
  72. },
  73. /**
  74. * Label du champ
  75. * Si non défini, c'est le nom de propriété qui est utilisé
  76. */
  77. label: {
  78. type: String,
  79. required: false,
  80. default: null,
  81. },
  82. /**
  83. * Liste des éléments de la liste
  84. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-items
  85. */
  86. items: {
  87. type: Array as PropType<Array<object>>,
  88. required: false,
  89. default: () => [],
  90. },
  91. /**
  92. * Définit si le champ est en lecture seule
  93. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
  94. */
  95. readonly: {
  96. type: Boolean,
  97. required: false,
  98. },
  99. /**
  100. * Le model est l'objet lui-même, et non pas son id (ou la propriété définie avec itemValue)
  101. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-return-object
  102. */
  103. returnObject: {
  104. type: Boolean,
  105. default: false,
  106. },
  107. /**
  108. * Autorise la sélection multiple
  109. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
  110. */
  111. multiple: {
  112. type: Boolean,
  113. default: false,
  114. },
  115. /**
  116. * Propriété de l'objet à utiliser comme label
  117. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-title
  118. */
  119. itemTitle: {
  120. type: String,
  121. default: 'title',
  122. },
  123. /**
  124. * Propriété de l'objet à utiliser comme clé (et correspondant au v-model)
  125. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-value
  126. */
  127. itemValue: {
  128. type: String,
  129. default: 'id',
  130. },
  131. /**
  132. * Icône de gauche
  133. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-prepend-inner-icon
  134. */
  135. prependInnerIcon: {
  136. type: String,
  137. default: null,
  138. },
  139. /**
  140. * Rends les résultats sous forme de puces
  141. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
  142. */
  143. chips: {
  144. type: Boolean,
  145. default: false,
  146. },
  147. /**
  148. * Permet de retirer une puce directement
  149. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-closable-chips
  150. */
  151. closableChips: {
  152. type: Boolean,
  153. default: false,
  154. },
  155. /**
  156. * Le contenu de la liste est en cours de chargement
  157. */
  158. isLoading: {
  159. type: Boolean,
  160. required: false,
  161. default: false,
  162. },
  163. /**
  164. * Propriété de l'objet utilisé pour grouper les items ; laisser null pour ne pas grouper
  165. */
  166. group: {
  167. type: String,
  168. required: false,
  169. default: null,
  170. },
  171. /**
  172. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-hide-no-data
  173. */
  174. hideNoData: {
  175. type: Boolean,
  176. required: false,
  177. default: false,
  178. },
  179. // TODO: c'est quoi?
  180. slotText: {
  181. type: Array,
  182. required: false,
  183. default: null,
  184. },
  185. /**
  186. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-no-filter
  187. */
  188. noFilter: {
  189. type: Boolean,
  190. default: false,
  191. },
  192. /**
  193. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-auto-select-first
  194. */
  195. autoSelectFirst: {
  196. type: Boolean,
  197. default: true,
  198. },
  199. // TODO: c'est quoi?
  200. translate: {
  201. type: Boolean,
  202. default: false,
  203. },
  204. /**
  205. * Règles de validation
  206. * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
  207. */
  208. rules: {
  209. type: Array,
  210. required: false,
  211. default: () => [],
  212. },
  213. /**
  214. * Le champ est-il actuellement en état d'erreur
  215. */
  216. error: {
  217. type: Boolean,
  218. required: false,
  219. },
  220. /**
  221. * Si le champ est en état d'erreur, quel est le message d'erreur?
  222. */
  223. errorMessage: {
  224. type: String,
  225. required: false,
  226. default: null,
  227. },
  228. /**
  229. * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
  230. */
  231. variant: {
  232. type: String as PropType<
  233. | 'filled'
  234. | 'outlined'
  235. | 'plain'
  236. | 'underlined'
  237. | 'solo'
  238. | 'solo-inverted'
  239. | 'solo-filled'
  240. | undefined
  241. >,
  242. required: false,
  243. default: 'outlined',
  244. },
  245. })
  246. const i18n = useI18n()
  247. const search: Ref<string | null> = ref(null)
  248. const fieldLabel: string = props.label ?? props.field
  249. const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
  250. const emit = defineEmits([
  251. 'update:model-value',
  252. 'update:search',
  253. 'update:focused',
  254. 'update:menu',
  255. ])
  256. const onUpdate = (event: string) => {
  257. updateViolationState()
  258. emit('update:model-value', event)
  259. }
  260. /**
  261. * On construit l'Array à double entrée contenant les groups (headers) et les items
  262. * TODO: à revoir
  263. *
  264. * @param items
  265. */
  266. const groupItems = (items: Array<AnyJson>): Array<Array<string>> => {
  267. const group = props.group as string | null
  268. if (group === null) {
  269. return items
  270. }
  271. const itemsByGroup: Array<Array<string>> = []
  272. let groupValue = null
  273. for (const item of items) {
  274. if (item) {
  275. groupValue = item[group]
  276. if (!itemsByGroup[groupValue]) {
  277. itemsByGroup[groupValue] = []
  278. }
  279. itemsByGroup[groupValue].push(item)
  280. }
  281. }
  282. return itemsByGroup
  283. }
  284. /**
  285. * Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
  286. * TODO: à revoir
  287. *
  288. * @param groupedItems
  289. */
  290. const prepareGroups = (groupedItems: Array<Array<string>>): Array<AnyJson> => {
  291. let finalItems: Array<AnyJson> = []
  292. for (const group in groupedItems) {
  293. // Si un groupe est présent, alors on créé le groupe options header
  294. if (group !== 'undefined') {
  295. finalItems.push({ header: i18n.t(group as string) })
  296. }
  297. // On parcourt les items pour préparer les texts / slotTexts à afficher
  298. finalItems = finalItems.concat(
  299. groupedItems[group].map((item: AnyJson) => {
  300. return prepareItem(item)
  301. }),
  302. )
  303. }
  304. return finalItems
  305. }
  306. /**
  307. * Construction d'un item
  308. * TODO: à revoir
  309. *
  310. * @param item
  311. */
  312. const prepareItem = (item: object): AnyJson => {
  313. const slotTextDisplay: Array<string> = []
  314. const itemTextDisplay: Array<string> = []
  315. item = ObjectUtils.cloneAndFlatten(item)
  316. // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
  317. if (props.slotText) {
  318. for (const text of props.slotText) {
  319. slotTextDisplay.push(
  320. props.translate ? i18n.t(item[text as string]) : item[text as string],
  321. )
  322. }
  323. }
  324. for (const text of props.itemTitle) {
  325. itemTextDisplay.push(
  326. props.translate ? i18n.t(item[text as string]) : item[text as string],
  327. )
  328. }
  329. // On reconstruit l'objet
  330. return Object.assign({}, item, {
  331. itemTextDisplay: itemTextDisplay.join(' '),
  332. slotTextDisplay: slotTextDisplay.join(' '),
  333. })
  334. }
  335. onUnmounted(() => {
  336. updateViolationState()
  337. search.value = null
  338. })
  339. </script>
  340. <style scoped lang="scss">
  341. :deep(.v-chip__close .v-icon) {
  342. font-size: 16px;
  343. color: rgb(var(--v-theme-on-neutral));
  344. }
  345. </style>