Fix search by adding result id

- Apollo cache requires an id to commit to their store
- add id to each query result
- refactor out the store
- still can use a lot of refactoring
This commit is contained in:
mattwr18 2019-12-13 12:30:00 +01:00
parent 80e076fc86
commit ebc5cf392d
6 changed files with 208 additions and 261 deletions

View File

@ -1,15 +1,15 @@
export default { import uuid from 'uuid/v4'
SearchResult: {
__resolveType(obj, context, info) { const transformReturnType = record => {
if (obj.encryptedPassword) { return {
return 'User' id: uuid(),
} searchResults: {
if (obj.content) { __typename: record.get('type'),
return 'Post' ...record.get('resource').properties,
}
return null
}, },
}, }
}
export default {
Query: { Query: {
findResources: async (_parent, args, context, _resolveInfo) => { findResources: async (_parent, args, context, _resolveInfo) => {
const query = args.query const query = args.query
@ -21,14 +21,14 @@ export default {
const userQuery = query.replace(/\s/g, '~ ') + '~' const userQuery = query.replace(/\s/g, '~ ') + '~'
const postCypher = ` const postCypher = `
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as post, score YIELD node as resource, score
MATCH (post)<-[:WROTE]-(user:User) MATCH (resource)<-[:WROTE]-(user:User)
WHERE score >= 0.2 WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true AND NOT user.deleted = true AND NOT user.disabled = true
AND NOT post.deleted = true AND NOT post.disabled = true AND NOT resource.deleted = true AND NOT resource.disabled = true
AND NOT user.id in COALESCE($filter.author_not.id_in, []) AND NOT user.id in COALESCE($filter.author_not.id_in, [])
AND NOT (:User { id: $thisUserId })-[:BLOCKED]->(user) AND NOT (:User { id: $thisUserId })-[:BLOCKED]->(user)
RETURN post RETURN resource, labels(resource)[0] AS type
LIMIT $limit LIMIT $limit
` `
const session = context.driver.session() const session = context.driver.session()
@ -41,12 +41,12 @@ export default {
session.close() session.close()
const userCypher = ` const userCypher = `
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as user, score YIELD node as resource, score
MATCH (user) MATCH (resource)
WHERE score >= 0.2 WHERE score >= 0.2
AND NOT user.deleted = true AND NOT user.disabled = true AND NOT resource.deleted = true AND NOT resource.disabled = true
AND NOT (:User { id: $thisUserId })-[:BLOCKED]->(user) AND NOT (:User { id: $thisUserId })-[:BLOCKED]->(resource)
RETURN user RETURN resource, labels(resource)[0] AS type
LIMIT $limit LIMIT $limit
` `
const userResults = await session.run(userCypher, { const userResults = await session.run(userCypher, {
@ -57,10 +57,8 @@ export default {
}) })
session.close() session.close()
const result = [] const result = postResults.records.concat(userResults.records).map(transformReturnType)
postResults.records
.concat(userResults.records)
.forEach(record => result.push(record._fields[0].properties))
return result return result
}, },
}, },

View File

@ -1,4 +1,9 @@
union SearchResult = Post | User type SearchResult {
id: ID!
searchResults: ResourceResults
}
union ResourceResults = Post | User
type Query { type Query {
findResources(query: String!, limit: Int = 5): [SearchResult]! findResources(query: String!, limit: Int = 5): [SearchResult]!

View File

@ -23,7 +23,7 @@
:no-options-available="emptyText" :no-options-available="emptyText"
:icon-right="isActive ? 'close' : null" :icon-right="isActive ? 'close' : null"
:filter="item => item" :filter="item => item"
:options="results" :options="searchResults"
:auto-reset-search="!searchValue" :auto-reset-search="!searchValue"
:placeholder="$t('search.placeholder')" :placeholder="$t('search.placeholder')"
:loading="pending" :loading="pending"
@ -45,7 +45,7 @@
</ds-flex-item> </ds-flex-item>
</ds-flex> </ds-flex>
<ds-flex v-if="option.__typename === 'User'"> <ds-flex v-if="option.__typename === 'User'">
<ds-flex-item class="search-option" :class="{'extra-space': isFirstOfType(option)}"> <ds-flex-item class="search-option" :class="{ 'extra-space': isFirstOfType(option) }">
<ds-avatar class="avatar" name="option.name" image="option.avatar" /> <ds-avatar class="avatar" name="option.name" image="option.avatar" />
<div> <div>
<ds-text class="userinfo"> <ds-text class="userinfo">
@ -93,6 +93,8 @@
<script> <script>
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { findResourcesQuery } from '~/graphql/Search.js'
export default { export default {
name: 'SearchInput', name: 'SearchInput',
props: { props: {
@ -100,30 +102,22 @@ export default {
type: String, type: String,
default: 'nav-search', default: 'nav-search',
}, },
value: {
type: String,
default: '',
},
results: {
type: Array,
default: () => [],
},
delay: { delay: {
type: Number, type: Number,
default: 300, default: 300,
}, },
pending: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
searchProcess: null, searchProcess: null,
value: '',
pending: false,
isOpen: false, isOpen: false,
lastSearchTerm: '', lastSearchTerm: '',
unprocessedSearchInput: '', unprocessedSearchInput: '',
searchValue: '', searchValue: '',
quickValue: '',
searchResults: [],
} }
}, },
computed: { computed: {
@ -136,21 +130,42 @@ export default {
}, },
}, },
methods: { methods: {
async query(value) { async query(value) {
if (isEmpty(value) || value.length < 3) { if (
this.clear() isEmpty(value) ||
return value.length < 3 ||
} this.quickValue.toLowerCase() === value.toLowerCase()
this.$emit('search', value) ) {
this.clear()
return
}
this.quickValue = value
this.pending = true
try {
const {
data: { findResources },
} = await this.$apollo.query({
query: findResourcesQuery,
variables: {
query: value,
},
})
const searchResults = findResources.map(searchResult => searchResult.searchResults)
this.searchResults = searchResults
} catch (error) {
this.searchResults = []
} finally {
this.pending = false
}
}, },
handleInput(e) { handleInput(e) {
clearTimeout(this.searchProcess) clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : '' this.value = e.target ? e.target.value.trim() : ''
this.isOpen = true this.isOpen = true
this.unprocessedSearchInput = value this.unprocessedSearchInput = this.value
this.searchProcess = setTimeout(() => { this.searchProcess = setTimeout(() => {
this.lastSearchTerm = value this.lastSearchTerm = this.value
this.query(value) this.query(this.value)
}, this.delay) }, this.delay)
}, },
onSelect(item) { onSelect(item) {
@ -193,8 +208,10 @@ export default {
} }
}, },
clear() { clear() {
this.$emit('clear') this.pending = false
clearTimeout(this.searchProcess) this.searchResults = []
this.quickValue = ''
this.clearTimeout(this.searchProcess)
this.isOpen = false this.isOpen = false
this.unprocessedSearchInput = '' this.unprocessedSearchInput = ''
this.lastSearchTerm = '' this.lastSearchTerm = ''
@ -202,8 +219,8 @@ export default {
}, },
isFirstOfType(option) { isFirstOfType(option) {
return ( return (
this.results.findIndex(o => o === option) === this.searchResults.findIndex(o => o === option) ===
this.results.findIndex(o => o.__typename === option.__typename) this.searchResults.findIndex(o => o.__typename === option.__typename)
) )
}, },
}, },
@ -211,109 +228,109 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.search { .search {
display: flex; display: flex;
align-self: center; align-self: center;
width: 100%; width: 100%;
position: relative; position: relative;
$padding-left: $space-x-small; $padding-left: $space-x-small;
.search-option-label { .search-option-label {
align-self: center; align-self: center;
padding-left: $padding-left; padding-left: $padding-left;
} }
.search-option-meta { .search-option-meta {
align-self: center; align-self: center;
.ds-flex { .ds-flex {
flex-direction: column; flex-direction: column;
} }
} }
&, &,
.ds-select-dropdown { .ds-select-dropdown {
transition: box-shadow 100ms; transition: box-shadow 100ms;
max-height: 70vh; max-height: 70vh;
} }
&.is-open { &.is-open {
.ds-select-dropdown { .ds-select-dropdown {
box-shadow: $box-shadow-x-large; box-shadow: $box-shadow-x-large;
} }
} }
.ds-select-dropdown-message { .ds-select-dropdown-message {
opacity: 0.5; opacity: 0.5;
padding-left: $padding-left; padding-left: $padding-left;
} }
.search-clear-btn { .search-clear-btn {
right: 0; right: 0;
z-index: 10; z-index: 10;
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 36px; width: 36px;
cursor: pointer; cursor: pointer;
} }
.search-meta { .search-meta {
float: right; float: right;
padding-top: 2px; padding-top: 2px;
white-space: nowrap; white-space: nowrap;
word-wrap: none; word-wrap: none;
.base-icon { .base-icon {
vertical-align: sub; vertical-align: sub;
} }
} }
.ds-select { .ds-select {
z-index: $z-index-dropdown + 1; z-index: $z-index-dropdown + 1;
} }
.ds-select-option-hover { .ds-select-option-hover {
.ds-text-size-small, .ds-text-size-small,
.ds-text-size-small-x { .ds-text-size-small-x {
color: $text-color-soft; color: $text-color-soft;
} }
} }
.field { .field {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.control { .control {
width: 100%; width: 100%;
} }
.search-option-heading { .search-option-heading {
font-weight: bold; font-weight: bold;
cursor: default; cursor: default;
background-color: white; background-color: white;
margin: -8px; margin: -8px;
padding: 8px; padding: 8px;
} }
.avatar { .avatar {
display: inline-block; display: inline-block;
float: left; float: left;
margin-right: 4px; margin-right: 4px;
height: 100%; height: 100%;
vertical-align: middle; vertical-align: middle;
} }
.userinfo { .userinfo {
display: flex; display: flex;
align-items: center; align-items: center;
> .ds-text { > .ds-text {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: $space-xx-small; margin-left: $space-xx-small;
} }
} }
.user { .user {
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
&:hover, &:hover,
&.active { &.active {
z-index: 999; z-index: 999;
} }
} }
.username { .username {
color: #17b53f; color: #17b53f;
} }
.extra-space { .extra-space {
margin-top: 8px; margin-top: 8px;
padding-top: 4px; padding-top: 4px;
} }
} }
</style> </style>

29
webapp/graphql/Search.js Normal file
View File

@ -0,0 +1,29 @@
import gql from 'graphql-tag'
export const findResourcesQuery = gql`
query($query: String!) {
findResources(query: $query, limit: 5) {
id
searchResults {
__typename
... on Post {
id
title
slug
commentsCount
shoutedCount
createdAt
author {
name
}
}
... on User {
id
name
slug
avatar
}
}
}
}
`

View File

@ -21,15 +21,7 @@
:class="{ 'hide-mobile-menu': !toggleMobileMenu }" :class="{ 'hide-mobile-menu': !toggleMobileMenu }"
> >
<div id="nav-search-box" v-if="isLoggedIn"> <div id="nav-search-box" v-if="isLoggedIn">
<search-input <search-input id="nav-search" :delay="300" @select="goToResource" />
id="nav-search"
:delay="300"
:pending="quickSearchPending"
:results="quickSearchResults"
@clear="quickSearchClear"
@search="value => quickSearch({ value })"
@select="goToResource"
/>
</div> </div>
</ds-flex-item> </ds-flex-item>
<ds-flex-item <ds-flex-item
@ -122,8 +114,6 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
isLoggedIn: 'auth/isLoggedIn', isLoggedIn: 'auth/isLoggedIn',
quickSearchResults: 'search/quickResults',
quickSearchPending: 'search/quickPending',
}), }),
showFilterPostsDropdown() { showFilterPostsDropdown() {
const [firstRoute] = this.$route.matched const [firstRoute] = this.$route.matched

View File

@ -1,92 +0,0 @@
import gql from 'graphql-tag'
import isString from 'lodash/isString'
export const state = () => {
return {
quickResults: [],
quickPending: false,
quickValue: '',
}
}
export const mutations = {
SET_QUICK_RESULTS(state, results) {
state.quickResults = results || []
state.quickPending = false
},
SET_QUICK_PENDING(state, pending) {
state.quickPending = pending
},
SET_QUICK_VALUE(state, value) {
state.quickValue = value
},
}
export const getters = {
quickResults(state) {
return state.quickResults
},
quickPending(state) {
return state.quickPending
},
quickValue(state) {
return state.quickValue
},
}
export const actions = {
async quickSearch({ commit, getters }, { value }) {
value = isString(value) ? value.trim() : ''
const lastVal = getters.quickValue
if (value.length < 3 || lastVal.toLowerCase() === value.toLowerCase()) {
return
}
commit('SET_QUICK_VALUE', value)
commit('SET_QUICK_PENDING', true)
await this.app.apolloProvider.defaultClient
.query({
query: gql`
query findResources($query: String!) {
findResources(query: $query, limit: 5) {
__typename
... on Post {
id
title
slug
commentsCount
shoutedCount
createdAt
author {
name
}
}
... on User {
id
name
slug
avatar
}
}
}
`,
variables: {
query: value,
},
})
.then(res => {
commit('SET_QUICK_RESULTS', res.data.findResources || [])
})
.catch(() => {
commit('SET_QUICK_RESULTS', [])
})
.finally(() => {
commit('SET_QUICK_PENDING', false)
})
return getters.quickResults
},
async quickClear({ commit }) {
commit('SET_QUICK_PENDING', false)
commit('SET_QUICK_RESULTS', [])
commit('SET_QUICK_VALUE', '')
},
}