diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 3b42ae7fe..a4c41871f 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -85,6 +85,8 @@ export default shield( Query: { '*': deny, findPosts: allow, + findUsers: allow, + findResources: allow, embed: allow, Category: allow, Tag: allow, diff --git a/backend/src/schema/resolvers/helpers/filterForBlockedUsers.js b/backend/src/schema/resolvers/helpers/filterForBlockedUsers.js new file mode 100644 index 000000000..b646038f0 --- /dev/null +++ b/backend/src/schema/resolvers/helpers/filterForBlockedUsers.js @@ -0,0 +1,25 @@ +import { getBlockedUsers, getBlockedByUsers } from '../users.js' +import { mergeWith, isArray } from 'lodash' + +export const filterForBlockedUsers = async (params, context) => { + if (!context.user) return params + const [blockedUsers, blockedByUsers] = await Promise.all([ + getBlockedUsers(context), + getBlockedByUsers(context), + ]) + const blockedUsersIds = [...blockedByUsers.map(b => b.id), ...blockedUsers.map(b => b.id)] + if (!blockedUsersIds.length) return params + + params.filter = mergeWith( + params.filter, + { + author_not: { id_in: blockedUsersIds }, + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue) + } + }, + ) + return params +} diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 47223faea..4a857a63c 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,33 +1,10 @@ import uuid from 'uuid/v4' import { neo4jgraphql } from 'neo4j-graphql-js' +import { isEmpty } from 'lodash' import fileUpload from './fileUpload' -import { getBlockedUsers, getBlockedByUsers } from './users.js' -import { mergeWith, isArray, isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' - -const filterForBlockedUsers = async (params, context) => { - if (!context.user) return params - const [blockedUsers, blockedByUsers] = await Promise.all([ - getBlockedUsers(context), - getBlockedByUsers(context), - ]) - const badIds = [...blockedByUsers.map(b => b.id), ...blockedUsers.map(b => b.id)] - if (!badIds.length) return params - - params.filter = mergeWith( - params.filter, - { - author_not: { id_in: badIds }, - }, - (objValue, srcValue) => { - if (isArray(objValue)) { - return objValue.concat(srcValue) - } - }, - ) - return params -} +import { filterForBlockedUsers } from './helpers/filterForBlockedUsers' const maintainPinnedPosts = params => { const pinnedPostFilter = { pinned: true } diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js new file mode 100644 index 000000000..5316ccd9a --- /dev/null +++ b/backend/src/schema/resolvers/searches.js @@ -0,0 +1,74 @@ +import log from './helpers/databaseLogger' + +export default { + Query: { + findResources: async (_parent, args, context, _resolveInfo) => { + const { query, limit } = args + const { id: thisUserId } = context.user + // see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description + const myQuery = query + .replace(/\s+/g, ' ') + .replace(/[[@#:*~\\$|^\]?/"'(){}+?!,.-;]/g, '') + .split(' ') + .map(s => (s.toLowerCase().match(/^(not|and|or)$/) ? '"' + s + '"' : s + '*')) + .join(' ') + const postCypher = ` + CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) + YIELD node as resource, score + MATCH (resource)<-[:WROTE]-(author:User) + WHERE score >= 0.5 + AND NOT ( + author.deleted = true OR author.disabled = true + OR resource.deleted = true OR resource.disabled = true + OR (:User { id: $thisUserId })-[:BLOCKED]-(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)) + } + LIMIT $limit + ` + + const userCypher = ` + CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) + YIELD node as resource, score + MATCH (resource) + WHERE score >= 0.5 + AND NOT (resource.deleted = true OR resource.disabled = true + OR (:User { id: $thisUserId })-[:BLOCKED]-(resource)) + RETURN resource {.*, __typename: labels(resource)[0]} + LIMIT $limit + ` + + 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, + }) + return Promise.all([postTransactionResponse, userTransactionResponse]) + }) + + try { + const [postResults, userResults] = await searchResultPromise + log(postResults) + log(userResults) + return [...postResults.records, ...userResults.records].map(r => r.get('resource')) + } finally { + session.close() + } + }, + }, +} diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 35998b935..23c2ded4d 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -1,23 +1,3 @@ -type Query { - isLoggedIn: Boolean! - # Get the currently logged in User based on the given JWT Token - currentUser: User - findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]! - @cypher( - statement: """ - CALL db.index.fulltext.queryNodes('full_text_search', $query) - YIELD node as post, score - MATCH (post)<-[:WROTE]-(user:User) - WHERE score >= 0.2 - AND NOT user.deleted = true AND NOT user.disabled = true - AND NOT post.deleted = true AND NOT post.disabled = true - AND NOT user.id in COALESCE($filter.author_not.id_in, []) - RETURN post - LIMIT $limit - """ - ) -} - type Mutation { # Get a JWT Token for the given Email and password login(email: String!, password: String!): String! diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 2e4358b3e..71fcb9605 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -230,4 +230,18 @@ type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] + findPosts(query: String!, limit: Int = 10, filter: _PostFilter): [Post]! + @cypher( + statement: """ + CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) + YIELD node as post, score + MATCH (post)<-[:WROTE]-(user:User) + WHERE score >= 0.2 + AND NOT user.deleted = true AND NOT user.disabled = true + AND NOT post.deleted = true AND NOT post.disabled = true + AND NOT user.id in COALESCE($filter.author_not.id_in, []) + RETURN post + LIMIT $limit + """ + ) } diff --git a/backend/src/schema/types/type/Search.gql b/backend/src/schema/types/type/Search.gql new file mode 100644 index 000000000..2c22fa61f --- /dev/null +++ b/backend/src/schema/types/type/Search.gql @@ -0,0 +1,5 @@ +union SearchResult = Post | User + +type Query { + findResources(query: String!, limit: Int = 5): [SearchResult]! +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 243f45322..6c07e1cc2 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -161,7 +161,20 @@ type Query { ): [User] blockedUsers: [User] + isLoggedIn: Boolean! currentUser: User + findUsers(query: String!,limit: Int = 10, filter: _UserFilter): [User]! + @cypher( + statement: """ + CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) + YIELD node as post, score + MATCH (user) + WHERE score >= 0.2 + AND NOT user.deleted = true AND NOT user.disabled = true + RETURN user + LIMIT $limit + """ + ) } type Mutation { @@ -179,8 +192,7 @@ type Mutation { termsAndConditionsAgreedAt: String allowEmbedIframes: Boolean showShoutsPublicly: Boolean - - locale: String + locale: String ): User DeleteUser(id: ID!, resource: [Deletable]): User diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 35b2d1346..020607bf0 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -1,21 +1,24 @@ import { When, Then } from "cypress-cucumber-preprocessor/steps"; When("I search for {string}", value => { - cy.get("#nav-search") + cy.get(".searchable-input .ds-select-search") .focus() .type(value); }); -Then("I should have one post in the select dropdown", () => { - cy.get(".input .ds-select-dropdown").should($li => { +Then("I should have one item in the select dropdown", () => { + cy.get(".searchable-input .ds-select-dropdown").should($li => { expect($li).to.have.length(1); }); }); Then("the search has no results", () => { - cy.get(".input .ds-select-dropdown").should($li => { + cy.get(".searchable-input .ds-select-dropdown").should($li => { expect($li).to.have.length(1); }); cy.get(".ds-select-dropdown").should("contain", 'Nothing found'); + cy.get(".searchable-input .ds-select-search") + .focus() + .type("{esc}"); }); Then("I should see the following posts in the select dropdown:", table => { @@ -24,26 +27,33 @@ 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"); + table.hashes().forEach(({ slug }) => { + cy.get(".ds-select-dropdown").should("contain", slug); + }); +}); + When("I type {string} and press Enter", value => { - cy.get("#nav-search") + cy.get(".searchable-input .ds-select-search") .focus() .type(value) .type("{enter}", { force: true }); }); When("I type {string} and press escape", value => { - cy.get("#nav-search") + cy.get(".searchable-input .ds-select-search") .focus() .type(value) .type("{esc}"); }); Then("the search field should clear", () => { - cy.get("#nav-search").should("have.text", ""); + cy.get(".searchable-input .ds-select-search").should("have.text", ""); }); -When("I select an entry", () => { - cy.get(".input .ds-select-dropdown ul li") +When("I select a post entry", () => { + cy.get(".searchable-input .search-post") .first() .trigger("click"); }); @@ -75,3 +85,13 @@ Then( ); } ); + +Then("I select a user entry", () => { + cy.get(".searchable-input .userinfo") + .first() + .trigger("click"); +}) + +Then("I should be on the user's profile", () => { + cy.location("pathname").should("eq", "/profile/user-for-search/search-for-me") +}) diff --git a/cypress/integration/search/Search.feature b/cypress/integration/search/Search.feature index c1afc5b97..e83f58477 100644 --- a/cypress/integration/search/Search.feature +++ b/cypress/integration/search/Search.feature @@ -9,18 +9,23 @@ Feature: Search | id | title | content | | p1 | 101 Essays that will change the way you think | 101 Essays, of course! | | p2 | No searched for 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 | + | not-to-be-found | Not to be found | just-an-id | + Given I am logged in Scenario: Search for specific words When I search for "Essays" - Then I should have one post in the select dropdown + Then I should have one item in the select dropdown Then I should see the following posts in the select dropdown: | title | | 101 Essays that will change the way you think | Scenario: Press enter starts search - When I type "Essa" and press Enter - Then I should have one post in the select dropdown + When I type "Es" and press Enter + Then I should have one item in the select dropdown Then I should see the following posts in the select dropdown: | title | | 101 Essays that will change the way you think | @@ -31,11 +36,20 @@ Feature: Search Scenario: Select entry goes to post When I search for "Essays" - And I select an entry + And I select a post entry Then I should be on the post's page Scenario: Select dropdown content When I search for "Essays" - Then I should have one post in the select dropdown + Then I should have one item in the select dropdown Then I should see posts with the searched-for term in the select dropdown And I should not see posts without the searched-for term in the select dropdown + + Scenario: Search for users + Given I search for "Search" + Then I should have one item in the select dropdown + And I should see the following users in the select dropdown: + | slug | + | search-for-me | + And I select a user entry + Then I should be on the user's profile \ No newline at end of file diff --git a/neo4j/db_setup.sh b/neo4j/db_setup.sh index ba90ee5f4..b7562d0c9 100755 --- a/neo4j/db_setup.sh +++ b/neo4j/db_setup.sh @@ -2,7 +2,6 @@ ENV_FILE=$(dirname "$0")/.env [[ -f "$ENV_FILE" ]] && source "$ENV_FILE" - if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables." echo "Setting up database constraints and indexes will probably fail because of authentication errors." @@ -21,7 +20,8 @@ CALL db.indexes(); ' | cypher-shell echo ' -CALL db.index.fulltext.createNodeIndex("full_text_search",["Post"],["title", "content"]); +CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"]); +CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"]); CREATE CONSTRAINT ON (p:Post) ASSERT p.id IS UNIQUE; CREATE CONSTRAINT ON (c:Comment) ASSERT c.id IS UNIQUE; CREATE CONSTRAINT ON (c:Category) ASSERT c.id IS UNIQUE; diff --git a/webapp/components/SearchInput.spec.js b/webapp/components/SearchInput.spec.js deleted file mode 100644 index 8cc8b9459..000000000 --- a/webapp/components/SearchInput.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import { mount } from '@vue/test-utils' -import SearchInput from './SearchInput.vue' - -const localVue = global.localVue - -localVue.filter('truncate', () => 'truncated string') -localVue.filter('dateTime', () => Date.now) - -describe('SearchInput.vue', () => { - let mocks - let propsData - - beforeEach(() => { - propsData = {} - }) - - describe('mount', () => { - const Wrapper = () => { - mocks = { - $t: () => {}, - } - return mount(SearchInput, { mocks, localVue, propsData }) - } - - it('renders', () => { - expect(Wrapper().is('div')).toBe(true) - }) - - it('has id "nav-search"', () => { - expect(Wrapper().contains('#nav-search')).toBe(true) - }) - - it('defaults to an empty value', () => { - expect(Wrapper().vm.value).toBe('') - }) - - it('defaults to id "nav-search"', () => { - expect(Wrapper().vm.id).toBe('nav-search') - }) - - it('default to a 300 millisecond delay from the time the user stops typing to when the search starts', () => { - expect(Wrapper().vm.delay).toEqual(300) - }) - - it('defaults to an empty array as results', () => { - expect(Wrapper().vm.results).toEqual([]) - }) - - it('defaults to pending false, as in the search is not pending', () => { - expect(Wrapper().vm.pending).toBe(false) - }) - - it('accepts values as a string', () => { - propsData = { value: 'abc' } - const wrapper = Wrapper() - expect(wrapper.vm.value).toEqual('abc') - }) - - describe('testing custom functions', () => { - let select - let wrapper - - beforeEach(() => { - wrapper = Wrapper() - select = wrapper.find('.ds-select') - select.trigger('focus') - select.element.value = 'abcd' - }) - - it('opens the select dropdown when focused on', () => { - expect(wrapper.vm.isOpen).toBe(true) - }) - - it('opens the select dropdown and blurs after focused on', () => { - select.trigger('blur') - expect(wrapper.vm.isOpen).toBe(false) - }) - - it('is clearable', () => { - select.trigger('input') - select.trigger('keyup.esc') - expect(wrapper.emitted().clear.length).toBe(1) - }) - - it('changes the unprocessedSearchInput as the value changes', () => { - select.trigger('input') - expect(wrapper.vm.unprocessedSearchInput).toBe('abcd') - }) - - it('searches for the term when enter is pressed', async () => { - select.trigger('input') - select.trigger('keyup.enter') - await expect(wrapper.emitted().search[0]).toEqual(['abcd']) - }) - - it('calls onDelete when the delete key is pressed', () => { - const spy = jest.spyOn(wrapper.vm, 'onDelete') - select.trigger('input') - select.trigger('keyup.delete') - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('calls query when a user starts a search by pressing enter', () => { - const spy = jest.spyOn(wrapper.vm, 'query') - select.trigger('input') - select.trigger('keyup.enter') - expect(spy).toHaveBeenCalledWith('abcd') - }) - - it('calls onSelect when a user selects an item in the search dropdown menu', async () => { - // searched for term in the browser, copied the results from Vuex in Vue dev tools - propsData = { - results: [ - { - __typename: 'Post', - author: { - __typename: 'User', - id: 'u5', - name: 'Trick', - slug: 'trick', - }, - commentsCount: 0, - createdAt: '2019-03-13T11:00:20.835Z', - id: 'p10', - label: 'Eos aut illo omnis quis eaque et iure aut.', - shoutedCount: 0, - slug: 'eos-aut-illo-omnis-quis-eaque-et-iure-aut', - value: 'Eos aut illo omnis quis eaque et iure aut.', - }, - ], - } - wrapper = Wrapper() - select.trigger('input') - const results = wrapper.find('.ds-select-option') - results.trigger('click') - await expect(wrapper.emitted().select[0]).toEqual(propsData.results) - }) - }) - }) -}) diff --git a/webapp/components/SearchInput.vue b/webapp/components/SearchInput.vue deleted file mode 100644 index b1b967355..000000000 --- a/webapp/components/SearchInput.vue +++ /dev/null @@ -1,268 +0,0 @@ - - - - - diff --git a/webapp/components/features/SearchField/SearchField.spec.js b/webapp/components/features/SearchField/SearchField.spec.js new file mode 100644 index 000000000..05daf7a9c --- /dev/null +++ b/webapp/components/features/SearchField/SearchField.spec.js @@ -0,0 +1,64 @@ +import { config, mount } from '@vue/test-utils' +import Vuex from 'vuex' +import SearchField from './SearchField.vue' +import SearchableInput from '~/components/generic/SearchableInput/SearchableInput' +import { searchResults } from '~/components/generic/SearchableInput/SearchableInput.story' +const localVue = global.localVue + +localVue.filter('truncate', () => 'truncated string') +localVue.filter('dateTime', () => Date.now) +config.stubs['nuxt-link'] = '' + +describe('SearchField.vue', () => { + let mocks, wrapper, getters + beforeEach(() => { + mocks = { + $apollo: { + query: jest.fn(), + }, + $t: jest.fn(string => string), + } + getters = { 'auth/isModerator': () => false } + wrapper = Wrapper() + }) + + const Wrapper = () => { + const store = new Vuex.Store({ + getters, + }) + return mount(SearchField, { mocks, localVue, store }) + } + + describe('mount', () => { + describe('Emitted events', () => { + let searchableInputComponent + beforeEach(() => { + searchableInputComponent = wrapper.find(SearchableInput) + }) + + describe('query event', () => { + it('calls an apollo query', () => { + searchableInputComponent.vm.$emit('query', 'abcd') + expect(mocks.$apollo.query).toHaveBeenCalledWith( + expect.objectContaining({ variables: { query: 'abcd' } }), + ) + }) + }) + + describe('clearSearch event', () => { + beforeEach(() => { + wrapper.setData({ searchResults, pending: true }) + searchableInputComponent.vm.$emit('clearSearch') + }) + + it('clears searchResults', () => { + expect(wrapper.vm.searchResults).toEqual([]) + }) + + it('set pending to false', () => { + expect(wrapper.vm.pending).toBe(false) + }) + }) + }) + }) +}) diff --git a/webapp/components/features/SearchField/SearchField.vue b/webapp/components/features/SearchField/SearchField.vue new file mode 100644 index 000000000..29ab8650d --- /dev/null +++ b/webapp/components/features/SearchField/SearchField.vue @@ -0,0 +1,51 @@ + + + diff --git a/webapp/components/generic/SearchHeading/SearchHeading.spec.js b/webapp/components/generic/SearchHeading/SearchHeading.spec.js new file mode 100644 index 000000000..2ddd3e9ba --- /dev/null +++ b/webapp/components/generic/SearchHeading/SearchHeading.spec.js @@ -0,0 +1,27 @@ +import { mount } from '@vue/test-utils' +import SearchHeading from './SearchHeading.vue' + +const localVue = global.localVue + +describe('SearchHeading.vue', () => { + let mocks, wrapper, propsData + beforeEach(() => { + mocks = { + $t: jest.fn(string => string), + } + propsData = { + resourceType: 'Post', + } + wrapper = Wrapper() + }) + + const Wrapper = () => { + return mount(SearchHeading, { mocks, localVue, propsData }) + } + + describe('mount', () => { + it('renders heading', () => { + expect(wrapper.text()).toMatch('search.heading.Post') + }) + }) +}) diff --git a/webapp/components/generic/SearchHeading/SearchHeading.vue b/webapp/components/generic/SearchHeading/SearchHeading.vue new file mode 100644 index 000000000..fdd9357ab --- /dev/null +++ b/webapp/components/generic/SearchHeading/SearchHeading.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/components/generic/SearchPost/SearchPost.spec.js b/webapp/components/generic/SearchPost/SearchPost.spec.js new file mode 100644 index 000000000..39cf5dcc0 --- /dev/null +++ b/webapp/components/generic/SearchPost/SearchPost.spec.js @@ -0,0 +1,64 @@ +import { shallowMount } from '@vue/test-utils' +import SearchPost from './SearchPost.vue' + +const localVue = global.localVue +localVue.filter('dateTime', d => d) + +describe('SearchPost.vue', () => { + let mocks, wrapper, propsData + beforeEach(() => { + mocks = { + $t: jest.fn(string => string), + } + propsData = { + option: { + title: 'Post Title', + commentsCount: 3, + shoutedCount: 6, + createdAt: '23.08.2019', + author: { + name: 'Post Author', + }, + }, + } + wrapper = Wrapper() + }) + + const Wrapper = () => { + return shallowMount(SearchPost, { mocks, localVue, propsData }) + } + + describe('shallowMount', () => { + it('renders post title', () => { + expect(wrapper.find('.search-option-label').text()).toMatch('Post Title') + }) + + it('renders post commentsCount', () => { + expect( + wrapper + .find('.search-post-meta') + .findAll('span') + .filter(item => item.text() === '3') + .exists(), + ).toBe(true) + }) + + it('renders post shoutedCount', () => { + expect( + wrapper + .find('.search-post-meta') + .findAll('span') + .filter(item => item.text() === '6') + .exists(), + ).toBe(true) + }) + + it('renders post author', () => { + expect(wrapper.find('.search-post-author').text()).toContain('Post Author') + }) + + it('renders post createdAt', () => { + expect(wrapper.find('.search-post-author').text()).toContain('23.08.2019') + }) + }) +}) diff --git a/webapp/components/generic/SearchPost/SearchPost.vue b/webapp/components/generic/SearchPost/SearchPost.vue new file mode 100644 index 000000000..3da6056ba --- /dev/null +++ b/webapp/components/generic/SearchPost/SearchPost.vue @@ -0,0 +1,71 @@ + + + diff --git a/webapp/components/generic/SearchableInput/SearchableInput.spec.js b/webapp/components/generic/SearchableInput/SearchableInput.spec.js new file mode 100644 index 000000000..db314630f --- /dev/null +++ b/webapp/components/generic/SearchableInput/SearchableInput.spec.js @@ -0,0 +1,115 @@ +import { config, mount } from '@vue/test-utils' +import Vuex from 'vuex' +import Vue from 'vue' +import SearchableInput from './SearchableInput' +import { searchResults } from '~/components/generic/SearchableInput/SearchableInput.story' + +const localVue = global.localVue + +localVue.filter('truncate', () => 'truncated string') +localVue.filter('dateTime', () => Date.now) +config.stubs['nuxt-link'] = '' + +describe('SearchableInput.vue', () => { + let mocks, propsData, getters, wrapper + + beforeEach(() => { + propsData = {} + mocks = { + $router: { + push: jest.fn(), + }, + $t: jest.fn(string => string), + } + getters = { 'auth/isModerator': () => false } + wrapper = Wrapper() + }) + const Wrapper = () => { + const store = new Vuex.Store({ + getters, + }) + return mount(SearchableInput, { mocks, localVue, propsData, store }) + } + + describe('mount', () => { + describe('testing custom functions', () => { + let select + + beforeEach(() => { + select = wrapper.find('.ds-select') + select.trigger('focus') + select.element.value = 'abcd' + }) + + it('opens the select dropdown when focused on', () => { + expect(wrapper.find('.is-open').exists()).toBe(true) + }) + + it('opens the select dropdown and blurs after focused on', () => { + select.trigger('blur') + expect(wrapper.find('.is-open').exists()).toBe(false) + }) + + it('is clearable', () => { + select.trigger('input') + select.trigger('keyup.esc') + expect(wrapper.find('.is-open').exists()).toBe(false) + }) + + it('changes the unprocessedSearchInput as the value changes', () => { + select.trigger('input') + 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') + select.trigger('keyup.delete') + expect(spy).toHaveBeenCalledTimes(1) + }) + + describe('navigating to resource', () => { + beforeEach(() => { + propsData = { options: searchResults } + wrapper = Wrapper() + select = wrapper.find('.ds-select') + select.trigger('focus') + }) + + it('pushes to post page', async () => { + select.element.value = 'Post' + select.trigger('input') + const post = wrapper.find('.search-post') + post.trigger('click') + await Vue.nextTick().then(() => { + expect(mocks.$router.push).toHaveBeenCalledWith({ + name: 'post-id-slug', + params: { id: 'post-by-jenny', slug: 'user-post-by-jenny' }, + }) + }) + }) + + it("pushes to user's profile", async () => { + select.element.value = 'Bob' + select.trigger('input') + const users = wrapper.findAll('.userinfo') + const bob = users.filter(item => item.text() === '@bob-der-baumeister') + bob.trigger('click') + await Vue.nextTick().then(() => { + expect(mocks.$router.push).toHaveBeenCalledWith({ + name: 'profile-id-slug', + params: { id: 'u2', slug: 'bob-der-baumeister' }, + }) + }) + }) + }) + }) + }) +}) diff --git a/webapp/components/generic/SearchableInput/SearchableInput.story.js b/webapp/components/generic/SearchableInput/SearchableInput.story.js new file mode 100644 index 000000000..68feaadba --- /dev/null +++ b/webapp/components/generic/SearchableInput/SearchableInput.story.js @@ -0,0 +1,115 @@ +import { storiesOf } from '@storybook/vue' +import { withA11y } from '@storybook/addon-a11y' +import SearchableInput from './SearchableInput.vue' +import helpers from '~/storybook/helpers' + +helpers.init() + +export const searchResults = [ + { + id: 'post-by-jenny', + __typename: 'Post', + slug: 'user-post-by-jenny', + title: 'User Post by Jenny', + value: 'User Post by Jenny', + shoutedCount: 0, + commentCount: 4, + createdAt: '2019-11-13T03:03:16.155Z', + author: { + id: 'u3', + name: 'Jenny Rostock', + slug: 'jenny-rostock', + }, + }, + { + id: 'f48f00a0-c412-432f-8334-4276a4e15d1c', + __typename: 'Post', + slug: 'eum-quos-est-molestiae-enim-magni-consequuntur-sed-commodi-eos', + title: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.', + value: 'Eum quos est molestiae enim magni consequuntur sed commodi eos.', + shoutedCount: 0, + commentCount: 0, + createdAt: '2019-11-13T03:00:45.478Z', + author: { + id: 'u6', + name: 'Louie', + slug: 'louie', + }, + }, + { + id: 'p7', + __typename: 'Post', + slug: 'this-is-post-7', + title: 'This is post #7', + value: 'This is post #7', + shoutedCount: 1, + commentCount: 1, + createdAt: '2019-11-13T03:00:23.098Z', + author: { + id: 'u6', + name: 'Louie', + slug: 'louie', + }, + }, + { + id: 'p12', + __typename: 'Post', + slug: 'this-is-post-12', + title: 'This is post #12', + value: 'This is post #12', + shoutedCount: 0, + commentCount: 12, + createdAt: '2019-11-13T03:00:23.098Z', + author: { + id: 'u6', + name: 'Louie', + slug: 'louie', + }, + }, + { + id: 'u1', + __typename: 'User', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + name: 'Peter Lustig', + slug: 'peter-lustig', + }, + { + id: 'cdbca762-0632-4564-b646-415a0c42d8b8', + __typename: 'User', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + name: 'Herbert Schultz', + slug: 'herbert-schultz', + }, + { + id: 'u2', + __typename: 'User', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + name: 'Bob der Baumeister', + slug: 'bob-der-baumeister', + }, + { + id: '7b654f72-f4da-4315-8bed-39de0859754b', + __typename: 'User', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbc9e03ebcc384b920c31542af2d27dd8eea9dc2_full.jpg', + name: 'Tonya Mohr', + slug: 'tonya-mohr', + }, +] + +storiesOf('Search Field', module) + .addDecorator(withA11y) + .addDecorator(helpers.layout) + .add('test', () => ({ + components: { SearchableInput }, + store: helpers.store, + data: () => ({ + searchResults, + }), + template: ` + + `, + })) diff --git a/webapp/components/generic/SearchableInput/SearchableInput.vue b/webapp/components/generic/SearchableInput/SearchableInput.vue new file mode 100644 index 000000000..cc9269ecf --- /dev/null +++ b/webapp/components/generic/SearchableInput/SearchableInput.vue @@ -0,0 +1,227 @@ + + + diff --git a/webapp/graphql/Search.js b/webapp/graphql/Search.js new file mode 100644 index 000000000..9b142b429 --- /dev/null +++ b/webapp/graphql/Search.js @@ -0,0 +1,24 @@ +import gql from 'graphql-tag' +import { userFragment, postFragment } from './Fragments' + +export const findResourcesQuery = gql` + ${userFragment} + ${postFragment} + + query($query: String!) { + findResources(query: $query, limit: 5) { + __typename + ... on Post { + ...post + commentsCount + shoutedCount + author { + ...user + } + } + ... on User { + ...user + } + } + } +` diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue index 94a9c0912..6be75cd35 100644 --- a/webapp/layouts/default.vue +++ b/webapp/layouts/default.vue @@ -19,18 +19,10 @@ - +