feat: search suggestions + results UI improvements
This commit is contained in:
		| @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). | ||||
|  | ||||
| ## [2.0.0-beta.XX] - 2019-XX-XX | ||||
| ### Added | ||||
| - Added Search Results overlay | ||||
| - Added Search Engine - PostgreSQL | ||||
| - Added Search Engine - DB Basic | ||||
| - Added Git changes processing (add/modify/delete) | ||||
| - Added Storage last sync date in status panel | ||||
| - Added Dev Flags | ||||
|   | ||||
| @@ -76,6 +76,8 @@ | ||||
|               @keyup.esc='searchClose' | ||||
|               @focus='searchFocus' | ||||
|               @blur='searchBlur' | ||||
|               @keyup.down='searchMove(`down`)' | ||||
|               @keyup.up='searchMove(`up`)' | ||||
|             ) | ||||
|               v-progress-linear( | ||||
|                 indeterminate, | ||||
| @@ -253,7 +255,10 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     searchEnter() { | ||||
|       this.searchIsLoading = true | ||||
|       this.$root.$emit('searchEnter', true) | ||||
|     }, | ||||
|     searchMove(dir) { | ||||
|       this.$root.$emit('searchMove', dir) | ||||
|     }, | ||||
|     pageNew () { | ||||
|       this.newPageModal = true | ||||
|   | ||||
| @@ -16,9 +16,9 @@ | ||||
|         .subheading No pages matching your query. | ||||
|       template(v-if='results.length > 0') | ||||
|         v-subheader.white--text Found {{response.totalHits}} results | ||||
|         v-list.radius-7(two-line) | ||||
|         v-list.search-results-items.radius-7(two-line) | ||||
|           template(v-for='(item, idx) of results') | ||||
|             v-list-tile(@click='', :key='item.id') | ||||
|             v-list-tile(@click='goToPage(item)', :key='item.id', :class='idx === cursor ? `highlighted` : ``') | ||||
|               v-list-tile-avatar(tile) | ||||
|                 img(src='/svg/icon-selective-highlighting.svg') | ||||
|               v-list-tile-content | ||||
| @@ -36,9 +36,9 @@ | ||||
|         ) | ||||
|       template(v-if='suggestions.length > 0') | ||||
|         v-subheader.white--text.mt-3 Did you mean... | ||||
|         v-list.radius-7(dense, dark) | ||||
|         v-list.search-results-suggestions.radius-7(dense, dark) | ||||
|           template(v-for='(term, idx) of suggestions') | ||||
|             v-list-tile(:key='term', @click='setSearchTerm(term)') | ||||
|             v-list-tile(:key='term', @click='setSearchTerm(term)', :class='idx + results.length === cursor ? `highlighted` : ``') | ||||
|               v-list-tile-avatar | ||||
|                 v-icon search | ||||
|               v-list-tile-content | ||||
| @@ -66,6 +66,7 @@ export default { | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       cursor: 0, | ||||
|       pagination: 1, | ||||
|       response: { | ||||
|         results: [], | ||||
| @@ -95,16 +96,40 @@ export default { | ||||
|   }, | ||||
|   watch: { | ||||
|     search(newValue, oldValue) { | ||||
|       this.cursor = 0 | ||||
|       if (newValue.length < 2) { | ||||
|         this.response.results = [] | ||||
|         this.response.suggestions = [] | ||||
|       } else { | ||||
|         this.searchIsLoading = true | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setSearchTerm(term) { | ||||
|       this.search = term | ||||
|     }, | ||||
|     goToPage(item) { | ||||
|       window.location.assign(`/${item.locale}/${item.path}`) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$root.$on('searchMove', (dir) => { | ||||
|       this.cursor += (dir === 'up' ? -1 : 1) | ||||
|       if (this.cursor < -1) { | ||||
|         this.cursor = -1 | ||||
|       } else if (this.cursor > this.results.length + this.suggestions.length - 1) { | ||||
|         this.cursor = this.results.length + this.suggestions.length - 1 | ||||
|       } | ||||
|     }) | ||||
|     this.$root.$on('searchEnter', () => { | ||||
|       if (this.cursor >= 0 && this.cursor < this.results.length) { | ||||
|         this.goToPage(_.nth(this.results, this.cursor)) | ||||
|       } else if (this.cursor >= 0) { | ||||
|         this.setSearchTerm(_.nth(this.suggestions, this.cursor - this.results.length)) | ||||
|       } | ||||
|     }) | ||||
|   }, | ||||
|   apollo: { | ||||
|     response: { | ||||
|       query: searchPagesQuery, | ||||
| @@ -178,6 +203,18 @@ export default { | ||||
|       width: 200px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &-items { | ||||
|     .highlighted { | ||||
|       background-color: mc('blue', '50'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &-suggestions { | ||||
|     .highlighted { | ||||
|       background-color: mc('blue', '500'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes searchResultsReveal { | ||||
|   | ||||
| @@ -12,7 +12,7 @@ module.exports = { | ||||
|    * INIT | ||||
|    */ | ||||
|   async init() { | ||||
|     // -> Create Index | ||||
|     // -> Create Search Index | ||||
|     const indexExists = await WIKI.models.knex.schema.hasTable('pagesVector') | ||||
|     if (!indexExists) { | ||||
|       await WIKI.models.knex.schema.createTable('pagesVector', table => { | ||||
| @@ -21,11 +21,19 @@ module.exports = { | ||||
|         table.string('locale') | ||||
|         table.string('title') | ||||
|         table.string('description') | ||||
|         table.specificType('titleTk', 'TSVECTOR') | ||||
|         table.specificType('descriptionTk', 'TSVECTOR') | ||||
|         table.specificType('contentTk', 'TSVECTOR') | ||||
|         table.specificType('tokens', 'TSVECTOR') | ||||
|       }) | ||||
|     } | ||||
|     // -> Create Words Index | ||||
|     const wordsExists = await WIKI.models.knex.schema.hasTable('pagesWords') | ||||
|     if (!wordsExists) { | ||||
|       await WIKI.models.knex.raw(` | ||||
|         CREATE TABLE "pagesWords" AS SELECT word FROM ts_stat( | ||||
|           'SELECT to_tsvector(''simple'', pages."title") || to_tsvector(''simple'', pages."description") || to_tsvector(''simple'', pages."content") FROM pages WHERE pages."isPublished" AND NOT pages."isPrivate"' | ||||
|         )`) | ||||
|       await WIKI.models.knex.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm') | ||||
|       await WIKI.models.knex.raw(`CREATE INDEX "pageWords_idx" ON "pagesWords" USING GIN (word gin_trgm_ops)`) | ||||
|     } | ||||
|   }, | ||||
|   /** | ||||
|    * QUERY | ||||
| @@ -35,14 +43,20 @@ module.exports = { | ||||
|    */ | ||||
|   async query(q, opts) { | ||||
|     try { | ||||
|       let suggestions = [] | ||||
|       const results = await WIKI.models.knex.raw(` | ||||
|         SELECT id, path, locale, title, description | ||||
|         FROM "pagesVector", to_tsquery(?) query | ||||
|         WHERE (query @@ "titleTk") OR (query @@ "descriptionTk") OR (query @@ "contentTk") | ||||
|         WHERE query @@ "tokens" | ||||
|         ORDER BY ts_rank(tokens, query) DESC | ||||
|       `, [tsquery(q)]) | ||||
|       if (results.rows.length < 5) { | ||||
|         const suggestResults = await WIKI.models.knex.raw(`SELECT word, word <-> ? AS rank FROM "pagesWords" WHERE similarity(word, ?) > 0.2 ORDER BY rank LIMIT 5;`, [q, q]) | ||||
|         suggestions = suggestResults.rows.map(r => r.word) | ||||
|       } | ||||
|       return { | ||||
|         results: results.rows, | ||||
|         suggestions: [], | ||||
|         suggestions, | ||||
|         totalHits: results.rows.length | ||||
|       } | ||||
|     } catch (err) { | ||||
| @@ -58,8 +72,8 @@ module.exports = { | ||||
|    */ | ||||
|   async created(page) { | ||||
|     await WIKI.models.knex.raw(` | ||||
|       INSERT INTO "pagesVector" (path, locale, title, description, "titleTk", "descriptionTk", "contentTk") VALUES ( | ||||
|         '?', '?', '?', '?', to_tsvector('?'), to_tsvector('?'), to_tsvector('?') | ||||
|       INSERT INTO "pagesVector" (path, locale, title, description, tokens) VALUES ( | ||||
|         '?', '?', '?', '?', (setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'A') || setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'B') || setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'C')) | ||||
|       ) | ||||
|     `, [page.path, page.locale, page.title, page.description, page.title, page.description, page.content]) | ||||
|   }, | ||||
| @@ -73,9 +87,9 @@ module.exports = { | ||||
|       UPDATE "pagesVector" SET | ||||
|         title = '?', | ||||
|         description = '?', | ||||
|         "titleTk" = to_tsvector('?'), | ||||
|         "descriptionTk" = to_tsvector('?'), | ||||
|         "contentTk" = to_tsvector('?') | ||||
|         tokens = (setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'A') || | ||||
|         setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'B') || | ||||
|         setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'C')) | ||||
|       WHERE path = '?' AND locale = '?' LIMIT 1 | ||||
|     `, [page.title, page.description, page.title, page.description, page.content, page.path, page.locale]) | ||||
|   }, | ||||
| @@ -110,8 +124,11 @@ module.exports = { | ||||
|   async rebuild() { | ||||
|     await WIKI.models.knex('pagesVector').truncate() | ||||
|     await WIKI.models.knex.raw(` | ||||
|       INSERT INTO "pagesVector" (path, locale, title, description, "titleTk", "descriptionTk", "contentTk") | ||||
|         SELECT path, "localeCode" AS locale, title, description, to_tsvector(title) AS "titleTk", to_tsvector(description) AS "descriptionTk", to_tsvector(content) AS "contentTk" | ||||
|       INSERT INTO "pagesVector" (path, locale, title, description, "tokens") | ||||
|         SELECT path, "localeCode" AS locale, title, description, | ||||
|           (setweight(to_tsvector('${this.config.dictLanguage}', title), 'A') || | ||||
|           setweight(to_tsvector('${this.config.dictLanguage}', description), 'B') || | ||||
|           setweight(to_tsvector('${this.config.dictLanguage}', content), 'C')) AS tokens | ||||
|         FROM "pages" | ||||
|         WHERE pages."isPublished" AND NOT pages."isPrivate"`) | ||||
|   } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user