Basic Search Is Working For Users And Posts

The story of SearchInput.vue throws errors because of line 81, dateTime. What must be included to fix this?
The search results shown by the frontend are sometimes differnt from the response of the backend. It shows no results found though there are results incoming.
Tests are not implemented yet.
This commit is contained in:
Moriz Wahl 2019-12-10 00:09:30 +01:00
parent fa3d6c6c08
commit 72e4d0abbc
9 changed files with 371 additions and 345 deletions

View File

@ -85,7 +85,7 @@ export default shield(
'*': deny, '*': deny,
findPosts: allow, findPosts: allow,
findUsers: allow, findUsers: allow,
findAnything: allow, findResources: allow,
embed: allow, embed: allow,
Category: allow, Category: allow,
Tag: allow, Tag: allow,

View File

@ -11,7 +11,7 @@ export default {
}, },
}, },
Query: { Query: {
findAnything: async (_parent, args, context, _resolveInfo) => { findResources: async (_parent, args, context, _resolveInfo) => {
const query = args.query const query = args.query
const filter = {} const filter = {}
const limit = args.limit const limit = args.limit

View File

@ -1,5 +1,5 @@
union SearchResult = Post | User union SearchResult = Post | User
type Query { type Query {
findAnything(query: String!, limit: Int = 5): [SearchResult]! findResources(query: String!, limit: Int = 5): [SearchResult]!
} }

View File

@ -6,16 +6,14 @@ import helpers from '~/storybook/helpers'
helpers.init() helpers.init()
export const results = [ export const results = [
{
heading: 'Contributions'
},
{ {
id: 'de100841-2336-4b01-a574-f1bd2c0b262a', id: 'de100841-2336-4b01-a574-f1bd2c0b262a',
searchType: 'Contributions', __typename: 'Post',
slug: 'user-post-by-jenny', slug: 'user-post-by-jenny',
label: 'User Post by Jenny', title: 'User Post by Jenny',
value: 'User Post by Jenny', value: 'User Post by Jenny',
shoutedCount: 0, shoutedCount: 0,
commentCount: 4,
createdAt: '2019-11-13T03:03:16.155Z', createdAt: '2019-11-13T03:03:16.155Z',
author: { author: {
id: 'u3', id: 'u3',
@ -25,11 +23,12 @@ export const results = [
}, },
{ {
id: 'f48f00a0-c412-432f-8334-4276a4e15d1c', id: 'f48f00a0-c412-432f-8334-4276a4e15d1c',
searchType: 'Contributions', __typename: 'Post',
slug: 'eum-quos-est-molestiae-enim-magni-consequuntur-sed-commodi-eos', slug: 'eum-quos-est-molestiae-enim-magni-consequuntur-sed-commodi-eos',
label: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.', title: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.',
value: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.', value: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.',
shoutedCount: 0, shoutedCount: 0,
commentCount: 0,
createdAt: '2019-11-13T03:00:45.478Z', createdAt: '2019-11-13T03:00:45.478Z',
author: { author: {
id: 'u6', id: 'u6',
@ -39,11 +38,12 @@ export const results = [
}, },
{ {
id: 'p7', id: 'p7',
searchType: 'Contributions', __typename: 'Post',
slug: 'this-is-post-7', slug: 'this-is-post-7',
label: 'This is post #7', title: 'This is post #7',
value: 'This is post #7', value: 'This is post #7',
shoutedCount: 1, shoutedCount: 1,
commentCount: 1,
createdAt: '2019-11-13T03:00:23.098Z', createdAt: '2019-11-13T03:00:23.098Z',
author: { author: {
id: 'u6', id: 'u6',
@ -53,11 +53,12 @@ export const results = [
}, },
{ {
id: 'p12', id: 'p12',
searchType: 'Contributions', __typename: 'Post',
slug: 'this-is-post-12', slug: 'this-is-post-12',
label: 'This is post #12', title: 'This is post #12',
value: 'This is post #12', value: 'This is post #12',
shoutedCount: 0, shoutedCount: 0,
commentCount: 12,
createdAt: '2019-11-13T03:00:23.098Z', createdAt: '2019-11-13T03:00:23.098Z',
author: { author: {
id: 'u6', id: 'u6',
@ -65,43 +66,36 @@ export const results = [
slug: 'louie', slug: 'louie',
}, },
}, },
{
heading: 'Users'
},
{ {
id: 'u1', id: 'u1',
searchType: 'Users', __typename: 'User',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
name: 'Peter Lustig', name: 'Peter Lustig',
label: 'Peter Lustig',
slug: 'peter-lustig', slug: 'peter-lustig',
}, },
{ {
id: 'cdbca762-0632-4564-b646-415a0c42d8b8', id: 'cdbca762-0632-4564-b646-415a0c42d8b8',
searchType: 'Users', __typename: 'User',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
name: 'Herbert Schultz', name: 'Herbert Schultz',
label: 'Herbert Schultz',
slug: 'herbert-schultz', slug: 'herbert-schultz',
}, },
{ {
id: 'u2', id: 'u2',
searchType: 'Users', __typename: 'User',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
name: 'Bob der Baumeister', name: 'Bob der Baumeister',
label: 'Bob der Baumeister',
slug: 'bob-der-baumeister', slug: 'bob-der-baumeister',
}, },
{ {
id: '7b654f72-f4da-4315-8bed-39de0859754b', id: '7b654f72-f4da-4315-8bed-39de0859754b',
searchType: 'Users', __typename: 'User',
avatar: avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg',
name: 'Tonya Mohr', name: 'Tonya Mohr',
label: 'Tonya Mohr',
slug: 'tonya-mohr', slug: 'tonya-mohr',
}, },
] ]

View File

@ -1,309 +1,315 @@
<template> <template>
<div <div
class="search" class="search"
aria-label="search" aria-label="search"
role="search" role="search"
:class="{ :class="{
'is-active': isActive, 'is-active': isActive,
'is-open': isOpen, 'is-open': isOpen,
}" }"
> >
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<a v-if="isActive" class="search-clear-btn" @click="clear">&nbsp;</a> <a v-if="isActive" class="search-clear-btn" @click="clear">&nbsp;</a>
<ds-select <ds-select
:id="id" :id="id"
ref="input" ref="input"
v-model="searchValue" v-model="searchValue"
class="input" class="input"
name="search" name="search"
type="search" type="search"
icon="search" icon="search"
label-prop="id" label-prop="id"
: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="results"
:auto-reset-search="!searchValue" :auto-reset-search="!searchValue"
:placeholder="$t('search.placeholder')" :placeholder="$t('search.placeholder')"
:loading="pending" :loading="pending"
@keyup.enter.native="onEnter" @keyup.enter.native="onEnter"
@focus.capture.native="onFocus" @focus.capture.native="onFocus"
@blur.capture.native="onBlur" @blur.capture.native="onBlur"
@keyup.delete.native="onDelete" @keyup.delete.native="onDelete"
@keyup.esc.native="clear" @keyup.esc.native="clear"
@input.exact="onSelect" @input.exact="onSelect"
@input.native="handleInput" @input.native="handleInput"
@click.capture.native="isOpen = true" @click.capture.native="isOpen = true"
> >
<template slot="option" slot-scope="{ option }"> <template slot="option" slot-scope="{ option }">
<ds-flex v-if="option.heading" class="search-option-heading"> <ds-flex v-if="isFirstOfType(option)" class="search-option-heading">
<ds-flex-item> <ds-flex-item>
<ds-heading soft size="h5">{{ option.heading }}</ds-heading> <ds-heading soft size="h5">
</ds-flex-item> {{ $t(`search.heading.${option.__typename}`) }}
</ds-flex> </ds-heading>
<ds-flex v-else-if="option.searchType === 'Users'"> </ds-flex-item>
<ds-flex-item class="search-option"> </ds-flex>
<ds-avatar class="avatar" name="option.name" image="option.avatar" /> <ds-flex v-else-if="option.__typename === 'User'">
<div> <ds-flex-item class="search-option">
<ds-text class="userinfo"> <ds-avatar class="avatar" name="option.name" image="option.avatar" />
<b class="username">{{ option.label | truncate(70) }}</b> <div>
</ds-text> <ds-text class="userinfo">
</div> <b class="username">{{ option.name | truncate(70) }}</b>
<ds-text align="left" size="small" color="soft"> </ds-text>
@{{ option.slug | truncate(70) }} </div>
</ds-text> <ds-text align="left" size="small" color="soft">
</ds-flex-item> @{{ option.slug | truncate(70) }}
</ds-flex> </ds-text>
<ds-flex v-else> </ds-flex-item>
<ds-flex-item class="search-option-label"> </ds-flex>
<ds-text>{{ option.label | truncate(70) }}</ds-text> <ds-flex v-else>
</ds-flex-item> <ds-flex-item class="search-option-label">
<ds-flex-item class="search-option-meta" width="280px"> <ds-text>{{ option.title | truncate(70) }}</ds-text>
<ds-flex> </ds-flex-item>
<ds-flex-item> <ds-flex-item class="search-option-meta" width="280px">
<ds-text size="small" color="softer" class="search-meta"> <ds-flex>
<span style="text-align: right;"> <ds-flex-item>
<b>{{ option.commentsCount }}</b> <ds-text size="small" color="softer" class="search-meta">
<base-icon name="comments" /> <span style="text-align: right;">
</span> <b>{{ option.commentsCount }}</b>
<span style="width: 36px; display: inline-block; text-align: right;"> <base-icon name="comments" />
<b>{{ option.shoutedCount }}</b> </span>
<base-icon name="bullhorn" /> <span style="width: 36px; display: inline-block; text-align: right;">
</span> <b>{{ option.shoutedCount }}</b>
</ds-text> <base-icon name="bullhorn" />
</ds-flex-item> </span>
<ds-flex-item> </ds-text>
<ds-text size="small" color="softer" align="right"> </ds-flex-item>
{{ option.author.name | truncate(32) }} - <ds-flex-item>
{{ option.createdAt }} <ds-text size="small" color="softer" align="right">
<!-- removed | dateTime('dd.MM.yyyy') --> {{ option.author.name | truncate(32) }} -
</ds-text> {{ option.createdAt | dateTime('dd.MM.yyyy') }}
</ds-flex-item> </ds-text>
</ds-flex> </ds-flex-item>
</ds-flex-item> </ds-flex>
</ds-flex> </ds-flex-item>
</template> </ds-flex>
</ds-select> </template>
</div> </ds-select>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
export default { export default {
name: 'SearchInput', name: 'SearchInput',
props: { props: {
id: { id: {
type: String, type: String,
default: 'nav-search', default: 'nav-search',
}, },
value: { value: {
type: String, type: String,
default: '', default: '',
}, },
results: { results: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
delay: { delay: {
type: Number, type: Number,
default: 300, default: 300,
}, },
pending: { pending: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, },
data() { data() {
return { return {
searchProcess: null, searchProcess: null,
isOpen: false, isOpen: false,
lastSearchTerm: '', lastSearchTerm: '',
unprocessedSearchInput: '', unprocessedSearchInput: '',
searchValue: '', searchValue: '',
} }
}, },
computed: { computed: {
// #: Unused at the moment? // #: Unused at the moment?
isActive() { isActive() {
return !isEmpty(this.lastSearchTerm) return !isEmpty(this.lastSearchTerm)
}, },
emptyText() { emptyText() {
return this.isActive && !this.pending ? this.$t('search.failed') : this.$t('search.hint') return this.isActive && !this.pending ? this.$t('search.failed') : this.$t('search.hint')
}, },
}, },
methods: { methods: {
async query(value) { async query(value) {
if (isEmpty(value) || value.length < 3) { if (isEmpty(value) || value.length < 3) {
this.clear() this.clear()
return return
} }
this.$emit('search', value) this.$emit('search', value)
}, },
handleInput(e) { handleInput(e) {
clearTimeout(this.searchProcess) clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : '' const value = e.target ? e.target.value.trim() : ''
this.isOpen = true this.isOpen = true
this.unprocessedSearchInput = value this.unprocessedSearchInput = value
this.searchProcess = setTimeout(() => { this.searchProcess = setTimeout(() => {
this.lastSearchTerm = value this.lastSearchTerm = value
this.query(value) this.query(value)
}, this.delay) }, this.delay)
}, },
onSelect(item) { onSelect(item) {
this.isOpen = false this.isOpen = false
console.log('onSelect', item) this.$emit('select', item)
this.$emit('select', item) this.$nextTick(() => {
this.$nextTick(() => { this.searchValue = this.lastSearchTerm
this.searchValue = this.lastSearchTerm })
}) },
}, onFocus(e) {
onFocus(e) { clearTimeout(this.searchProcess)
clearTimeout(this.searchProcess) this.isOpen = true
this.isOpen = true },
}, onBlur(e) {
onBlur(e) { this.searchValue = this.lastSearchTerm
this.searchValue = this.lastSearchTerm // this.$nextTick(() => {
// this.$nextTick(() => { // this.searchValue = this.lastSearchTerm
// this.searchValue = this.lastSearchTerm // })
// }) this.isOpen = false
this.isOpen = false clearTimeout(this.searchProcess)
clearTimeout(this.searchProcess) },
}, onDelete(e) {
onDelete(e) { clearTimeout(this.searchProcess)
clearTimeout(this.searchProcess) const value = e.target ? e.target.value.trim() : ''
const value = e.target ? e.target.value.trim() : '' if (isEmpty(value)) {
if (isEmpty(value)) { this.clear()
this.clear() } else {
} else { this.handleInput(e)
this.handleInput(e) }
} },
}, /**
/** * TODO: on enter we should go to a dedicated search page!?
* TODO: on enter we should go to a dedicated search page!? */
*/ onEnter(e) {
onEnter(e) { // this.isOpen = false
// this.isOpen = false clearTimeout(this.searchProcess)
clearTimeout(this.searchProcess) if (!this.pending) {
if (!this.pending) { // this.lastSearchTerm = this.unprocessedSearchInput
// this.lastSearchTerm = this.unprocessedSearchInput this.query(this.unprocessedSearchInput)
this.query(this.unprocessedSearchInput) }
} },
}, clear() {
clear() { this.$emit('clear')
this.$emit('clear') clearTimeout(this.searchProcess)
clearTimeout(this.searchProcess) this.isOpen = false
this.isOpen = false this.unprocessedSearchInput = ''
this.unprocessedSearchInput = '' this.lastSearchTerm = ''
this.lastSearchTerm = '' this.searchValue = ''
this.searchValue = '' },
}, isFirstOfType(option) {
}, return (
} this.results.findIndex(o => o === option) ===
this.results.findIndex(o => o.__typename === option.__typename)
)
},
},
}
</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;
} }
} }
</style> </style>

View File

@ -28,7 +28,7 @@
:results="quickSearchResults" :results="quickSearchResults"
@clear="quickSearchClear" @clear="quickSearchClear"
@search="value => quickSearch({ value })" @search="value => quickSearch({ value })"
@select="goToPost" @select="goToResource"
/> />
</div> </div>
</ds-flex-item> </ds-flex-item>
@ -140,12 +140,24 @@ export default {
quickSearchClear: 'search/quickClear', quickSearchClear: 'search/quickClear',
quickSearch: 'search/quickSearch', quickSearch: 'search/quickSearch',
}), }),
goToPost(item) { goToResource(item) {
this.$nextTick(() => { this.$nextTick(() => {
this.$router.push({ switch (item.__typename) {
name: 'post-id-slug', case 'Post':
params: { id: item.id, slug: item.slug }, this.$router.push({
}) name: 'post-id-slug',
params: { id: item.id, slug: item.slug },
})
break
case 'User':
this.$router.push({
name: 'profile-id-slug',
params: { id: item.id, slug: item.slug },
})
break
default:
break
}
}) })
}, },
toggleMobileMenuView() { toggleMobileMenuView() {

View File

@ -513,7 +513,11 @@
"search": { "search": {
"placeholder": "Suchen", "placeholder": "Suchen",
"hint": "Wonach suchst Du?", "hint": "Wonach suchst Du?",
"failed": "Nichts gefunden" "failed": "Nichts gefunden",
"heading": {
"Post": "Beiträge",
"User": "Benutzer"
}
}, },
"components": { "components": {
"password-reset": { "password-reset": {

View File

@ -197,7 +197,11 @@
"search": { "search": {
"placeholder": "Search", "placeholder": "Search",
"hint": "What are you searching for?", "hint": "What are you searching for?",
"failed": "Nothing found" "failed": "Nothing found",
"heading": {
"Post": "Posts",
"User": "Users"
}
}, },
"settings": { "settings": {
"name": "Settings", "name": "Settings",

View File

@ -46,29 +46,35 @@ export const actions = {
await this.app.apolloProvider.defaultClient await this.app.apolloProvider.defaultClient
.query({ .query({
query: gql` query: gql`
query findPosts($query: String!, $filter: _PostFilter) { query findResources($query: String!) {
findPosts(query: $query, limit: 10, filter: $filter) { findResources(query: $query, limit: 5) {
id __typename
slug ... on Post {
label: title id
value: title title
shoutedCount slug
createdAt commentsCount
author { shoutedCount
createdAt
author {
name
}
}
... on User {
id id
name name
slug slug
avatar
} }
} }
} }
`, `,
variables: { variables: {
query: value.replace(/\s/g, '~ ') + '~', query: value,
filter: {},
}, },
}) })
.then(res => { .then(res => {
commit('SET_QUICK_RESULTS', res.data.findPosts || []) commit('SET_QUICK_RESULTS', res.data.findResources || [])
}) })
.catch(() => { .catch(() => {
commit('SET_QUICK_RESULTS', []) commit('SET_QUICK_RESULTS', [])