mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge pull request #2262 from Human-Connection/1463-search-for-users
🍰 Search For Users
This commit is contained in:
commit
1f06a862e7
@ -85,6 +85,8 @@ export default shield(
|
|||||||
Query: {
|
Query: {
|
||||||
'*': deny,
|
'*': deny,
|
||||||
findPosts: allow,
|
findPosts: allow,
|
||||||
|
findUsers: allow,
|
||||||
|
findResources: allow,
|
||||||
embed: allow,
|
embed: allow,
|
||||||
Category: allow,
|
Category: allow,
|
||||||
Tag: allow,
|
Tag: allow,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -1,33 +1,10 @@
|
|||||||
import uuid from 'uuid/v4'
|
import uuid from 'uuid/v4'
|
||||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
import fileUpload from './fileUpload'
|
import fileUpload from './fileUpload'
|
||||||
import { getBlockedUsers, getBlockedByUsers } from './users.js'
|
|
||||||
import { mergeWith, isArray, isEmpty } from 'lodash'
|
|
||||||
import { UserInputError } from 'apollo-server'
|
import { UserInputError } from 'apollo-server'
|
||||||
import Resolver from './helpers/Resolver'
|
import Resolver from './helpers/Resolver'
|
||||||
|
import { filterForBlockedUsers } from './helpers/filterForBlockedUsers'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const maintainPinnedPosts = params => {
|
const maintainPinnedPosts = params => {
|
||||||
const pinnedPostFilter = { pinned: true }
|
const pinnedPostFilter = { pinned: true }
|
||||||
|
|||||||
74
backend/src/schema/resolvers/searches.js
Normal file
74
backend/src/schema/resolvers/searches.js
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -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 {
|
type Mutation {
|
||||||
# Get a JWT Token for the given Email and password
|
# Get a JWT Token for the given Email and password
|
||||||
login(email: String!, password: String!): String!
|
login(email: String!, password: String!): String!
|
||||||
|
|||||||
@ -230,4 +230,18 @@ type Query {
|
|||||||
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
|
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
|
||||||
PostsEmotionsByCurrentUser(postId: ID!): [String]
|
PostsEmotionsByCurrentUser(postId: ID!): [String]
|
||||||
profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post]
|
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
|
||||||
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
5
backend/src/schema/types/type/Search.gql
Normal file
5
backend/src/schema/types/type/Search.gql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
union SearchResult = Post | User
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
findResources(query: String!, limit: Int = 5): [SearchResult]!
|
||||||
|
}
|
||||||
@ -161,7 +161,20 @@ type Query {
|
|||||||
): [User]
|
): [User]
|
||||||
|
|
||||||
blockedUsers: [User]
|
blockedUsers: [User]
|
||||||
|
isLoggedIn: Boolean!
|
||||||
currentUser: User
|
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 {
|
type Mutation {
|
||||||
@ -179,7 +192,6 @@ type Mutation {
|
|||||||
termsAndConditionsAgreedAt: String
|
termsAndConditionsAgreedAt: String
|
||||||
allowEmbedIframes: Boolean
|
allowEmbedIframes: Boolean
|
||||||
showShoutsPublicly: Boolean
|
showShoutsPublicly: Boolean
|
||||||
|
|
||||||
locale: String
|
locale: String
|
||||||
): User
|
): User
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,24 @@
|
|||||||
import { When, Then } from "cypress-cucumber-preprocessor/steps";
|
import { When, Then } from "cypress-cucumber-preprocessor/steps";
|
||||||
When("I search for {string}", value => {
|
When("I search for {string}", value => {
|
||||||
cy.get("#nav-search")
|
cy.get(".searchable-input .ds-select-search")
|
||||||
.focus()
|
.focus()
|
||||||
.type(value);
|
.type(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
Then("I should have one post in the select dropdown", () => {
|
Then("I should have one item in the select dropdown", () => {
|
||||||
cy.get(".input .ds-select-dropdown").should($li => {
|
cy.get(".searchable-input .ds-select-dropdown").should($li => {
|
||||||
expect($li).to.have.length(1);
|
expect($li).to.have.length(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Then("the search has no results", () => {
|
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);
|
expect($li).to.have.length(1);
|
||||||
});
|
});
|
||||||
cy.get(".ds-select-dropdown").should("contain", 'Nothing found');
|
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 => {
|
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 => {
|
When("I type {string} and press Enter", value => {
|
||||||
cy.get("#nav-search")
|
cy.get(".searchable-input .ds-select-search")
|
||||||
.focus()
|
.focus()
|
||||||
.type(value)
|
.type(value)
|
||||||
.type("{enter}", { force: true });
|
.type("{enter}", { force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
When("I type {string} and press escape", value => {
|
When("I type {string} and press escape", value => {
|
||||||
cy.get("#nav-search")
|
cy.get(".searchable-input .ds-select-search")
|
||||||
.focus()
|
.focus()
|
||||||
.type(value)
|
.type(value)
|
||||||
.type("{esc}");
|
.type("{esc}");
|
||||||
});
|
});
|
||||||
|
|
||||||
Then("the search field should clear", () => {
|
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", () => {
|
When("I select a post entry", () => {
|
||||||
cy.get(".input .ds-select-dropdown ul li")
|
cy.get(".searchable-input .search-post")
|
||||||
.first()
|
.first()
|
||||||
.trigger("click");
|
.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")
|
||||||
|
})
|
||||||
|
|||||||
@ -9,18 +9,23 @@ Feature: Search
|
|||||||
| id | title | content |
|
| id | title | content |
|
||||||
| p1 | 101 Essays that will change the way you think | 101 Essays, of course! |
|
| 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 |
|
| 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
|
Given I am logged in
|
||||||
|
|
||||||
Scenario: Search for specific words
|
Scenario: Search for specific words
|
||||||
When I search for "Essays"
|
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:
|
Then I should see the following posts in the select dropdown:
|
||||||
| title |
|
| title |
|
||||||
| 101 Essays that will change the way you think |
|
| 101 Essays that will change the way you think |
|
||||||
|
|
||||||
Scenario: Press enter starts search
|
Scenario: Press enter starts search
|
||||||
When I type "Essa" and press Enter
|
When I type "Es" and press Enter
|
||||||
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:
|
Then I should see the following posts in the select dropdown:
|
||||||
| title |
|
| title |
|
||||||
| 101 Essays that will change the way you think |
|
| 101 Essays that will change the way you think |
|
||||||
@ -31,11 +36,20 @@ Feature: Search
|
|||||||
|
|
||||||
Scenario: Select entry goes to post
|
Scenario: Select entry goes to post
|
||||||
When I search for "Essays"
|
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
|
Then I should be on the post's page
|
||||||
|
|
||||||
Scenario: Select dropdown content
|
Scenario: Select dropdown content
|
||||||
When I search for "Essays"
|
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
|
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
|
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
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
ENV_FILE=$(dirname "$0")/.env
|
ENV_FILE=$(dirname "$0")/.env
|
||||||
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
|
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
|
||||||
|
|
||||||
if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then
|
if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then
|
||||||
echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables."
|
echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables."
|
||||||
echo "Setting up database constraints and indexes will probably fail because of authentication errors."
|
echo "Setting up database constraints and indexes will probably fail because of authentication errors."
|
||||||
@ -21,7 +20,8 @@ CALL db.indexes();
|
|||||||
' | cypher-shell
|
' | cypher-shell
|
||||||
|
|
||||||
echo '
|
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 (p:Post) ASSERT p.id IS UNIQUE;
|
||||||
CREATE CONSTRAINT ON (c:Comment) ASSERT c.id IS UNIQUE;
|
CREATE CONSTRAINT ON (c:Comment) ASSERT c.id IS UNIQUE;
|
||||||
CREATE CONSTRAINT ON (c:Category) ASSERT c.id IS UNIQUE;
|
CREATE CONSTRAINT ON (c:Category) ASSERT c.id IS UNIQUE;
|
||||||
|
|||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="search"
|
|
||||||
aria-label="search"
|
|
||||||
role="search"
|
|
||||||
:class="{
|
|
||||||
'is-active': isActive,
|
|
||||||
'is-open': isOpen,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="field">
|
|
||||||
<div class="control">
|
|
||||||
<a v-if="isActive" class="search-clear-btn" @click="clear"> </a>
|
|
||||||
<ds-select
|
|
||||||
:id="id"
|
|
||||||
ref="input"
|
|
||||||
v-model="searchValue"
|
|
||||||
class="input"
|
|
||||||
name="search"
|
|
||||||
type="search"
|
|
||||||
icon="search"
|
|
||||||
label-prop="id"
|
|
||||||
:no-options-available="emptyText"
|
|
||||||
:icon-right="isActive ? 'close' : null"
|
|
||||||
:filter="item => item"
|
|
||||||
:options="results"
|
|
||||||
:auto-reset-search="!searchValue"
|
|
||||||
:placeholder="$t('search.placeholder')"
|
|
||||||
:loading="pending"
|
|
||||||
@keyup.enter.native="onEnter"
|
|
||||||
@focus.capture.native="onFocus"
|
|
||||||
@blur.capture.native="onBlur"
|
|
||||||
@keyup.delete.native="onDelete"
|
|
||||||
@keyup.esc.native="clear"
|
|
||||||
@input.exact="onSelect"
|
|
||||||
@input.native="handleInput"
|
|
||||||
@click.capture.native="isOpen = true"
|
|
||||||
>
|
|
||||||
<template slot="option" slot-scope="{ option }">
|
|
||||||
<ds-flex>
|
|
||||||
<ds-flex-item class="search-option-label">
|
|
||||||
<ds-text>{{ option.label | truncate(70) }}</ds-text>
|
|
||||||
</ds-flex-item>
|
|
||||||
<ds-flex-item class="search-option-meta" width="280px">
|
|
||||||
<ds-flex>
|
|
||||||
<ds-flex-item>
|
|
||||||
<ds-text size="small" color="softer" class="search-meta">
|
|
||||||
<span style="text-align: right;">
|
|
||||||
<b>{{ option.commentsCount }}</b>
|
|
||||||
<base-icon name="comments" />
|
|
||||||
</span>
|
|
||||||
<span style="width: 36px; display: inline-block; text-align: right;">
|
|
||||||
<b>{{ option.shoutedCount }}</b>
|
|
||||||
<base-icon name="bullhorn" />
|
|
||||||
</span>
|
|
||||||
</ds-text>
|
|
||||||
</ds-flex-item>
|
|
||||||
<ds-flex-item>
|
|
||||||
<ds-text size="small" color="softer" align="right">
|
|
||||||
{{ option.author.name | truncate(32) }} -
|
|
||||||
{{ option.createdAt | dateTime('dd.MM.yyyy') }}
|
|
||||||
</ds-text>
|
|
||||||
</ds-flex-item>
|
|
||||||
</ds-flex>
|
|
||||||
</ds-flex-item>
|
|
||||||
</ds-flex>
|
|
||||||
</template>
|
|
||||||
</ds-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { isEmpty } from 'lodash'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'SearchInput',
|
|
||||||
props: {
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: 'nav-search',
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
results: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
delay: {
|
|
||||||
type: Number,
|
|
||||||
default: 300,
|
|
||||||
},
|
|
||||||
pending: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchProcess: null,
|
|
||||||
isOpen: false,
|
|
||||||
lastSearchTerm: '',
|
|
||||||
unprocessedSearchInput: '',
|
|
||||||
searchValue: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
// #: Unused at the moment?
|
|
||||||
isActive() {
|
|
||||||
return !isEmpty(this.lastSearchTerm)
|
|
||||||
},
|
|
||||||
emptyText() {
|
|
||||||
return this.isActive && !this.pending ? this.$t('search.failed') : this.$t('search.hint')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async query(value) {
|
|
||||||
if (isEmpty(value) || value.length < 3) {
|
|
||||||
this.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$emit('search', value)
|
|
||||||
},
|
|
||||||
handleInput(e) {
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
const value = e.target ? e.target.value.trim() : ''
|
|
||||||
this.isOpen = true
|
|
||||||
this.unprocessedSearchInput = value
|
|
||||||
this.searchProcess = setTimeout(() => {
|
|
||||||
this.lastSearchTerm = value
|
|
||||||
this.query(value)
|
|
||||||
}, this.delay)
|
|
||||||
},
|
|
||||||
onSelect(item) {
|
|
||||||
this.isOpen = false
|
|
||||||
this.$emit('select', item)
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.searchValue = this.lastSearchTerm
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onFocus(e) {
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
this.isOpen = true
|
|
||||||
},
|
|
||||||
onBlur(e) {
|
|
||||||
this.searchValue = this.lastSearchTerm
|
|
||||||
// this.$nextTick(() => {
|
|
||||||
// this.searchValue = this.lastSearchTerm
|
|
||||||
// })
|
|
||||||
this.isOpen = false
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
},
|
|
||||||
onDelete(e) {
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
const value = e.target ? e.target.value.trim() : ''
|
|
||||||
if (isEmpty(value)) {
|
|
||||||
this.clear()
|
|
||||||
} else {
|
|
||||||
this.handleInput(e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* TODO: on enter we should go to a dedicated seach page!?
|
|
||||||
*/
|
|
||||||
onEnter(e) {
|
|
||||||
// this.isOpen = false
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
if (!this.pending) {
|
|
||||||
// this.lastSearchTerm = this.unprocessedSearchInput
|
|
||||||
this.query(this.unprocessedSearchInput)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
this.$emit('clear')
|
|
||||||
clearTimeout(this.searchProcess)
|
|
||||||
this.isOpen = false
|
|
||||||
this.unprocessedSearchInput = ''
|
|
||||||
this.lastSearchTerm = ''
|
|
||||||
this.searchValue = ''
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.search {
|
|
||||||
display: flex;
|
|
||||||
align-self: center;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
$padding-left: $space-x-small;
|
|
||||||
|
|
||||||
.search-option-label {
|
|
||||||
align-self: center;
|
|
||||||
padding-left: $padding-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-option-meta {
|
|
||||||
align-self: center;
|
|
||||||
|
|
||||||
.ds-flex {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&,
|
|
||||||
.ds-select-dropdown {
|
|
||||||
transition: box-shadow 100ms;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-open {
|
|
||||||
.ds-select-dropdown {
|
|
||||||
box-shadow: $box-shadow-x-large;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-select-dropdown-message {
|
|
||||||
opacity: 0.5;
|
|
||||||
padding-left: $padding-left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear-btn {
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
position: absolute;
|
|
||||||
height: 100%;
|
|
||||||
width: 36px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-meta {
|
|
||||||
float: right;
|
|
||||||
padding-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
word-wrap: none;
|
|
||||||
|
|
||||||
.base-icon {
|
|
||||||
vertical-align: sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-select {
|
|
||||||
z-index: $z-index-dropdown + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ds-select-option-hover {
|
|
||||||
.ds-text-size-small,
|
|
||||||
.ds-text-size-small-x {
|
|
||||||
color: $text-color-soft;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
64
webapp/components/features/SearchField/SearchField.spec.js
Normal file
64
webapp/components/features/SearchField/SearchField.spec.js
Normal file
@ -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'] = '<span><slot /></span>'
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
51
webapp/components/features/SearchField/SearchField.vue
Normal file
51
webapp/components/features/SearchField/SearchField.vue
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<searchable-input
|
||||||
|
data-test="search-field"
|
||||||
|
:loading="pending"
|
||||||
|
:options="searchResults"
|
||||||
|
@query="query"
|
||||||
|
@clearSearch="clear"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { findResourcesQuery } from '~/graphql/Search.js'
|
||||||
|
import SearchableInput from '~/components/generic/SearchableInput/SearchableInput.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SearchField',
|
||||||
|
components: {
|
||||||
|
SearchableInput,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pending: false,
|
||||||
|
searchResults: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async query(value) {
|
||||||
|
this.pending = true
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { findResources },
|
||||||
|
} = await this.$apollo.query({
|
||||||
|
query: findResourcesQuery,
|
||||||
|
variables: {
|
||||||
|
query: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.searchResults = findResources
|
||||||
|
} catch (error) {
|
||||||
|
this.searchResults = []
|
||||||
|
} finally {
|
||||||
|
this.pending = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.pending = false
|
||||||
|
this.searchResults = []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
26
webapp/components/generic/SearchHeading/SearchHeading.vue
Normal file
26
webapp/components/generic/SearchHeading/SearchHeading.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<ds-flex-item class="search-heading">
|
||||||
|
<ds-heading soft size="h5">
|
||||||
|
{{ $t(`search.heading.${resourceType}`) }}
|
||||||
|
</ds-heading>
|
||||||
|
</ds-flex-item>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SearchHeading',
|
||||||
|
props: {
|
||||||
|
resourceType: { type: String, required: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
.search-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: default;
|
||||||
|
background-color: white;
|
||||||
|
margin: -8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
webapp/components/generic/SearchPost/SearchPost.spec.js
Normal file
64
webapp/components/generic/SearchPost/SearchPost.spec.js
Normal file
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
71
webapp/components/generic/SearchPost/SearchPost.vue
Normal file
71
webapp/components/generic/SearchPost/SearchPost.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<ds-flex class="search-post">
|
||||||
|
<ds-flex-item class="search-option-label">
|
||||||
|
<ds-text>{{ option.title | truncate(70) }}</ds-text>
|
||||||
|
</ds-flex-item>
|
||||||
|
<ds-flex-item class="search-option-meta" width="280px">
|
||||||
|
<ds-flex>
|
||||||
|
<ds-flex-item>
|
||||||
|
<ds-text size="small" color="softer" class="search-post-meta">
|
||||||
|
<span class="comments-count">
|
||||||
|
{{ option.commentsCount }}
|
||||||
|
<base-icon name="comments" />
|
||||||
|
</span>
|
||||||
|
<span class="shouted-count">
|
||||||
|
{{ option.shoutedCount }}
|
||||||
|
<base-icon name="bullhorn" />
|
||||||
|
</span>
|
||||||
|
</ds-text>
|
||||||
|
</ds-flex-item>
|
||||||
|
<ds-flex-item>
|
||||||
|
<ds-text size="small" color="softer" align="right" class="search-post-author">
|
||||||
|
{{ option.author.name | truncate(32) }} -
|
||||||
|
{{ option.createdAt | dateTime('dd.MM.yyyy') }}
|
||||||
|
</ds-text>
|
||||||
|
</ds-flex-item>
|
||||||
|
</ds-flex>
|
||||||
|
</ds-flex-item>
|
||||||
|
</ds-flex>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SearchPost',
|
||||||
|
props: {
|
||||||
|
option: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
.search-post {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.search-option-label {
|
||||||
|
align-self: center;
|
||||||
|
padding-left: $space-x-small;
|
||||||
|
}
|
||||||
|
.search-option-meta {
|
||||||
|
align-self: center;
|
||||||
|
.ds-flex {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-post-meta {
|
||||||
|
float: right;
|
||||||
|
padding-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: none;
|
||||||
|
.base-icon {
|
||||||
|
vertical-align: sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.shouted-count {
|
||||||
|
width: 36px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
.comments-count {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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'] = '<span><slot /></span>'
|
||||||
|
|
||||||
|
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' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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: `
|
||||||
|
<searchable-input :options="searchResults" />
|
||||||
|
`,
|
||||||
|
}))
|
||||||
227
webapp/components/generic/SearchableInput/SearchableInput.vue
Normal file
227
webapp/components/generic/SearchableInput/SearchableInput.vue
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="searchable-input"
|
||||||
|
aria-label="search"
|
||||||
|
role="search"
|
||||||
|
:class="{
|
||||||
|
'is-active': isActive,
|
||||||
|
'is-open': isOpen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<ds-button v-if="isActive" icon="close" ghost class="search-clear-btn" @click="clear" />
|
||||||
|
<ds-select
|
||||||
|
type="search"
|
||||||
|
icon="search"
|
||||||
|
v-model="searchValue"
|
||||||
|
:id="id"
|
||||||
|
label-prop="id"
|
||||||
|
:icon-right="isActive ? 'close' : null"
|
||||||
|
:options="options"
|
||||||
|
:loading="loading"
|
||||||
|
:filter="item => item"
|
||||||
|
:no-options-available="emptyText"
|
||||||
|
:auto-reset-search="!searchValue"
|
||||||
|
:placeholder="$t('search.placeholder')"
|
||||||
|
@click.capture.native="isOpen = true"
|
||||||
|
@focus.capture.native="onFocus"
|
||||||
|
@input.native="handleInput"
|
||||||
|
@keyup.enter.native="onEnter"
|
||||||
|
@keyup.delete.native="onDelete"
|
||||||
|
@keyup.esc.native="clear"
|
||||||
|
@blur.capture.native="onBlur"
|
||||||
|
@input.exact="onSelect"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<span v-if="isFirstOfType(option)" class="search-heading">
|
||||||
|
<search-heading :resource-type="option.__typename" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="option.__typename === 'User'"
|
||||||
|
:class="{ 'option-with-heading': isFirstOfType(option), 'flex-span': true }"
|
||||||
|
>
|
||||||
|
<hc-user :user="option" :showPopover="false" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="option.__typename === 'Post'"
|
||||||
|
:class="{ 'option-with-heading': isFirstOfType(option), 'flex-span': true }"
|
||||||
|
>
|
||||||
|
<search-post :option="option" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ds-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
|
||||||
|
import SearchPost from '~/components/generic/SearchPost/SearchPost.vue'
|
||||||
|
import HcUser from '~/components/User/User.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SearchableInput',
|
||||||
|
components: {
|
||||||
|
SearchHeading,
|
||||||
|
SearchPost,
|
||||||
|
HcUser,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
id: { type: String },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
searchValue: '',
|
||||||
|
value: '',
|
||||||
|
unprocessedSearchInput: '',
|
||||||
|
searchProcess: null,
|
||||||
|
previousSearchTerm: '',
|
||||||
|
delay: 300,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
emptyText() {
|
||||||
|
return this.isActive && !this.pending ? this.$t('search.failed') : this.$t('search.hint')
|
||||||
|
},
|
||||||
|
isActive() {
|
||||||
|
return !isEmpty(this.previousSearchTerm)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isFirstOfType(option) {
|
||||||
|
return (
|
||||||
|
this.options.findIndex(o => o === option) ===
|
||||||
|
this.options.findIndex(o => o.__typename === option.__typename)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFocus(event) {
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
this.isOpen = true
|
||||||
|
},
|
||||||
|
handleInput(event) {
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
this.value = event.target ? event.target.value.replace(/\s+/g, ' ').trim() : ''
|
||||||
|
this.isOpen = true
|
||||||
|
this.unprocessedSearchInput = this.value
|
||||||
|
if (isEmpty(this.value) || this.value.replace(/\s+/g, '').length < 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.searchProcess = setTimeout(() => {
|
||||||
|
this.previousSearchTerm = this.value
|
||||||
|
this.$emit('query', this.value)
|
||||||
|
}, this.delay)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* TODO: on enter we should go to a dedicated search page!?
|
||||||
|
*/
|
||||||
|
onEnter(event) {
|
||||||
|
this.isOpen = false
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
if (!this.pending) {
|
||||||
|
this.previousSearchTerm = this.unprocessedSearchInput
|
||||||
|
this.$emit('query', this.unprocessedSearchInput)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete(event) {
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
const value = event.target ? event.target.value.trim() : ''
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
this.clear()
|
||||||
|
} else {
|
||||||
|
this.handleInput(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.isOpen = false
|
||||||
|
this.unprocessedSearchInput = ''
|
||||||
|
this.previousSearchTerm = ''
|
||||||
|
this.searchValue = ''
|
||||||
|
this.$emit('clearSearch')
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
},
|
||||||
|
onBlur(event) {
|
||||||
|
this.searchValue = this.previousSearchTerm
|
||||||
|
this.isOpen = false
|
||||||
|
clearTimeout(this.searchProcess)
|
||||||
|
},
|
||||||
|
onSelect(item) {
|
||||||
|
this.isOpen = false
|
||||||
|
this.goToResource(item)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.searchValue = this.previousSearchTerm
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isPost(item) {
|
||||||
|
return item.__typename === 'Post'
|
||||||
|
},
|
||||||
|
goToResource(item) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$router.push({
|
||||||
|
name: this.isPost(item) ? 'post-id-slug' : 'profile-id-slug',
|
||||||
|
params: { id: item.id, slug: item.slug },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
.searchable-input {
|
||||||
|
display: flex;
|
||||||
|
align-self: center;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
$padding-left: $space-x-small;
|
||||||
|
.option-with-heading {
|
||||||
|
margin-top: $space-x-small;
|
||||||
|
padding-top: $space-xx-small;
|
||||||
|
}
|
||||||
|
.flex-span {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ds-select-dropdown {
|
||||||
|
transition: box-shadow 100ms;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
&.is-open {
|
||||||
|
.ds-select-dropdown {
|
||||||
|
box-shadow: $box-shadow-x-large;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ds-select-dropdown-message {
|
||||||
|
opacity: 0.5;
|
||||||
|
padding-left: $padding-left;
|
||||||
|
}
|
||||||
|
.search-clear-btn {
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ds-select {
|
||||||
|
z-index: $z-index-dropdown + 1;
|
||||||
|
}
|
||||||
|
.ds-select-option-hover {
|
||||||
|
.ds-text-size-small,
|
||||||
|
.ds-text-size-small-x {
|
||||||
|
color: $text-color-soft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
webapp/graphql/Search.js
Normal file
24
webapp/graphql/Search.js
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -19,18 +19,10 @@
|
|||||||
<ds-flex-item
|
<ds-flex-item
|
||||||
:width="{ base: '85%', sm: '85%', md: '50%', lg: '50%' }"
|
:width="{ base: '85%', sm: '85%', md: '50%', lg: '50%' }"
|
||||||
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
|
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
|
||||||
|
id="nav-search-box"
|
||||||
|
v-if="isLoggedIn"
|
||||||
>
|
>
|
||||||
<div id="nav-search-box" v-if="isLoggedIn">
|
<search-field />
|
||||||
<search-input
|
|
||||||
id="nav-search"
|
|
||||||
:delay="300"
|
|
||||||
:pending="quickSearchPending"
|
|
||||||
:results="quickSearchResults"
|
|
||||||
@clear="quickSearchClear"
|
|
||||||
@search="value => quickSearch({ value })"
|
|
||||||
@select="goToPost"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item
|
<ds-flex-item
|
||||||
v-if="isLoggedIn"
|
v-if="isLoggedIn"
|
||||||
@ -90,9 +82,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters, mapActions } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
|
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
|
||||||
import SearchInput from '~/components/SearchInput.vue'
|
import SearchField from '~/components/features/SearchField/SearchField.vue'
|
||||||
import Modal from '~/components/Modal'
|
import Modal from '~/components/Modal'
|
||||||
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
|
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
|
||||||
import seo from '~/mixins/seo'
|
import seo from '~/mixins/seo'
|
||||||
@ -104,7 +96,7 @@ import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
|
|||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
LocaleSwitch,
|
LocaleSwitch,
|
||||||
SearchInput,
|
SearchField,
|
||||||
Modal,
|
Modal,
|
||||||
NotificationMenu,
|
NotificationMenu,
|
||||||
AvatarMenu,
|
AvatarMenu,
|
||||||
@ -122,8 +114,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
isLoggedIn: 'auth/isLoggedIn',
|
isLoggedIn: 'auth/isLoggedIn',
|
||||||
quickSearchResults: 'search/quickResults',
|
|
||||||
quickSearchPending: 'search/quickPending',
|
|
||||||
}),
|
}),
|
||||||
showFilterPostsDropdown() {
|
showFilterPostsDropdown() {
|
||||||
const [firstRoute] = this.$route.matched
|
const [firstRoute] = this.$route.matched
|
||||||
@ -136,18 +126,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions({
|
|
||||||
quickSearchClear: 'search/quickClear',
|
|
||||||
quickSearch: 'search/quickSearch',
|
|
||||||
}),
|
|
||||||
goToPost(item) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$router.push({
|
|
||||||
name: 'post-id-slug',
|
|
||||||
params: { id: item.id, slug: item.slug },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleMobileMenuView() {
|
toggleMobileMenuView() {
|
||||||
this.toggleMobileMenu = !this.toggleMobileMenu
|
this.toggleMobileMenu = !this.toggleMobileMenu
|
||||||
},
|
},
|
||||||
|
|||||||
@ -515,7 +515,11 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Suchen",
|
"placeholder": "Suchen",
|
||||||
"hint": "Wonach suchst Du?",
|
"hint": "Wonach suchst Du?",
|
||||||
"failed": "Nichts gefunden"
|
"failed": "Nichts gefunden",
|
||||||
|
"heading": {
|
||||||
|
"Post": "Beiträge",
|
||||||
|
"User": "Benutzer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"password-reset": {
|
"password-reset": {
|
||||||
|
|||||||
@ -199,7 +199,11 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search",
|
"placeholder": "Search",
|
||||||
"hint": "What are you searching for?",
|
"hint": "What are you searching for?",
|
||||||
"failed": "Nothing found"
|
"failed": "Nothing found",
|
||||||
|
"heading": {
|
||||||
|
"Post": "Posts",
|
||||||
|
"User": "Users"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"name": "Settings",
|
"name": "Settings",
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
import gql from 'graphql-tag'
|
|
||||||
import isString from 'lodash/isString'
|
|
||||||
|
|
||||||
export const state = () => {
|
|
||||||
return {
|
|
||||||
quickResults: [],
|
|
||||||
quickPending: false,
|
|
||||||
quickValue: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mutations = {
|
|
||||||
SET_QUICK_RESULTS(state, results) {
|
|
||||||
state.quickResults = results || []
|
|
||||||
state.quickPending = false
|
|
||||||
},
|
|
||||||
SET_QUICK_PENDING(state, pending) {
|
|
||||||
state.quickPending = pending
|
|
||||||
},
|
|
||||||
SET_QUICK_VALUE(state, value) {
|
|
||||||
state.quickValue = value
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getters = {
|
|
||||||
quickResults(state) {
|
|
||||||
return state.quickResults
|
|
||||||
},
|
|
||||||
quickPending(state) {
|
|
||||||
return state.quickPending
|
|
||||||
},
|
|
||||||
quickValue(state) {
|
|
||||||
return state.quickValue
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
async quickSearch({ commit, getters }, { value }) {
|
|
||||||
value = isString(value) ? value.trim() : ''
|
|
||||||
const lastVal = getters.quickValue
|
|
||||||
if (value.length < 3 || lastVal.toLowerCase() === value.toLowerCase()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commit('SET_QUICK_VALUE', value)
|
|
||||||
commit('SET_QUICK_PENDING', true)
|
|
||||||
await this.app.apolloProvider.defaultClient
|
|
||||||
.query({
|
|
||||||
query: gql`
|
|
||||||
query findPosts($query: String!, $filter: _PostFilter) {
|
|
||||||
findPosts(query: $query, limit: 10, filter: $filter) {
|
|
||||||
id
|
|
||||||
slug
|
|
||||||
label: title
|
|
||||||
value: title
|
|
||||||
shoutedCount
|
|
||||||
createdAt
|
|
||||||
author {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
variables: {
|
|
||||||
query: value.replace(/\s/g, '~ ') + '~',
|
|
||||||
filter: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
commit('SET_QUICK_RESULTS', res.data.findPosts || [])
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
commit('SET_QUICK_RESULTS', [])
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
commit('SET_QUICK_PENDING', false)
|
|
||||||
})
|
|
||||||
return getters.quickResults
|
|
||||||
},
|
|
||||||
async quickClear({ commit }) {
|
|
||||||
commit('SET_QUICK_PENDING', false)
|
|
||||||
commit('SET_QUICK_RESULTS', [])
|
|
||||||
commit('SET_QUICK_VALUE', '')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user