소스 검색

add the Accesses autocomplete and minor fixes

Olivier Massot 2 년 전
부모
커밋
e0f7538be2

+ 0 - 2
components/Layout/Parameters/Website.vue

@@ -97,7 +97,6 @@ if (organizationProfile.parametersId === null) {
 
 const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
 
-
 const { data: subdomains, pending: subdomainsPending } = fetchCollection(Subdomain, null, ref({ 'organization' : organizationProfile.id }) )
 
 const canAddNewSubdomain: ComputedRef<boolean> = computed(() => subdomains.value && subdomains.value.items.length < 3)
@@ -111,7 +110,6 @@ const onAddSubdomainClick = () => {
     throw new Error('Max number of subdomains reached')
   }
   navigateTo('/parameters/subdomains/new')
-
 }
 </script>
 

+ 11 - 2
components/Ui/Input/Autocomplete.vue

@@ -15,7 +15,7 @@ Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
         item-text="itemTextDisplay"
         :item-value="itemValue"
         :no-filter="noFilter"
-        auto-select-first
+        :auto-select-first="autoSelectFirst"
         :multiple="multiple"
         :loading="isLoading"
         :return-object="returnObject"
@@ -166,11 +166,20 @@ const props = defineProps({
     required: false,
     default: null
   },
-  // TODO: c'est quoi?
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-no-filter
+   */
   noFilter: {
     type: Boolean,
     default: false
   },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-auto-select-first
+   */
+  autoSelectFirst: {
+    type: Boolean,
+    default: true
+  },
   // TODO: c'est quoi?
   translate: {
     type: Boolean,

+ 104 - 19
components/Ui/Input/Autocomplete/Accesses.vue

@@ -11,13 +11,14 @@ Champs autocomplete dédié à la recherche des access d'une structure
         :field="field"
         :label="label"
         :items="items"
+        item-value="id"
         :isLoading="pending"
         :multiple="multiple"
         :chips="chips"
-        no-filter
+        :auto-select-first="false"
         prependIcon="fas fa-magnifying-glass"
         :return-object="false"
-        @update:model-value="$emit('update:model-value', $event)"
+        @update:model-value="onUpdateModelValue"
         @update:search="onUpdateSearch"
     />
   </main>
@@ -29,6 +30,9 @@ import {computed, ComputedRef, Ref} from "@vue/reactivity";
 import {AnyJson, AssociativeArray} from "~/types/data";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import Access from "~/models/Access/Access";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import ArrayUtils from "~/services/utils/arrayUtils";
+import * as _ from 'lodash-es'
 
 const props = defineProps({
   /**
@@ -89,48 +93,129 @@ const props = defineProps({
   chips: {
     type: Boolean,
     default: false
+  },
+  /**
+   * Closes the menu and clear the current search after the selection has been updated
+   */
+  clearSearchAfterUpdate: {
+    type: Boolean,
+    default: false
   }
 })
 
-const { fetchCollection } = useEntityFetch()
+/**
+ * Element de la liste autocomplete
+ */
+interface AccessListItem {
+  id: number | string,
+  title: string
+}
 
+const { fetchCollection } = useEntityFetch()
+const { em } = useEntityManager()
 const i18n = useI18n()
 
+/**
+ * Génère un AccessListItem à partir d'un Access
+ * @param access
+ */
+const accessToItem = (access: Access): AccessListItem => {
+  return {
+    id: access.id,
+    title: access.person ? `${access.person.givenName} ${access.person.name}` : i18n.t('unknown')
+  }
+}
+
+const getFromStore = (id: number) => {
+  return em.find(Access, id)
+}
+
+const initialized: Ref<boolean> = ref(false)
+
+/**
+ * Saisie de l'utilisateur utilisée pour filtrer la recherche
+ */
 const nameFilter: Ref<string | null> = ref(null)
 
+/**
+ * Query transmise à l'API lors des changements de filtre de recherche
+ */
 const query: ComputedRef<AnyJson> = computed(() => {
-  let q = props.filters.value ?? {}
+  let q: AnyJson = {'groups[]': 'access_people_ref'}
+
+  if (!initialized.value) {
+    q['id[in]'] = Array.isArray(props.modelValue) ? props.modelValue.join(',') : props.modelValue
+    return q
+  }
+
   if (nameFilter.value !== null) {
     q['fullname'] = nameFilter.value
   }
-  q['groups[]'] = 'access_people_ref'
 
   return q
 })
 
-const { data: collection, pending, refresh } = await fetchCollection(Access, null, query)
-
-const accessToItem = (access: Access): { id: number | string, title: string } => {
-  return {
-    id: access.id,
-    title: access.person ? `${access.person.givenName} ${access.person.name}` : i18n.t('unknown')
+/**
+ * On commence par fetcher les accesses déjà actifs, pour affichage des noms
+ */
+const { data: collection, pending, refresh } = await fetchCollection(
+    Access,
+    null,
+    query
+)
+initialized.value = true
+
+/**
+ * Contenu de la liste autocomplete
+ */
+const items: ComputedRef<Array<AccessListItem>> = computed(() => {
+  let items = props.modelValue.map(getFromStore).map(accessToItem)
+
+  //@ts-ignore
+  const fetchedItems = collection.value.items.map(accessToItem)
+
+  for (let item of fetchedItems) {
+    if (!items.some((existingItem: AccessListItem) => existingItem.id === item.id)) {
+      items.push(item)
+    }
   }
-}
 
-const items: ComputedRef<Array<{ id: number | string, title: string }>> = computed(() => {
-  if (!pending.value && collection.value && collection.value.items) {
-    // @ts-ignore
-    return collection.value.items.map(accessToItem)
-  }
-  return []
+  return ArrayUtils.sortObjectsByProp(items, 'title') as Array<AccessListItem>
 })
 
+
+/**
+ * Délai entre le dernier caractère saisi et la requête de vérification de la mise à jour des résultats (en ms)
+ */
+const inputDelay = 600
+
+/**
+ * Version debounced de la fonction refresh
+ * @see https://docs-lodash.com/v4/debounce/
+ */
+const refreshDebounced: _.DebouncedFunc<() => void> = _.debounce(async () => {
+  await refresh();
+}, inputDelay)
+
+// ### Events
 const emit = defineEmits(['update:model-value'])
 
+/**
+ * La recherche textuelle a changé.
+ * @param event
+ */
 const onUpdateSearch = (event: string) => {
   nameFilter.value = event
-  refresh()
+  refreshDebounced()
 }
+
+const onUpdateModelValue = (event: Array<number>) => {
+  if (props.clearSearchAfterUpdate) {
+    nameFilter.value = ""
+  }
+  emit('update:model-value', event)
+}
+
 </script>
 
 <style scoped lang="scss">

+ 1 - 1
models/Organization/Parameters.ts

@@ -49,7 +49,7 @@ export default class Parameters extends ApiModel {
   declare desactivateOpentalentSiteWeb: boolean
 
   @Attr([])
-  declare publicationDirectors: []
+  declare publicationDirectors: number[]
 
   @Str(null)
   declare bulletinPeriod: string|null

+ 1 - 1
services/data/entityManager.ts

@@ -134,7 +134,7 @@ class EntityManager {
      * @param id
      */
     // @ts-ignore
-    public find<T extends ApiResource>(model: typeof T, id: number): T {
+    public find<T extends ApiResource>(model: typeof T, id: number | string): T {
         const repository = this.getRepository(model)
         return repository.find(id) as T
     }

+ 21 - 0
services/utils/stringUtils.ts

@@ -0,0 +1,21 @@
+
+export default class StringUtils
+{
+    /**
+     * Normalise une chaine de caractères en retirant la casse et les caractères spéciaux, à des fins de recherche
+     * par exemple
+     * @param s
+     */
+    public static normalize(s: string): string {
+        return s
+            .toLowerCase()
+            .replace(/[éèẽëê]/g, 'e')
+            .replace(/[ç]/g, 'c')
+            .replace(/[îïĩ]/g, 'i')
+            .replace(/[àã]/g, 'a')
+            .replace(/[öôõ]/g, 'o')
+            .replace(/[ûüũ]/g, 'u')
+            .replace(/[-]/g, ' ')
+            .trim()
+    }
+}