mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
fix(webapp): fix group performance (#8656)
* seed more Yoga group members * implement groupMembers pagination * load limited amount of group members * force show all members in group member list * remove unused import * - added virtual scrolling to ProfileList * - fixed linter error * load all when clicking the button * seed 3000 users * cleanup * lint * hide search when not all members are visible * fix email factory * - increased profileListVisibleCount to 6 --------- Co-authored-by: Sebastian Stein <sebastian@codepassion.de>
This commit is contained in:
parent
6ae392b6e3
commit
a0e4b49833
@ -11,6 +11,9 @@ import slugify from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { generateInviteCode } from '@graphql/resolvers/inviteCodes'
|
||||
import { isUniqueFor } from '@middleware/sluggifyMiddleware'
|
||||
import uniqueSlug from '@middleware/slugify/uniqueSlug'
|
||||
import { Context } from '@src/server'
|
||||
|
||||
import { getDriver, getNeode } from './neo4j'
|
||||
|
||||
@ -22,8 +25,9 @@ const uniqueImageUrl = (imageUrl) => {
|
||||
return newUrl.toString()
|
||||
}
|
||||
|
||||
export const cleanDatabase = async ({ withMigrations } = { withMigrations: false }) => {
|
||||
const driver = getDriver()
|
||||
|
||||
export const cleanDatabase = async ({ withMigrations } = { withMigrations: false }) => {
|
||||
const session = driver.session()
|
||||
|
||||
const clean = `
|
||||
@ -89,9 +93,7 @@ Factory.define('basicUser')
|
||||
showShoutsPublicly: false,
|
||||
locale: 'en',
|
||||
})
|
||||
.attr('slug', ['slug', 'name'], (slug, name) => {
|
||||
return slug || slugify(name, { lower: true })
|
||||
})
|
||||
.attr('slug', null)
|
||||
.attr('encryptedPassword', ['password'], (password) => {
|
||||
// eslint-disable-next-line n/no-sync
|
||||
return hashSync(password, 10)
|
||||
@ -121,13 +123,24 @@ Factory.define('userWithAboutEmpty')
|
||||
Factory.define('user')
|
||||
.extend('basicUser')
|
||||
.option('about', faker.lorem.paragraph)
|
||||
.option('email', faker.internet.exampleEmail)
|
||||
.option('email', null)
|
||||
.option('avatar', () =>
|
||||
Factory.build('image', {
|
||||
url: faker.image.avatar(),
|
||||
}),
|
||||
)
|
||||
.after(async (buildObject, options) => {
|
||||
// Ensure unique slug
|
||||
if (!buildObject.slug) {
|
||||
buildObject.slug = await uniqueSlug(
|
||||
buildObject.name,
|
||||
isUniqueFor({ driver } as unknown as Context, 'User'),
|
||||
)
|
||||
}
|
||||
// Ensure unique email
|
||||
if (!options.email) {
|
||||
options.email = `${buildObject.slug as string}@example.org`
|
||||
}
|
||||
const [user, email, avatar] = await Promise.all([
|
||||
neode.create('User', buildObject),
|
||||
neode.create('EmailAddress', { email: options.email }),
|
||||
|
||||
@ -1197,11 +1197,22 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const additionalUsers: any[] = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const user = await Factory.build('user')
|
||||
await jennyRostock.relateTo(user, 'following')
|
||||
await user.relateTo(jennyRostock, 'following')
|
||||
additionalUsers.push(user)
|
||||
|
||||
const userObj = await user.toJson()
|
||||
authenticatedUser = userObj
|
||||
|
||||
await mutate({
|
||||
mutation: joinGroupMutation(),
|
||||
variables: {
|
||||
groupId: 'g2',
|
||||
userId: userObj.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Jenny users
|
||||
|
||||
@ -80,15 +80,18 @@ export default {
|
||||
}
|
||||
},
|
||||
GroupMembers: async (_object, params, context: Context, _resolveInfo) => {
|
||||
const { id: groupId } = params
|
||||
const { id: groupId, first = 25, offset = 0 } = params
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const groupMemberCypher = `
|
||||
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
|
||||
RETURN user {.*, myRoleInGroup: membership.role}
|
||||
SKIP toInteger($offset) LIMIT toInteger($first)
|
||||
`
|
||||
const transactionResponse = await txc.run(groupMemberCypher, {
|
||||
groupId,
|
||||
first,
|
||||
offset,
|
||||
})
|
||||
return transactionResponse.records.map((record) => record.get('user'))
|
||||
})
|
||||
@ -468,6 +471,9 @@ export default {
|
||||
isMutedByMe:
|
||||
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
|
||||
},
|
||||
count: {
|
||||
membersCount: '<-[:MEMBER_OF]-(related:User)',
|
||||
},
|
||||
}),
|
||||
name: async (parent, _args, context: Context, _resolveInfo) => {
|
||||
if (!context.user) {
|
||||
|
||||
@ -38,6 +38,8 @@ type Group {
|
||||
|
||||
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
|
||||
|
||||
membersCount: Int! @cypher(statement: "MATCH (this)<-[:MEMBER_OF]-(r:User) RETURN COUNT(DISTINCT r)")
|
||||
|
||||
myRole: GroupMemberRole # if 'null' then the current user is no member
|
||||
|
||||
posts: [Post] @relation(name: "IN", direction: "IN")
|
||||
@ -78,8 +80,8 @@ type Query {
|
||||
|
||||
GroupMembers(
|
||||
id: ID!
|
||||
# first: Int # not implemented yet
|
||||
# offset: Int # not implemented yet
|
||||
first: Int
|
||||
offset: Int
|
||||
# orderBy: [_UserOrdering] # not implemented yet
|
||||
# filter: _UserFilter # not implemented yet
|
||||
): [User]
|
||||
|
||||
@ -5,7 +5,7 @@ import type { Context } from '@src/server'
|
||||
|
||||
import uniqueSlug from './slugify/uniqueSlug'
|
||||
|
||||
const isUniqueFor = (context: Context, type: string) => {
|
||||
export const isUniqueFor = (context: Context, type: string) => {
|
||||
return async (slug: string) => {
|
||||
const session = context.driver.session()
|
||||
try {
|
||||
|
||||
@ -4,24 +4,44 @@
|
||||
<h5 class="title spacer-x-small">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<ul :class="profilesClass">
|
||||
|
||||
<!-- Virtual Scroller for better performance -->
|
||||
<recycle-scroller
|
||||
v-if="isMoreAsVisible && showVirtualScroll"
|
||||
:items="filteredConnections"
|
||||
:item-size="itemHeight"
|
||||
key-field="id"
|
||||
:class="profilesClass"
|
||||
class="profiles-virtual"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<div class="connections__item">
|
||||
<user-teaser :user="item" />
|
||||
</div>
|
||||
</recycle-scroller>
|
||||
|
||||
<!-- Normal list for only a few items -->
|
||||
<ul v-else :class="profilesClass">
|
||||
<li
|
||||
v-for="connection in filteredConnections"
|
||||
v-for="connection in displayedConnections"
|
||||
:key="connection.id"
|
||||
class="connections__item"
|
||||
>
|
||||
<user-teaser :user="connection" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ds-input
|
||||
v-if="isMoreAsVisible"
|
||||
v-if="isMoreAsVisible && !hasMore"
|
||||
:name="uniqueName"
|
||||
:placeholder="filter"
|
||||
:placeholder="filterPlaceholder"
|
||||
:value="filter"
|
||||
class="spacer-x-small"
|
||||
icon="filter"
|
||||
size="small"
|
||||
@input.native="setFilter"
|
||||
/>
|
||||
|
||||
<base-button
|
||||
v-if="hasMore"
|
||||
:loading="loading"
|
||||
@ -42,14 +62,19 @@
|
||||
|
||||
<script>
|
||||
import { escape } from 'xregexp/xregexp-all.js'
|
||||
// @ts-ignore
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser'
|
||||
|
||||
export const profileListVisibleCount = 7
|
||||
export const profileListVisibleCount = 6
|
||||
const VIRTUAL_SCROLL_THRESHOLD = 50
|
||||
|
||||
export default {
|
||||
name: 'ProfileList',
|
||||
components: {
|
||||
UserTeaser,
|
||||
RecycleScroller,
|
||||
},
|
||||
props: {
|
||||
uniqueName: { type: String, required: true },
|
||||
@ -63,6 +88,8 @@ export default {
|
||||
return {
|
||||
profileListVisibleCount,
|
||||
filter: null,
|
||||
itemHeight: 56,
|
||||
filterPlaceholder: this.$t('common.filter', 'Filter...'),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -72,17 +99,34 @@ export default {
|
||||
isMoreAsVisible() {
|
||||
return this.profiles.length > this.profileListVisibleCount
|
||||
},
|
||||
showVirtualScroll() {
|
||||
return process.client && this.filteredConnections.length > VIRTUAL_SCROLL_THRESHOLD
|
||||
},
|
||||
profilesClass() {
|
||||
return `profiles${this.isMoreAsVisible ? ' --overflow' : ''}`
|
||||
},
|
||||
displayedConnections() {
|
||||
return this.isMoreAsVisible
|
||||
? this.filteredConnections
|
||||
: this.filteredConnections.slice(0, this.profileListVisibleCount)
|
||||
},
|
||||
filteredConnections() {
|
||||
if (!this.filter) {
|
||||
return this.profiles
|
||||
}
|
||||
|
||||
// @example
|
||||
// this.filter = 'foo';
|
||||
// fuzzyExpression = /([^f]*f)([^o]*o)([^o]*o)/i
|
||||
const filterLower = this.filter.toLowerCase()
|
||||
|
||||
const simpleMatches = this.profiles.filter((user) => {
|
||||
const name = (user.name || '').toLowerCase()
|
||||
const slug = (user.slug || '').toLowerCase()
|
||||
return name.includes(filterLower) || slug.includes(filterLower)
|
||||
})
|
||||
|
||||
if (simpleMatches.length > 0) {
|
||||
return simpleMatches
|
||||
}
|
||||
|
||||
const fuzzyExpression = new RegExp(
|
||||
`${this.filter.split('').reduce((expr, c) => `${expr}([^${escape(c)}]*${escape(c)})`, '')}`,
|
||||
'i',
|
||||
@ -151,6 +195,26 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.profiles-virtual {
|
||||
height: $size-height-connections;
|
||||
padding: $space-none;
|
||||
|
||||
&.--overflow {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.connections__item {
|
||||
padding: $space-xx-small;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: $background-color-primary-inverse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nobody-message {
|
||||
text-align: center;
|
||||
color: $text-color-soft;
|
||||
@ -160,4 +224,8 @@ export default {
|
||||
margin-bottom: $space-small;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-recycle-scroller__item-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -194,6 +194,7 @@ export const groupQuery = (i18n) => {
|
||||
lng
|
||||
lat
|
||||
}
|
||||
membersCount
|
||||
myRole
|
||||
inviteCodes {
|
||||
createdAt
|
||||
@ -212,8 +213,8 @@ export const groupQuery = (i18n) => {
|
||||
|
||||
export const groupMembersQuery = () => {
|
||||
return gql`
|
||||
query ($id: ID!) {
|
||||
GroupMembers(id: $id) {
|
||||
query ($id: ID!, $first: Int, $offset: Int) {
|
||||
GroupMembers(id: $id, first: $first, offset: $offset) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
|
||||
@ -61,6 +61,7 @@
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vue-scrollto": "^2.20.0",
|
||||
"vue-sweetalert-icons": "~4.3.1",
|
||||
"vue-virtual-scroller": "^1.1.2",
|
||||
"vue2-datepicker": "^3.11.1",
|
||||
"vuex-i18n": "~1.13.1",
|
||||
"xregexp": "^5.1.2",
|
||||
|
||||
@ -55,29 +55,11 @@
|
||||
<count-to
|
||||
slot="count"
|
||||
:start-val="membersCountStartValue"
|
||||
:end-val="groupMembers.length"
|
||||
:end-val="group.membersCount"
|
||||
/>
|
||||
</ds-number>
|
||||
</client-only>
|
||||
</ds-flex-item>
|
||||
<!-- <ds-flex-item>
|
||||
<client-only>
|
||||
<ds-number :label="$t('profile.followers')">
|
||||
<count-to
|
||||
slot="count"
|
||||
:start-val="followedByCountStartValue"
|
||||
:end-val="user.followedByCount"
|
||||
/>
|
||||
</ds-number>
|
||||
</client-only>
|
||||
</ds-flex-item> -->
|
||||
<!-- <ds-flex-item>
|
||||
<client-only>
|
||||
<ds-number :label="$t('profile.following')">
|
||||
<count-to slot="count" :end-val="user.followingCount" />
|
||||
</ds-number>
|
||||
</client-only>
|
||||
</ds-flex-item> -->
|
||||
</ds-flex>
|
||||
<div class="action-buttons">
|
||||
<base-button danger v-if="group.isMutedByMe" @click="unmuteGroup" icon="volume-up">
|
||||
@ -194,25 +176,11 @@
|
||||
? $t('group.membersListTitleNotAllowedSeeingGroupMembers')
|
||||
: null
|
||||
"
|
||||
:allProfilesCount="isAllowedSeeingGroupMembers ? groupMembers.length : 0"
|
||||
:allProfilesCount="isAllowedSeeingGroupMembers ? group.membersCount : 0"
|
||||
:profiles="isAllowedSeeingGroupMembers ? groupMembers : []"
|
||||
:loading="$apollo.loading"
|
||||
@fetchAllProfiles="fetchAllMembers"
|
||||
/>
|
||||
<!-- <ds-space />
|
||||
<follow-list
|
||||
:loading="$apollo.loading"
|
||||
:user="user"
|
||||
type="followedBy"
|
||||
@fetchAllConnections="fetchAllConnections"
|
||||
/>
|
||||
<ds-space />
|
||||
<follow-list
|
||||
:loading="$apollo.loading"
|
||||
:user="user"
|
||||
type="following"
|
||||
@fetchAllConnections="fetchAllConnections"
|
||||
/> -->
|
||||
<!-- <social-media :user-name="groupName" :user="user" /> -->
|
||||
</ds-flex-item>
|
||||
|
||||
@ -310,8 +278,6 @@ import Category from '~/components/Category'
|
||||
import ContentViewer from '~/components/Editor/ContentViewer'
|
||||
import CountTo from '~/components/CountTo.vue'
|
||||
import Empty from '~/components/Empty/Empty'
|
||||
// import FollowButton from '~/components/Button/FollowButton'
|
||||
// import FollowList from '~/components/features/ProfileList/FollowList'
|
||||
import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu'
|
||||
import JoinLeaveButton from '~/components/Button/JoinLeaveButton'
|
||||
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
|
||||
@ -340,8 +306,6 @@ export default {
|
||||
ContentViewer,
|
||||
CountTo,
|
||||
Empty,
|
||||
// FollowButton,
|
||||
// FollowList,
|
||||
GroupContentMenu,
|
||||
JoinLeaveButton,
|
||||
PostTeaser,
|
||||
@ -373,11 +337,8 @@ export default {
|
||||
pageSize: 6,
|
||||
// tabActive: 'post',
|
||||
filter,
|
||||
// followedByCountStartValue: 0,
|
||||
// followedByCount: 7,
|
||||
// followingCount: 7,
|
||||
membersCountStartValue: 0,
|
||||
membersCountToLoad: Infinity,
|
||||
membersCountToLoad: 25,
|
||||
updateGroupMutation,
|
||||
isDescriptionCollapsed: true,
|
||||
}
|
||||
@ -593,7 +554,7 @@ export default {
|
||||
}
|
||||
},
|
||||
fetchAllMembers() {
|
||||
this.membersCountToLoad = Infinity
|
||||
this.membersCountToLoad = this.group.membersCount
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
@ -621,8 +582,6 @@ export default {
|
||||
variables() {
|
||||
return {
|
||||
id: this.$route.params.id,
|
||||
// followedByCount: this.followedByCount,
|
||||
// followingCount: this.followingCount,
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
@ -637,6 +596,7 @@ export default {
|
||||
variables() {
|
||||
return {
|
||||
id: this.$route.params.id,
|
||||
first: this.membersCountToLoad,
|
||||
}
|
||||
},
|
||||
skip() {
|
||||
|
||||
@ -45,6 +45,7 @@ export default {
|
||||
variables() {
|
||||
return {
|
||||
id: this.group.id,
|
||||
first: 999999,
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
|
||||
@ -17417,6 +17417,11 @@ schema-utils@^3.0.0:
|
||||
ajv "^6.12.5"
|
||||
ajv-keywords "^3.5.2"
|
||||
|
||||
scrollparent@^2.0.1:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.1.0.tgz#6cae915c953835886a6ba0d77fdc2bb1ed09076d"
|
||||
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
|
||||
|
||||
select@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
|
||||
@ -19825,11 +19830,21 @@ vue-no-ssr@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz#875f3be6fb0ae41568a837f3ac1a80eaa137b998"
|
||||
integrity sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==
|
||||
|
||||
vue-observe-visibility@^0.4.4:
|
||||
version "0.4.6"
|
||||
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz#878cb8ebcf3078e40807af29774e97105ebd519e"
|
||||
integrity sha512-xo0CEVdkjSjhJoDdLSvoZoQrw/H2BlzB5jrCBKGZNXN2zdZgMuZ9BKrxXDjNP2AxlcCoKc8OahI3F3r3JGLv2Q==
|
||||
|
||||
vue-observe-visibility@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
|
||||
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
|
||||
|
||||
vue-resize@^0.4.5:
|
||||
version "0.4.5"
|
||||
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-0.4.5.tgz#4777a23042e3c05620d9cbda01c0b3cc5e32dcea"
|
||||
integrity sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==
|
||||
|
||||
vue-resize@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-1.0.1.tgz#c120bed4e09938771d622614f57dbcf58a5147ee"
|
||||
@ -19900,6 +19915,15 @@ vue-template-es2015-compiler@^1.9.0:
|
||||
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
|
||||
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
|
||||
|
||||
vue-virtual-scroller@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz#b8a6362f177abf3f2149ce1eac18013c71fe353b"
|
||||
integrity sha512-SkUyc7QHCJFB5h1Fya7LxVizlVzOZZuFVipBGHYoTK8dwLs08bIz/tclvRApYhksaJIm/nn51inzO2UjpGJPMQ==
|
||||
dependencies:
|
||||
scrollparent "^2.0.1"
|
||||
vue-observe-visibility "^0.4.4"
|
||||
vue-resize "^0.4.5"
|
||||
|
||||
vue2-datepicker@^3.11.1:
|
||||
version "3.11.1"
|
||||
resolved "https://registry.yarnpkg.com/vue2-datepicker/-/vue2-datepicker-3.11.1.tgz#b2124e15f694d0fd43a92558f6929ec29338d241"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user