Separate concerns in components

- SearchResources is a feature component that handles communication with
the backend and fetches the search results
- Those results are passed to SearchableInput which displays the results
in a ds-select dropdown and handles interacting with them
- SearchInput renders the SearchHeading, SearchPost, and HcUser generic
components
- Would love to make the SearchableInput more generic and reusable, or
create a new reusable component for this, but I think this will happen
just when we migrate the Search.vue from the styleguide

Co-authored-by: Moriz Wahl <moriz.wahl@gmx.de>
This commit is contained in:
mattwr18 2019-12-18 19:50:01 +01:00
parent 2242c001b4
commit d74d2072ba
8 changed files with 199 additions and 231 deletions

View File

@ -1,50 +0,0 @@
<template>
<ds-flex-item class="search-option">
<ds-avatar class="avatar" name="option.name" image="option.avatar" />
<div>
<ds-text class="userinfo">
<b class="username">{{ option.name | truncate(70) }}</b>
</ds-text>
</div>
<ds-text align="left" size="small" color="soft">@{{ option.slug | truncate(70) }}</ds-text>
</ds-flex-item>
</template>
<script>
export default {
name: 'SearchUser',
props: {
option: { type: Object, required: true },
},
}
</script>
<style>
.avatar {
display: inline-block;
float: left;
margin-right: 4px;
height: 100%;
vertical-align: middle;
}
.userinfo {
display: flex;
align-items: center;
> .ds-text {
display: flex;
align-items: center;
margin-left: $space-xx-small;
}
}
.user {
white-space: nowrap;
position: relative;
display: flex;
align-items: center;
&:hover,
&.active {
z-index: 999;
}
}
.username {
color: #17b53f;
}
</style>

View File

@ -1,12 +1,12 @@
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
import SearchResources from './SearchResources.vue'
const localVue = global.localVue
localVue.filter('truncate', () => 'truncated string')
localVue.filter('dateTime', () => Date.now)
describe('SearchInput.vue', () => {
describe('SearchResources.vue', () => {
let mocks
let propsData
@ -19,7 +19,7 @@ describe('SearchInput.vue', () => {
mocks = {
$t: () => {},
}
return mount(SearchInput, { mocks, localVue, propsData })
return mount(SearchResources, { mocks, localVue, propsData })
}
it('renders', () => {
@ -27,7 +27,7 @@ describe('SearchInput.vue', () => {
})
it('has id "nav-search"', () => {
expect(Wrapper().contains('#nav-search')).toBe(true)
expect(Wrapper().contains('[data-test="search-resources"]')).toBe(true)
})
it('defaults to an empty value', () => {
@ -82,9 +82,9 @@ describe('SearchInput.vue', () => {
expect(wrapper.emitted().clear.length).toBe(1)
})
it('changes the unprocessedSearchInput as the value changes', () => {
it('changes the unprocessedSearchResources as the value changes', () => {
select.trigger('input')
expect(wrapper.vm.unprocessedSearchInput).toBe('abcd')
expect(wrapper.vm.unprocessedSearchResources).toBe('abcd')
})
it('searches for the term when enter is pressed', async () => {

View File

@ -1,6 +1,6 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import SearchInput from './SearchInput.vue'
import SearchResources from './SearchResources.vue'
import helpers from '~/storybook/helpers'
helpers.init()
@ -104,7 +104,7 @@ storiesOf('Search Input', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('test', () => ({
components: { SearchInput },
components: { SearchResources },
store: helpers.store,
data: () => ({
results: results,

View File

@ -0,0 +1,51 @@
<template>
<searchable-input
data-test="search-resources"
id="search-resources"
:loading="pending"
:options="searchResults"
@query="query"
@clearSearch="clear"
/>
</template>
<script>
import { findResourcesQuery } from '~/graphql/Search.js'
import SearchableInput from '~/components/generic/SearchableInput/SearchableInput.vue'
export default {
components: {
SearchableInput,
},
data() {
return {
pending: false,
searchResults: [],
}
},
methods: {
async query(value) {
this.pending = true
try {
const {
data: { findResources },
} = await this.$apollo.query({
query: findResourcesQuery,
variables: {
query: value,
},
})
this.searchResults = findResources
} catch (error) {
this.searchResults = []
} finally {
this.pending = false
}
},
clear() {
this.pending = false
this.searchResults = []
},
},
}
</script>

View File

@ -13,4 +13,3 @@ export default {
},
}
</script>
<style></style>

View File

@ -1,5 +1,5 @@
<template>
<ds-flex class="post-search-item">
<ds-flex class="search-post">
<ds-flex-item class="search-option-label">
<ds-text>{{ option.title | truncate(70) }}</ds-text>
</ds-flex-item>
@ -35,8 +35,27 @@ export default {
},
}
</script>
<style>
.post-search-item {
<style lang="scss">
.search-post {
width: 100%;
}
.search-option-label {
align-self: center;
padding-left: $space-x-small;
}
.search-option-meta {
align-self: center;
.ds-flex {
flex-direction: column;
}
}
.search-meta {
float: right;
padding-top: 2px;
white-space: nowrap;
word-wrap: none;
.base-icon {
vertical-align: sub;
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div
class="search"
class="searchable-input"
aria-label="search"
role="search"
:class="{
@ -12,149 +12,141 @@
<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"
v-model="searchValue"
:id="id"
label-prop="id"
:no-options-available="emptyText"
:icon-right="isActive ? 'close' : null"
:options="options"
:loading="loading"
:filter="item => item"
:options="searchResults"
:no-options-available="emptyText"
:auto-reset-search="!searchValue"
:placeholder="$t('search.placeholder')"
:loading="pending"
@keyup.enter.native="onEnter"
@click.capture.native="isOpen = true"
@focus.capture.native="onFocus"
@blur.capture.native="onBlur"
@input.native="handleInput"
@keyup.enter.native="onEnter"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@blur.capture.native="onBlur"
@input.exact="onSelect"
@input.native="handleInput"
@click.capture.native="isOpen = true"
>
<template slot="option" slot-scope="{ option }">
<ds-flex v-if="isFirstOfType(option)" class="search-option-heading">
<span v-if="isFirstOfType(option)" class="search-heading">
<search-heading :resource-type="option.__typename" />
</ds-flex>
<ds-flex
</span>
<span
v-if="option.__typename === 'User'"
:class="{ 'extra-space': isFirstOfType(option) }"
:class="{ 'extra-space': isFirstOfType(option), 'flex-span': true }"
>
<search-user :option="option" />
</ds-flex>
<ds-flex
<hc-user :user="option" :showPopover="false" />
</span>
<span
v-if="option.__typename === 'Post'"
:class="{ 'extra-space': isFirstOfType(option) }"
:class="{ 'extra-space': isFirstOfType(option), 'flex-span': true }"
>
<search-post :option="option" />
</ds-flex>
</span>
</template>
</ds-select>
</div>
</div>
</div>
</template>
<script>
import { isEmpty } from 'lodash'
import { findResourcesQuery } from '~/graphql/Search.js'
import SearchHeading from './SearchHeading.vue'
import SearchPost from './SearchPost.vue'
import SearchUser from './SearchUser.vue'
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
import SearchPost from '~/components/generic/SearchPost/SearchPost.vue'
import HcUser from '~/components/User/User.vue'
export default {
components: {
SearchHeading,
SearchPost,
SearchUser,
HcUser,
},
name: 'SearchInput',
props: {
id: {
type: String,
default: 'nav-search',
},
delay: {
type: Number,
default: 300,
},
id: { type: String },
loading: { type: Boolean, default: false },
options: { type: Array, default: () => [] },
},
data() {
return {
searchProcess: null,
value: '',
pending: false,
isOpen: false,
lastSearchTerm: '',
unprocessedSearchInput: '',
searchValue: '',
quickValue: '',
searchResults: [],
value: '',
unprocessedSearchInput: '',
searchProcess: null,
lastSearchTerm: '',
delay: 300,
}
},
computed: {
// #: Unused at the moment?
isActive() {
return !isEmpty(this.lastSearchTerm)
},
emptyText() {
return this.isActive && !this.pending ? this.$t('search.failed') : this.$t('search.hint')
},
isActive() {
return !isEmpty(this.lastSearchTerm)
},
},
methods: {
async query(value) {
if (
isEmpty(value) ||
value.length < 3 ||
this.quickValue.toLowerCase() === value.toLowerCase()
) {
this.clear()
return
}
this.quickValue = value
this.pending = true
try {
const {
data: { findResources },
} = await this.$apollo.query({
query: findResourcesQuery,
variables: {
query: value,
},
})
this.searchResults = findResources
} catch (error) {
this.searchResults = []
} finally {
this.pending = false
}
isFirstOfType(option) {
return (
this.options.findIndex(o => o === option) ===
this.options.findIndex(o => o.__typename === option.__typename)
)
},
handleInput(e) {
onFocus(event) {
clearTimeout(this.searchProcess)
this.value = e.target ? e.target.value.trim() : ''
this.isOpen = true
},
handleInput(event) {
clearTimeout(this.searchProcess)
this.value = event.target ? event.target.value.trim() : ''
this.isOpen = true
this.unprocessedSearchInput = this.value
if (isEmpty(this.value) || this.value.length < 3) {
return
}
this.searchProcess = setTimeout(() => {
this.lastSearchTerm = this.value
this.query(this.value)
this.$emit('query', this.value)
}, this.delay)
},
onSelect(item) {
/**
* TODO: on enter we should go to a dedicated search page!?
*/
onEnter(event) {
this.isOpen = false
this.$emit('select', item)
this.$nextTick(() => {
this.searchValue = this.lastSearchTerm
})
},
onFocus(e) {
clearTimeout(this.searchProcess)
this.isOpen = true
if (!this.pending) {
this.lastSearchTerm = this.unprocessedSearchInput
this.$emit('query', this.unprocessedSearchInput)
}
},
onBlur(e) {
onDelete(event) {
clearTimeout(this.searchProcess)
const value = event.target ? event.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(event)
}
},
clear() {
this.isOpen = false
this.unprocessedSearchInput = ''
this.lastSearchTerm = ''
this.searchValue = ''
this.$emit('clearSearch')
clearTimeout(this.searchProcess)
},
onBlur(event) {
this.searchValue = this.lastSearchTerm
// this.$nextTick(() => {
// this.searchValue = this.lastSearchTerm
@ -162,62 +154,59 @@ export default {
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 search page!?
*/
onEnter(e) {
// this.isOpen = false
clearTimeout(this.searchProcess)
if (!this.pending) {
// this.lastSearchTerm = this.unprocessedSearchInput
this.query(this.unprocessedSearchInput)
}
},
clear() {
this.pending = false
this.searchResults = []
this.quickValue = ''
clearTimeout(this.searchProcess)
onSelect(item) {
this.isOpen = false
this.unprocessedSearchInput = ''
this.lastSearchTerm = ''
this.searchValue = ''
this.goToResource(item)
this.$nextTick(() => {
this.searchValue = this.lastSearchTerm
})
},
isFirstOfType(option) {
return (
this.searchResults.findIndex(o => o === option) ===
this.searchResults.findIndex(o => o.__typename === option.__typename)
)
goToResource(item) {
this.$nextTick(() => {
switch (item.__typename) {
case 'Post':
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
}
})
},
},
}
</script>
<style lang="scss">
.search {
.searchable-input {
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-heading {
display: flex;
flex-wrap: wrap;
font-weight: bold;
cursor: default;
background-color: white;
margin: -8px;
padding: 8px;
}
.search-option-meta {
align-self: center;
.ds-flex {
flex-direction: column;
}
.extra-space {
margin-top: 8px;
padding-top: 4px;
}
.flex-span {
display: flex;
flex-wrap: wrap;
}
&,
.ds-select-dropdown {
@ -241,15 +230,6 @@ export default {
width: 36px;
cursor: pointer;
}
.search-meta {
float: right;
padding-top: 2px;
white-space: nowrap;
word-wrap: none;
.base-icon {
vertical-align: sub;
}
}
.ds-select {
z-index: $z-index-dropdown + 1;
}
@ -267,16 +247,5 @@ export default {
.control {
width: 100%;
}
.search-option-heading {
font-weight: bold;
cursor: default;
background-color: white;
margin: -8px;
padding: 8px;
}
.extra-space {
margin-top: 8px;
padding-top: 4px;
}
}
</style>

View File

@ -21,7 +21,7 @@
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
>
<div id="nav-search-box" v-if="isLoggedIn">
<search-input id="nav-search" :delay="300" @select="goToResource" />
<search-resources />
</div>
</ds-flex-item>
<ds-flex-item
@ -84,7 +84,7 @@
<script>
import { mapGetters } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import SearchInput from '~/components/SearchInput/SearchInput.vue'
import SearchResources from '~/components/features/SearchResources/SearchResources.vue'
import Modal from '~/components/Modal'
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
import seo from '~/mixins/seo'
@ -96,7 +96,7 @@ import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
export default {
components: {
LocaleSwitch,
SearchInput,
SearchResources,
Modal,
NotificationMenu,
AvatarMenu,
@ -126,26 +126,6 @@ export default {
},
},
methods: {
goToResource(item) {
this.$nextTick(() => {
switch (item.__typename) {
case 'Post':
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() {
this.toggleMobileMenu = !this.toggleMobileMenu
},