Merge pull request #5543 from Ocelot-Social-Community/search-groups

feat: 🍰 Search For Groups
This commit is contained in:
Wolfgang Huß 2022-10-23 14:25:42 +02:00 committed by GitHub
commit 717b36b60c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 269 additions and 18 deletions

View File

@ -202,6 +202,8 @@ jobs:
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
- name: backend | Initialize Database
run: docker-compose exec -T backend yarn db:migrate init
- name: backend | Migrate Database Up
run: docker-compose exec -T backend yarn db:migrate up
- name: backend | Unit test
run: docker-compose exec -T backend yarn test
##########################################################################

View File

@ -302,6 +302,7 @@ export default shield(
searchResults: allow,
searchPosts: allow,
searchUsers: allow,
searchGroups: allow,
searchHashtags: allow,
embed: allow,
Category: allow,

View File

@ -66,6 +66,21 @@ const searchHashtagsSetup = {
limit: 'LIMIT $limit',
}
const searchGroupsSetup = {
fulltextIndex: 'group_fulltext_search',
match: `MATCH (resource:Group)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(resource)
WITH user, resource, membership`,
whereClause: `WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
AND (resource.groupType IN ['public', 'closed']
OR membership.role IN ['usual', 'admin', 'owner'])`,
withClause: 'WITH resource, membership',
returnClause: 'resource { .*, myRole: membership.role, __typename: labels(resource)[0] }',
limit: 'LIMIT $limit',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
limit: '',
@ -83,6 +98,10 @@ const countHashtagsSetup = {
...searchHashtagsSetup,
...countSetup,
}
const countGroupsSetup = {
...searchGroupsSetup,
...countSetup,
}
const searchResultPromise = async (session, setup, params) => {
return session.readTransaction(async (transaction) => {
@ -113,6 +132,7 @@ const multiSearchMap = [
{ symbol: '!', setup: searchPostsSetup, resultName: 'posts' },
{ symbol: '@', setup: searchUsersSetup, resultName: 'users' },
{ symbol: '#', setup: searchHashtagsSetup, resultName: 'hashtags' },
{ symbol: '&', setup: searchGroupsSetup, resultName: 'groups' },
]
export default {
@ -178,13 +198,36 @@ export default {
}),
}
},
searchGroups: async (_parent, args, context, _resolveInfo) => {
const { query, groupsOffset, firstGroups } = args
let userId = null
if (context.user) userId = context.user.id
return {
groupCount: getSearchResults(
context,
countGroupsSetup,
{
query: queryString(query),
skip: 0,
userId,
},
countResultCallback,
),
groups: getSearchResults(context, searchGroupsSetup, {
query: queryString(query),
skip: groupsOffset,
limit: firstGroups,
userId,
}),
}
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
let userId = null
if (context.user) userId = context.user.id
const searchType = query.replace(/^([!@#]?).*$/, '$1')
const searchString = query.replace(/^([!@#])/, '')
const searchType = query.replace(/^([!@#&]?).*$/, '$1')
const searchString = query.replace(/^([!@#&])/, '')
const params = {
query: queryString(searchString),
@ -197,6 +240,7 @@ export default {
return [
...(await getSearchResults(context, searchPostsSetup, params)),
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchGroupsSetup, params)),
...(await getSearchResults(context, searchHashtagsSetup, params)),
]

View File

@ -1,4 +1,4 @@
union SearchResult = Post | User | Tag
union SearchResult = Post | User | Tag | Group
type postSearchResults {
postCount: Int
@ -15,9 +15,15 @@ type hashtagSearchResults {
hashtags: [Tag]!
}
type groupSearchResults {
groupCount: Int
groups: [Group]!
}
type Query {
searchPosts(query: String!, firstPosts: Int, postsOffset: Int): postSearchResults!
searchUsers(query: String!, firstUsers: Int, usersOffset: Int): userSearchResults!
searchGroups(query: String!, firstGroups: Int, groupsOffset: Int): groupSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
}

View File

@ -36,6 +36,7 @@ describe('SearchResults', () => {
}
propsData = {
pageSize: 12,
search: '',
}
wrapper = Wrapper()
})
@ -169,7 +170,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 12 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 12 })
})
it('displays the next page button when next-button is clicked', async () => {
@ -199,7 +200,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 24 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 24 })
})
it('deactivates next page button when next-button is clicked twice', async () => {
@ -234,7 +235,7 @@ describe('SearchResults', () => {
await wrapper.vm.$nextTick()
await expect(
wrapper.vm.$options.apollo.searchPosts.variables.bind(wrapper.vm)(),
).toMatchObject({ query: undefined, firstPosts: 12, postsOffset: 0 })
).toMatchObject({ query: '', firstPosts: 12, postsOffset: 0 })
})
})
})

View File

@ -59,6 +59,14 @@
</base-card>
</ds-grid-item>
</template>
<!-- groups -->
<template v-if="activeTab === 'Group'">
<ds-grid-item v-for="group in activeResources" :key="group.id" :row-span="2">
<base-card :wideContent="true" class="group-teaser-card-wrapper">
<group-teaser :group="{ ...group, name: group.groupName }" />
</base-card>
</ds-grid-item>
</template>
<!-- hashtags -->
<template v-if="activeTab === 'Hashtag'">
<ds-grid-item v-for="hashtag in activeResources" :key="hashtag.id" :row-span="2">
@ -100,17 +108,19 @@
<script>
import postListActions from '~/mixins/postListActions'
import { searchPosts, searchUsers, searchHashtags } from '~/graphql/Search.js'
import { searchPosts, searchUsers, searchGroups, searchHashtags } from '~/graphql/Search.js'
import HcEmpty from '~/components/Empty/Empty'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem'
import PostTeaser from '~/components/PostTeaser/PostTeaser'
import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import GroupTeaser from '~/components/Group/GroupTeaser'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import HcHashtag from '~/components/Hashtag/Hashtag'
export default {
name: 'SearchResults',
components: {
TabNavigation,
HcEmpty,
@ -119,6 +129,7 @@ export default {
PostTeaser,
PaginationButtons,
UserTeaser,
GroupTeaser,
HcHashtag,
},
mixins: [postListActions],
@ -135,24 +146,29 @@ export default {
return {
posts: [],
users: [],
groups: [],
hashtags: [],
postCount: 0,
userCount: 0,
groupCount: 0,
hashtagCount: 0,
postPage: 0,
userPage: 0,
groupPage: 0,
hashtagPage: 0,
activeTab: null,
firstPosts: this.pageSize,
firstUsers: this.pageSize,
firstGroups: this.pageSize,
firstHashtags: this.pageSize,
postsOffset: 0,
usersOffset: 0,
groupsOffset: 0,
hashtagsOffset: 0,
}
},
@ -160,18 +176,21 @@ export default {
activeResources() {
if (this.activeTab === 'Post') return this.posts
if (this.activeTab === 'User') return this.users
if (this.activeTab === 'Group') return this.groups
if (this.activeTab === 'Hashtag') return this.hashtags
return []
},
activeResourceCount() {
if (this.activeTab === 'Post') return this.postCount
if (this.activeTab === 'User') return this.userCount
if (this.activeTab === 'Group') return this.groupCount
if (this.activeTab === 'Hashtag') return this.hashtagCount
return 0
},
activePage() {
if (this.activeTab === 'Post') return this.postPage
if (this.activeTab === 'User') return this.userPage
if (this.activeTab === 'Group') return this.groupPage
if (this.activeTab === 'Hashtag') return this.hashtagPage
return 0
},
@ -189,6 +208,12 @@ export default {
count: this.userCount,
disabled: this.userCount === 0,
},
{
type: 'Group',
title: this.$t('search.heading.Group', {}, this.groupCount),
count: this.groupCount,
disabled: this.groupCount === 0,
},
{
type: 'Hashtag',
title: this.$t('search.heading.Tag', {}, this.hashtagCount),
@ -200,24 +225,27 @@ export default {
hasPrevious() {
if (this.activeTab === 'Post') return this.postsOffset > 0
if (this.activeTab === 'User') return this.usersOffset > 0
if (this.activeTab === 'Group') return this.groupsOffset > 0
if (this.activeTab === 'Hashtag') return this.hashtagsOffset > 0
return false
},
hasNext() {
if (this.activeTab === 'Post') return (this.postPage + 1) * this.pageSize < this.postCount
if (this.activeTab === 'User') return (this.userPage + 1) * this.pageSize < this.userCount
if (this.activeTab === 'Group') return (this.groupPage + 1) * this.pageSize < this.groupCount
if (this.activeTab === 'Hashtag')
return (this.hashtagPage + 1) * this.pageSize < this.hashtagCount
return false
},
searchCount() {
return this.postCount + this.userCount + this.hashtagCount
return this.postCount + this.userCount + this.groupCount + this.hashtagCount
},
},
methods: {
clearPage() {
this.postPage = 0
this.userPage = 0
this.groupPage = 0
this.hashtagPage = 0
},
switchTab(tabType) {
@ -235,6 +263,10 @@ export default {
this.userPage--
this.usersOffset = this.userPage * this.pageSize
break
case 'Group':
this.groupPage--
this.groupsOffset = this.groupPage * this.pageSize
break
case 'Hashtag':
this.hashtagPage--
this.hashtagsOffset = this.hashtagPage * this.pageSize
@ -252,6 +284,10 @@ export default {
this.userPage++
this.usersOffset += this.pageSize
break
case 'Group':
this.groupPage++
this.groupsOffset += this.pageSize
break
case 'Hashtag':
this.hashtagPage++
this.hashtagsOffset += this.pageSize
@ -270,7 +306,7 @@ export default {
variables() {
const { firstHashtags, hashtagsOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstHashtags,
hashtagsOffset,
}
@ -281,7 +317,12 @@ export default {
update({ searchHashtags }) {
this.hashtags = searchHashtags.hashtags
this.hashtagCount = searchHashtags.hashtagCount
if (this.postCount === 0 && this.userCount === 0 && this.hashtagCount > 0)
if (
this.postCount === 0 &&
this.userCount === 0 &&
this.groupCount === 0 &&
this.hashtagCount > 0
)
this.activeTab = 'Hashtag'
},
fetchPolicy: 'cache-and-network',
@ -293,7 +334,7 @@ export default {
variables() {
const { firstUsers, usersOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstUsers,
usersOffset,
}
@ -315,7 +356,7 @@ export default {
variables() {
const { firstPosts, postsOffset, search } = this
return {
query: search,
query: search.replace(/^([!@#&])/, ''),
firstPosts,
postsOffset,
}
@ -330,6 +371,29 @@ export default {
},
fetchPolicy: 'cache-and-network',
},
searchGroups: {
query() {
return searchGroups(this.i18n)
},
variables() {
const { firstGroups, groupsOffset, search } = this
return {
query: search.replace(/^([!@#&])/, ''),
firstGroups,
groupsOffset,
}
},
skip() {
return !this.search
},
update({ searchGroups }) {
this.groups = searchGroups.groups
this.groupCount = searchGroups.groupCount
if (this.postCount === 0 && this.userCount === 0 && this.groupCount > 0)
this.activeTab = 'Group'
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>
@ -365,6 +429,10 @@ export default {
opacity: 0.8;
}
}
.group-teaser-card-wrapper {
padding: 0;
}
}
.grid-total-search-results {

View File

@ -0,0 +1,38 @@
<template>
<section class="search-group">
<p class="label">{{ option.groupName | truncate(70) }}</p>
<div class="metadata"></div>
</section>
</template>
<script>
export default {
name: 'SearchGroup',
props: {
option: { type: Object, required: true },
},
}
</script>
<style lang="scss">
.search-group {
display: flex;
> .label {
flex-grow: 1;
padding: 0 $space-x-small;
}
> .metadata {
display: flex;
flex-direction: column;
align-items: flex-end;
color: $text-color-softer;
font-size: $font-size-small;
> .counts > .counter-icon {
margin: 0 $space-x-small;
}
}
}
</style>

View File

@ -35,6 +35,12 @@
>
<search-post :option="option" />
</p>
<p
v-if="option.__typename === 'Group'"
:class="{ 'option-with-heading': isFirstOfType(option) }"
>
<search-group :option="option" />
</p>
<p
v-if="option.__typename === 'Tag'"
:class="{ 'option-with-heading': isFirstOfType(option) }"
@ -51,6 +57,7 @@
import { isEmpty } from 'lodash'
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
import SearchPost from '~/components/generic/SearchPost/SearchPost.vue'
import SearchGroup from '~/components/generic/SearchGroup/SearchGroup.vue'
import HcHashtag from '~/components/Hashtag/Hashtag.vue'
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
@ -58,6 +65,7 @@ export default {
name: 'SearchableInput',
components: {
SearchHeading,
SearchGroup,
SearchPost,
HcHashtag,
UserTeaser,
@ -140,8 +148,17 @@ export default {
this.searchValue = this.previousSearchTerm
})
},
isPost(item) {
return item.__typename === 'Post'
getRouteName(item) {
switch (item.__typename) {
case 'Post':
return 'post-id-slug'
case 'User':
return 'profile-id-slug'
case 'Group':
return 'group-id-slug'
default:
return null
}
},
isTag(item) {
return item.__typename === 'Tag'
@ -150,7 +167,7 @@ export default {
this.$nextTick(() => {
if (!this.isTag(item)) {
this.$router.push({
name: this.isPost(item) ? 'post-id-slug' : 'profile-id-slug',
name: this.getRouteName(item),
params: { id: item.id, slug: item.slug },
})
} else {

View File

@ -62,6 +62,29 @@ export const postFragment = gql`
}
`
export const groupFragment = gql`
fragment group on Group {
id
groupName: name
slug
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
locationName
myRole
}
`
export const postCountsFragment = gql`
fragment postCounts on Post {
commentsCount

View File

@ -1,9 +1,15 @@
import gql from 'graphql-tag'
import { userFragment, postFragment, tagsCategoriesAndPinnedFragment } from './Fragments'
import {
userFragment,
postFragment,
groupFragment,
tagsCategoriesAndPinnedFragment,
} from './Fragments'
export const searchQuery = gql`
${userFragment}
${postFragment}
${groupFragment}
query ($query: String!) {
searchResults(query: $query, limit: 5) {
@ -24,6 +30,9 @@ export const searchQuery = gql`
... on Tag {
id
}
... on Group {
...group
}
}
}
`
@ -52,6 +61,46 @@ export const searchPosts = gql`
}
`
export const searchGroups = (i18n) => {
const lang = i18n ? i18n.locale().toUpperCase() : 'EN'
return gql`
query ($query: String!, $firstGroups: Int, $groupsOffset: Int) {
searchGroups(query: $query, firstGroups: $firstGroups, groupsOffset: $groupsOffset) {
groupCount
groups {
__typename
id
groupName: name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
avatar {
url
}
locationName
location {
name: name${lang}
}
myRole
}
}
}
`
}
export const searchUsers = gql`
${userFragment}

View File

@ -745,11 +745,12 @@
"failed": "Nichts gefunden",
"for": "Suche nach ",
"heading": {
"Group": "Gruppe ::: Gruppen",
"Post": "Beitrag ::: Beiträge",
"Tag": "Hashtag ::: Hashtags",
"User": "Benutzer ::: Benutzer"
},
"hint": "Wonach suchst Du? Nutze !… für Beiträge, @… für Mitglieder, #… für Hashtags",
"hint": "Wonach suchst Du? Nutze !… für Beiträge, @… für Mitglieder, &… für Gruppen, #… für Hashtags",
"no-results": "Keine Ergebnisse für \"{search}\" gefunden. Versuch' es mit einem anderen Begriff!",
"page": "Seite",
"placeholder": "Suchen",

View File

@ -745,11 +745,12 @@
"failed": "Nothing found",
"for": "Searching for ",
"heading": {
"Group": "Group ::: Groups",
"Post": "Post ::: Posts",
"Tag": "Hashtag ::: Hashtags",
"User": "User ::: Users"
},
"hint": "What are you searching for? Use !… for posts, @… for users, #… for hashtags.",
"hint": "What are you searching for? Use !… for posts, @… for users, &… for groups, #… for hashtags.",
"no-results": "No results found for \"{search}\". Try a different search term!",
"page": "Page",
"placeholder": "Search",