feat(webapp): badges admin settings (#8401)

Adds a link to badges settings in the user table, where admins can set the available badges.
This commit is contained in:
Max 2025-04-22 19:28:51 +02:00 committed by GitHub
parent 7592fe29be
commit c090db3866
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1050 additions and 10 deletions

View File

@ -0,0 +1,46 @@
import { render, fireEvent, screen } from '@testing-library/vue'
import BadgesSection from './BadgesSection.vue'
const localVue = global.localVue
const badge1 = {
id: 'badge1',
icon: 'icon1',
type: 'type1',
description: 'description1',
isActive: true,
}
const badge2 = {
id: 'badge2',
icon: 'icon2',
type: 'type1',
description: 'description2',
isActive: false,
}
describe('Admin/BadgesSection', () => {
let wrapper
const Wrapper = () => {
return render(BadgesSection, {
localVue,
propsData: {
badges: [badge1, badge2],
},
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.baseElement).toMatchSnapshot()
})
it('emits toggleButton', async () => {
const button = screen.getByAltText(badge1.description)
await fireEvent.click(button)
expect(wrapper.emitted().toggleBadge[0][0]).toEqual(badge1)
})
})

View File

@ -0,0 +1,55 @@
<template>
<div class="badge-section">
<h4>{{ title }}</h4>
<div class="badge-container">
<button
v-for="badge in badges"
:key="badge.id"
@click="toggleBadge(badge)"
:class="{ badge, inactive: !badge.isActive }"
>
<img :src="badge.icon" :alt="badge.description" />
</button>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
},
badges: {
type: Array,
},
},
methods: {
toggleBadge(badge) {
this.$emit('toggleBadge', badge)
},
},
}
</script>
<style scoped>
.badge-section h4 {
margin-bottom: 24px;
}
.badge-container {
display: flex;
margin-bottom: 36px;
flex-flow: row wrap;
gap: 10px;
}
.badge:hover {
cursor: pointer;
}
.badge img {
width: 70px;
}
.badge.inactive {
opacity: 0.3;
filter: grayscale(0.4);
}
</style>

View File

@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Admin/BadgesSection renders 1`] = `
<body>
<div>
<div
class="badge-section"
>
<h4>
</h4>
<div
class="badge-container"
>
<button
class="badge"
>
<img
alt="description1"
src="icon1"
/>
</button>
<button
class="badge inactive"
>
<img
alt="description2"
src="icon2"
/>
</button>
</div>
</div>
</div>
</body>
`;

View File

@ -90,6 +90,23 @@ export const adminUserQuery = () => {
`
}
export const adminUserBadgesQuery = () => {
return gql`
query User($id: ID!) {
User(id: $id) {
id
name
badgeTrophies {
id
}
badgeVerification {
id
}
}
}
`
}
export const mapUserQuery = (i18n) => {
const lang = i18n.locale().toUpperCase()
return gql`

View File

@ -0,0 +1,54 @@
import gql from 'graphql-tag'
export const queryBadges = () => gql`
query {
Badge {
id
type
icon
description
}
}
`
export const setVerificationBadge = () => gql`
mutation ($badgeId: ID!, $userId: ID!) {
setVerificationBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`
export const rewardTrophyBadge = () => gql`
mutation ($badgeId: ID!, $userId: ID!) {
rewardTrophyBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`
export const revokeBadge = () => gql`
mutation ($badgeId: ID!, $userId: ID!) {
revokeBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`

View File

@ -10,6 +10,28 @@
"saveCategories": "Themen speichern"
},
"admin": {
"badges": {
"description": "Stelle die verfügbaren Auszeichnungen für diesen Nutzer ein.",
"revokeTrophy": {
"error": "Trophäe konnte nicht widerrufen werden!",
"success": "Trophäe erfolgreich widerrufen"
},
"revokeVerification": {
"error": "Verifizierung konnte nicht gesetzt werden!",
"success": "Verifizierung erfolgreich widerrufen"
},
"rewardTrophy": {
"error": "Trophäe konnte nicht vergeben werden!",
"success": "Trophäe erfolgreich vergeben!"
},
"setVerification": {
"error": "Verifizierung konnte nicht gesetzt werden!",
"success": "Verifizierung erfolgreich gesetzt"
},
"title": "Auszeichnungen",
"trophyBadges": "Trophäen",
"verificationBadges": "Verifizierungen"
},
"categories": {
"categoryName": "Name",
"name": "Themen",
@ -68,13 +90,15 @@
"roleChanged": "Rolle erfolgreich geändert!",
"table": {
"columns": {
"badges": "Auszeichnungen",
"createdAt": "Erstellt am",
"email": "E-Mail",
"name": "Name",
"number": "Nr.",
"role": "Rolle",
"slug": "Alias"
}
},
"edit": "Bearbeiten"
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": "Save topics"
},
"admin": {
"badges": {
"description": "Configure the available badges for this user",
"revokeTrophy": {
"error": "Trophy could not be revoked!",
"success": "Trophy successfully revoked!"
},
"revokeVerification": {
"error": "Verification could not be revoked!",
"success": "Verification succesfully revoked"
},
"rewardTrophy": {
"error": "Trophy could not be rewarded!",
"success": "Trophy successfully rewarded!"
},
"setVerification": {
"error": "Verification could not be set!",
"success": "Verification successfully set!"
},
"title": "Badges",
"trophyBadges": "Trophies",
"verificationBadges": "Verifications"
},
"categories": {
"categoryName": "Name",
"name": "Topics",
@ -68,13 +90,15 @@
"roleChanged": "Role changed successfully!",
"table": {
"columns": {
"badges": "Badges",
"createdAt": "Created at",
"email": "E-mail",
"name": "Name",
"number": "No.",
"role": "Role",
"slug": "Slug"
}
},
"edit": "Edit"
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Nombre",
"name": "Categorías",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": "Creado el",
"email": "Correo electrónico",
"name": "Nombre",
"number": "No.",
"role": "Rol",
"slug": "Alias"
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Nom",
"name": "Catégories",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": "Créé à",
"email": "Mail",
"name": "Nom",
"number": "Num.",
"role": "Rôle",
"slug": "Slug"
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Nome",
"name": "Categorie",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": null,
"email": null,
"name": null,
"number": null,
"role": null,
"slug": null
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Naam",
"name": "Categorieën",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": null,
"email": null,
"name": null,
"number": null,
"role": null,
"slug": null
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Nazwa",
"name": "Kategorie",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": null,
"email": null,
"name": null,
"number": null,
"role": null,
"slug": null
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Nome",
"name": "Categorias",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": "Criado em",
"email": "E-mail",
"name": "Nome",
"number": "N.º",
"role": "Função",
"slug": "Slug"
}
},
"edit": null
}
}
},

View File

@ -10,6 +10,28 @@
"saveCategories": null
},
"admin": {
"badges": {
"description": null,
"revokeTrophy": {
"error": null,
"success": null
},
"revokeVerification": {
"error": null,
"success": null
},
"rewardTrophy": {
"error": null,
"success": null
},
"setVerification": {
"error": null,
"success": null
},
"title": null,
"trophyBadges": null,
"verificationBadges": null
},
"categories": {
"categoryName": "Имя",
"name": "Категории",
@ -68,13 +90,15 @@
"roleChanged": null,
"table": {
"columns": {
"badges": null,
"createdAt": "Дата создания",
"email": "Эл. почта",
"name": "Имя",
"number": "№",
"role": "Роль",
"slug": "Алиас"
}
},
"edit": null
}
}
},

View File

@ -207,6 +207,15 @@ export default {
'X-API-TOKEN': CONFIG.BACKEND_TOKEN,
},
},
'/img': {
// make this configurable (nuxt-dotenv)
target: CONFIG.GRAPHQL_URI,
toProxy: true, // cloudflare needs that
headers: {
'X-UI-Request': true,
'X-API-TOKEN': CONFIG.BACKEND_TOKEN,
},
},
},
// Give apollo module options

View File

@ -0,0 +1,104 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`.vue renders 1`] = `
<body>
<div>
<section
class="ds-section"
>
<div
class="ds-section-content"
>
<div
class="ds-container ds-container-x-large"
>
<div
class="ds-space"
style="margin-bottom: 32px;"
>
<h1
class="ds-heading ds-heading-h3"
>
User1
-
admin.badges.title
</h1>
<p
class="ds-text"
>
admin.badges.description
</p>
</div>
<article
class="base-card"
>
<div
class="badge-section"
>
<h4>
admin.badges.verificationBadges
</h4>
<div
class="badge-container"
>
<button
class="badge"
>
<img
alt="description-v-1"
src="icon1"
/>
</button>
<button
class="badge inactive"
>
<img
alt="description-v-2"
src="icon2"
/>
</button>
</div>
</div>
<div
class="badge-section"
>
<h4>
admin.badges.trophyBadges
</h4>
<div
class="badge-container"
>
<button
class="badge inactive"
>
<img
alt="description-t-1"
src="icon3"
/>
</button>
<button
class="badge"
>
<img
alt="description-t-2"
src="icon4"
/>
</button>
</div>
</div>
<!---->
</article>
</div>
</div>
</section>
</div>
</body>
`;

View File

@ -0,0 +1,326 @@
import { render, fireEvent, screen } from '@testing-library/vue'
import BadgesPage from './_id.vue'
const localVue = global.localVue
const availableBadges = [
{
id: 'verification-badge-1',
icon: 'icon1',
type: 'verification',
description: 'description-v-1',
},
{
id: 'verification-badge-2',
icon: 'icon2',
type: 'verification',
description: 'description-v-2',
},
{
id: 'trophy-badge-1',
icon: 'icon3',
type: 'trophy',
description: 'description-t-1',
},
{
id: 'trophy-badge-2',
icon: 'icon4',
type: 'trophy',
description: 'description-t-2',
},
]
const user = {
id: 'user1',
name: 'User1',
badgeVerification: {
id: 'verification-badge-1',
},
badgeTrophies: [
{
id: 'trophy-badge-2',
},
],
}
describe('.vue', () => {
let wrapper
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn((v) => v),
$apollo: {
User: {
query: jest.fn(),
},
badges: {
query: jest.fn(),
},
mutate: jest.fn(),
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
}
})
const Wrapper = () => {
return render(BadgesPage, {
mocks,
localVue,
data: () => ({
user,
badges: availableBadges,
}),
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.baseElement).toMatchSnapshot()
})
describe('after clicking an inactive verification badge', () => {
let button
beforeEach(() => {
button = screen.getByAltText(availableBadges[1].description)
})
describe('and successful server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockResolvedValue({
data: {
setVerificationBadge: {
id: 'user1',
badgeVerification: {
id: availableBadges[1].id,
},
badgeTrophies: [],
},
},
})
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[1].id,
userId: 'user1',
},
})
})
it('shows success message', async () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('admin.badges.setVerification.success')
})
})
describe('and failed server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[1].id,
userId: 'user1',
},
})
})
it('shows error message', async () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('admin.badges.setVerification.error')
})
})
describe('after clicking an inactive trophy badge', () => {
let button
beforeEach(() => {
button = screen.getByAltText(availableBadges[2].description)
})
describe('and successful server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockResolvedValue({
data: {
setTrophyBadge: {
id: 'user1',
badgeVerification: null,
badgeTrophies: [
{
id: availableBadges[2].id,
},
],
},
},
})
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[2].id,
userId: 'user1',
},
})
})
it('shows success message', async () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('admin.badges.rewardTrophy.success')
})
})
describe('and failed server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[2].id,
userId: 'user1',
},
})
})
it('shows error message', async () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('admin.badges.rewardTrophy.error')
})
})
})
describe('after clicking an active verification badge', () => {
let button
beforeEach(() => {
button = screen.getByAltText(availableBadges[0].description)
})
describe('and successful server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockResolvedValue({
data: {
setVerificationBadge: {
id: 'user1',
badgeVerification: null,
badgeTrophies: [],
},
},
})
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[0].id,
userId: 'user1',
},
})
})
it('shows success message', async () => {
expect(mocks.$toast.success).toHaveBeenCalledWith(
'admin.badges.revokeVerification.success',
)
})
})
describe('and failed server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[0].id,
userId: 'user1',
},
})
})
it('shows error message', async () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('admin.badges.revokeVerification.error')
})
})
})
})
describe('after clicking an active trophy badge', () => {
let button
beforeEach(() => {
button = screen.getByAltText(availableBadges[3].description)
})
describe('and successful server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockResolvedValue({
data: {
setTrophyBadge: {
id: 'user1',
badgeVerification: null,
badgeTrophies: [
{
id: availableBadges[3].id,
},
],
},
},
})
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[3].id,
userId: 'user1',
},
})
})
it('shows success message', async () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('admin.badges.revokeTrophy.success')
})
})
describe('and failed server response', () => {
beforeEach(async () => {
mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
await fireEvent.click(button)
})
it('calls the mutation', async () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: expect.anything(),
variables: {
badgeId: availableBadges[3].id,
userId: 'user1',
},
})
})
it('shows error message', async () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('admin.badges.revokeTrophy.error')
})
})
})
})

View File

@ -0,0 +1,163 @@
<template>
<ds-section>
<ds-space>
<ds-heading size="h3">
{{ user && user.name }}
-
{{ $t('admin.badges.title') }}
</ds-heading>
<ds-text>{{ $t('admin.badges.description') }}</ds-text>
</ds-space>
<base-card>
<badges-section
:title="$t('admin.badges.verificationBadges')"
:badges="verificationBadges"
@toggleBadge="toggleBadge"
/>
<badges-section
:title="$t('admin.badges.trophyBadges')"
:badges="trophyBadges"
@toggleBadge="toggleBadge"
/>
</base-card>
</ds-section>
</template>
<script>
import BadgesSection from '~/components/_new/features/Admin/Badges/BadgesSection.vue'
import {
queryBadges,
rewardTrophyBadge,
revokeBadge,
setVerificationBadge,
} from '~/graphql/admin/Badges'
import { adminUserBadgesQuery } from '~/graphql/User'
export default {
components: {
BadgesSection,
},
data() {
return {
user: null,
badges: [],
}
},
apollo: {
User: {
query() {
return adminUserBadgesQuery()
},
variables() {
return {
id: this.$route.params.id,
}
},
update({ User }) {
this.user = User[0]
},
},
Badge: {
query() {
return queryBadges()
},
update({ Badge }) {
this.badges = Badge
},
},
},
computed: {
verificationBadges() {
if (!this.user) return []
return this.badges
.filter((badge) => badge.type === 'verification')
.map((badge) => ({
...badge,
isActive: this.user.badgeVerification?.id === badge.id,
}))
},
trophyBadges() {
if (!this.user?.badgeTrophies) return []
return this.badges
.filter((badge) => badge.type === 'trophy')
.map((badge) => ({
...badge,
isActive: this.user.badgeTrophies.some((userBadge) => userBadge.id === badge.id),
}))
},
},
methods: {
toggleBadge(badge) {
if (badge.isActive) {
this.revokeBadge(badge)
return
}
if (badge.type === 'verification') {
this.setVerificationBadge(badge.id)
} else {
this.rewardTrophyBadge(badge.id)
}
},
async rewardTrophyBadge(badgeId) {
try {
await this.$apollo.mutate({
mutation: rewardTrophyBadge(),
variables: {
badgeId,
userId: this.user.id,
},
})
this.$toast.success(this.$t('admin.badges.rewardTrophy.success'))
} catch (error) {
this.$toast.error(this.$t('admin.badges.rewardTrophy.error'))
}
},
async revokeBadge(badge) {
try {
await this.$apollo.mutate({
mutation: revokeBadge(),
variables: {
badgeId: badge.id,
userId: this.user.id,
},
})
this.$toast.success(
this.$t(
badge.type === 'verification'
? 'admin.badges.revokeVerification.success'
: 'admin.badges.revokeTrophy.success',
),
)
} catch (error) {
this.$toast.error(
this.$t(
badge.type === 'verification'
? 'admin.badges.revokeVerification.error'
: 'admin.badges.revokeTrophy.error',
),
)
}
},
async setVerificationBadge(badgeId) {
try {
await this.$apollo.mutate({
mutation: setVerificationBadge(),
variables: {
badgeId,
userId: this.user.id,
},
})
this.$toast.success(this.$t('admin.badges.setVerification.success'))
} catch (error) {
this.$toast.error(this.$t('admin.badges.setVerification.error'))
}
},
},
}
</script>

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import Vuex from 'vuex'
import Users from './users.vue'
import Users from './index.vue'
const localVue = global.localVue

View File

@ -63,6 +63,16 @@
<ds-text v-else>{{ scope.row.role }}</ds-text>
</template>
</template>
<template #badges="scope">
<nuxt-link
:to="{
name: 'admin-users-id',
params: { id: scope.row.id },
}"
>
{{ $t('admin.users.table.edit') }}
</nuxt-link>
</template>
</ds-table>
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @next="next" @back="back" />
</base-card>
@ -132,6 +142,10 @@ export default {
label: this.$t('admin.users.table.columns.role'),
align: 'right',
},
badges: {
label: this.$t('admin.users.table.columns.badges'),
align: 'right',
},
}
},
},