diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 685b5ef0e..f562b5f15 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -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( diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index f4f8c654b..528dfb233 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -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, diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 58fa63f8d..7b157fc65 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -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) }, }, } diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index 3d7bd039d..a859bf296 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -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
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+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
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+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
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+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, + }) + }) + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js index 064f17f48..3f2f70d48 100644 --- a/backend/src/schema/resolvers/searches/queryString.js +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -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) { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index cce45ae6e..6509f0e68 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -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, }) }) diff --git a/backend/src/schema/types/type/Search.gql b/backend/src/schema/types/type/Search.gql index 1ce38001d..9537b5a84 100644 --- a/backend/src/schema/types/type/Search.gql +++ b/backend/src/schema/types/type/Search.gql @@ -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]! } diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 1feece77e..5eae20a22 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -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", () => { diff --git a/cypress/integration/search/Search.feature b/cypress/integration/search/Search.feature index b77b45d8e..d128838f3 100644 --- a/cypress/integration/search/Search.feature +++ b/cypress/integration/search/Search.feature @@ -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 | diff --git a/webapp/assets/_new/styles/resets.scss b/webapp/assets/_new/styles/resets.scss index 72a6184b3..4545634cc 100644 --- a/webapp/assets/_new/styles/resets.scss +++ b/webapp/assets/_new/styles/resets.scss @@ -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; } diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss index 74699a097..5ab1e8aef 100644 --- a/webapp/assets/_new/styles/tokens.scss +++ b/webapp/assets/_new/styles/tokens.scss @@ -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; /** diff --git a/webapp/components/_new/features/SearchResults/SearchResults.spec.js b/webapp/components/_new/features/SearchResults/SearchResults.spec.js new file mode 100644 index 000000000..91c12be14 --- /dev/null +++ b/webapp/components/_new/features/SearchResults/SearchResults.spec.js @@ -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'] = '' +config.stubs['nuxt-link'] = '' + +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 }) + }) + }) + }) + }) +}) diff --git a/webapp/components/_new/features/SearchResults/SearchResults.story.js b/webapp/components/_new/features/SearchResults/SearchResults.story.js new file mode 100644 index 000000000..cfc7754dc --- /dev/null +++ b/webapp/components/_new/features/SearchResults/SearchResults.story.js @@ -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: `
+ +
+
    +
  • + + + + +
  • +
+
+
`, + })) + .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: `
+ +
+
    +
  • + + + + +
  • +
+
+
`, + })) diff --git a/webapp/components/_new/features/SearchResults/SearchResults.vue b/webapp/components/_new/features/SearchResults/SearchResults.vue new file mode 100644 index 000000000..b37534510 --- /dev/null +++ b/webapp/components/_new/features/SearchResults/SearchResults.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/webapp/components/_new/generic/PaginationButtons/PaginationButtons.spec.js b/webapp/components/_new/generic/PaginationButtons/PaginationButtons.spec.js index f214ba55e..03c66e345 100644 --- a/webapp/components/_new/generic/PaginationButtons/PaginationButtons.spec.js +++ b/webapp/components/_new/generic/PaginationButtons/PaginationButtons.spec.js @@ -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) + }) + }) }) }) diff --git a/webapp/components/_new/generic/PaginationButtons/PaginationButtons.vue b/webapp/components/_new/generic/PaginationButtons/PaginationButtons.vue index 592492f7d..b2ebe9139 100644 --- a/webapp/components/_new/generic/PaginationButtons/PaginationButtons.vue +++ b/webapp/components/_new/generic/PaginationButtons/PaginationButtons.vue @@ -1,18 +1,26 @@ @@ -20,12 +28,31 @@ + + diff --git a/webapp/components/_new/generic/TabNavigation/TabNavigation.vue b/webapp/components/_new/generic/TabNavigation/TabNavigation.vue new file mode 100644 index 000000000..d34399e45 --- /dev/null +++ b/webapp/components/_new/generic/TabNavigation/TabNavigation.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/webapp/components/features/SearchField/SearchField.vue b/webapp/components/features/SearchField/SearchField.vue index 29ab8650d..6aa7db865 100644 --- a/webapp/components/features/SearchField/SearchField.vue +++ b/webapp/components/features/SearchField/SearchField.vue @@ -9,7 +9,7 @@