Browse Source

feat: search suggestions + results UI improvements

pull/795/head
Nick 5 years ago
parent
commit
f7664339f4
4 changed files with 80 additions and 18 deletions
  1. 3
      CHANGELOG.md
  2. 7
      client/components/common/nav-header.vue
  3. 45
      client/components/common/search-results.vue
  4. 43
      server/modules/search/postgres/engine.js

3
CHANGELOG.md

@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## [2.0.0-beta.XX] - 2019-XX-XX ## [2.0.0-beta.XX] - 2019-XX-XX
### Added ### Added
- Added Search Results overlay
- Added Search Engine - PostgreSQL
- Added Search Engine - DB Basic
- Added Git changes processing (add/modify/delete) - Added Git changes processing (add/modify/delete)
- Added Storage last sync date in status panel - Added Storage last sync date in status panel
- Added Dev Flags - Added Dev Flags

7
client/components/common/nav-header.vue

@ -76,6 +76,8 @@
@keyup.esc='searchClose' @keyup.esc='searchClose'
@focus='searchFocus' @focus='searchFocus'
@blur='searchBlur' @blur='searchBlur'
@keyup.down='searchMove(`down`)'
@keyup.up='searchMove(`up`)'
) )
v-progress-linear( v-progress-linear(
indeterminate, indeterminate,
@ -253,7 +255,10 @@ export default {
} }
}, },
searchEnter() { searchEnter() {
this.searchIsLoading = true
this.$root.$emit('searchEnter', true)
},
searchMove(dir) {
this.$root.$emit('searchMove', dir)
}, },
pageNew () { pageNew () {
this.newPageModal = true this.newPageModal = true

45
client/components/common/search-results.vue

@ -16,9 +16,9 @@
.subheading No pages matching your query. .subheading No pages matching your query.
template(v-if='results.length > 0') template(v-if='results.length > 0')
v-subheader.white--text Found {{response.totalHits}} results 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') 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) v-list-tile-avatar(tile)
img(src='/svg/icon-selective-highlighting.svg') img(src='/svg/icon-selective-highlighting.svg')
v-list-tile-content v-list-tile-content
@ -36,9 +36,9 @@
) )
template(v-if='suggestions.length > 0') template(v-if='suggestions.length > 0')
v-subheader.white--text.mt-3 Did you mean... 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') 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-list-tile-avatar
v-icon search v-icon search
v-list-tile-content v-list-tile-content
@ -66,6 +66,7 @@ export default {
}, },
data() { data() {
return { return {
cursor: 0,
pagination: 1, pagination: 1,
response: { response: {
results: [], results: [],
@ -95,16 +96,40 @@ export default {
}, },
watch: { watch: {
search(newValue, oldValue) { search(newValue, oldValue) {
this.cursor = 0
if (newValue.length < 2) { if (newValue.length < 2) {
this.response.results = [] this.response.results = []
this.response.suggestions = []
} else {
this.searchIsLoading = true
} }
} }
}, },
methods: { methods: {
setSearchTerm(term) { setSearchTerm(term) {
this.search = 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: { apollo: {
response: { response: {
query: searchPagesQuery, query: searchPagesQuery,
@ -178,6 +203,18 @@ export default {
width: 200px; width: 200px;
} }
} }
&-items {
.highlighted {
background-color: mc('blue', '50');
}
}
&-suggestions {
.highlighted {
background-color: mc('blue', '500');
}
}
} }
@keyframes searchResultsReveal { @keyframes searchResultsReveal {

43
server/modules/search/postgres/engine.js

@ -12,7 +12,7 @@ module.exports = {
* INIT * INIT
*/ */
async init() { async init() {
// -> Create Index
// -> Create Search Index
const indexExists = await WIKI.models.knex.schema.hasTable('pagesVector') const indexExists = await WIKI.models.knex.schema.hasTable('pagesVector')
if (!indexExists) { if (!indexExists) {
await WIKI.models.knex.schema.createTable('pagesVector', table => { await WIKI.models.knex.schema.createTable('pagesVector', table => {
@ -21,11 +21,19 @@ module.exports = {
table.string('locale') table.string('locale')
table.string('title') table.string('title')
table.string('description') 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 * QUERY
@ -35,14 +43,20 @@ module.exports = {
*/ */
async query(q, opts) { async query(q, opts) {
try { try {
let suggestions = []
const results = await WIKI.models.knex.raw(` const results = await WIKI.models.knex.raw(`
SELECT id, path, locale, title, description SELECT id, path, locale, title, description
FROM "pagesVector", to_tsquery(?) query 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)]) `, [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 { return {
results: results.rows, results: results.rows,
suggestions: [],
suggestions,
totalHits: results.rows.length totalHits: results.rows.length
} }
} catch (err) { } catch (err) {
@ -58,8 +72,8 @@ module.exports = {
*/ */
async created(page) { async created(page) {
await WIKI.models.knex.raw(` 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]) `, [page.path, page.locale, page.title, page.description, page.title, page.description, page.content])
}, },
@ -73,9 +87,9 @@ module.exports = {
UPDATE "pagesVector" SET UPDATE "pagesVector" SET
title = '?', title = '?',
description = '?', 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 WHERE path = '?' AND locale = '?' LIMIT 1
`, [page.title, page.description, page.title, page.description, page.content, page.path, page.locale]) `, [page.title, page.description, page.title, page.description, page.content, page.path, page.locale])
}, },
@ -110,8 +124,11 @@ module.exports = {
async rebuild() { async rebuild() {
await WIKI.models.knex('pagesVector').truncate() await WIKI.models.knex('pagesVector').truncate()
await WIKI.models.knex.raw(` 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" FROM "pages"
WHERE pages."isPublished" AND NOT pages."isPrivate"`) WHERE pages."isPublished" AND NOT pages."isPrivate"`)
} }

Loading…
Cancel
Save