mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
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:
parent
2242c001b4
commit
d74d2072ba
@ -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>
|
||||
@ -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 () => {
|
||||
@ -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,
|
||||
@ -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>
|
||||
@ -13,4 +13,3 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style></style>
|
||||
@ -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>
|
||||
@ -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"> </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>
|
||||
@ -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
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user