Merge branch '2144-Add_Search_Results_Page' of https://github.com/Human-Connection/Human-Connection into HC-2144-Add_Search_Results_Page

# Conflicts:
#	webapp/pages/profile/_id/_slug.vue
This commit is contained in:
Wolfgang Huß 2021-01-18 14:12:13 +01:00
commit 7dbc833e22
34 changed files with 25835 additions and 304 deletions

View File

@ -931,6 +931,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
const additionalUsers = await Promise.all(
[...Array(30).keys()].map(() => Factory.build('user')),
)
await Promise.all(
additionalUsers.map(async (user) => {
await jennyRostock.relateTo(user, 'following')
@ -938,6 +939,26 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}),
)
await Promise.all(
[...Array(30).keys()].map((index) => Factory.build('user', { name: `Jenny${index}` })),
)
await Promise.all(
[...Array(30).keys()].map(() =>
Factory.build(
'post',
{ content: `Jenny ${faker.lorem.sentence()}` },
{
categoryIds: ['cat1'],
author: jennyRostock,
image: Factory.build('image', {
url: faker.image.unsplash.objects(),
}),
},
),
),
)
await Promise.all(
[...Array(30).keys()].map(() =>
Factory.build(

View File

@ -86,7 +86,10 @@ export default shield(
'*': deny,
findPosts: allow,
findUsers: allow,
findResources: allow,
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
searchHashtags: allow,
embed: allow,
Category: allow,
Tag: allow,

View File

@ -3,90 +3,200 @@ import { queryString } from './searches/queryString'
// see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description
const cypherTemplate = (setup) => `
CALL db.index.fulltext.queryNodes('${setup.fulltextIndex}', $query)
YIELD node AS resource, score
${setup.match}
${setup.whereClause}
${setup.withClause}
RETURN
${setup.returnClause}
AS result
SKIP $skip
${setup.limit}
`
const simpleWhereClause =
'WHERE score >= 0.0 AND NOT (resource.deleted = true OR resource.disabled = true)'
const postWhereClause = `WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $userId})-[:MUTED]->(author)
)`
const searchPostsSetup = {
fulltextIndex: 'post_fulltext_search',
match: 'MATCH (resource:Post)<-[:WROTE]-(author:User)',
whereClause: postWhereClause,
withClause: `WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
[(resource)<-[:SHOUTED]-(user:User) | user] AS shouter`,
returnClause: `resource {
.*,
__typename: labels(resource)[0],
author: properties(author),
commentsCount: toString(size(comments)),
shoutedCount: toString(size(shouter))
}`,
limit: 'LIMIT $limit',
}
const searchUsersSetup = {
fulltextIndex: 'user_fulltext_search',
match: 'MATCH (resource:User)',
whereClause: simpleWhereClause,
withClause: '',
returnClause: 'resource {.*, __typename: labels(resource)[0]}',
limit: 'LIMIT $limit',
}
const searchHashtagsSetup = {
fulltextIndex: 'tag_fulltext_search',
match: 'MATCH (resource:Tag)',
whereClause: simpleWhereClause,
withClause: '',
returnClause: 'resource {.*, __typename: labels(resource)[0]}',
limit: 'LIMIT $limit',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
limit: '',
}
const countUsersSetup = {
...searchUsersSetup,
...countSetup,
}
const countPostsSetup = {
...searchPostsSetup,
...countSetup,
}
const countHashtagsSetup = {
...searchHashtagsSetup,
...countSetup,
}
const searchResultPromise = async (session, setup, params) => {
return session.readTransaction(async (transaction) => {
return transaction.run(cypherTemplate(setup), params)
})
}
const searchResultCallback = (result) => {
return result.records.map((r) => r.get('result'))
}
const countResultCallback = (result) => {
return result.records[0].get('result')
}
const getSearchResults = async (context, setup, params, resultCallback = searchResultCallback) => {
const session = context.driver.session()
try {
const results = await searchResultPromise(session, setup, params)
log(results)
return resultCallback(results)
} finally {
session.close()
}
}
const multiSearchMap = [
{ symbol: '!', setup: searchPostsSetup, resultName: 'posts' },
{ symbol: '@', setup: searchUsersSetup, resultName: 'users' },
{ symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' },
]
export default {
Query: {
findResources: async (_parent, args, context, _resolveInfo) => {
searchPosts: async (_parent, args, context, _resolveInfo) => {
const { query, postsOffset, firstPosts } = args
const { id: userId } = context.user
return {
postCount: getSearchResults(
context,
countPostsSetup,
{
query: queryString(query),
skip: 0,
userId,
},
countResultCallback,
),
posts: getSearchResults(context, searchPostsSetup, {
query: queryString(query),
skip: postsOffset,
limit: firstPosts,
userId,
}),
}
},
searchUsers: async (_parent, args, context, _resolveInfo) => {
const { query, usersOffset, firstUsers } = args
return {
userCount: getSearchResults(
context,
countUsersSetup,
{
query: queryString(query),
skip: 0,
},
countResultCallback,
),
users: getSearchResults(context, searchUsersSetup, {
query: queryString(query),
skip: usersOffset,
limit: firstUsers,
}),
}
},
searchHashtags: async (_parent, args, context, _resolveInfo) => {
const { query, hashtagsOffset, firstHashtags } = args
return {
hashtagCount: getSearchResults(
context,
countHashtagsSetup,
{
query: queryString(query),
skip: 0,
},
countResultCallback,
),
hashtags: getSearchResults(context, searchHashtagsSetup, {
query: queryString(query),
skip: hashtagsOffset,
limit: firstHashtags,
}),
}
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const { id: thisUserId } = context.user
const { id: userId } = context.user
const postCypher = `
CALL db.index.fulltext.queryNodes('post_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)<-[:WROTE]-(author:User)
WHERE score >= 0.0
AND NOT (
author.deleted = true OR author.disabled = true
OR resource.deleted = true OR resource.disabled = true
OR (:User {id: $thisUserId})-[:MUTED]->(author)
)
WITH resource, author,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] as comments,
[(resource)<-[:SHOUTED]-(user:User) | user] as shouter
RETURN resource {
.*,
__typename: labels(resource)[0],
author: properties(author),
commentsCount: toString(size(comments)),
shoutedCount: toString(size(shouter))
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')
const params = {
query: queryString(searchString),
skip: 0,
limit,
userId,
}
LIMIT $limit
`
const userCypher = `
CALL db.index.fulltext.queryNodes('user_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)
WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
RETURN resource {.*, __typename: labels(resource)[0]}
LIMIT $limit
`
const tagCypher = `
CALL db.index.fulltext.queryNodes('tag_fulltext_search', $query)
YIELD node as resource, score
MATCH (resource)
WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
RETURN resource {.*, __typename: labels(resource)[0]}
LIMIT $limit
`
if (searchType === '')
return [
...(await getSearchResults(context, searchPostsSetup, params)),
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchHashtagsSetup, params)),
]
const myQuery = queryString(query)
const session = context.driver.session()
const searchResultPromise = session.readTransaction(async (transaction) => {
const postTransactionResponse = transaction.run(postCypher, {
query: myQuery,
limit,
thisUserId,
})
const userTransactionResponse = transaction.run(userCypher, {
query: myQuery,
limit,
thisUserId,
})
const tagTransactionResponse = transaction.run(tagCypher, {
query: myQuery,
limit,
})
return Promise.all([
postTransactionResponse,
userTransactionResponse,
tagTransactionResponse,
])
})
try {
const [postResults, userResults, tagResults] = await searchResultPromise
log(postResults)
log(userResults)
log(tagResults)
return [...postResults.records, ...userResults.records, ...tagResults.records].map((r) =>
r.get('resource'),
)
} finally {
session.close()
}
params.limit = 15
const type = multiSearchMap.find((obj) => obj.symbol === searchType)
return getSearchResults(context, type.setup, params)
},
},
}

View File

@ -29,7 +29,7 @@ afterAll(async () => {
const searchQuery = gql`
query($query: String!) {
findResources(query: $query, limit: 5) {
searchResults(query: $query, limit: 5) {
__typename
... on Post {
id
@ -47,6 +47,21 @@ const searchQuery = gql`
}
}
`
const searchPostQuery = gql`
query($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
id
title
content
}
}
}
`
describe('resolvers/searches', () => {
let variables
@ -65,7 +80,7 @@ describe('resolvers/searches', () => {
variables = { query: 'John' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
id: 'a-user',
name: 'John Doe',
@ -95,7 +110,7 @@ describe('resolvers/searches', () => {
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'a-post',
@ -114,7 +129,7 @@ describe('resolvers/searches', () => {
variables = { query: 'BEITRAG' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'a-post',
@ -132,7 +147,7 @@ describe('resolvers/searches', () => {
it('returns empty search results', async () => {
await expect(
query({ query: searchQuery, variables: { query: 'Unfug' } }),
).resolves.toMatchObject({ data: { findResources: [] } })
).resolves.toMatchObject({ data: { searchResults: [] } })
})
})
@ -189,7 +204,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'Post',
id: 'a-post',
@ -216,7 +231,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'tee-ei' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'g-post',
@ -235,7 +250,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '„teeei“' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'g-post',
@ -256,7 +271,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '(a - b)²' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -277,7 +292,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '(a-b)²' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -298,7 +313,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '+ b² 2.' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'c-post',
@ -321,7 +336,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'der panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Post',
id: 'd-post',
@ -349,7 +364,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'Vorü Subs' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'Post',
id: 'd-post',
@ -395,7 +410,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: '-maria-' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.arrayContaining([
searchResults: expect.arrayContaining([
{
__typename: 'User',
id: 'c-user',
@ -416,6 +431,128 @@ und hinter tausend Stäben keine Welt.`,
})
})
describe('adding a user and a hashtag with a name that is content of a post', () => {
beforeAll(async () => {
await Promise.all([
Factory.build('user', {
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
}),
await Factory.build('tag', { id: 'Panther' }),
])
})
describe('query the word that contains the post, the hashtag and the name of the user', () => {
it('finds the user, the post and the hashtag', async () => {
variables = { query: 'panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('@query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the user', async () => {
variables = { query: '@panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('!query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the post', async () => {
variables = { query: '!panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Tag',
id: 'Panther',
},
]),
},
errors: undefined,
})
})
})
describe('#query the word that contains the post, the hashtag and the name of the user', () => {
it('only finds the hashtag', async () => {
variables = { query: '#panther' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
searchResults: expect.not.arrayContaining([
{
__typename: 'User',
id: 'f-user',
name: 'Peter Panther',
slug: 'peter-panther',
},
{
__typename: 'Post',
id: 'd-post',
title: 'Der Panther',
content: `Sein Blick ist vom Vorübergehn der Stäbe<br>
so müd geworden, daß er nichts mehr hält.<br>
Ihm ist, als ob es tausend Stäbe gäbe<br>
und hinter tausend Stäben keine Welt.`,
},
]),
},
errors: undefined,
})
})
})
})
describe('adding a post, written by a user who is muted by the authenticated user', () => {
beforeAll(async () => {
const mutedUser = await Factory.build('user', {
@ -440,7 +577,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'beitrag' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: expect.not.arrayContaining([
searchResults: expect.not.arrayContaining([
{
__typename: 'Post',
id: 'muted-post',
@ -465,7 +602,7 @@ und hinter tausend Stäben keine Welt.`,
variables = { query: 'myha' }
await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({
data: {
findResources: [
searchResults: [
{
__typename: 'Tag',
id: 'myHashtag',
@ -477,6 +614,30 @@ und hinter tausend Stäben keine Welt.`,
})
})
})
describe('searchPostQuery', () => {
describe('query with limit 1', () => {
it('has a count greater than 1', async () => {
variables = { query: 'beitrag', firstPosts: 1, postsOffset: 0 }
await expect(query({ query: searchPostQuery, variables })).resolves.toMatchObject({
data: {
searchPosts: {
postCount: 2,
posts: [
{
__typename: 'Post',
id: 'a-post',
title: 'Beitrag',
content: 'Ein erster Beitrag',
},
],
},
},
errors: undefined,
})
})
})
})
})
})
})

View File

@ -39,7 +39,8 @@ const matchBeginningOfWords = (str) => {
}
export function normalizeWhitespace(str) {
return str.replace(/\s+/g, ' ').trim()
// delete the first character if it is !, @ or #
return str.replace(/^([!@#])/, '').replace(/\s+/g, ' ').trim()
}
export function escapeSpecialCharacters(str) {

View File

@ -265,9 +265,9 @@ describe('UpdateUser', () => {
})
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States of America' }
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States of America' } },
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States' } },
errors: undefined,
})
})

View File

@ -1,5 +1,23 @@
union SearchResult = Post | User | Tag
type Query {
findResources(query: String!, limit: Int = 5): [SearchResult]!
type postSearchResults {
postCount: Int
posts: [Post]!
}
type userSearchResults {
userCount: Int
users: [User]!
}
type hashtagSearchResults {
hashtagCount: Int
hashtags: [Tag]!
}
type Query {
searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults!
searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
}

View File

@ -37,7 +37,7 @@ Then("I should see the following posts in the select dropdown:", table => {
});
Then("I should see the following users in the select dropdown:", table => {
cy.get(".ds-heading").should("contain", "Users");
cy.get(".search-heading").should("contain", "Users");
table.hashes().forEach(({ slug }) => {
cy.get(".ds-select-dropdown").should("contain", slug);
});
@ -85,6 +85,26 @@ Then(
}
);
Then("I should see the search results page", () => {
cy.location("pathname").should(
"eq",
"/search/search-results"
);
cy.location("search").should(
"eq",
"?search=PR"
);
});
Then("I should see the following posts on the search results page",
() => {
cy.get(".post-teaser").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",
() => {

View File

@ -8,7 +8,7 @@ Feature: Search
And we have the following posts in our database:
| id | title | content |
| p1 | 101 Essays that will change the way you think | 101 Essays, of course (PR)! |
| p2 | No searched for content | will be found in this post, I guarantee |
| p2 | No content | will be found in this post, I guarantee |
And we have the following user accounts:
| slug | name | id |
| search-for-me | Search for me | user-for-search |
@ -23,10 +23,10 @@ Feature: Search
| title |
| 101 Essays that will change the way you think |
Scenario: Press enter starts search
Scenario: Press enter opens search page
When I type "PR" and press Enter
Then I should have one item in the select dropdown
Then I should see the following posts in the select dropdown:
Then I should see the search results page
Then I should see the following posts on the search results page
| title |
| 101 Essays that will change the way you think |

View File

@ -16,13 +16,14 @@ h3,
h4,
h5,
h6,
p {
p,
li {
margin: 0;
}
ul,
ol {
ol,
ul {
list-style-type: none;
padding: 0;
margin: 0;
padding: 0;
}

View File

@ -251,7 +251,7 @@ $size-ribbon: 6px;
*/
$size-width-filter-sidebar: 85px;
$size-width-paginate: 100px;
$size-width-paginate: 200px;
$size-max-width-filter-menu: 1026px;
/**

View File

@ -0,0 +1,234 @@
import { config, mount } from '@vue/test-utils'
import Vuex from 'vuex'
import SearchResults from './SearchResults'
import helpers from '~/storybook/helpers'
helpers.init()
const localVue = global.localVue
localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('SearchResults', () => {
let mocks, getters, propsData, wrapper
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(SearchResults, { mocks, localVue, propsData, store })
}
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
getters = {
'auth/user': () => {
return { id: 'u343', name: 'Matt' }
},
'auth/isModerator': () => false,
}
propsData = {
pageSize: 12,
}
// Wolle jest.useFakeTimers()
wrapper = Wrapper()
})
afterEach(() => {
// Wolle jest.clearAllMocks()
})
describe('mount', () => {
it('renders tab-navigation component', () => {
expect(wrapper.find('.tab-navigation').exists()).toBe(true)
})
describe('searchResults', () => {
describe('contains no results', () => {
it('renders hc-empty component', () => {
expect(wrapper.find('.hc-empty').exists()).toBe(true)
})
})
// Wolle beforeEach(jest.useFakeTimers)
describe('result contains 25 posts, 8 users and 0 hashtags', () => {
beforeEach(async () => {
wrapper.setData({
posts: helpers.fakePost(12),
postCount: 25,
users: helpers.fakeUser(8),
userCount: 8,
activeTab: 'Post',
})
// Wolle await wrapper.vm.$nextTick()
})
it('shows a total of 33 results', () => {
expect(wrapper.find('.total-search-results').text()).toContain('33')
})
it('shows tab with 25 posts found', () => {
const postTab = wrapper.find('[data-test="Post-tab"]')
// console.log('postTab: ', postTab.html())
// console.log('wrapper: ', wrapper)
// Wolle jest.runAllTimers()
expect(postTab.text()).toContain('25')
})
it('shows tab with 8 users found', () => {
const userTab = wrapper.find('[data-test="User-tab"]')
expect(userTab.text()).toContain('8')
})
it('shows tab with 0 hashtags found', () => {
expect(wrapper.find('.Hashtag-tab').text()).toContain('0')
})
it('has post tab as active tab', () => {
expect(wrapper.find('.Post-tab').classes('--active')).toBe(true)
})
it('has user tab inactive', () => {
expect(wrapper.find('.User-tab').classes('--active')).toBe(false)
})
it('has hashtag tab disabled', () => {
expect(wrapper.find('.Hashtag-tab').classes('--disabled')).toBe(true)
})
it('displays 12 (pageSize) posts', () => {
expect(wrapper.findAll('.post-teaser')).toHaveLength(12)
})
it('has post tab inactive after clicking on user tab', async () => {
wrapper.find('.User-tab').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.Post-tab').classes('--active')).toBe(false)
})
it('has user tab active after clicking on user tab', async () => {
wrapper.find('.User-tab').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.User-tab').classes('--active')).toBe(true)
})
it('displays 8 users after clicking on user tab', async () => {
wrapper.find('.User-tab').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.findAll('.user-teaser')).toHaveLength(8)
})
it('shows the pagination buttons for posts', () => {
expect(wrapper.find('.pagination-buttons').exists()).toBe(true)
})
it('shows no pagination buttons for users', async () => {
wrapper.find('.User-tab').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-buttons').exists()).toBe(false)
})
it('displays page 1 of 3 for the 25 posts', () => {
expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'1 / 3',
)
})
it('displays the next page button for the 25 posts', () => {
expect(wrapper.find('.next-button').exists()).toBe(true)
})
it('deactivates previous page button for the 25 posts', () => {
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toEqual('disabled')
})
it('displays page 2 / 3 when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'2 / 3',
)
})
it('sets apollo searchPosts offset to 12 when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 12 })
})
it('displays the next page button when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.next-button').exists()).toBe(true)
})
it('displays the previous page button when next-button is clicked', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.previous-button').exists()).toBe(true)
})
it('displays page 3 / 3 when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'3 / 3',
)
})
it('sets apollo searchPosts offset to 24 when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 24 })
})
it('deactivates next page button when next-button is clicked twice', async () => {
const nextButton = wrapper.find('[data-test="next-button"]')
nextButton.trigger('click')
nextButton.trigger('click')
await wrapper.vm.$nextTick()
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('displays the previous page button when next-button is clicked twice', async () => {
wrapper.find('.next-button').trigger('click')
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.previous-button').exists()).toBe(true)
})
it('displays page 1 / 3 when previous-button is clicked after next-button', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
wrapper.find('.previous-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(wrapper.find('.pagination-pageCount').text().replace(/\s+/g, ' ')).toContain(
'1 / 3',
)
})
it('sets apollo searchPosts offset to 0 when previous-button is clicked after next-button', async () => {
wrapper.find('.next-button').trigger('click')
await wrapper.vm.$nextTick()
wrapper.find('.previous-button').trigger('click')
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 0 })
})
})
})
})
})

View File

@ -0,0 +1,148 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import SearchResults from './SearchResults'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import helpers from '~/storybook/helpers'
import faker from 'faker'
import { post } from '~/components/PostTeaser/PostTeaser.story.js'
import { user } from '~/components/UserTeaser/UserTeaser.story.js'
helpers.init()
const postMock = (fields) => {
return {
...post,
id: faker.random.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
deleted: false,
disabled: false,
typename: 'Post',
...fields,
}
}
const userMock = (fields) => {
return {
...user,
id: faker.random.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
deleted: false,
disabled: false,
typename: 'User',
...fields,
}
}
const posts = [
postMock(),
postMock({ author: user }),
postMock({ title: faker.lorem.sentence() }),
postMock({ contentExcerpt: faker.lorem.paragraph() }),
postMock({ author: user }),
postMock({ title: faker.lorem.sentence() }),
postMock({ author: user }),
]
const users = [
userMock(),
userMock({ slug: 'louie-rider', name: 'Louie Rider' }),
userMock({ slug: 'louicinda-johnson', name: 'Louicinda Jonhson' }),
userMock({ slug: 'sam-louie', name: 'Sam Louie' }),
userMock({ slug: 'loucette', name: 'Loucette Rider' }),
userMock({ slug: 'louis', name: 'Louis' }),
userMock({ slug: 'louanna', name: 'Louanna' }),
]
storiesOf('SearchResults', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('given users', () => ({
components: { TabNavigation, PostTeaser, UserTeaser },
store: helpers.store,
data: () => ({
searchResults: users,
activeTab: 'users',
}),
computed: {
posts() {
return []
},
users() {
return this.searchResults
},
activeResources() {
if (this.activeTab === 'posts') return this.posts
else if (this.activeTab === 'users') return this.users
},
tabOptions() {
return [
{ type: 'posts', title: `0 Posts` },
{ type: 'users', title: `${users.length} Users` },
]
},
},
template: `<div class="search-results">
<tab-navigation
:tabs="tabOptions"
:activeTab="activeTab"
@switch-tab="tab => activeTab = tab"
/>
<section>
<ul v-if="activeResources.length">
<li v-for="resource in activeResources" :key="resource.key" class="list">
<post-teaser v-if="activeTab === 'posts'" :post="resource" />
<base-card v-else-if="activeTab === 'users'" :wideContent="true">
<user-teaser :user="resource" />
</base-card>
</li>
</ul>
</section>
</div>`,
}))
.add('given posts', () => ({
components: { TabNavigation, PostTeaser, UserTeaser, SearchResults },
store: helpers.store,
data: () => ({
searchResults: posts,
activeTab: 'posts',
}),
computed: {
posts() {
return this.searchResults
},
users() {
return []
},
activeResources() {
if (this.activeTab === 'posts') return this.posts
else if (this.activeTab === 'users') return this.users
},
tabOptions() {
return [
{ type: 'posts', title: `${posts.length} Posts` },
{ type: 'users', title: `0 Users` },
]
},
},
template: `<div class="search-results">
<tab-navigation
:tabs="tabOptions"
:activeTab="activeTab"
@switch-tab="tab => activeTab = tab"
/>
<section>
<ul v-if="activeResources.length">
<li v-for="resource in activeResources" :key="resource.key" class="list">
<post-teaser v-if="activeTab === 'posts'" :post="resource" />
<base-card v-else-if="activeTab === 'users'" :wideContent="true">
<user-teaser :user="resource" />
</base-card>
</li>
</ul>
</section>
</div>`,
}))

View File

@ -0,0 +1,459 @@
<template>
<div id="search-results" class="search-results">
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<!-- search text -->
<ds-grid-item class="grid-total-search-results" :row-span="1" column-span="fullWidth">
<ds-space margin-bottom="xxx-small" margin-top="xxx-small" centered>
<ds-text class="total-search-results">
{{ $t('search.for') }}
<strong>{{ '"' + (search || '') + '"' }}</strong>
</ds-text>
</ds-space>
</ds-grid-item>
<!-- tabs -->
<new-tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<!-- search results -->
<template v-if="!(!activeResourceCount || searchCount === 0)">
<!-- pagination buttons -->
<ds-grid-item v-if="activeResourceCount > pageSize" :row-span="2" column-span="fullWidth">
<ds-space centered>
<pagination-buttons
:hasNext="hasNext"
:showPageCounter="true"
:hasPrevious="hasPrevious"
:activePage="activePage"
:activeResourceCount="activeResourceCount"
:key="'Top'"
:pageSize="pageSize"
@back="previousResults"
@next="nextResults"
/>
</ds-space>
</ds-grid-item>
<!-- posts -->
<template v-if="activeTab === 'Post'">
<masonry-grid-item
v-for="post in activeResources"
:key="post.id"
:imageAspectRatio="post.image && post.image.aspectRatio"
>
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
<!-- users -->
<template v-if="activeTab === 'User'">
<ds-grid-item v-for="user in activeResources" :key="user.id" :row-span="2">
<base-card :wideContent="true">
<user-teaser :user="user" />
</base-card>
</ds-grid-item>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<ds-grid-item v-for="hashtag in activeResources" :key="hashtag.id" :row-span="2">
<base-card :wideContent="true">
<hc-hashtag :id="hashtag.id" />
</base-card>
</ds-grid-item>
</template>
<!-- pagination buttons -->
<ds-grid-item v-if="activeResourceCount > pageSize" :row-span="2" column-span="fullWidth">
<ds-space centered>
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
:activePage="activePage"
:showPageCounter="true"
:activeResourceCount="activeResourceCount"
:key="'Bottom'"
:pageSize="pageSize"
:srollTo="'#search-results'"
@back="previousResults"
@next="nextResults"
/>
</ds-space>
</ds-grid-item>
</template>
<!-- Wolle <template v-else-if="$apollo.loading">
<ds-grid-item column-span="fullWidth">
<ds-space centered>
<ds-spinner size="base"></ds-spinner>
</ds-space>
</ds-grid-item>
</template> -->
<!-- Wolle double! -->
<!-- Wolle <template v-else>
<ds-grid-item column-span="fullWidth">
<hc-empty margin="xx-large" icon="file" />
</ds-grid-item>
</template> -->
<!-- no results -->
<ds-grid-item v-else :row-span="7" column-span="fullWidth">
<ds-space centered>
<hc-empty icon="tasks" :message="$t('search.no-results', { search })" />
</ds-space>
</ds-grid-item>
</masonry-grid>
</ds-flex-item>
<!-- Wolle old -->
<!-- <tab-navigation :tabs="tabOptions" :activeTab="activeTab" @switch-tab="switchTab" />
<section
:class="['results', activeTab === 'User' && '--user', !activeResourceCount > 0 && '--empty']"
>
<hc-empty
v-if="!activeResourceCount || searchCount === 0"
icon="tasks"
:message="$t('search.no-results', { search })"
/>
<template>
<pagination-buttons
v-if="activeResourceCount > pageSize"
:hasNext="hasNext"
:showPageCounter="true"
:hasPrevious="hasPrevious"
:activePage="activePage"
:activeResourceCount="activeResourceCount"
:key="'Top'"
:pageSize="pageSize"
@back="previousResults"
@next="nextResults"
/>
<masonry-grid v-if="activeTab === 'Post'">
<masonry-grid-item v-for="resource in activeResources" :key="resource.key">
<post-teaser :post="resource" />
</masonry-grid-item>
</masonry-grid>
<ul v-if="activeTab === 'User'" class="user-list">
<li v-for="resource in activeResources" :key="resource.key" class="item">
<base-card :wideContent="true">
<user-teaser :user="resource" />
</base-card>
</li>
</ul>
<ul v-if="activeTab === 'Hashtag'" class="hashtag-list">
<li v-for="resource in activeResources" :key="resource.key" class="item">
<base-card :wideContent="true">
<hc-hashtag :id="resource.id" />
</base-card>
</li>
</ul>
<pagination-buttons
v-if="activeResourceCount > pageSize"
:hasNext="hasNext"
:hasPrevious="hasPrevious"
:activePage="activePage"
:showPageCounter="true"
:activeResourceCount="activeResourceCount"
:key="'Bottom'"
:pageSize="pageSize"
:srollTo="'#search-results'"
@back="previousResults"
@next="nextResults"
/>
</template>
</section> -->
</div>
</template>
<script>
import postListActions from '~/mixins/postListActions'
import { searchPosts, searchUsers, searchHashtags } from '~/graphql/Search.js'
import HcEmpty from '~/components/Empty/Empty'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
// Wolle import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import NewTabNavigation from '~/components/_new/generic/TabNavigation/NewTabNavigation'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import HcHashtag from '~/components/Hashtag/Hashtag'
export default {
components: {
// Wolle TabNavigation,
NewTabNavigation,
HcEmpty,
MasonryGrid,
MasonryGridItem,
PostTeaser,
PaginationButtons,
UserTeaser,
HcHashtag,
},
mixins: [postListActions],
props: {
search: {
type: String,
},
pageSize: {
type: Number,
default: 12,
},
},
data() {
return {
posts: [],
users: [],
hashtags: [],
postCount: 0,
userCount: 0,
hashtagCount: 0,
postPage: 0,
userPage: 0,
hashtagPage: 0,
activeTab: null,
firstPosts: this.pageSize,
firstUsers: this.pageSize,
firstHashtags: this.pageSize,
postsOffset: 0,
usersOffset: 0,
hashtagsOffset: 0,
}
},
computed: {
activeResources() {
if (this.activeTab === 'Post') return this.posts
if (this.activeTab === 'User') return this.users
if (this.activeTab === 'Hashtag') return this.hashtags
return []
},
activeResourceCount() {
if (this.activeTab === 'Post') return this.postCount
if (this.activeTab === 'User') return this.userCount
if (this.activeTab === 'Hashtag') return this.hashtagCount
return 0
},
activePage() {
if (this.activeTab === 'Post') return this.postPage
if (this.activeTab === 'User') return this.userPage
if (this.activeTab === 'Hashtag') return this.hashtagPage
return 0
},
tabOptions() {
return [
{
type: 'Post',
title: this.$t('search.heading.Post', {}, this.postCount),
count: this.postCount,
disabled: this.postCount === 0,
},
{
type: 'User',
title: this.$t('search.heading.User', {}, this.userCount),
count: this.userCount,
disabled: this.userCount === 0,
},
{
type: 'Hashtag',
title: this.$t('search.heading.Tag', {}, this.hashtagCount),
count: this.hashtagCount,
disabled: this.hashtagCount === 0,
},
]
},
hasPrevious() {
if (this.activeTab === 'Post') return this.postsOffset > 0
if (this.activeTab === 'User') return this.usersOffset > 0
if (this.activeTab === 'Hashtag') return this.hashtagsOffset > 0
return false
},
hasNext() {
if (this.activeTab === 'Post') return (this.postPage + 1) * this.pageSize < this.postCount
if (this.activeTab === 'User') return (this.userPage + 1) * this.pageSize < this.userCount
if (this.activeTab === 'Hashtag')
return (this.hashtagPage + 1) * this.pageSize < this.hashtagCount
return false
},
searchCount() {
return this.postCount + this.userCount + this.hashtagCount
},
},
methods: {
clearPage() {
this.postPage = 0
this.userPage = 0
this.hashtagPage = 0
},
switchTab(tab) {
if (this.activeTab !== tab) {
this.activeTab = tab
}
},
previousResults() {
switch (this.activeTab) {
case 'Post':
this.postPage--
this.postsOffset = this.postPage * this.pageSize
break
case 'User':
this.userPage--
this.usersOffset = this.userPage * this.pageSize
break
case 'Hashtag':
this.hashtagPage--
this.hashtagsOffset = this.hashtagPage * this.pageSize
break
}
},
nextResults() {
// scroll to top??
switch (this.activeTab) {
case 'Post':
this.postPage++
this.postsOffset += this.pageSize
break
case 'User':
this.userPage++
this.usersOffset += this.pageSize
break
case 'Hashtag':
this.hashtagPage++
this.hashtagsOffset += this.pageSize
break
}
},
refetchPostList() {
this.$apollo.queries.searchPosts.refetch()
},
},
apollo: {
searchHashtags: {
query() {
return searchHashtags
},
variables() {
const { firstHashtags, hashtagsOffset, search } = this
return {
query: search,
firstHashtags,
hashtagsOffset,
}
},
skip() {
return !this.search
},
update({ searchHashtags }) {
this.hashtags = searchHashtags.hashtags
this.hashtagCount = searchHashtags.hashtagCount
if (this.postCount === 0 && this.userCount === 0 && this.hashtagCount > 0)
this.activeTab = 'Hashtag'
},
fetchPolicy: 'cache-and-network',
},
searchUsers: {
query() {
return searchUsers
},
variables() {
const { firstUsers, usersOffset, search } = this
return {
query: search,
firstUsers,
usersOffset,
}
},
skip() {
return !this.search
},
update({ searchUsers }) {
this.users = searchUsers.users
this.userCount = searchUsers.userCount
if (this.postCount === 0 && this.userCount > 0) this.activeTab = 'User'
},
fetchPolicy: 'cache-and-network',
},
searchPosts: {
query() {
return searchPosts
},
variables() {
const { firstPosts, postsOffset, search } = this
return {
query: search,
firstPosts,
postsOffset,
}
},
skip() {
return !this.search
},
update({ searchPosts }) {
this.posts = searchPosts.posts
this.postCount = searchPosts.postCount
if (this.postCount > 0) this.activeTab = 'Post'
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
<style lang="scss">
// Wolle check if still needed 👇🏼
.search-results {
> .results {
/* display: inline-block;*/
padding: $space-small;
background-color: $color-neutral-80;
border-radius: 0 $border-radius-base $border-radius-base $border-radius-base;
&.--user {
width: 100%;
max-width: 600px;
}
&.--empty {
width: 100%;
max-width: 600px;
background-color: transparent;
border: $border-size-base solid $color-neutral-80;
}
}
.user-list > .item {
transition: opacity 0.1s;
&:not(:last-child) {
margin-bottom: $space-small;
}
&:hover {
opacity: 0.8;
}
}
}
// Wolle new
.grid-total-search-results {
padding: 0;
margin: 0;
// margin-bottom: $space-x-small;
// > .base-button {
// display: block;
// width: 100%;
// margin-bottom: $space-x-small;
// }
}
</style>

View File

@ -5,62 +5,81 @@ import PaginationButtons from './PaginationButtons'
const localVue = global.localVue
describe('PaginationButtons.vue', () => {
let propsData = {}
const propsData = {
showPageCounter: true,
activePage: 1,
activeResourceCount: 57,
}
let wrapper
let nextButton
let backButton
const mocks = {
$t: jest.fn(),
}
const Wrapper = () => {
return mount(PaginationButtons, { propsData, localVue })
return mount(PaginationButtons, { mocks, propsData, localVue })
}
describe('mount', () => {
describe('next button', () => {
beforeEach(() => {
propsData.hasNext = true
wrapper = Wrapper()
nextButton = wrapper.find('[data-test="next-button"]')
})
beforeEach(() => {
wrapper = Wrapper()
})
describe('next button', () => {
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
nextButton = wrapper.find('[data-test="next-button"]')
const nextButton = wrapper.find('[data-test="next-button"]')
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('is enabled if hasNext is true', () => {
it('is enabled if hasNext is true', async () => {
wrapper.setProps({ hasNext: true })
await wrapper.vm.$nextTick()
const nextButton = wrapper.find('[data-test="next-button"]')
expect(nextButton.attributes().disabled).toBeUndefined()
})
it('emits next when clicked', async () => {
await nextButton.trigger('click')
wrapper.setProps({ hasNext: true })
await wrapper.vm.$nextTick()
wrapper.find('[data-test="next-button"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted().next).toHaveLength(1)
})
})
describe('back button', () => {
beforeEach(() => {
propsData.hasPrevious = true
wrapper = Wrapper()
backButton = wrapper.find('[data-test="previous-button"]')
})
describe('previous button', () => {
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
backButton = wrapper.find('[data-test="previous-button"]')
expect(backButton.attributes().disabled).toEqual('disabled')
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toEqual('disabled')
})
it('is enabled if hasPrevious is true', () => {
expect(backButton.attributes().disabled).toBeUndefined()
it('is enabled if hasPrevious is true', async () => {
wrapper.setProps({ hasPrevious: true })
await wrapper.vm.$nextTick()
const previousButton = wrapper.find('[data-test="previous-button"]')
expect(previousButton.attributes().disabled).toBeUndefined()
})
it('emits back when clicked', async () => {
await backButton.trigger('click')
wrapper.setProps({ hasPrevious: true })
await wrapper.vm.$nextTick()
wrapper.find('[data-test="previous-button"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted().back).toHaveLength(1)
})
})
describe('page counter', () => {
it('displays the page counter when showPageCount is true', () => {
const paginationPageCount = wrapper.find('[data-test="pagination-pageCount"]')
expect(paginationPageCount.text().replace(/\s+/g, ' ')).toEqual('2 / 3')
})
it('does not display the page counter when showPageCount is false', async () => {
wrapper.setProps({ showPageCounter: false })
await wrapper.vm.$nextTick()
const paginationPageCount = wrapper.find('[data-test="pagination-pageCount"]')
expect(paginationPageCount.exists()).toEqual(false)
})
})
})
})

View File

@ -1,18 +1,26 @@
<template>
<div class="pagination-buttons">
<base-button
@click="$emit('back')"
class="previous-button"
:disabled="!hasPrevious"
icon="arrow-left"
circle
data-test="previous-button"
@click="$emit('back')"
/>
<span v-if="showPageCounter" class="pagination-pageCount" data-test="pagination-pageCount">
{{ $t('search.page') }} {{ activePage + 1 }} /
{{ Math.floor((activeResourceCount - 1) / pageSize) + 1 }}
</span>
<base-button
@click="$emit('next')"
class="next-button"
:disabled="!hasNext"
icon="arrow-right"
circle
data-test="next-button"
@click="$emit('next')"
/>
</div>
</template>
@ -20,12 +28,31 @@
<script>
export default {
props: {
pageSize: {
type: Number,
default: 24,
},
hasNext: {
type: Boolean,
default: false,
},
hasPrevious: {
type: Boolean,
},
activePage: {
type: Number,
default: 0,
},
totalResultCount: {
type: Number,
default: 0,
},
activeResourceCount: {
type: Number,
default: 0,
},
showPageCounter: {
type: Boolean,
default: false,
},
},
@ -39,4 +66,10 @@ export default {
width: $size-width-paginate;
margin: $space-x-small auto;
}
.pagination-pageCount {
justify-content: space-around;
margin: 8px auto;
}
</style>

View File

@ -0,0 +1,173 @@
<template>
<!-- Wolle siehe TabNavigator <ul class="tab-navigation">
<li
v-for="tab in tabs"
:key="tab.type"
:class="[
activeTab === tab.type && '--active',
tab.disabled && '--disabled',
'tab',
tab.type + '-tab',
]"
:style="tabWidth"
role="button"
:data-test="tab.type + '-tab'"
@click="$emit('switch-tab', tab.type)"
>
<ds-space margin="small">
{{ tab.count }}
</ds-space>
</li>
</ul> -->
<!-- Wolle <ds-grid-item class="profile-top-navigation" :row-span="3" column-span="fullWidth"> -->
<ds-grid-item class="profile-top-navigation" :row-span="tabs.length" column-span="fullWidth">
<base-card class="ds-tab-nav">
<ul class="Tabs">
<li
v-for="tab in tabs"
:key="tab.type"
:class="[
'Tabs__tab',
'pointer',
activeTab === tab.type && '--active',
tab.disabled && '--disabled',
]"
>
<a @click="switchTab(tab)">
<ds-space margin="small">
<!-- Wolle translate -->
<!-- <client-only placeholder="Loading..."> -->
<client-only :placeholder="$t('client-only.loading')">
<ds-number :label="tab.title">
<hc-count-to slot="count" :end-val="tab.count" />
</ds-number>
</client-only>
</ds-space>
</a>
</li>
</ul>
</base-card>
</ds-grid-item>
</template>
<script>
import HcCountTo from '~/components/CountTo.vue'
// Wolle import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
export default {
components: {
HcCountTo,
// Wolle MasonryGridItem,
},
props: {
tabs: {
type: Array,
required: true,
},
activeTab: {
type: String,
},
},
// Wolle computed: {
// tabWidth() {
// return 'width: ' + String(100.0 / this.tabs.length) + '%'
// },
// },
methods: {
switchTab(tab) {
if (!tab.disabled) {
this.$emit('switch-tab', tab.type)
}
},
},
}
</script>
<style lang="scss">
// Wolle clean up
// .tab-navigation {
// display: flex;
// margin-top: $space-small;
// > .tab {
// font-weight: $font-weight-bold;
// padding: $space-x-small $space-small;
// margin-right: $space-xx-small;
// border-radius: $border-radius-base $border-radius-base 0 0;
// background: $color-neutral-100;
// cursor: pointer;
// &.--active {
// background: $color-neutral-80;
// border: none;
// }
// &.--disabled {
// background: $background-color-disabled;
// border: $border-size-base solid $color-neutral-80;
// border-bottom: none;
// pointer-events: none;
// cursor: default;
// }
// &:hover:not(.--active) {
// background: $color-neutral-85;
// }
// }
// }
.pointer {
cursor: pointer;
}
.Tabs {
position: relative;
background-color: #fff;
height: 100%;
display: flex;
margin: 0;
padding: 0;
list-style: none;
&__tab {
text-align: center;
height: 100%;
flex-grow: 1;
&:hover {
border-bottom: 2px solid #c9c6ce;
}
&.--active {
border-bottom: 2px solid #17b53f;
}
&.--disabled {
opacity: $opacity-disabled;
&:hover {
border-bottom: none;
}
}
}
}
.profile-top-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav.base-card {
padding: 0;
.ds-tab-nav-item {
// Wolle is this doubled?
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
}
}
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<ul class="tab-navigation">
<!-- Wolle Fix warning for 'switchTab', see below. -->
<li
v-for="tab in tabs"
:key="tab.type"
:class="[
activeTab === tab.type && '--active',
tab.disabled && '--disabled',
'tab',
tab.type + '-tab',
]"
:style="tabWidth"
role="button"
:data-test="tab.type + '-tab'"
@click="$emit('switch-tab', tab.type)"
>
<!-- Wolle <ds-space :class="lowerCase('Post-tab')" margin="small"> -->
<ds-space margin="small">
<!-- <ds-number :label="tab.title"> -->
<!-- Wolle <hc-count-to slot="count" :end-val="tab.count" /> -->
{{ tab.count }}
<!-- </ds-number> -->
<!-- <client-only placeholder="Loading...">
<ds-number :label="tab.title">
Wolle <hc-count-to slot="count" :end-val="tab.count" />
{{ tab.count }}
</ds-number>
</client-only> -->
</ds-space>
</li>
</ul>
</template>
<script>
// Wolle import HcCountTo from '~/components/CountTo.vue'
export default {
// Wolle components: {
// HcCountTo,
// },
props: {
tabs: {
type: Array,
required: true,
},
activeTab: {
type: String,
},
},
computed: {
tabWidth() {
return 'width: ' + String(100.0 / this.tabs.length) + '%'
},
},
}
</script>
<style lang="scss">
.tab-navigation {
display: flex;
margin-top: $space-small;
> .tab {
font-weight: $font-weight-bold;
padding: $space-x-small $space-small;
margin-right: $space-xx-small;
border-radius: $border-radius-base $border-radius-base 0 0;
background: $color-neutral-100;
cursor: pointer;
&.--active {
background: $color-neutral-80;
border: none;
}
&.--disabled {
background: $background-color-disabled;
border: $border-size-base solid $color-neutral-80;
border-bottom: none;
pointer-events: none;
cursor: default;
}
&:hover:not(.--active) {
background: $color-neutral-85;
}
}
}
</style>

View File

@ -9,7 +9,7 @@
</template>
<script>
import { findResourcesQuery } from '~/graphql/Search.js'
import { searchQuery } from '~/graphql/Search.js'
import SearchableInput from '~/components/generic/SearchableInput/SearchableInput.vue'
export default {
@ -28,14 +28,14 @@ export default {
this.pending = true
try {
const {
data: { findResources },
data: { searchResults },
} = await this.$apollo.query({
query: findResourcesQuery,
query: searchQuery,
variables: {
query: value,
},
})
this.searchResults = findResources
this.searchResults = searchResults
} catch (error) {
this.searchResults = []
} finally {

View File

@ -1,6 +1,6 @@
<template>
<ds-heading soft size="h5" class="search-heading">
{{ $t(`search.heading.${resourceType}`) }}
{{ $t(`search.heading.${resourceType}`, {}, 2) }}
</ds-heading>
</template>
<script>

View File

@ -60,13 +60,6 @@ describe('SearchableInput.vue', () => {
expect(select.element.value).toBe('abcd')
})
it('searches for the term when enter is pressed', async () => {
select.element.value = 'ab'
select.trigger('input')
select.trigger('keyup.enter')
await expect(wrapper.emitted().query[0]).toEqual(['ab'])
})
it('calls onDelete when the delete key is pressed', () => {
const spy = jest.spyOn(wrapper.vm, 'onDelete')
select.trigger('input')
@ -117,5 +110,15 @@ describe('SearchableInput.vue', () => {
expect(mocks.$router.push).toHaveBeenCalledWith('?hashtag=Hashtag')
})
})
it('opens the search result page when enter is pressed', async () => {
select.element.value = 'ab'
select.trigger('input')
select.trigger('keyup.enter')
expect(mocks.$router.push).toHaveBeenCalledWith({
path: '/search/search-results',
query: { search: 'ab' },
})
})
})
})

View File

@ -107,15 +107,12 @@ export default {
this.$emit('query', this.value)
}, this.delay)
},
/**
* TODO: on enter we should go to a dedicated search page!?
*/
onEnter(event) {
clearTimeout(this.searchProcess)
if (!this.loading) {
this.previousSearchTerm = this.unprocessedSearchInput
this.$emit('query', this.unprocessedSearchInput)
}
this.$router.push({
path: '/search/search-results',
query: { search: this.unprocessedSearchInput },
})
this.$emit('clearSearch')
},
onDelete(event) {
clearTimeout(this.searchProcess)

View File

@ -1,12 +1,12 @@
import gql from 'graphql-tag'
import { userFragment, postFragment } from './Fragments'
import { userFragment, postFragment, tagsCategoriesAndPinnedFragment } from './Fragments'
export const findResourcesQuery = gql`
export const searchQuery = gql`
${userFragment}
${postFragment}
query($query: String!) {
findResources(query: $query, limit: 5) {
searchResults(query: $query, limit: 5) {
__typename
... on Post {
...post
@ -25,3 +25,51 @@ export const findResourcesQuery = gql`
}
}
`
export const searchPosts = gql`
${userFragment}
${postFragment}
${tagsCategoriesAndPinnedFragment}
query($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
...post
...tagsCategoriesAndPinned
commentsCount
shoutedCount
author {
...user
}
}
}
}
`
export const searchUsers = gql`
${userFragment}
query($query: String!, $firstUsers: Int, $usersOffset: Int) {
searchUsers(query: $query, firstUsers: $firstUsers, usersOffset: $usersOffset) {
userCount
users {
__typename
...user
}
}
}
`
export const searchHashtags = gql`
query($query: String!, $firstHashtags: Int, $hashtagsOffset: Int) {
searchHashtags(query: $query, firstHashtags: $firstHashtags, hashtagsOffset: $hashtagsOffset) {
hashtagCount
hashtags {
__typename
id
}
}
}
`

View File

@ -75,6 +75,9 @@
}
}
},
"client-only": {
"loading": "Lade …"
},
"code-of-conduct": {
"subheader": "für das Soziale Netzwerk von {ORGANIZATION_NAME}"
},
@ -546,13 +549,18 @@
},
"search": {
"failed": "Nichts gefunden",
"for": "Suche nach ",
"heading": {
"Post": "Beiträge",
"Tag": "Hashtags",
"User": "Benutzer"
"Post": "Beitrag ::: Beiträge",
"Tag": "Hashtag ::: Hashtags",
"User": "Benutzer ::: Benutzer"
},
"hint": "Wonach suchst Du?",
"placeholder": "Suchen"
"no-results": "Keine Ergebnisse für \"{search}\" gefunden. Versuch' es mit einem anderen Begriff!",
"page": "Seite",
"placeholder": "Suchen",
"results": "Ergebnis gefunden ::: Ergebnisse gefunden",
"title": "Suchergebnisse"
},
"settings": {
"blocked-users": {

View File

@ -75,6 +75,9 @@
}
}
},
"client-only": {
"loading": "Loading …"
},
"code-of-conduct": {
"subheader": "for the social network of {ORGANIZATION_NAME}"
},
@ -546,13 +549,18 @@
},
"search": {
"failed": "Nothing found",
"for": "Searching for ",
"heading": {
"Post": "Posts",
"Tag": "Hashtags",
"User": "Users"
"Post": "Post ::: Posts",
"Tag": "Hashtag ::: Hashtags",
"User": "User ::: Users"
},
"hint": "What are you searching for?",
"placeholder": "Search"
"no-results": "No results found for \"{search}\". Try a different search term!",
"page": "Page",
"placeholder": "Search",
"results": "result found ::: results found",
"title": "Search Results"
},
"settings": {
"blocked-users": {

View File

@ -0,0 +1,39 @@
import PostMutations from '~/graphql/PostMutations'
export default {
methods: {
removePostFromList(deletedPost, posts) {
return posts.filter((post) => {
return post.id !== deletedPost.id
})
},
pinPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: {
id: post.id
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: {
id: post.id
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
},
}

23910
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -28,9 +28,9 @@
>
<post-teaser
:post="post"
@removePostFromList="deletePost"
@pinPost="pinPost"
@unpinPost="unpinPost"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
@ -64,6 +64,7 @@
</template>
<script>
import postListActions from '~/mixins/postListActions'
// import DonationInfo from '~/components/DonationInfo/DonationInfo.vue'
import HashtagsFilter from '~/components/HashtagsFilter/HashtagsFilter.vue'
import HcEmpty from '~/components/Empty/Empty'
@ -72,7 +73,6 @@ import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { mapGetters, mapMutations } from 'vuex'
import { filterPosts } from '~/graphql/PostQuery.js'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
import links from '~/constants/links.js'
@ -85,6 +85,7 @@ export default {
MasonryGrid,
MasonryGridItem,
},
mixins: [postListActions],
data() {
const { hashtag = null } = this.$route.query
return {
@ -162,41 +163,14 @@ export default {
updateQuery: UpdateQuery(this, { $state, pageKey: 'Post' }),
})
},
deletePost(deletedPost) {
this.posts = this.posts.filter((post) => {
return post.id !== deletedPost.id
})
},
resetPostList() {
this.offset = 0
this.posts = []
this.hasMore = true
},
pinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
refetchPostList() {
this.resetPostList()
this.$apollo.queries.Post.refetch()
},
},
apollo: {

View File

@ -90,7 +90,7 @@ describe('PostIndex', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
})
it('refreshes the notificaitons', () => {
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
})

View File

@ -27,7 +27,9 @@
<post-teaser
:post="relatedPost"
:width="{ base: '100%', lg: 1 }"
@removePostFromList="removePostFromList"
@removePostFromList="post.relatedContributions = removePostFromList(relatedPost, post.relatedContributions)"
@pinPost="pinPost(relatedPost, refetchPostList)"
@unpinPost="unpinPost(relatedPost, refetchPostList)"
/>
</masonry-grid-item>
</masonry-grid>
@ -37,6 +39,7 @@
</template>
<script>
import postListActions from '~/mixins/postListActions'
import HcEmpty from '~/components/Empty/Empty'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcCategory from '~/components/Category'
@ -47,10 +50,6 @@ import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { sortTagsAlphabetically } from '~/components/utils/PostHelpers'
export default {
transition: {
name: 'slide-up',
mode: 'out-in',
},
components: {
PostTeaser,
HcCategory,
@ -59,6 +58,11 @@ export default {
MasonryGrid,
MasonryGridItem,
},
transition: {
name: 'slide-up',
mode: 'out-in',
},
mixins: [postListActions],
computed: {
post() {
return this.Post ? this.Post[0] || {} : {}
@ -68,10 +72,8 @@ export default {
},
},
methods: {
removePostFromList(deletedPost) {
this.post.relatedContributions = this.post.relatedContributions.filter((contribution) => {
return contribution.id !== deletedPost.id
})
refetchPostList() {
this.$apollo.queries.Post.refetch()
},
},
apollo: {

View File

@ -108,7 +108,9 @@
<ds-flex-item :width="{ base: '100%', sm: 3, md: 5, lg: 3 }">
<masonry-grid>
<ds-grid-item class="profile-top-navigation" :row-span="3" column-span="fullWidth">
<!-- TapNavigation -->
<new-tab-navigation :tabs="tabOptions" :activeTab="tabActive" @switch-tab="handleTab" />
<!-- Wolle <ds-grid-item class="profile-top-navigation" :row-span="3" column-span="fullWidth">
<base-card class="ds-tab-nav">
<ul class="Tabs">
<li class="Tabs__tab pointer" :class="{ active: tabActive === 'post' }">
@ -150,8 +152,9 @@
</li>
</ul>
</base-card>
</ds-grid-item>
</ds-grid-item> -->
<!-- feed -->
<ds-grid-item :row-span="2" column-span="fullWidth">
<ds-space centered>
<nuxt-link :to="{ name: 'post-create' }">
@ -181,9 +184,9 @@
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
@removePostFromList="removePostFromList"
@pinPost="pinPost"
@unpinPost="unpinPost"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
/>
</masonry-grid-item>
</template>
@ -210,6 +213,7 @@
<script>
import uniqBy from 'lodash/uniqBy'
import postListActions from '~/mixins/postListActions'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue'
@ -221,6 +225,7 @@ import HcUpload from '~/components/Upload'
import UserAvatar from '~/components/_new/generic/UserAvatar/UserAvatar'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import NewTabNavigation from '~/components/_new/generic/TabNavigation/NewTabNavigation'
import { profilePagePosts } from '~/graphql/PostQuery'
import UserQuery from '~/graphql/User'
import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
@ -251,7 +256,9 @@ export default {
MasonryGrid,
MasonryGridItem,
FollowList,
NewTabNavigation,
},
mixins: [postListActions],
transition: {
name: 'slide-up',
mode: 'out-in',
@ -286,17 +293,36 @@ export default {
const { slug } = this.user || {}
return slug && `@${slug}`
},
tabOptions() {
return [
{
type: 'post',
title: this.$t('common.post', null, this.user.contributionsCount),
count: this.user.contributionsCount,
disabled: this.user.contributionsCount === 0,
},
{
type: 'comment',
title: this.$t('profile.commented'),
count: this.user.commentedCount,
disabled: this.user.commentedCount === 0,
},
{
type: 'shout',
title: this.$t('profile.shouted'),
count: this.user.shoutedCount,
disabled: this.user.shoutedCount === 0,
},
]
},
},
methods: {
removePostFromList(deletedPost) {
this.posts = this.posts.filter((post) => {
return post.id !== deletedPost.id
})
},
handleTab(tab) {
this.tabActive = tab
this.filter = tabToFilterMapping({ tab, id: this.$route.params.id })
this.resetPostList()
if (this.tabActive !== tab) {
this.tabActive = tab
this.filter = tabToFilterMapping({ tab, id: this.$route.params.id })
this.resetPostList()
}
},
uniq(items, field = 'id') {
return uniqBy(items, field)
@ -321,6 +347,10 @@ export default {
this.posts = []
this.hasMore = true
},
refetchPostList() {
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
},
async muteUser(user) {
try {
await this.$apollo.mutate({ mutation: muteUser(), variables: { id: user.id } })
@ -361,40 +391,6 @@ export default {
this.$apollo.queries.User.refetch()
}
},
async deleteUser(userdata) {
this.$store.commit('modal/SET_OPEN', {
name: 'delete',
data: {
userdata: userdata,
},
})
},
pinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
this.resetPostList()
this.$apollo.queries.profilePagePosts.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
optimisticFollow({ followedByCurrentUser }) {
/*
* Note: followedByCountStartValue is updated to avoid counting from 0 when follow/unfollow
@ -457,33 +453,33 @@ export default {
</script>
<style lang="scss">
.pointer {
cursor: pointer;
}
// Wolle .pointer {
// cursor: pointer;
// }
.Tabs {
position: relative;
background-color: #fff;
height: 100%;
display: flex;
margin: 0;
padding: 0;
list-style: none;
// Wolle .Tabs {
// position: relative;
// background-color: #fff;
// height: 100%;
// display: flex;
// margin: 0;
// padding: 0;
// list-style: none;
&__tab {
text-align: center;
height: 100%;
flex-grow: 1;
// &__tab {
// text-align: center;
// height: 100%;
// flex-grow: 1;
&:hover {
border-bottom: 2px solid #c9c6ce;
}
// &:hover {
// border-bottom: 2px solid #c9c6ce;
// }
&.active {
border-bottom: 2px solid #17b53f;
}
}
}
// &.active {
// border-bottom: 2px solid #17b53f;
// }
// }
// }
.profile-avatar.user-avatar {
margin: auto;
margin-top: -60px;
@ -495,26 +491,26 @@ export default {
right: $space-x-small;
}
}
.profile-top-navigation {
position: sticky;
top: 53px;
z-index: 2;
}
.ds-tab-nav.base-card {
padding: 0;
// Wolle .profile-top-navigation {
// position: sticky;
// top: 53px;
// z-index: 2;
// }
// Wolle .ds-tab-nav.base-card {
// padding: 0;
.ds-tab-nav-item {
&.ds-tab-nav-item-active {
border-bottom: 3px solid #17b53f;
&:first-child {
border-bottom-left-radius: $border-radius-x-large;
}
&:last-child {
border-bottom-right-radius: $border-radius-x-large;
}
}
}
}
// .ds-tab-nav-item {
// &.ds-tab-nav-item-active {
// border-bottom: 3px solid #17b53f;
// &:first-child {
// border-bottom-left-radius: $border-radius-x-large;
// }
// &:last-child {
// border-bottom-right-radius: $border-radius-x-large;
// }
// }
// }
// }
.profile-post-add-button {
box-shadow: $box-shadow-x-large;
}

View File

@ -0,0 +1,24 @@
<template>
<search-results :search="search" />
</template>
<script>
import SearchResults from '~/components/_new/features/SearchResults/SearchResults'
export default {
layout: 'default',
watchQuery: ['search'],
head() {
return {
title: this.$t('search.title'),
}
},
components: {
SearchResults,
},
asyncData(context) {
const { search = null } = context.query
return { search }
},
}
</script>

17
webapp/store/search.js Normal file
View File

@ -0,0 +1,17 @@
export const state = () => {
return {
searchValue: '',
}
}
export const mutations = {
SET_VALUE(state, ctx) {
state.searchValue = ctx.searchValue || ''
},
}
export const getters = {
searchValue(state) {
return state.searchValue
},
}

View File

@ -70,6 +70,20 @@ const helpers = {
}
})
},
fakePost(n) {
return new Array(n || 1).fill(0).map(() => {
const title = faker.lorem.words()
const content = faker.lorem.paragraph()
return {
id: faker.random.uuid(),
title,
content,
slug: faker.lorem.slug(title),
shoutedCount: faker.random.number(),
commentsCount: faker.random.number(),
}
})
},
}
export default helpers