Merge pull request #1293 from gradido/paginate-user-table

feat: Paginate User Table
This commit is contained in:
Moriz Wahl 2022-01-18 13:22:03 +01:00 committed by GitHub
commit cee930b8f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 349 additions and 74 deletions

View File

@ -520,7 +520,7 @@ jobs:
report_name: Coverage Backend
type: lcov
result_path: ./backend/coverage/lcov.info
min_coverage: 41
min_coverage: 45
token: ${{ github.token }}
##############################################################################

View File

@ -1,14 +1,22 @@
import gql from 'graphql-tag'
export const searchUsers = gql`
query ($searchText: String!) {
searchUsers(searchText: $searchText) {
userId
firstName
lastName
email
creation
emailChecked
query ($searchText: String!, $currentPage: Int, $pageSize: Int, $notActivated: Boolean) {
searchUsers(
searchText: $searchText
currentPage: $currentPage
pageSize: $pageSize
notActivated: $notActivated
) {
userCount
userList {
userId
firstName
lastName
email
creation
emailChecked
}
}
}
`

View File

@ -1,4 +1,5 @@
{
"all_emails": "Alle Nutzer",
"bookmark": "bookmark",
"confirmed": "bestätigt",
"creation_form": {
@ -53,7 +54,7 @@
"transactionlist": {
"title": "Alle geschöpften Transaktionen für den Nutzer"
},
"unregistered_emails": "Unregistrierte E-Mails",
"unregistered_emails": "Nur unregistrierte Nutzer",
"unregister_mail": {
"button": "Registrierungs-Email bestätigen, jetzt senden",
"error": "Fehler beim Senden des Bestätigungs-Links an den Benutzer: {message}",

View File

@ -1,4 +1,5 @@
{
"all_emails": "All users",
"bookmark": "Remember",
"confirmed": "confirmed",
"creation_form": {
@ -53,7 +54,7 @@
"transactionlist": {
"title": "All creation-transactions for the user"
},
"unregistered_emails": "Unregistered e-mails",
"unregistered_emails": "Only unregistered users",
"unregister_mail": {
"button": "Confirm registration email, send now",
"error": "Error sending the confirmation link to the user: {message}",

View File

@ -6,22 +6,25 @@ const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
searchUsers: [
{
userId: 1,
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
},
{
userId: 2,
firstName: 'Benjamin',
lastName: 'Blümchen',
email: 'benjamin@bluemchen.de',
creation: [800, 600, 400],
},
],
searchUsers: {
userCount: 2,
userList: [
{
userId: 1,
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
},
{
userId: 2,
firstName: 'Benjamin',
lastName: 'Blümchen',
email: 'benjamin@bluemchen.de',
creation: [800, 600, 400],
},
],
},
},
})
@ -227,6 +230,22 @@ describe('Creation', () => {
})
})
describe('watchers', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('calls API when criteria changes', async () => {
await wrapper.setData({ criteria: 'XX' })
expect(apolloQueryMock).toBeCalled()
})
it('calls API when currentPage changes', async () => {
await wrapper.setData({ currentPage: 2 })
expect(apolloQueryMock).toBeCalled()
})
})
describe('apollo returns error', () => {
beforeEach(() => {
apolloQueryMock.mockRejectedValue({

View File

@ -18,6 +18,13 @@
:creation="creation"
@update-item="updateItem"
/>
<b-pagination
pills
v-model="currentPage"
per-page="perPage"
:total-rows="rows"
align="center"
></b-pagination>
</b-col>
<b-col cols="12" lg="6" class="shadow p-3 mb-5 rounded bg-info">
<user-table
@ -101,6 +108,9 @@ export default {
radioSelectedMass: '',
criteria: '',
creation: [null, null, null],
rows: 0,
currentPage: 1,
perPage: 25,
}
},
async created() {
@ -113,10 +123,13 @@ export default {
query: searchUsers,
variables: {
searchText: this.criteria,
currentPage: this.currentPage,
pageSize: this.perPage,
},
})
.then((result) => {
this.itemsList = result.data.searchUsers.map((user) => {
this.rows = result.data.searchUsers.userCount
this.itemsList = result.data.searchUsers.userList.map((user) => {
return {
...user,
showDetails: false,
@ -153,5 +166,13 @@ export default {
this.itemsMassCreation = []
},
},
watch: {
currentPage() {
this.getUsers()
},
criteria() {
this.getUsers()
},
},
}
</script>

View File

@ -5,15 +5,18 @@ const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
searchUsers: [
{
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
emailChecked: false,
},
],
searchUsers: {
userCount: 1,
userList: [
{
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
emailChecked: false,
},
],
},
},
})

View File

@ -3,7 +3,7 @@
<div style="text-align: right">
<b-button block variant="danger" @click="unconfirmedRegisterMails">
<b-icon icon="envelope" variant="light"></b-icon>
{{ $t('unregistered_emails') }}
{{ filterCheckedEmails ? $t('all_emails') : $t('unregistered_emails') }}
</b-button>
</div>
<label>{{ $t('user_search') }}</label>
@ -21,6 +21,14 @@
:fieldsTable="fields"
:criteria="criteria"
/>
<b-pagination
pills
size="lg"
v-model="currentPage"
per-page="perPage"
:total-rows="rows"
align="center"
></b-pagination>
<div></div>
</div>
</template>
@ -67,14 +75,16 @@ export default {
beforeLastMonth: {
short: this.$moment().subtract(2, 'month').format('MMMM'),
},
filterCheckedEmails: false,
rows: 0,
currentPage: 1,
perPage: 25,
}
},
methods: {
unconfirmedRegisterMails() {
this.searchResult = this.searchResult.filter((user) => {
return !user.emailChecked
})
this.filterCheckedEmails = !this.filterCheckedEmails
this.getUsers()
},
getUsers() {
this.$apollo
@ -82,16 +92,25 @@ export default {
query: searchUsers,
variables: {
searchText: this.criteria,
currentPage: this.currentPage,
pageSize: this.perPage,
notActivated: this.filterCheckedEmails,
},
})
.then((result) => {
this.searchResult = result.data.searchUsers
this.rows = result.data.searchUsers.userCount
this.searchResult = result.data.searchUsers.userList
})
.catch((error) => {
this.$toasted.error(error.message)
})
},
},
watch: {
currentPage() {
this.getUsers()
},
},
created() {
this.getUsers()
},

View File

@ -1,15 +1,18 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'],
moduleNameMapper: {
'@entity/(.*)': '<rootDir>/../database/build/entity/$1',
// This is hack to fix a problem with the library `ts-mysql-migrate` which does differentiate between its ts/js state
'@dbTools/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1',
},
module.exports = async () => {
process.env.TZ = 'UTC'
return {
verbose: true,
preset: 'ts-jest',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'],
moduleNameMapper: {
'@entity/(.*)': '<rootDir>/../database/build/entity/$1',
// This is hack to fix a problem with the library `ts-mysql-migrate` which does differentiate between its ts/js state
'@dbTools/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1',
},
}
}

View File

@ -5,7 +5,7 @@ import CONFIG from '../config'
const klicktippConnector = new KlicktippConnector()
export const signIn = async (
export const klicktippSignIn = async (
email: string,
language: string,
firstName?: string,

View File

@ -0,0 +1,16 @@
import { ArgsType, Field, Int } from 'type-graphql'
@ArgsType()
export default class SearchUsersArgs {
@Field(() => String)
searchText: string
@Field(() => Int, { nullable: true })
currentPage?: number
@Field(() => Int, { nullable: true })
pageSize?: number
@Field(() => Boolean, { nullable: true })
notActivated?: boolean
}

View File

@ -1,4 +1,4 @@
import { ObjectType, Field } from 'type-graphql'
import { ObjectType, Field, Int } from 'type-graphql'
@ObjectType()
export class UserAdmin {
@ -20,3 +20,12 @@ export class UserAdmin {
@Field(() => Boolean)
emailChecked: boolean
}
@ObjectType()
export class SearchUsersResult {
@Field(() => Int)
userCount: number
@Field(() => [UserAdmin])
userList: UserAdmin[]
}

View File

@ -3,7 +3,7 @@
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx } from 'type-graphql'
import { getCustomRepository, Raw } from 'typeorm'
import { UserAdmin } from '../model/UserAdmin'
import { UserAdmin, SearchUsersResult } from '../model/UserAdmin'
import { PendingCreation } from '../model/PendingCreation'
import { CreatePendingCreations } from '../model/CreatePendingCreations'
import { UpdatePendingCreation } from '../model/UpdatePendingCreation'
@ -14,6 +14,7 @@ import { LoginPendingTasksAdminRepository } from '../../typeorm/repository/Login
import { UserRepository } from '../../typeorm/repository/User'
import CreatePendingCreationArgs from '../arg/CreatePendingCreationArgs'
import UpdatePendingCreationArgs from '../arg/UpdatePendingCreationArgs'
import SearchUsersArgs from '../arg/SearchUsersArgs'
import moment from 'moment'
import { Transaction } from '@entity/Transaction'
import { TransactionCreation } from '@entity/TransactionCreation'
@ -26,11 +27,13 @@ import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
@Resolver()
export class AdminResolver {
@Authorized([RIGHTS.SEARCH_USERS])
@Query(() => [UserAdmin])
async searchUsers(@Arg('searchText') searchText: string): Promise<UserAdmin[]> {
@Query(() => SearchUsersResult)
async searchUsers(
@Args() { searchText, currentPage = 1, pageSize = 25, notActivated = false }: SearchUsersArgs,
): Promise<SearchUsersResult> {
const userRepository = getCustomRepository(UserRepository)
const users = await userRepository.findBySearchCriteria(searchText)
const adminUsers = await Promise.all(
let adminUsers = await Promise.all(
users.map(async (user) => {
const adminUser = new UserAdmin()
adminUser.userId = user.id
@ -42,7 +45,12 @@ export class AdminResolver {
return adminUser
}),
)
return adminUsers
if (notActivated) adminUsers = adminUsers.filter((u) => !u.emailChecked)
const first = (currentPage - 1) * pageSize
return {
userCount: adminUsers.length,
userList: adminUsers.slice(first, first + pageSize),
}
}
@Authorized([RIGHTS.CREATE_PENDING_CREATION])

View File

@ -6,7 +6,7 @@ import {
getKlickTippUser,
getKlicktippTagMap,
unsubscribe,
signIn,
klicktippSignIn,
} from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS'
import SubscribeNewsletterArgs from '../arg/SubscribeNewsletterArgs'
@ -36,6 +36,6 @@ export class KlicktippResolver {
async subscribeNewsletter(
@Args() { email, language }: SubscribeNewsletterArgs,
): Promise<boolean> {
return await signIn(email, language)
return await klicktippSignIn(email, language)
}
}

View File

@ -13,6 +13,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User'
import CONFIG from '../../config'
import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail'
import { klicktippSignIn } from '../../apis/KlicktippController'
jest.mock('../../mailer/sendAccountActivationEmail', () => {
return {
@ -21,6 +22,13 @@ jest.mock('../../mailer/sendAccountActivationEmail', () => {
}
})
jest.mock('../../apis/KlicktippController', () => {
return {
__esModule: true,
klicktippSignIn: jest.fn(),
}
})
let mutate: any
let con: any
@ -220,6 +228,157 @@ describe('UserResolver', () => {
})
})
})
describe('setPassword', () => {
const createUserMutation = gql`
mutation (
$email: String!
$firstName: String!
$lastName: String!
$language: String!
$publisherId: Int
) {
createUser(
email: $email
firstName: $firstName
lastName: $lastName
language: $language
publisherId: $publisherId
)
}
`
const createUserVariables = {
email: 'peter@lustig.de',
firstName: 'Peter',
lastName: 'Lustig',
language: 'de',
publisherId: 1234,
}
const setPasswordMutation = gql`
mutation ($code: String!, $password: String!) {
setPassword(code: $code, password: $password)
}
`
let result: any
let emailOptIn: string
describe('valid optin code and valid password', () => {
let loginUser: any
let newLoginUser: any
let newUser: any
beforeAll(async () => {
await mutate({ mutation: createUserMutation, variables: createUserVariables })
const loginEmailOptIn = await getRepository(LoginEmailOptIn)
.createQueryBuilder('login_email_optin')
.getMany()
loginUser = await getRepository(LoginUser).createQueryBuilder('login_user').getMany()
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
result = await mutate({
mutation: setPasswordMutation,
variables: { code: emailOptIn, password: 'Aa12345_' },
})
newLoginUser = await getRepository(LoginUser).createQueryBuilder('login_user').getMany()
newUser = await getRepository(User).createQueryBuilder('state_user').getMany()
})
afterAll(async () => {
await resetDB()
})
it('sets email checked to true', () => {
expect(newLoginUser[0].emailChecked).toBeTruthy()
})
it('updates the password', () => {
expect(newLoginUser[0].password).toEqual('3917921995996627700')
})
it('updates the public Key on both user tables', () => {
expect(newLoginUser[0].pubKey).toEqual(expect.any(Buffer))
expect(newLoginUser[0].pubKey).not.toEqual(loginUser[0].pubKey)
expect(newLoginUser[0].pubKey).toEqual(newUser[0].pubkey)
})
it('updates the private Key', () => {
expect(newLoginUser[0].privKey).toEqual(expect.any(Buffer))
expect(newLoginUser[0].privKey).not.toEqual(loginUser[0].privKey)
})
it('removes the optin', async () => {
await expect(
getRepository(LoginEmailOptIn).createQueryBuilder('login_email_optin').getMany(),
).resolves.toHaveLength(0)
})
it('calls the klicktipp API', () => {
expect(klicktippSignIn).toBeCalledWith(
loginUser[0].email,
loginUser[0].language,
loginUser[0].firstName,
loginUser[0].lastName,
)
})
it('returns true', () => {
expect(result).toBeTruthy()
})
})
describe('no valid password', () => {
beforeAll(async () => {
await mutate({ mutation: createUserMutation, variables: createUserVariables })
const loginEmailOptIn = await getRepository(LoginEmailOptIn)
.createQueryBuilder('login_email_optin')
.getMany()
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
result = await mutate({
mutation: setPasswordMutation,
variables: { code: emailOptIn, password: 'not-valid' },
})
})
afterAll(async () => {
await resetDB()
})
it('throws an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
),
],
}),
)
})
})
describe('no valid optin code', () => {
beforeAll(async () => {
await mutate({ mutation: createUserMutation, variables: createUserVariables })
result = await mutate({
mutation: setPasswordMutation,
variables: { code: 'not valid', password: 'Aa12345_' },
})
})
afterAll(async () => {
await resetDB()
})
it('throws an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Could not login with emailVerificationCode')],
}),
)
})
})
})
})
afterAll(async () => {

View File

@ -23,7 +23,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail } from '../../mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail'
import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys'
import { signIn } from '../../apis/KlicktippController'
import { klicktippSignIn } from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { ROLE_ADMIN } from '../../auth/ROLES'
@ -641,7 +641,12 @@ export class UserResolver {
// TODO do we always signUp the user? How to handle things with old users?
if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) {
try {
await signIn(loginUser.email, loginUser.language, loginUser.firstName, loginUser.lastName)
await klicktippSignIn(
loginUser.email,
loginUser.language,
loginUser.firstName,
loginUser.lastName,
)
} catch {
// TODO is this a problem?
// eslint-disable-next-line no-console

View File

@ -1,5 +1,5 @@
import { MiddlewareFn } from 'type-graphql'
import { /* signIn, */ getKlickTippUser } from '../apis/KlicktippController'
import { /* klicktippSignIn, */ getKlickTippUser } from '../apis/KlicktippController'
import { KlickTipp } from '../graphql/model/KlickTipp'
import CONFIG from '../config/index'
@ -12,7 +12,7 @@ import CONFIG from '../config/index'
// // Do Something here before resolver is called
// const result = await next()
// // Do Something here after resolver is completed
// await signIn(result.email, result.language, result.firstName, result.lastName)
// await klicktippSignIn(result.email, result.language, result.firstName, result.lastName)
// return result
// }

View File

@ -7,6 +7,7 @@ import { CreateBibiBloxbergSeed } from './seeds/users/bibi-bloxberg.seed'
import { CreateRaeuberHotzenplotzSeed } from './seeds/users/raeuber-hotzenplotz.seed'
import { CreateBobBaumeisterSeed } from './seeds/users/bob-baumeister.seed'
import { CreateGarrickOllivanderSeed } from './seeds/users/garrick-ollivander.seed'
import { CreateUserSeed } from './seeds/create-user.seed'
import { DecayStartBlockSeed } from './seeds/decay-start-block.seed'
import { resetDB, pool, migration } from './helpers'
@ -45,6 +46,10 @@ const run = async (command: string) => {
await runSeeder(CreateBibiBloxbergSeed)
await runSeeder(CreateRaeuberHotzenplotzSeed)
await runSeeder(CreateBobBaumeisterSeed)
// eslint-disable-next-line prefer-spread
Array.apply(null, Array(96)).forEach(async () => {
await runSeeder(CreateUserSeed)
})
await runSeeder(CreateGarrickOllivanderSeed)
break
default:

View File

@ -1,11 +1,8 @@
import { Factory, Seeder } from 'typeorm-seeding'
import { User } from '../../entity/User'
// import { LoginUser } from '../../entity/LoginUser'
import { userSeeder } from './helpers/user-helpers'
export class CreateUserSeed implements Seeder {
public async run(factory: Factory): Promise<void> {
// const loginUser = await factory(LoginUser)().make()
// console.log(loginUser.email)
await factory(User)().create()
await userSeeder(factory, {})
}
}

View File

@ -27,6 +27,7 @@ import { Factory } from 'typeorm-seeding'
export const userSeeder = async (factory: Factory, userData: UserInterface): Promise<void> => {
const user = await factory(User)(createUserContext(userData)).create()
if (!userData.email) userData.email = user.email
const loginUser = await factory(LoginUser)(createLoginUserContext(userData)).create()
await factory(LoginUserBackup)(createLoginUserBackupContext(userData, loginUser)).create()