NatLibFi/Skosmos

View on GitHub
resource/js/vocab-search.js

Summary

Maintainability
B
6 hrs
Test Coverage
/* global Vue */

const vocabSearch = Vue.createApp({
  data () {
    return {
      languages: [],
      selectedLanguage: null,
      searchTerm: null,
      searchCounter: null,
      renderedResultsList: [],
      languageStrings: null,
      msgs: null,
      showDropdown: false,
      showNotation: null
    }
  },
  mounted () {
    this.languages = window.SKOSMOS.languageOrder
    this.selectedLanguage = this.parseSearchLang()
    this.searchCounter = 0 // used for matching the query and the response in case there are many responses
    this.languageStrings = window.SKOSMOS.language_strings
    this.msgs = window.SKOSMOS.msgs[window.SKOSMOS.lang] ?? window.SKOSMOS.msgs.en
    this.renderedResultsList = []
    this.showNotation = window.SKOSMOS.showNotation
  },
  methods: {
    autoComplete () {
      const delayMs = 300

      // when new autocomplete is fired, empty the previous result
      this.renderedResultsList = []

      // cancel the timer for upcoming API call
      clearTimeout(this._timerId)
      this.hideAutoComplete()

      // TODO: if the search term is in cache, use the cache

      // delay call, but don't execute if the search term is not at least two characters
      if (this.searchTerm.length > 1) {
        this._timerId = setTimeout(() => { this.search() }, delayMs)
      }
    },
    search () {
      const mySearchCounter = this.searchCounter + 1 // make sure we can identify this search later in case of several ongoing searches
      this.searchCounter = mySearchCounter
      let skosmosSearchUrl = 'rest/v1/' + window.SKOSMOS.vocab + '/search?'
      const skosmosSearchUrlParams = new URLSearchParams({ query: this.formatSearchTerm(), unique: true })
      if (this.selectedLanguage !== 'all') skosmosSearchUrlParams.set('lang', this.selectedLanguage)
      skosmosSearchUrl += skosmosSearchUrlParams.toString()

      fetch(skosmosSearchUrl)
        .then(data => data.json())
        .then(data => {
          if (mySearchCounter === this.searchCounter) {
            this.renderedResultsList = data.results // update results (update cache if it is implemented)
            this.renderResults() // render after the fetch has finished
          }
        })
    },
    formatSearchTerm () {
      if (this.searchTerm.includes('*')) { return this.searchTerm }
      return this.searchTerm + '*'
    },
    notationMatches (searchTerm, notation) {
      if (notation && notation.toLowerCase().includes(searchTerm.toLowerCase())) {
        return true
      }
      return false
    },
    parseSearchLang () {
      // if content language can be found from uri params, use that and update it to SKOSMOS object and to search lang cookie
      const urlParams = new URLSearchParams(window.location.search)
      const paramLang = urlParams.get('clang')
      const anyLang = urlParams.get('anylang')
      if (anyLang) {
        this.changeLang('all')
        return 'all'
      }
      if (paramLang) {
        this.changeLang(paramLang)
        return paramLang
      }
      // use searchLangCookie if it can be found, otherwise pick content lang from SKOSMOS object
      const cookies = document.cookie.split('; ')
      const searchLangCookie = cookies.find(cookie =>
        cookie.startsWith('SKOSMOS_SEARCH_LANG='))
      if (searchLangCookie) {
        const selectedLanguage = searchLangCookie.split('=')[1]
        if (selectedLanguage !== 'all') {
          window.SKOSMOS.content_lang = selectedLanguage
        }
        return selectedLanguage
      } else {
        return window.SKOSMOS.content_lang
      }
    },
    renderMatchingPart (searchTerm, label, lang = null) {
      if (label) {
        let langSpec = ''
        if (lang && this.selectedLanguage === 'all') {
          langSpec = ' (' + lang + ')'
        }
        const searchTermLowerCase = searchTerm.toLowerCase()
        const labelLowerCase = label.toLowerCase()
        if (labelLowerCase.includes(searchTermLowerCase)) {
          const startIndex = labelLowerCase.indexOf(searchTermLowerCase)
          const endIndex = startIndex + searchTermLowerCase.length
          return {
            before: label.substring(0, startIndex),
            match: label.substring(startIndex, endIndex),
            after: label.substring(endIndex) + langSpec
          }
        }
        return label + langSpec
      }
      return null
    },
    translateType (type) {
      return window.SKOSMOS.msgs[window.SKOSMOS.lang][type]
    },
    /*
     * renderResults is used when the search string has been indexed in the cache
     * it also shows the autocomplete results list
     * TODO: Showing labels in other languages, extra concept information and such goes here
     */
    renderResults () {
      // TODO: get the results list form cache if it is implemented
      const renderedSearchTerm = this.searchTerm // save the search term in case it changes while rendering

      this.renderedResultsList.forEach(result => {
        if ('hiddenLabel' in result) {
          result.hitType = 'hidden'
          result.hit = this.renderMatchingPart(renderedSearchTerm, result.prefLabel, result.lang)
        } else if ('altLabel' in result) {
          result.hitType = 'alt'
          result.hit = this.renderMatchingPart(renderedSearchTerm, result.altLabel, result.lang)
          result.hitPref = this.renderMatchingPart(renderedSearchTerm, result.prefLabel)
        } else {
          if (this.notationMatches(renderedSearchTerm, result.notation)) {
            result.hitType = 'notation'
            result.hit = this.renderMatchingPart(renderedSearchTerm, result.notation, result.lang)
          } else if ('matchedPrefLabel' in result) {
            result.hitType = 'pref'
            result.hit = this.renderMatchingPart(renderedSearchTerm, result.matchedPrefLabel, result.lang)
          } else if ('prefLabel' in result) {
            result.hitType = 'pref'
            result.hit = this.renderMatchingPart(renderedSearchTerm, result.prefLabel, result.lang)
          }
        }
        if ('uri' in result) { // create relative Skosmos page URL from the search result URI
          result.pageUrl = window.SKOSMOS.vocab + '/' + window.SKOSMOS.lang + '/page?'
          const urlParams = new URLSearchParams({ uri: result.uri })
          result.pageUrl += urlParams.toString()
        }
        // render search result renderedTypes
        if (result.type.length > 1) { // remove the type for SKOS concepts if the result has more than one type
          result.type.splice(result.type.indexOf('skos:Concept'), 1)
        }
        // use the translateType function to map translations for the type IRIs
        result.renderedType = result.type.map(this.translateType).join(', ')
        result.showNotation = this.showNotation
      })

      if (this.renderedResultsList.length === 0) { // show no results message
        this.renderedResultsList.push({
          prefLabel: this.msgs['No results'],
          lang: window.SKOSMOS.lang
        })
      }
      this.showAutoComplete()
    },
    hideAutoComplete () {
      this.showDropdown = false
      this.$forceUpdate()
    },
    gotoSearchPage () {
      if (!this.searchTerm) return

      const currentVocab = window.SKOSMOS.vocab + '/' + window.SKOSMOS.lang + '/'
      const vocabHref = window.location.href.substring(0, window.location.href.lastIndexOf(window.SKOSMOS.vocab)) + currentVocab
      const searchUrlParams = new URLSearchParams({ clang: window.SKOSMOS.content_lang, q: this.searchTerm })
      if (this.selectedLanguage === 'all') searchUrlParams.set('anylang', 'true')
      const searchUrl = vocabHref + 'search?' + searchUrlParams.toString()
      window.location.href = searchUrl
    },
    changeLang (lang) {
      this.selectedLanguage = lang
      this.setSearchLangCookie(lang)
      this.resetSearchTermAndHideDropdown()
    },
    changeContentLangAndReload (lang) {
      this.changeLang(lang)
      const params = new URLSearchParams(window.location.search)
      if (lang === 'all') {
        params.set('anylang', 'true')
      } else {
        params.delete('anylang')
        params.set('clang', lang)
      }
      this.$forceUpdate()
      window.location.search = params.toString()
    },
    resetSearchTermAndHideDropdown () {
      this.searchTerm = ''
      this.renderedResultsList = []
      this.hideAutoComplete()
    },
    /*
     * Show the existing autocomplete list if it was hidden by onClickOutside()
     */
    showAutoComplete () {
      this.showDropdown = true
      this.$forceUpdate()
    },
    setSearchLangCookie (lang) {
      // The cookie path should be relative if the baseHref is known
      let cookiePath = '/'
      if (window.SKOSMOS.baseHref && window.SKOSMOS.baseHref.replace(window.origin, '')) {
        cookiePath = window.SKOSMOS.baseHref.replace(window.origin, '')
      }
      document.cookie = `SKOSMOS_SEARCH_LANG=${this.selectedLanguage};path=${cookiePath}`
    }
  },
  template: `
    <div class="d-flex my-auto ms-auto">
      <div class="d-flex justify-content-end input-group ms-auto" id="search-wrapper">

      <div class="dropdown" id="language-selector">
        <button class="btn btn-outline-secondary dropdown-toggle"
           role="button"
           data-bs-toggle="dropdown"
           aria-expanded="false"
           aria-label="Select search language"
           v-if="languageStrings">
          {{ languageStrings[selectedLanguage] }}
          <i class="fa-solid fa-chevron-down"></i>
        </button>
        <ul class="dropdown-menu" id="language-list" role="menu">
          <li v-for="(value, key) in languageStrings" :key="key" role="none">
            <a
              class="dropdown-item"
              :value="key"
              @click="changeContentLangAndReload(key)"
              @keydown.enter="changeContentLangAndReload(key)"
              role="menuitem"
              tabindex=0 >
              {{ value }}
            </a>
          </li>
        </ul>
      </div>

        <span id="headerbar-search" class="dropdown">
          <input type="search"
            class="form-control"
            id="search-field"
            autocomplete="off"
            data-bs-toggle=""
            aria-label="Text input with dropdown button"
            placeholder="Search..."
            v-click-outside="hideAutoComplete"
            v-model="searchTerm"
            @input="autoComplete()"
            @keyup.enter="gotoSearchPage()"
            @click="showAutoComplete()">
          <ul id="search-autocomplete-results" class="dropdown-menu" :class="{ 'show': showDropdown }"
            aria-labelledby="search-field">
            <li class="autocomplete-result container" v-for="result in renderedResultsList"
              :key="result.prefLabel" >
              <template v-if="result.pageUrl">
                <a :href=result.pageUrl>
                  <div class="row pb-1">
                    <div class="col" v-if="result.hitType == 'hidden'">
                      <span class="result">
                        <template v-if="result.showNotation">
                          {{ result.notation }}&nbsp;
                        </template>
                        <template v-if="result.hit.hasOwnProperty('match')">
                          {{ result.hit.before }}<b>{{ result.hit.match }}</b>{{ result.hit.after }}
                        </template>
                        <template v-else>
                          {{ result.hit }}
                        </template>
                      </span>
                    </div>
                    <div class="col" v-else-if="result.hitType == 'alt'">
                      <span>
                        <i>
                          <template v-if="result.showNotation">
                            {{ result.notation }}&nbsp;
                          </template>
                          <template v-if="result.hit.hasOwnProperty('match')">
                            {{ result.hit.before }}<b>{{ result.hit.match }}</b>{{ result.hit.after }}
                          </template>
                          <template v-else>
                            {{ result.hit }}
                          </template>
                        </i>
                      </span>
                      <span> &rarr;&nbsp;<span class="result">
                          <template v-if="result.showNotation">
                            {{ result.notation }}&nbsp;
                          </template>
                          <template v-if="result.hitPref.hasOwnProperty('match')">
                            {{ result.hitPref.before }}<b>{{ result.hitPref.match }}</b>{{ result.hitPref.after }}
                          </template>
                          <template v-else>
                            {{ result.hitPref }}
                          </template>
                        </span>
                      </span>
                    </div>
                    <div class="col" v-else-if="result.hitType == 'notation'">
                      <span class="result">
                        <template v-if="result.hit.hasOwnProperty('match')">
                          {{ result.hit.before }}<b>{{ result.hit.match }}</b>{{ result.hit.after }}
                        </template>
                        <template v-else>
                          {{ result.hit }}
                        </template>
                      </span>
                      <span>
                        &nbsp;{{ result.prefLabel }}
                      </span>
                    </div>
                    <div class="col" v-else-if="result.hitType == 'pref'">
                      <span class="result">
                        <template v-if="result.showNotation">
                          {{ result.notation }}&nbsp;
                        </template>
                        <template v-if="result.hit.hasOwnProperty('match')">
                          {{ result.hit.before }}<b>{{ result.hit.match }}</b>{{ result.hit.after }}
                        </template>
                        <template v-else>
                          {{ result.hit }}
                        </template>
                      </span>
                    </div>
                    <div class="col-auto align-self-end pr-1" v-html="result.renderedType"></div>
                  </div>
                </a>
              </template>
              <template v-else>
                {{ result.prefLabel }}
              </template>
            </li>
          </ul>
        </span>
        <button id="clear-button" class="btn btn-danger" type="clear" v-if="searchTerm" @click="resetSearchTermAndHideDropdown()">
          <i class="fa-solid fa-xmark"></i>
        </button>
        <button id="search-button" class="btn btn-outline-secondary" aria-label="Search" @click="gotoSearchPage()">
          <i class="fa-solid fa-magnifying-glass"></i>
        </button>
      </div>
    </div>
  `
})

vocabSearch.directive('click-outside', {
  beforeMount: (el, binding) => {
    el.clickOutsideEvent = event => {
      // Ensure the click was outside the element
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event) // Call the method provided in the directive's value
      }
    }
    document.addEventListener('click', el.clickOutsideEvent)
  },
  unmounted: el => {
    document.removeEventListener('click', el.clickOutsideEvent)
  }
})

vocabSearch.mount('#search-vocab')