Browse Source

feat: search filters + ux improvements

NGPixel 1 year ago
parent
commit
dbfe7d4fbb

+ 28 - 14
server/graph/resolvers/page.mjs

@@ -56,25 +56,30 @@ export default {
         throw new Error('Limit must be between 1 and 100.')
       }
       try {
-        const dictName = 'english'
+        const dictName = 'english' // TODO: Use provided locale or fallback on site locale
+        const searchCols = [
+          'id',
+          'path',
+          'localeCode AS locale',
+          'title',
+          'description',
+          'icon',
+          'updatedAt',
+          WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'),
+          WIKI.db.knex.raw('count(*) OVER() AS total')
+        ]
+
+        if (WIKI.config.search.termHighlighting) {
+          searchCols.push(WIKI.db.knex.raw(`ts_headline(?, "searchContent", query, 'MaxWords=5, MinWords=3, MaxFragments=5') AS highlight`, [dictName]))
+        }
+
         const results = await WIKI.db.knex
-          .select(
-            'id',
-            'path',
-            'localeCode AS locale',
-            'title',
-            'description',
-            'icon',
-            'updatedAt',
-            WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'),
-            WIKI.db.knex.raw(`ts_headline(?, "searchContent", query, 'MaxWords=5, MinWords=3, MaxFragments=5') AS highlight`, [dictName]),
-            WIKI.db.knex.raw('count(*) OVER() AS total')
-          )
+          .select(searchCols)
           .fromRaw('pages, websearch_to_tsquery(?, ?) query', [dictName, args.query])
           .where('siteId', args.siteId)
           .where(builder => {
             if (args.path) {
-              builder.where('path', 'ILIKE', `${path}%`)
+              builder.where('path', 'ILIKE', `${args.path}%`)
             }
             if (args.locale?.length > 0) {
               builder.whereIn('localeCode', args.locale)
@@ -91,6 +96,15 @@ export default {
           .offset(args.offset || 0)
           .limit(args.limit || 25)
 
+        // -> Remove highlights without matches
+        if (WIKI.config.search.termHighlighting) {
+          for (const r of results) {
+            if (r.highlight?.indexOf('<b>') < 0) {
+              r.highlight = null
+            }
+          }
+        }
+
         return {
           results,
           totalHits: results?.length > 0 ? results[0].total : 0

+ 5 - 1
server/locales/en.json

@@ -525,7 +525,7 @@
   "admin.scheduler.waitUntil": "Start",
   "admin.search.configSaveSuccess": "Search engine configuration saved successfully.",
   "admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides",
-  "admin.search.dictOverridesHint": "One override per line, in the format: en=english",
+  "admin.search.dictOverridesHint": "JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. { \"en\": \"english\" }",
   "admin.search.engineConfig": "Engine Configuration",
   "admin.search.engineNoConfig": "This engine has no configuration options you can modify.",
   "admin.search.highlighting": "Enable Term Highlighting",
@@ -1757,9 +1757,13 @@
   "profile.title": "Profile",
   "profile.uploadNewAvatar": "Upload New Image",
   "profile.viewPublicProfile": "View Public Profile",
+  "search.filterLocaleDisplay": "Any locale | {n} locale only | {count} locales selected",
   "search.filters": "Filters",
   "search.results": "Search Results",
   "search.sortBy": "Sort By",
+  "search.sortByLastUpdated": "Last Updated",
+  "search.sortByRelevance": "Relevance",
+  "search.sortByTitle": "Title",
   "tags.clearSelection": "Clear Selection",
   "tags.currentSelection": "Current Selection",
   "tags.locale": "Locale",

+ 7 - 0
ux/src/components/HeaderNav.vue

@@ -59,6 +59,13 @@ q-header.bg-header.text-white.site-header(
             outline
             @click='searchField.focus()'
             )
+          q-badge.q-mr-sm(
+            v-else-if='siteStore.search && siteStore.search !== siteStore.searchLastQuery'
+            label='Press Enter'
+            color='grey-7'
+            outline
+            @click='searchField.focus()'
+            )
           q-icon.cursor-pointer(
             name='las la-times'
             size='20px'

+ 1 - 1
ux/src/pages/AdminSearch.vue

@@ -62,7 +62,7 @@ q-page.admin-flags
                 language='json'
                 :min-height='250'
               )
-              q-item-label(caption) JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. { "en": "english" }
+              q-item-label(caption) {{ t('admin.search.dictOverridesHint') }}
 
     .col-12.col-lg-5.gt-md
       .q-pa-md.text-center

+ 79 - 32
ux/src/pages/Search.vue

@@ -6,21 +6,25 @@ q-layout(view='hHh Lpr lff')
       .layout-search-sd
         .text-header {{ t('search.sortBy') }}
         q-list(dense, padding)
-          q-item(clickable, active)
+          q-item(
+            v-for='item of orderByOptions'
+            clickable
+            :active='item.value === state.params.orderBy'
+            @click='setOrderBy(item.value)'
+            )
             q-item-section(side)
-              q-icon(name='las la-stream', color='primary')
+              q-icon(:name='item.icon', :color='item.value === state.params.orderBy ? `primary` : ``')
             q-item-section
-              q-item-label Relevance
-            q-item-section(side)
-              q-icon(name='mdi-chevron-double-down', size='sm', color='primary')
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-heading')
-            q-item-section Title
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-calendar')
-            q-item-section Last Updated
+              q-item-label {{ item.label }}
+            q-item-section(
+              v-if='item.value === state.params.orderBy'
+              side
+              )
+              q-icon(
+                :name='state.params.orderByDirection === `desc` ? `mdi-chevron-double-down` : `mdi-chevron-double-up`'
+                size='sm'
+                color='primary'
+                )
         .text-header {{ t('search.filters') }}
         .q-pa-sm
           q-input(
@@ -28,7 +32,7 @@ q-layout(view='hHh Lpr lff')
             dense
             placeholder='Path starting with...'
             prefix='/'
-            v-model='state.filterPath'
+            v-model='state.params.filterPath'
             )
             template(v-slot:prepend)
               q-icon(name='las la-caret-square-right', size='xs')
@@ -55,7 +59,7 @@ q-layout(view='hHh Lpr lff')
               q-icon(name='las la-user-edit', size='xs')
           q-select.q-mt-sm(
             outlined
-            v-model='state.filterLocale'
+            v-model='state.params.filterLocale'
             emit-value
             map-options
             dense
@@ -63,14 +67,21 @@ q-layout(view='hHh Lpr lff')
             :options='siteStore.locales.active'
             option-value='code'
             option-label='name'
+            options-dense
             multiple
-            :display-value='t(`admin.groups.selectedLocales`, { n: state.filterLocale.length > 0 ? state.filterLocale[0].toUpperCase() : state.filterLocale.length }, state.filterLocale.length)'
+            :display-value='t(`search.filterLocaleDisplay`, { n: state.params.filterLocale.length > 0 ? state.params.filterLocale[0].toUpperCase() : state.params.filterLocale.length }, state.params.filterLocale.length)'
             )
             template(v-slot:prepend)
               q-icon(name='las la-language', size='xs')
+            template(v-slot:option='scope')
+              q-item(v-bind='scope.itemProps')
+                q-item-section(side)
+                  q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)')
+                q-item-section
+                  q-item-label(v-html='scope.opt.name')
           q-select.q-mt-sm(
             outlined
-            v-model='state.filterEditor'
+            v-model='state.params.filterEditor'
             emit-value
             map-options
             dense
@@ -81,7 +92,7 @@ q-layout(view='hHh Lpr lff')
               q-icon(name='las la-pen-nib', size='xs')
           q-select.q-mt-sm(
             outlined
-            v-model='state.filterPublishState'
+            v-model='state.params.filterPublishState'
             emit-value
             map-options
             dense
@@ -95,6 +106,9 @@ q-layout(view='hHh Lpr lff')
           span {{t('search.results')}}
           q-space
           span.text-caption #[strong {{ state.total }}] results
+        .q-pa-lg(v-if='state.results.length < 1')
+          span(v-if='siteStore.search && siteStore.searchLastQuery') No results found for #[strong "{{ siteStore.searchLastQuery }}"] with the current filters.
+          span(v-else): em Enter a query in the search field above and press Enter.
         q-list(separator)
           q-item(
             v-for='item of state.results'
@@ -105,8 +119,8 @@ q-layout(view='hHh Lpr lff')
               q-avatar(color='primary' text-color='white' rounded :icon='item.icon')
             q-item-section
               q-item-label {{ item.title }}
-              q-item-label(caption) {{ item.description }}
-              q-item-label.text-highlight(caption, v-html='item.highlight')
+              q-item-label(v-if='item.description', caption) {{ item.description }}
+              q-item-label.text-highlight(v-if='item.highlight', caption, v-html='item.highlight')
             q-item-section(side)
               .flex
                 q-chip(
@@ -129,10 +143,10 @@ q-layout(view='hHh Lpr lff')
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
-import { computed, onMounted, reactive, watch } from 'vue'
+import { computed, onMounted, onUnmounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import gql from 'graphql-tag'
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, debounce } from 'lodash-es'
 import { DateTime } from 'luxon'
 
 import { useFlagsStore } from 'src/stores/flags'
@@ -172,15 +186,27 @@ useMeta({
 
 const state = reactive({
   loading: 0,
-  filterPath: '',
-  filterTags: [],
-  filterLocale: ['en'],
-  filterEditor: '',
-  filterPublishState: '',
+  params: {
+    filterPath: '',
+    filterTags: [],
+    filterLocale: [],
+    filterEditor: '',
+    filterPublishState: '',
+    orderBy: 'relevancy',
+    orderByDirection: 'desc'
+  },
   results: [],
   total: 0
 })
 
+const orderByOptions = computed(() => {
+  return [
+    { label: t('search.sortByRelevance'), value: 'relevancy', icon: 'las la-stream' },
+    { label: t('search.sortByTitle'), value: 'title', icon: 'las la-heading' },
+    { label: t('search.sortByLastUpdated'), value: 'updatedAt', icon: 'las la-calendar' }
+  ]
+})
+
 const editors = computed(() => {
   return [
     { label: 'Any editor', value: '' },
@@ -208,6 +234,8 @@ watch(() => route.query, async (newQueryObj) => {
   }
 }, { immediate: true })
 
+watch(() => state.params, debounce(performSearch, 500), { deep: true })
+
 // METHODS
 
 function pageStyle (offset, height) {
@@ -220,6 +248,15 @@ function humanizeDate (val) {
   return DateTime.fromISO(val).toFormat(userStore.preferredDateFormat)
 }
 
+function setOrderBy (val) {
+  if (val === state.params.orderBy) {
+    state.params.orderByDirection = state.params.orderByDirection === 'desc' ? 'asc' : 'desc'
+  } else {
+    state.params.orderBy = val
+    state.params.orderByDirection = val === 'title' ? 'asc' : 'desc'
+  }
+}
+
 async function performSearch () {
   siteStore.searchIsLoading = true
   try {
@@ -268,7 +305,13 @@ async function performSearch () {
       `,
       variables: {
         siteId: siteStore.id,
-        query: siteStore.search
+        query: siteStore.search,
+        path: state.params.filterPath,
+        locale: state.params.filterLocale,
+        editor: state.params.filterEditor,
+        publishState: state.params.filterPublishState || null,
+        orderBy: state.params.orderBy,
+        orderByDirection: state.params.orderByDirection
       },
       fetchPolicy: 'network-only'
     })
@@ -277,6 +320,7 @@ async function performSearch () {
     }
     state.results = cloneDeep(resp.data.searchPages.results)
     state.total = resp.data.searchPages.totalHits
+    siteStore.searchLastQuery = siteStore.search
   } catch (err) {
     $q.notify({
       type: 'negative',
@@ -290,13 +334,17 @@ async function performSearch () {
 // MOUNTED
 
 onMounted(() => {
-  if (siteStore.search) {
-    // performSearch()
-  } else {
+  if (!siteStore.search) {
     siteStore.searchIsLoading = false
   }
 })
 
+onUnmounted(() => {
+  siteStore.search = ''
+  siteStore.searchLastQuery = ''
+  siteStore.searchIsLoading = false
+})
+
 </script>
 
 <style lang="scss">
@@ -386,7 +434,6 @@ onMounted(() => {
 
     > b {
       background-color: rgba($yellow-7, .5);
-      padding: 0 3px;
       border-radius: 3px;
     }
   }

+ 1 - 0
ux/src/stores/site.js

@@ -16,6 +16,7 @@ export const useSiteStore = defineStore('site', {
     description: '',
     logoText: true,
     search: '',
+    searchLastQuery: '',
     searchIsLoading: false,
     printView: false,
     pageDataTemplates: [],