Merge pull request #264 from Human-Connection/37-full-text-search-top-bar

37 full text search top bar
This commit is contained in:
Robert Schäfer 2019-03-18 17:42:13 +01:00 committed by GitHub
commit 0c2a228b47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 724 additions and 11 deletions

3
.gitignore vendored
View File

@ -81,3 +81,6 @@ static/uploads
cypress/videos
cypress/screenshots/
cypress.env.json
# Apple macOS folder attribute file
.DS_Store

View File

@ -12,11 +12,11 @@
@click.prevent="toggleMenu()"
>
<ds-icon
style="margin-top: -2px; margin-right: 2px;"
style="margin-right: 2px;"
name="globe"
/> {{ current.code.toUpperCase() }}
<ds-icon
style="margin-top: -2px; margin-left: 2px"
style="margin-left: 2px"
size="xx-small"
name="angle-down"
/>

View File

@ -0,0 +1,144 @@
import { mount, createLocalVue } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.filter('truncate', () => 'truncated string')
localVue.filter('dateTime', () => Date.now)
describe('SearchInput.vue', () => {
let wrapper
let mocks
let propsData
beforeEach(() => {
propsData = {}
})
describe('mount', () => {
const Wrapper = () => {
mocks = {
$t: () => {}
}
return mount(SearchInput, { mocks, localVue, propsData })
}
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
it('has id "nav-search"', () => {
expect(Wrapper().contains('#nav-search')).toBe(true)
})
it('defaults to an empty value', () => {
expect(Wrapper().vm.value).toBe('')
})
it('defaults to id "nav-search"', () => {
expect(Wrapper().vm.id).toBe('nav-search')
})
it('default to a 300 millisecond delay from the time the user stops typing to when the search starts', () => {
expect(Wrapper().vm.delay).toEqual(300)
})
it('defaults to an empty array as results', () => {
expect(Wrapper().vm.results).toEqual([])
})
it('defaults to pending false, as in the search is not pending', () => {
expect(Wrapper().vm.pending).toBe(false)
})
it('accepts values as a string', () => {
propsData = { value: 'abc' }
const wrapper = Wrapper()
expect(wrapper.vm.value).toEqual('abc')
})
describe('testing custom functions', () => {
let select
let wrapper
beforeEach(() => {
wrapper = Wrapper()
select = wrapper.find('.ds-select')
select.trigger('focus')
select.element.value = 'abcd'
})
it('opens the select dropdown when focused on', () => {
expect(wrapper.vm.isOpen).toBe(true)
})
it('opens the select dropdown and blurs after focused on', () => {
select.trigger('blur')
expect(wrapper.vm.isOpen).toBe(false)
})
it('is clearable', () => {
select.trigger('input')
select.trigger('keyup.esc')
expect(wrapper.emitted().clear.length).toBe(1)
})
it('changes the unprocessedSearchInput as the value changes', () => {
select.trigger('input')
expect(wrapper.vm.unprocessedSearchInput).toBe('abcd')
})
it('searches for the term when enter is pressed', async () => {
select.trigger('input')
select.trigger('keyup.enter')
await expect(wrapper.emitted().search[0]).toEqual(['abcd'])
})
it('calls onDelete when the delete key is pressed', () => {
const spy = jest.spyOn(wrapper.vm, 'onDelete')
select.trigger('input')
select.trigger('keyup.delete')
expect(spy).toHaveBeenCalledTimes(1)
})
it('calls query when a user starts a search by pressing enter', () => {
const spy = jest.spyOn(wrapper.vm, 'query')
select.trigger('input')
select.trigger('keyup.enter')
expect(spy).toHaveBeenCalledWith('abcd')
})
it('calls onSelect when a user selects an item in the search dropdown menu', async () => {
// searched for term in the browser, copied the results from Vuex in Vue dev tools
propsData = {
results: [
{
__typename: 'Post',
author: {
__typename: 'User',
id: 'u5',
name: 'Trick',
slug: 'trick'
},
commentsCount: 0,
createdAt: '2019-03-13T11:00:20.835Z',
id: 'p10',
label: 'Eos aut illo omnis quis eaque et iure aut.',
shoutedCount: 0,
slug: 'eos-aut-illo-omnis-quis-eaque-et-iure-aut',
value: 'Eos aut illo omnis quis eaque et iure aut.'
}
]
}
wrapper = Wrapper()
select.trigger('input')
const results = wrapper.find('.ds-select-option')
results.trigger('click')
await expect(wrapper.emitted().select[0]).toEqual(propsData.results)
})
})
})
})

289
components/SearchInput.vue Normal file
View File

@ -0,0 +1,289 @@
<template>
<div
class="search"
aria-label="search"
role="search"
:class="{
'is-active': isActive,
'is-open': isOpen
}"
>
<div class="field">
<div class="control">
<a
v-if="isActive"
class="search-clear-btn"
@click="clear"
>
&nbsp;
</a>
<ds-select
:id="id"
ref="input"
v-model="searchValue"
class="input"
name="search"
type="search"
icon="search"
label-prop="id"
:no-options-available="emptyText"
:icon-right="isActive ? 'close' : null"
:filter="item => item"
:options="results"
:auto-reset-search="!searchValue"
:placeholder="$t('search.placeholder')"
:loading="pending"
@keyup.enter.native="onEnter"
@focus.capture.native="onFocus"
@blur.capture.native="onBlur"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@input.exact="onSelect"
@input.native="handleInput"
@click.capture.native="isOpen = true"
>
<template
slot="option"
slot-scope="{option}"
>
<ds-flex>
<ds-flex-item class="search-option-label">
<ds-text>
{{ option.label | truncate(70) }}
</ds-text>
</ds-flex-item>
<ds-flex-item
class="search-option-meta"
width="280px"
>
<ds-flex>
<ds-flex-item>
<ds-text
size="small"
color="softer"
class="search-meta"
>
<span style="text-align: right;">
<b>{{ option.commentsCount }}</b> <ds-icon name="comments" />
</span>
<span style="width: 36px; display: inline-block; text-align: right;">
<b>{{ option.shoutedCount }}</b> <ds-icon name="bullhorn" />
</span>
</ds-text>
</ds-flex-item>
<ds-flex-item>
<ds-text
size="small"
color="softer"
align="right"
>
{{ option.author.name | truncate(32) }} - {{ option.createdAt | dateTime('dd.MM.yyyy') }}
</ds-text>
</ds-flex-item>
</ds-flex>
</ds-flex-item>
</ds-flex>
</template>
</ds-select>
</div>
</div>
</div>
</template>
<script>
import { isEmpty } from 'lodash'
export default {
name: 'SearchInput',
props: {
id: {
type: String,
default: 'nav-search'
},
value: {
type: String,
default: ''
},
results: {
type: Array,
default: () => []
},
delay: {
type: Number,
default: 300
},
pending: {
type: Boolean,
default: false
}
},
data() {
return {
searchProcess: null,
isOpen: false,
lastSearchTerm: '',
unprocessedSearchInput: '',
searchValue: ''
}
},
computed: {
// #: Unused at the moment?
isActive() {
return !isEmpty(this.lastSearchTerm)
},
emptyText() {
return this.isActive && !this.pending
? this.$t('search.failed')
: this.$t('search.hint')
}
},
methods: {
async query(value) {
if (isEmpty(value) || value.length < 3) {
this.clear()
return
}
this.$emit('search', value)
},
handleInput(e) {
clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : ''
this.isOpen = true
this.unprocessedSearchInput = value
this.searchProcess = setTimeout(() => {
this.lastSearchTerm = value
this.query(value)
}, this.delay)
},
onSelect(item) {
this.isOpen = false
this.$emit('select', item)
this.$nextTick(() => {
this.searchValue = this.lastSearchTerm
})
},
onFocus(e) {
clearTimeout(this.searchProcess)
this.isOpen = true
},
onBlur(e) {
this.searchValue = this.lastSearchTerm
// this.$nextTick(() => {
// this.searchValue = this.lastSearchTerm
// })
this.isOpen = false
clearTimeout(this.searchProcess)
},
onDelete(e) {
clearTimeout(this.searchProcess)
const value = e.target ? e.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(e)
}
},
/**
* TODO: on enter we should go to a dedicated seach page!?
*/
onEnter(e) {
// this.isOpen = false
clearTimeout(this.searchProcess)
if (!this.pending) {
// this.lastSearchTerm = this.unprocessedSearchInput
this.query(this.unprocessedSearchInput)
}
},
clear() {
this.$emit('clear')
clearTimeout(this.searchProcess)
this.isOpen = false
this.unprocessedSearchInput = ''
this.lastSearchTerm = ''
this.searchValue = ''
}
}
}
</script>
<style lang="scss">
.search {
display: flex;
align-self: center;
width: 100%;
position: relative;
$padding-left: $space-x-small;
.search-option-label {
align-self: center;
padding-left: $padding-left;
}
.search-option-meta {
align-self: center;
.ds-flex {
flex-direction: column;
}
}
&,
.ds-select-dropdown {
transition: box-shadow 100ms;
max-height: 70vh;
}
&.is-open {
.ds-select-dropdown {
box-shadow: $box-shadow-x-large;
}
}
.ds-select-dropdown-message {
opacity: 0.5;
padding-left: $padding-left;
}
.search-clear-btn {
right: 0;
z-index: 10;
position: absolute;
height: 100%;
width: 36px;
cursor: pointer;
}
.search-meta {
float: right;
padding-top: 2px;
white-space: nowrap;
word-wrap: none;
.ds-icon {
vertical-align: sub;
}
}
.ds-select {
z-index: $z-index-dropdown + 1;
}
.ds-select-option-hover {
.ds-text-size-small,
.ds-text-size-small-x {
color: $text-color-soft;
}
}
.field {
width: 100%;
display: flex;
align-items: center;
}
.control {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,41 @@
Feature: Search
As a user
I would like to be able to search for specific words
In order to find related content
Background:
Given I have a user account
And we have the following posts in our database:
| Author | id | title | content |
| Brianna Wiest | p1 | 101 Essays that will change the way you think | 101 Essays, of course! |
| Brianna Wiest | p1 | No searched for content | will be found in this post, I guarantee |
Given I am logged in
Scenario: Search for specific words
When I search for "Essays"
Then I should have one post in the select dropdown
Then I should see the following posts in the select dropdown:
| title |
| 101 Essays that will change the way you think |
Scenario: Press enter starts search
When I type "Essa" and press Enter
Then I should have one post in the select dropdown
Then I should see the following posts in the select dropdown:
| title |
| 101 Essays that will change the way you think |
Scenario: Press escape clears search
When I type "Ess" and press escape
Then the search field should clear
Scenario: Select entry goes to post
When I search for "Essays"
And I select an entry
Then I should be on the post's page
Scenario: Select dropdown content
When I search for "Essays"
Then I should have one post in the select dropdown
Then I should see posts with the searched-for term in the select dropdown
And I should not see posts without the searched-for term in the select dropdown

View File

@ -0,0 +1,69 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
When('I search for {string}', value => {
cy.get('#nav-search')
.focus()
.type(value)
})
Then('I should have one post in the select dropdown', () => {
cy.get('.ds-select-dropdown').should($li => {
expect($li).to.have.length(1)
})
})
Then('I should see the following posts in the select dropdown:', table => {
table.hashes().forEach(({ title }) => {
cy.get('.ds-select-dropdown').should('contain', title)
})
})
When('I type {string} and press Enter', value => {
cy.get('#nav-search')
.focus()
.type(value)
.type('{enter}', { force: true })
})
When('I type {string} and press escape', value => {
cy.get('#nav-search')
.focus()
.type(value)
.type('{esc}')
})
Then('the search field should clear', () => {
cy.get('#nav-search').should('have.text', '')
})
When('I select an entry', () => {
cy.get('.ds-select-dropdown ul li')
.first()
.trigger('click')
})
Then("I should be on the post's page", () => {
cy.location('pathname').should(
'eq',
'/post/101-essays-that-will-change-the-way-you-think/'
)
})
Then(
'I should see posts with the searched-for term in the select dropdown',
() => {
cy.get('.ds-select-dropdown').should(
'contain',
'101 Essays that will change the way you think'
)
}
)
Then(
'I should not see posts without the searched-for term in the select dropdown',
() => {
cy.get('.ds-select-dropdown').should(
'not.contain',
'No searched for content'
)
}
)

View File

@ -1,21 +1,33 @@
<template>
<div class="layout-default">
<div class="main-navigation">
<ds-container style="padding: .5rem 2rem .2rem; display: flex;">
<ds-container class="main-navigation-container">
<div class="main-navigation-left">
<a
v-router-link
style="display: inline-flex"
href="/"
>
<ds-logo />
</a>
</div>
<div class="main-navigation-center hc-navbar-search">
<search-input
id="nav-search"
:delay="300"
:pending="quickSearchPending"
:results="quickSearchResults"
@clear="quickSearchClear"
@search="value => quickSearch({ value })"
@select="goToPost"
/>
</div>
<div class="main-navigation-right">
<no-ssr>
<locale-switch
class="topbar-locale-switch"
placement="bottom"
offset="12"
offset="23"
/>
</no-ssr>
<template v-if="isLoggedIn">
@ -99,25 +111,35 @@
</template>
<script>
import { mapGetters } from 'vuex'
import { mapGetters, mapActions } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch'
import Dropdown from '~/components/Dropdown'
import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal'
import seo from '~/components/mixins/seo'
export default {
components: {
Dropdown,
LocaleSwitch,
SearchInput,
Modal,
LocaleSwitch
},
mixins: [seo],
data() {
return {
mobileSearchVisible: false
}
},
computed: {
...mapGetters({
user: 'auth/user',
isLoggedIn: 'auth/isLoggedIn',
isModerator: 'auth/isModerator',
isAdmin: 'auth/isAdmin'
isAdmin: 'auth/isAdmin',
quickSearchResults: 'search/quickResults',
quickSearchPending: 'search/quickPending'
}),
routes() {
if (!this.user.slug) {
@ -153,6 +175,18 @@ export default {
}
},
methods: {
...mapActions({
quickSearchClear: 'search/quickClear',
quickSearch: 'search/quickSearch'
}),
goToPost(item) {
this.$nextTick(() => {
this.$router.push({
name: 'post-slug',
params: { slug: item.slug }
})
})
},
matcher(url, route) {
if (url.indexOf('/profile') === 0) {
// do only match own profile
@ -168,6 +202,8 @@ export default {
.topbar-locale-switch {
display: flex;
margin-right: $space-xx-small;
align-self: center;
display: inline-flex;
}
.main-navigation {
@ -176,13 +212,31 @@ export default {
}
}
.main-navigation-container {
padding: $space-x-small $space-large !important;
width: 100%;
align-items: center;
display: flex;
}
.main-navigation-left {
display: flex;
margin-right: auto;
flex: 1;
width: 100%;
align-items: center;
}
.main-navigation-center {
display: flex;
flex: auto;
width: 100%;
padding-right: $space-large;
padding-left: $space-large;
}
.main-navigation-right {
display: flex;
margin-left: auto;
flex: 1;
}
.avatar-menu-trigger {

View File

@ -17,6 +17,11 @@
"shouted": "Empfohlen",
"commented": "Kommentiert"
},
"search": {
"placeholder": "Suchen",
"hint": "Wonach suchst du?",
"failed": "Nichts gefunden"
},
"settings": {
"name": "Einstellungen",
"data": {

View File

@ -17,6 +17,11 @@
"shouted": "Shouted",
"commented": "Commented"
},
"search": {
"placeholder": "Search",
"hint": "What are you searching for?",
"failed": "Nothing found"
},
"settings": {
"name": "Settings",
"data": {

View File

@ -12,7 +12,10 @@
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue .",
"test": "jest",
"precommit": "yarn lint"
"precommit": "yarn lint",
"e2e:local": "cypress run --headed",
"e2e:ci": "npm-run-all --parallel --race start:ci 'cypress:ci --config baseUrl=http://localhost:3000'",
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand"
},
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true

View File

@ -92,7 +92,7 @@ export default {
query() {
return gql(`
query Post($first: Int, $offset: Int) {
Post(first: $first, offset: $offset, orderBy: createdAt_desc) {
Post(first: $first, offset: $offset) {
id
title
contentExcerpt

View File

@ -140,7 +140,15 @@ export default {
},
data() {
return {
ready: false
post: null,
ready: false,
title: 'loading'
}
},
watch: {
Post(post) {
this.post = post[0] || {}
this.title = this.post.title
}
},
async asyncData(context) {

View File

@ -19,6 +19,7 @@
:options="cities"
:label="$t('settings.data.labelCity')"
:placeholder="$t('settings.data.labelCity')"
:loading="loading"
@input.native="handleCityInput"
/>
<!-- eslint-enable vue/use-v-on-exact -->
@ -88,6 +89,7 @@ export default {
axiosSource: null,
cities: [],
sending: false,
loading: false,
formData: {}
}
},
@ -179,6 +181,7 @@ export default {
return
}
this.loading = true
this.axiosSource = CancelToken.source()
const place = encodeURIComponent(value)
@ -194,6 +197,9 @@ export default {
.then(res => {
this.cities = this.processCityResults(res)
})
.finally(() => {
this.loading = false
})
}
}
}

86
store/search.js Normal file
View File

@ -0,0 +1,86 @@
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 findPosts($filter: String!) {
findPosts(filter: $filter, limit: 10) {
id
slug
label: title
value: title,
shoutedCount
commentsCount
createdAt
author {
id
name
slug
}
}
}
`),
variables: {
filter: value.replace(/\s/g, '~ ') + '~'
}
})
.then(res => {
commit('SET_QUICK_RESULTS', res.data.findPosts || [])
})
.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', '')
}
}