Commit 203546e2 authored by Stephane Bouvry's avatar Stephane Bouvry
Browse files

Update Autocomplete (Person, Organisation) : Mise en place de l'autocompleter de PrimeVue

Fix : ecran de changement de projet + update de la méthode de recherche
parent 2a927814
Loading
Loading
Loading
Loading
+2 −16
Original line number Diff line number Diff line
@@ -791,25 +791,11 @@ class ProjectController extends AbstractOscarController
        if (strlen($search) < 2) {
            return $this->getResponseBadRequest("Not enough chars (2 required");
        }
        $datas = $this->getProjectService()->search($search)->getQuery()->getResult();

        $json = [
            'datas' => []
        ];

        /** @var Project $data */
        foreach ($datas as $data) {
            $json['datas'][] = [
                'id'          => $data->getId(),
                'label'       => $data->getLabel() . " - " . count($data->getActivities()) . " activité(s)",
                'acronym'     => $data->getAcronym(),
                'description' => $data->getDescription(),
            ];
        }
        $datas = $this->getProjectService()->search($search);

        $view = new JsonModel();

        $view->setVariables($json);
        $view->setVariables($datas);

        return $view;
    }
+26 −6
Original line number Diff line number Diff line
@@ -413,13 +413,33 @@ class ProjectService implements UseServiceContainer
     */
    public function search($search)
    {
        $params = [
            'q' => $search,
            'page' => 1,
            'rbp' => 50,
            'sort' => 'hit',
            'direction' => 'desc',
            'filters' => [],
            'project' => '1',
            'restricted_ids' => false,
            'exportable' => false,
        ];

        // IDS des PROJETS
        $idsProject = $this->getProjectGrantService()->searchProject($search);
        $idsProjectsEmpty = $this->getProjectRepository()->getEmptyIds($search);
        $idsProject = array_merge($idsProject, $idsProjectsEmpty);
        return $qb = $this->getBaseQuery()
            ->where('p.id IN (:ids)')
            ->setParameter('ids', $idsProject);
        $datas = $this->getProjectGrantService()->search2($params);
        return $datas;
//        $idsProject = $this->getProjectGrantService()->searchProject($search);
//        $idsProjectsEmpty = $this->getProjectRepository()->getEmptyIds($search);
//        $idsProject = array_values(array_unique(array_merge($idsProject, $idsProjectsEmpty)));
//
//        // Evite un IN() vide qui génère une erreur SQL (500) quand aucun projet ne correspond.
//        if (count($idsProject) === 0) {
//            $idsProject = [0];
//        }
//
//        return $qb = $this->getBaseQuery()
//            ->where('p.id IN (:ids)')
//            ->setParameter('ids', $idsProject);
    }

    /**
+1 −1
Original line number Diff line number Diff line
@@ -58,7 +58,7 @@
                    },
                    processResults: function (data, page) {
                        console.log(data);
                        return {results: data.datas};
                        return {results: data.projects};
                    },
                    cache: true
                },
+219 −316
Original line number Diff line number Diff line
<template>
  <div class="oscar-selector organization-selector"
       @click="handlerClick"
       @mouseleave="handlerMouseLeave"
       @mouseenter="handlerMouseEnter">

  <div class="oscar-selector organization-selector">
    <div class="input-group" style="position: relative;">
      <div v-if="error" class="displayed-value text-danger" style="">
        <i class="icon-attention-1" />
        {{ error }}
        <i @click="error = ''" class="icon-cancel-circled-outline button-cancel-value"></i>
      </div>
      <div v-if="displayValue" class="displayed-value" style="">
        {{ selectedLabel }}
        <i v-if="selectedValue" @click="handlerUnselect" class="icon-cancel-circled-outline button-cancel-value"></i>
      </div>
      <span class="input-group-addon">
      <div class="autocomplete-with-left-icon">
        <span class="autocomplete-left-icon">
          <i v-show="loading" class="icon-spinner animate-spin"></i>
          <i v-show="!loading" class="icon-building-filled"></i>
        </span>
      <input type="text" v-model="searchFor" @keyup="handlerKeyUp"
             placeholder="Rechercher une organisation..."
             class="form-control"/>
    </div>

    <div class="options" v-show="showSelector && options.length">
      <header>
        Résultat(s) : {{ options.length }} /
        <AutoComplete
          v-model="selectedOrganization"
          :suggestions="suggestions"
          optionLabel="displayLabel"
          dropdown
          forceSelection
          showClear
          class="form-control-autocomplete organization-autocomplete"
          placeholder="Rechercher une organisation..."
          @complete="handlerComplete"
          @item-select="handlerItemSelect"
          @clear="handlerClear"
        >
        <template #header>
          <div class="options-header" v-if="suggestions.length || error">
            <span v-if="!error">Resultat(s) : {{ suggestions.length }}</span>
            <label for="hidder">
          Afficher les structures fermées
              Afficher les structures fermees
              <input type="checkbox" id="hidder" value="on" v-model="hideOff" @change="handlerChangeHide" />
            </label>
      </header>
      <div class="option" v-for="o, i in optionsFiltered"
           @mouseover="highlightedIndex = i"
           @click.prevent.stop="handlerSelectIndex(o.id)"
           @mousedown.prevent.stop="handlerSelectIndex(o.id)"
           :id="'item_'+i"
           :class="{
                 'active': i == highlightedIndex,
                 'selected': o.id == selectedValue,
                 'closed': o.close
               }">
          </div>
        </template>
        <template #option="slotProps">
          <div class="option" :class="{ closed: slotProps.option.close === true }">
            <div class="option-title">
              <em class="cartouche code info">
            {{ o.code }}
                {{ slotProps.option.code }}
              </em>
              <span class="fullname">
                <strong>
                  {{ o.shortname }}
                </strong>
                <em>
                  {{ o.longname }}
                </em>

                <i class="icon-block" v-if="slotProps.option.close"></i>
                <strong>{{ slotProps.option.shortname }}</strong>
                <em>{{ slotProps.option.longname }}</em>
              </span>
          <span v-if="o.typeorg">
                ({{o.typeorg}})
              <span v-if="slotProps.option.typeorg">
                ({{ slotProps.option.typeorg }})
              </span>
            </div>
        <div class="option-infos location cartouche xs" v-if="o.city || o.country">
            <div class="option-infos location cartouche xs" v-if="slotProps.option.city || slotProps.option.country">
              <i class="icon-location"></i>
          <span class="location-segment city" v-if="o.city">
            {{ o.city }}
              <span class="location-segment city" v-if="slotProps.option.city">
                {{ slotProps.option.city }}
              </span>
          <span class="location-segment country" v-if="o.country">
            {{ o.country }}
              <span class="location-segment country" v-if="slotProps.option.country">
                {{ slotProps.option.country }}
              </span>
            </div>
          </div>
        </template>
        </AutoComplete>
      </div>
    </div>

    <div v-if="error" class="displayed-value text-danger">
      <i class="icon-attention-1" />
      {{ error }}
      <i @click="error = ''" class="icon-cancel-circled-outline button-cancel-value"></i>
    </div>
  </div>
</template>
<script>

<script>
import axios from "axios";
import AutoComplete from "primevue/autocomplete";

export default {
  components: {
    AutoComplete,
  },

  props: {
    value: { default: null },
    url: { default: '/organization', type: String, required: true },
    url: { default: "/organization", type: String, required: true },
  },
  emits: ['update:value'],

  components: {

  },
  emits: ["update:value", "change", "input"],

  data() {
    return {
      // Liste des structures
      options: [],

      lastUpdatedSearch: 0,
      delay: null,
      preloadedValue: false,
      selectedValue: null,
      selectedLabel : "",
      displayClosed: false,
      showSelector: false,
      searchFor: "",
      latency: null,
      highlightedIndex: null,
      selectedOrganization: null,
      suggestions: [],
      hideOff: false,
      loading: false,
      inside: false,
      displayValue: false,
      error: "",
      hideOff: false
    }
      lastQuery: "",
    };
  },

  computed: {
    filteredOptions(){
      let opts = [];
      if( this.displayClosed ){
        return this.options;
      } else {
        this.options.forEach(item => {
          if( !item.closed ){
            opts.push(item);
  watch: {
    value: {
      immediate: true,
      handler(newValue) {
        if (!newValue) {
          this.selectedOrganization = null;
          return;
        }
        });
        return opts;
        if (this.selectedOrganization && String(this.selectedOrganization.id) === String(newValue)) {
          return;
        }
        this.loadSelectedById(newValue);
      },

    optionsFiltered(){
      if( this.hideOff ){
        return this.options;
      }
      else {
        let output = [];
        this.options.forEach(i => {
          if( !i.closed ){
            output.push(i);
          }
        });
        return output;
      }
    }
    },

  mounted() {
    // Détection d'un valeur initiale

    document.addEventListener('click', this.handlerGlobalClick, true);

    if (this.value) {
      //   this.selectedValue = this.value;
      //   this.options.push({
      //     id: this.value,
      //     label: "Waiting for data"
      //   })
      //   this.value = null;
      //   this.searchOrganization(null, 'id:' + this.selectedValue);
      // } else {
      //   this.preloadedValue = true;
    }
  },

  // todo : unmount

  methods: {
    handlerChangeHide(){
      console.log("Permuter ouvert/fermé");
      this.search();
    },
    handlerGlobalClick(evt){
      if( !this.inside ){
        this.showSelector = false;
        this.displayValue = true;
      } else {
        this.displayValue = false;
        this.showSelector = true;
      }
    },

    handlerMouseLeave(){
      this.inside = false;
    },

    handlerMouseEnter(){
      this.inside = true;
    },

    handlerClick(e){
      this.displayValue = false;
    },

    handlerUnselect(){
      this.selectedValue = null;
      this.selectedLabel = "";
      this.showSelector = false;
      this.displayValue = false;
      this.$emit('change', null );
      this.$emit('update:value', null);
      this.$emit('input', null);
    mapOrganization(org) {
      const code = org.code || "";
      const shortname = org.shortname || org.fullname || "";
      return {
        ...org,
        displayLabel: `${code} ${shortname}`.trim(),
      };
    },

    handlerSelectIndex(id){
      console.log(`handlerSelectIndex(${id})`);
      this.options.forEach(i => {
        if( i.id == id ){
          this.selectedValue = i.id;
          this.selectedLabel = i.code +" " + i.shortname;
          this.showSelector = false;
          this.displayValue = true;
    handlerChangeHide() {
      if (this.lastQuery && this.lastQuery.length > 1) {
        this.search(this.lastQuery);
      }
      });


      this.$emit('change', { id: this.selectedValue, label: this.selectedLabel} );
      this.$emit('update:value', this.selectedValue);
      this.$emit('input', this.selectedValue);
    },

    handlerSelectPrev( scroll = false ){
      if( this.highlightedIndex == 0 ){
        this.showSelector = false;
      }
      if( this.highlightedIndex > 0 ){
        if(!this.showSelector) this.showSelector = true;
        this.highlightedIndex--;
      }
      if( scroll == true ){
        let itemId = '#item_' + this.highlightedIndex;
        let item = this.$el.querySelector(itemId);
        let el = this.$el.querySelectorAll('.options')[0];
        el.scrollTop = item.offsetTop;
        console.log("SCROLL", el.scrollTop);
        console.log("ITEM", itemId, item);
    handlerComplete(evt) {
      const query = (evt && evt.query ? evt.query : "").trim();
      this.lastQuery = query;
      if (query.length < 2) {
        this.suggestions = [];
        return;
      }
      this.search(query);
    },

    handlerSelectNext( scroll = false ){
      if(!this.showSelector) {
        this.showSelector = true;
      } else {
        if( this.highlightedIndex < this.options.length - 1 ){
          this.highlightedIndex++;
        }
      }
      if( scroll == true ){
        let itemId = '#item_' + this.highlightedIndex;
        let item = this.$el.querySelector(itemId);
        let el = this.$el.querySelectorAll('.options')[0];
        el.scrollTop = item.offsetTop;
        console.log("SCROLL", el.scrollTop);
        console.log("ITEM", itemId, item);
    handlerItemSelect(evt) {
      const selected = evt && evt.value ? evt.value : null;
      if (!selected) {
        return;
      }
      this.selectedOrganization = selected;
      const label = selected.displayLabel || `${selected.code || ""} ${selected.shortname || ""}`.trim();
      this.$emit("change", { id: selected.id, label });
      this.$emit("update:value", selected.id);
      this.$emit("input", selected.id);
    },

    updateSelectedDate(){

    },

    handlerKeyUp(e){
      switch(e.code){
        case "ArrowUp":
          this.handlerSelectPrev(true);
          break;

        case "ArrowDown":
          this.handlerSelectNext(true);
          break;

        case "ArrowLeft":
        case "ArrowRight":
          break;

        case "Enter":
          e.preventDefault();
          this.handlerSelectIndex(this.highlightedIndex);
          break;

        default:
          if( this.searchFor && this.searchFor.length > 1 ){
            this.handlerChange();
          }
      }
    handlerClear() {
      this.selectedOrganization = null;
      this.$emit("change", null);
      this.$emit("update:value", null);
      this.$emit("input", null);
    },

    handlerChange(e){
      // Système de retardement Eco+
      if (this.latency != null) {
        clearTimeout(this.latency);
      }
      let delayFunction = function () {
        this.search();
        clearTimeout(this.latency);
      }.bind(this);
      this.latency = setTimeout(delayFunction, 1000);
    loadSelectedById(id) {
      this.loading = true;
      this.error = "";
      const params = new URLSearchParams({
        q: `id:${id}`,
        active: "all",
        f: "json",
      });
      axios
        .get(`${this.url}?${params.toString()}`)
        .then((ok) => {
          const organizations = (ok.data && ok.data.organizations) || [];
          const mapped = organizations.map(this.mapOrganization);
          const found = mapped.find((o) => String(o.id) === String(id));
          this.selectedOrganization = found || null;
        })
        .catch((ko) => {
          this.error = this.errorMessageFromResponse(ko);
        })
        .finally(() => {
          this.loading = false;
        });
    },

    search(){
    search(query) {
      this.loading = true;
      this.showSelector = false;
      this.error = "";

      const params = {
        //'active': 1,
        'q': this.searchFor ?? "",
        'active': this.hideOff ? 'all' : '1',
        'f': 'json'
        q: query,
        active: this.hideOff ? "all" : "1",
        f: "json",
      };

      let urlBuilder = new URLSearchParams(params);
      let url = this.url + "?" + urlBuilder.toString();

      axios.get(url).then(
          ok => {
            this.options = ok.data.organizations;
            if( this.options.length > 0 ){
              this.highlightedIndex = 0;
              this.showSelector = true;
            }
      const urlBuilder = new URLSearchParams(params);
      axios
        .get(`${this.url}?${urlBuilder.toString()}`)
        .then((ok) => {
          this.suggestions = ((ok.data && ok.data.organizations) || []).map(this.mapOrganization);
        })
        .catch((ko) => {
          this.suggestions = [];
          this.error = this.errorMessageFromResponse(ko);
        })
        .finally(() => {
          this.loading = false;
        });
    },
          ko => {
            console.log(ko);
            switch(ko.status){

    errorMessageFromResponse(ko) {
      const status = ko && ko.response ? ko.response.status : null;
      switch (status) {
        case 401:
        case 403:
                this.error = "Vous avez été déconnecté (actualisé votre page pour vous reconnecter)";
                break;
          return "Vous avez ete deconnecte (actualisez la page pour vous reconnecter)";
        case 500:
                this.error = "La recherche a provoqué une erreur";
                break;
          return "La recherche a provoque une erreur";
        default:
                this.error = "Un problème inconnu est survenu";
            }
          return "Un probleme inconnu est survenu";
      }
      ).then(foo => {
        this.loading = false;
      })
    },

    /**
     * Selection d'une option.
     *
     * @param selected
     */
    setSelected(selected) {
      console.log("setSelected", selected);
      this.value = selected;
      this.$emit('change', this.value);
      //this.$emit('input', this.value);
  },
};
</script>

    /**
     * Déclenchement de la recherche (à la saisie).
     *
     * @param search
     * @param loading
     */
    handlerSearchOrganisation(search, loading) {
      if (search.length) {
        loading(true);
        // Système de retardement Eco+
        let delayFunction = function () {
          this.searchOrganization(loading, search, this);
          this.delay = null;
        }.bind(this);
        if (this.delay != null) {
          clearTimeout(this.delay);
<style scoped>
.form-control-autocomplete {
  width: 100%;
}
        this.delay = setTimeout(delayFunction, 1000);

.autocomplete-with-left-icon {
  position: relative;
  width: 100%;
}

.autocomplete-left-icon {
  position: absolute;
  left: 0.75rem;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  color: var(--bs-gray-700);
  pointer-events: none;
}

:deep(.organization-autocomplete .p-autocomplete-input) {
  padding-left: 2.25rem;
}

:global(.p-autocomplete-list .p-autocomplete-option) {
  margin: 0;
  padding: 0;
}
</script>
 No newline at end of file
.options-header {
  margin-top: 0.5rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
}

.option {
  width: 100%;
  border-bottom: thin solid var(--bs-gray-300);
  margin: 0;
  padding: .25em 0;
}

.option.closed {
  /*background: rgba(193, 210, 210, 0.5);*/
}

.option.closed .fullname,
.option.closed .code {
  text-decoration: line-through;
}
</style>
+226 −132

File changed.

Preview size limit exceeded, changes collapsed.

Loading