feat(webapp): badges UI (#8426)

- New badge UI, including editor.
- Adds config to enable/disable badges.

---------

Co-authored-by: Sebastian Stein <sebastian@codepassion.de>
Co-authored-by: Maximilian Harz <maxharz@gmail.com>
This commit is contained in:
sebastian2357 2025-04-25 18:55:46 +02:00 committed by GitHub
parent 0873fc748c
commit 2fd138697f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 5820 additions and 208 deletions

View File

@ -0,0 +1,76 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import BadgeSelection from './BadgeSelection.vue'
const localVue = global.localVue
describe('Badges.vue', () => {
const Wrapper = (propsData) => {
return render(BadgeSelection, {
propsData,
localVue,
})
}
describe('without badges', () => {
it('renders', () => {
const wrapper = Wrapper({ badges: [] })
expect(wrapper.container).toMatchSnapshot()
})
})
describe('with badges', () => {
const badges = [
{
id: '1',
icon: '/path/to/some/icon',
isDefault: false,
description: 'Some description',
},
{
id: '2',
icon: '/path/to/another/icon',
isDefault: true,
description: 'Another description',
},
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
]
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges })
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('clicking on a badge', () => {
beforeEach(async () => {
const badge = screen.getByText(badges[1].description)
await fireEvent.click(badge)
})
it('emits badge-selected with badge', async () => {
expect(wrapper.emitted()['badge-selected']).toEqual([[badges[1]]])
})
})
describe('clicking twice on a badge', () => {
beforeEach(async () => {
const badge = screen.getByText(badges[1].description)
await fireEvent.click(badge)
await fireEvent.click(badge)
})
it('emits badge-selected with null', async () => {
expect(wrapper.emitted()['badge-selected']).toEqual([[badges[1]], [null]])
})
})
})
})

View File

@ -0,0 +1,102 @@
<template>
<div class="badge-selection">
<button
v-for="(badge, index) in badges"
:key="badge.id"
class="badge-selection-item"
@click="handleBadgeClick(badge, index)"
>
<div class="badge-icon">
<img :src="badge.icon | proxyApiUrl" :alt="badge.id" />
</div>
<div class="badge-info">
<div class="badge-description">{{ badge.description }}</div>
</div>
</button>
</div>
</template>
<script>
export default {
name: 'BadgeSelection',
props: {
badges: {
type: Array,
default: () => [],
},
},
data() {
return {
selectedIndex: null,
}
},
methods: {
handleBadgeClick(badge, index) {
if (this.selectedIndex === index) {
this.selectedIndex = null
this.$emit('badge-selected', null)
return
}
this.selectedIndex = index
this.$emit('badge-selected', badge)
},
resetSelection() {
this.selectedIndex = null
},
},
}
</script>
<style lang="scss" scoped>
.badge-selection {
width: 100%;
max-width: 600px;
margin: 0 auto;
.badge-selection-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
background-color: #f5f5f5;
transition: all 0.2s ease;
width: 100%;
text-align: left;
cursor: pointer;
&:hover {
background-color: #e0e0e0;
}
.badge-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
margin-right: 16px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.badge-info {
flex-grow: 1;
.badge-title {
font-weight: bold;
font-size: 16px;
margin-bottom: 4px;
}
.badge-description {
font-size: 14px;
color: #666;
}
}
}
}
</style>

View File

@ -1,29 +1,114 @@
import { shallowMount } from '@vue/test-utils' import { render, screen, fireEvent } from '@testing-library/vue'
import Badges from './Badges.vue' import Badges from './Badges.vue'
const localVue = global.localVue
describe('Badges.vue', () => { describe('Badges.vue', () => {
let propsData const Wrapper = (propsData) => {
return render(Badges, {
propsData,
localVue,
})
}
beforeEach(() => { describe('without badges', () => {
propsData = {} it('renders in presentation mode', () => {
}) const wrapper = Wrapper({ badges: [], selectionMode: false })
expect(wrapper.container).toMatchSnapshot()
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(Badges, { propsData })
}
it('has class "hc-badges"', () => {
expect(Wrapper().find('.hc-badges').exists()).toBe(true)
}) })
describe('given a badge', () => { it('renders in selection mode', () => {
const wrapper = Wrapper({ badges: [], selectionMode: true })
expect(wrapper.container).toMatchSnapshot()
})
})
describe('with badges', () => {
const badges = [
{
id: '1',
icon: '/path/to/some/icon',
isDefault: false,
description: 'Some description',
},
{
id: '2',
icon: '/path/to/another/icon',
isDefault: true,
description: 'Another description',
},
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
]
describe('in presentation mode', () => {
let wrapper
beforeEach(() => { beforeEach(() => {
propsData.badges = [{ id: '1', icon: '/path/to/some/icon' }] wrapper = Wrapper({ badges, scale: 1.2, selectionMode: false })
}) })
it('proxies badge icon, which is just a URL without metadata', () => { it('renders', () => {
expect(Wrapper().find('img[src="/api/path/to/some/icon"]').exists()).toBe(true) expect(wrapper.container).toMatchSnapshot()
})
it('clicking on second badge does nothing', async () => {
const badge = screen.getByTitle(badges[1].description)
await fireEvent.click(badge)
expect(wrapper.emitted()).toEqual({})
})
})
describe('in selection mode', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges, scale: 1.2, selectionMode: true })
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
it('clicking on first badge does nothing', async () => {
const badge = screen.getByTitle(badges[0].description)
await fireEvent.click(badge)
expect(wrapper.emitted()).toEqual({})
})
describe('clicking on second badge', () => {
beforeEach(async () => {
const badge = screen.getByTitle(badges[1].description)
await fireEvent.click(badge)
})
it('selects badge', () => {
expect(wrapper.container).toMatchSnapshot()
})
it('emits badge-selected with index', async () => {
expect(wrapper.emitted()['badge-selected']).toEqual([[1]])
})
})
describe('clicking twice on second badge', () => {
beforeEach(async () => {
const badge = screen.getByTitle(badges[1].description)
await fireEvent.click(badge)
await fireEvent.click(badge)
})
it('deselects badge', () => {
expect(wrapper.container).toMatchSnapshot()
})
it('emits badge-selected with null', async () => {
expect(wrapper.emitted()['badge-selected']).toEqual([[1], [null]])
})
}) })
}) })
}) })

View File

@ -1,69 +1,171 @@
<template> <template>
<div :class="[badges.length === 2 && 'hc-badges-dual']" class="hc-badges"> <div :class="[badges.length === 2 && 'hc-badges-dual']" class="hc-badges">
<div v-for="badge in badges" :key="badge.id" class="hc-badge-container"> <component
<img :title="badge.key" :src="badge.icon | proxyApiUrl" class="hc-badge" /> :is="selectionMode ? 'button' : 'div'"
</div> class="hc-badge-container"
v-for="(badge, index) in badges"
:key="index"
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
@click="handleBadgeClick(index)"
>
<img :title="badge.description" :src="badge.icon | proxyApiUrl" class="hc-badge" />
</component>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'Badges',
props: { props: {
badges: { badges: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
selectionMode: {
type: Boolean,
default: false,
},
},
data() {
return {
selectedIndex: null,
}
},
methods: {
handleBadgeClick(index) {
if (!this.selectionMode || index === 0) {
return
}
if (this.selectedIndex === index) {
this.selectedIndex = null
this.$emit('badge-selected', null)
return
}
this.selectedIndex = index
this.$emit('badge-selected', index)
},
resetSelection() {
this.selectedIndex = null
},
}, },
} }
</script> </script>
<style lang="scss"> <style lang="scss">
@use 'sass:math';
.hc-badges { .hc-badges {
text-align: center;
position: relative; position: relative;
transform: scale(var(--badges-scale, 1));
$badge-size-x: 30px;
$badge-size-y: 26px;
$main-badge-size-x: 60px;
$main-badge-size-y: 52px;
$gap-x: -6px;
$gap-y: 1px;
$slot-x: $badge-size-x + $gap-x;
$slot-y: $badge-size-y + $gap-y;
$offset-y: calc($badge-size-y / 2) - 2 * $gap-x;
width: $main-badge-size-x + 4 * $badge-size-x + 4 * $gap-x;
height: $offset-y + 3 * $badge-size-y + 4 * $gap-y;
margin: auto;
.hc-badge-container { .hc-badge-container {
display: inline-block; position: absolute;
position: unset; width: $badge-size-x;
overflow: hidden; height: $badge-size-y;
vertical-align: middle;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
&.selectable {
cursor: pointer;
transition: transform 0.1s ease-in;
&:hover {
transform: scale(1.1);
}
}
&.selected {
filter: drop-shadow(0 0 0 $color-primary);
img {
opacity: 0.6;
}
}
} }
.hc-badge { .hc-badge {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
} }
$size: 30px; .hc-badge-container:nth-child(1) {
$offset: $size * -0.2; width: $main-badge-size-x;
height: $main-badge-size-y;
&.hc-badges-dual { top: $offset-y + calc($gap-y / 2) - 1px;
padding-top: math.div($size, 2) - 2; left: 0;
} }
.hc-badge-container { .hc-badge-container:nth-child(1)::before {
width: $size; content: '';
height: 26px; position: absolute;
margin-left: -1px; top: -20px;
left: 0;
width: 100%;
height: 20px;
background-image: url('/img/badges/stars.svg');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
&:nth-child(3n - 1) { .hc-badge-container:nth-child(2) {
margin-top: -$size - $offset - 3; top: $offset-y + calc(-1 * $gap-y / 2) - 1px;
margin-left: -$size * 0.33 - $offset - 2; left: $main-badge-size-x + $gap-x;
} }
&:nth-child(3n + 0) {
margin-top: $size + $offset + 3; .hc-badge-container:nth-child(3) {
margin-left: -$size; top: $offset-y + $slot-y + calc($gap-y / 2) - 1px;
} left: $main-badge-size-x + $gap-x;
&:nth-child(3n + 1) { }
margin-left: -6px;
} .hc-badge-container:nth-child(4) {
&:first-child { top: $offset-y + calc(-1 * $badge-size-y / 2) - (2 * $gap-y) - 0.5px;
margin-left: math.div(-$size, 3); left: $main-badge-size-x + $gap-x + $slot-x;
} }
&:last-child {
margin-right: math.div(-$size, 3); .hc-badge-container:nth-child(5) {
} top: $offset-y + calc($badge-size-y / 2) - 0.5px;
left: $main-badge-size-x + $gap-x + $slot-x;
}
.hc-badge-container:nth-child(6) {
top: $offset-y + (1.5 * $badge-size-y) + (2 * $gap-y) - 0.5px;
left: $main-badge-size-x + $gap-x + $slot-x;
}
.hc-badge-container:nth-child(7) {
top: $offset-y + calc(-1 * $gap-y / 2) - 1px;
left: $main-badge-size-x + $gap-x + (2 * $slot-x);
}
.hc-badge-container:nth-child(8) {
top: $offset-y + $slot-y + calc($gap-y / 2) - 1px;
left: $main-badge-size-x + $gap-x + (2 * $slot-x);
}
.hc-badge-container:nth-child(9) {
top: $offset-y + $slot-y - calc($badge-size-y / 2) - $gap-y - 0.5px;
left: $main-badge-size-x + $gap-x + (3 * $slot-x);
}
.hc-badge-container:nth-child(10) {
top: $offset-y + ($badge-size-y * 1.5) + (2 * $gap-y) - 0.5px;
left: $main-badge-size-x + $gap-x + (3 * $slot-x);
} }
} }
</style> </style>

View File

@ -0,0 +1,84 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Badges.vue with badges renders 1`] = `
<div>
<div
class="badge-selection"
>
<button
class="badge-selection-item"
>
<div
class="badge-icon"
>
<img
alt="1"
src="/api/path/to/some/icon"
/>
</div>
<div
class="badge-info"
>
<div
class="badge-description"
>
Some description
</div>
</div>
</button>
<button
class="badge-selection-item"
>
<div
class="badge-icon"
>
<img
alt="2"
src="/api/path/to/another/icon"
/>
</div>
<div
class="badge-info"
>
<div
class="badge-description"
>
Another description
</div>
</div>
</button>
<button
class="badge-selection-item"
>
<div
class="badge-icon"
>
<img
alt="3"
src="/api/path/to/third/icon"
/>
</div>
<div
class="badge-info"
>
<div
class="badge-description"
>
Third description
</div>
</div>
</button>
</div>
</div>
`;
exports[`Badges.vue without badges renders 1`] = `
<div>
<div
class="badge-selection"
/>
</div>
`;

View File

@ -0,0 +1,165 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Badges.vue with badges in presentation mode renders 1`] = `
<div>
<div
class="hc-badges"
scale="1.2"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/another/icon"
title="Another description"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</div>
</div>
</div>
`;
exports[`Badges.vue with badges in selection mode clicking on second badge selects badge 1`] = `
<div>
<div
class="hc-badges"
scale="1.2"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable selected"
>
<img
class="hc-badge"
src="/api/path/to/another/icon"
title="Another description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
`;
exports[`Badges.vue with badges in selection mode clicking twice on second badge deselects badge 1`] = `
<div>
<div
class="hc-badges"
scale="1.2"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/another/icon"
title="Another description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
`;
exports[`Badges.vue with badges in selection mode renders 1`] = `
<div>
<div
class="hc-badges"
scale="1.2"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/another/icon"
title="Another description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
`;
exports[`Badges.vue without badges renders in presentation mode 1`] = `
<div>
<div
class="hc-badges"
/>
</div>
`;
exports[`Badges.vue without badges renders in selection mode 1`] = `
<div>
<div
class="hc-badges"
/>
</div>
`;

View File

@ -35,6 +35,7 @@ const options = {
COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default
COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
BADGES_ENABLED: process.env.BADGES_ENABLED === 'true' || false,
} }
const CONFIG = { const CONFIG = {

View File

@ -26,9 +26,15 @@ export const locationFragment = (lang) => gql`
export const badgesFragment = gql` export const badgesFragment = gql`
fragment badges on User { fragment badges on User {
badgeTrophies { badgeTrophiesSelected {
id id
icon icon
description
}
badgeVerification {
id
icon
description
} }
} }
` `

View File

@ -405,6 +405,22 @@ export const currentUserQuery = gql`
query { query {
currentUser { currentUser {
...user ...user
badgeTrophiesSelected {
id
icon
description
isDefault
}
badgeTrophiesUnused {
id
icon
description
}
badgeVerification {
id
icon
description
}
email email
role role
about about
@ -466,3 +482,43 @@ export const userDataQuery = (i18n) => {
} }
` `
} }
export const setTrophyBadgeSelected = gql`
mutation ($slot: Int!, $badgeId: ID) {
setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) {
badgeTrophiesCount
badgeTrophiesSelected {
id
icon
description
isDefault
}
badgeTrophiesUnused {
id
icon
description
}
badgeTrophiesUnusedCount
}
}
`
export const resetTrophyBadgesSelected = gql`
mutation {
resetTrophyBadgesSelected {
badgeTrophiesCount
badgeTrophiesSelected {
id
icon
description
isDefault
}
badgeTrophiesUnused {
id
icon
description
}
badgeTrophiesUnusedCount
}
}
`

View File

@ -957,6 +957,16 @@
"title": "Suchergebnisse" "title": "Suchergebnisse"
}, },
"settings": { "settings": {
"badges": {
"click-to-select": "Klicke auf einen freien Platz, um eine Badge hinzufügen.",
"click-to-use": "Klicke auf eine Badge, um sie zu platzieren.",
"description": "Hier hast du die Möglichkeit zu entscheiden, wie deine bereits erworbenen Badges in deinem Profil gezeigt werden sollen.",
"name": "Badges",
"no-badges-available": "Im Moment stehen dir keine Badges zur Verfügung, die du hinzufügen könntest.",
"remove": "Badge entfernen",
"success-update": "Deine Badges wurden erfolgreich gespeichert.",
"verification": "Dies ist deine Verifikations-Badge und kann nicht geändert werden."
},
"blocked-users": { "blocked-users": {
"block": "Nutzer blockieren", "block": "Nutzer blockieren",
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": "Search Results" "title": "Search Results"
}, },
"settings": { "settings": {
"badges": {
"click-to-select": "Click on an empty space to add a badge.",
"click-to-use": "Click on a badge to use it in the selected slot.",
"description": "Here you can choose how to display your earned badges on your profile.",
"name": "Badges",
"no-badges-available": "You currently don't have any badges available to add.",
"remove": "Remove Badge",
"success-update": "Your badges have been updated successfully.",
"verification": "This is your verification badge and cannot be changed."
},
"blocked-users": { "blocked-users": {
"block": "Block user", "block": "Block user",
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": "Bloquear usuario", "block": "Bloquear usuario",
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": "Bloquer l'utilisateur", "block": "Bloquer l'utilisateur",
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": null, "block": null,
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": null, "block": null,
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": null, "block": null,
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": "Bloquear usuário", "block": "Bloquear usuário",
"columns": { "columns": {

View File

@ -957,6 +957,16 @@
"title": null "title": null
}, },
"settings": { "settings": {
"badges": {
"click-to-select": null,
"click-to-use": null,
"description": null,
"name": null,
"no-badges-available": null,
"remove": null,
"success-update": null,
"verification": null
},
"blocked-users": { "blocked-users": {
"block": "Блокировать", "block": "Блокировать",
"columns": { "columns": {

View File

@ -79,6 +79,7 @@
"@storybook/addon-actions": "^5.3.21", "@storybook/addon-actions": "^5.3.21",
"@storybook/addon-notes": "^5.3.18", "@storybook/addon-notes": "^5.3.18",
"@storybook/vue": "~7.4.0", "@storybook/vue": "~7.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/vue": "5", "@testing-library/vue": "5",
"@vue/cli-shared-utils": "~4.3.1", "@vue/cli-shared-utils": "~4.3.1",
"@vue/eslint-config-prettier": "~6.0.0", "@vue/eslint-config-prettier": "~6.0.0",

View File

@ -0,0 +1,427 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`settings.vue given badges are disabled renders 1`] = `
<div>
<div>
<div
class="ds-space"
style="margin-top: 16px; margin-bottom: 16px;"
>
<h1
class="ds-heading ds-heading-h1"
>
</h1>
</div>
<div
class="ds-space"
style="margin-top: 32px; margin-bottom: 32px;"
/>
<div
class="ds-flex"
style="margin-left: -8px; margin-right: -8px;"
>
<div
class="menu-container"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/my-email-address"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/security"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/privacy"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/my-social-media"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/muted-users"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/blocked-users"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/embeds"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/notifications"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/data-download"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/delete-account"
>
</a>
<!---->
</li>
</ul>
</nav>
</div>
<div
class="settings-content"
id="settings-content"
>
<transition-stub
appear="true"
name="slide-up"
>
<nuxt-child-stub />
</transition-stub>
</div>
</div>
</div>
</div>
`;
exports[`settings.vue given badges are enabled renders 1`] = `
<div>
<div>
<div
class="ds-space"
style="margin-top: 16px; margin-bottom: 16px;"
>
<h1
class="ds-heading ds-heading-h1"
>
</h1>
</div>
<div
class="ds-space"
style="margin-top: 32px; margin-bottom: 32px;"
/>
<div
class="ds-flex"
style="margin-left: -8px; margin-right: -8px;"
>
<div
class="menu-container"
>
<nav
class="ds-menu"
>
<ul
class="ds-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/my-email-address"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/badges"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/security"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/privacy"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/my-social-media"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/muted-users"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/blocked-users"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/embeds"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/notifications"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/data-download"
>
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
href="/settings/delete-account"
>
</a>
<!---->
</li>
</ul>
</nav>
</div>
<div
class="settings-content"
id="settings-content"
>
<transition-stub
appear="true"
name="slide-up"
>
<nuxt-child-stub />
</transition-stub>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,847 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Users given badges are disabled renders 1`] = `
<div
class="admin-users"
>
<article
class="base-card"
>
<h2
class="title"
>
admin.users.name
</h2>
<form
autocomplete="off"
class="ds-form"
novalidate="novalidate"
>
<div
class="ds-flex"
style="margin-left: -8px; margin-right: -8px;"
>
<div
class="ds-flex-item"
style="flex-basis: 90%; width: 90%; padding-left: 8px; padding-right: 8px; margin-bottom: 16px;"
>
<div
class="ds-form-item ds-input-size-base"
>
<label
class="ds-input-label"
style="display: none;"
>
</label>
<div
class="ds-input-wrap"
>
<div
class="ds-input-icon"
>
<span
aria-label="icon"
class="ds-icon"
>
<svg
class="ds-icon-svg"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 3c5.511 0 10 4.489 10 10s-4.489 10-10 10a9.923 9.923 0 01-6.313-2.25l-7.969 7.969-1.438-1.438 7.969-7.969a9.919 9.919 0 01-2.25-6.313c0-5.511 4.489-10 10-10zm0 2c-4.43 0-8 3.57-8 8s3.57 8 8 8 8-3.57 8-8-3.57-8-8-8z"
/>
</svg>
</span>
</div>
<input
class="ds-input ds-input-has-icon"
name="query"
placeholder="admin.users.form.placeholder"
tabindex="0"
type="text"
/>
<!---->
</div>
<transition-stub
name="ds-input-error"
>
<div
class="ds-input-error"
style="display: none;"
>
</div>
</transition-stub>
</div>
</div>
<div
class="ds-flex-item"
style="flex-basis: 30px; width: 30px; padding-left: 8px; padding-right: 8px; margin-bottom: 16px;"
>
<button
class="base-button --icon-only --circle --filled"
type="submit"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
</div>
</div>
</form>
<!---->
</article>
<article
class="base-card"
>
<div
class="ds-table-wrap"
>
<table
cellpadding="0"
cellspacing="0"
class="ds-table ds-table-condensed ds-table-bordered"
>
<colgroup>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
</colgroup>
<thead>
<tr>
<th
class="ds-table-head-col"
>
admin.users.table.columns.number
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.name
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.email
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.slug
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.createdAt
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
🖉
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
🗨
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
admin.users.table.columns.role
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="ds-table-col"
>
NaN.
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
User
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
<a
href="mailto:user@example.org"
>
<b>
user@example.org
</b>
</a>
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
user
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
<select />
</td>
</tr>
<tr>
<td
class="ds-table-col"
>
NaN.
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
User
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
<a
href="mailto:user2@example.org"
>
<b>
user2@example.org
</b>
</a>
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
user
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
<select />
</td>
</tr>
</tbody>
</table>
</div>
<div
class="pagination-buttons"
>
<button
class="previous-button base-button --icon-only --circle"
data-test="previous-button"
disabled="disabled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<!---->
<button
class="next-button base-button --icon-only --circle"
data-test="next-button"
disabled="disabled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
</div>
<!---->
</article>
</div>
`;
exports[`Users given badges are enabled renders 1`] = `
<div
class="admin-users"
>
<article
class="base-card"
>
<h2
class="title"
>
admin.users.name
</h2>
<form
autocomplete="off"
class="ds-form"
novalidate="novalidate"
>
<div
class="ds-flex"
style="margin-left: -8px; margin-right: -8px;"
>
<div
class="ds-flex-item"
style="flex-basis: 90%; width: 90%; padding-left: 8px; padding-right: 8px; margin-bottom: 16px;"
>
<div
class="ds-form-item ds-input-size-base"
>
<label
class="ds-input-label"
style="display: none;"
>
</label>
<div
class="ds-input-wrap"
>
<div
class="ds-input-icon"
>
<span
aria-label="icon"
class="ds-icon"
>
<svg
class="ds-icon-svg"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 3c5.511 0 10 4.489 10 10s-4.489 10-10 10a9.923 9.923 0 01-6.313-2.25l-7.969 7.969-1.438-1.438 7.969-7.969a9.919 9.919 0 01-2.25-6.313c0-5.511 4.489-10 10-10zm0 2c-4.43 0-8 3.57-8 8s3.57 8 8 8 8-3.57 8-8-3.57-8-8-8z"
/>
</svg>
</span>
</div>
<input
class="ds-input ds-input-has-icon"
name="query"
placeholder="admin.users.form.placeholder"
tabindex="0"
type="text"
/>
<!---->
</div>
<transition-stub
name="ds-input-error"
>
<div
class="ds-input-error"
style="display: none;"
>
</div>
</transition-stub>
</div>
</div>
<div
class="ds-flex-item"
style="flex-basis: 30px; width: 30px; padding-left: 8px; padding-right: 8px; margin-bottom: 16px;"
>
<button
class="base-button --icon-only --circle --filled"
type="submit"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
</div>
</div>
</form>
<!---->
</article>
<article
class="base-card"
>
<div
class="ds-table-wrap"
>
<table
cellpadding="0"
cellspacing="0"
class="ds-table ds-table-condensed ds-table-bordered"
>
<colgroup>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
<col
width=""
/>
</colgroup>
<thead>
<tr>
<th
class="ds-table-head-col"
>
admin.users.table.columns.number
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.name
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.email
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.slug
</th>
<th
class="ds-table-head-col"
>
admin.users.table.columns.createdAt
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
🖉
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
🗨
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
admin.users.table.columns.role
</th>
<th
class="ds-table-head-col ds-table-head-col-right"
>
admin.users.table.columns.badges
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="ds-table-col"
>
NaN.
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
User
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
<a
href="mailto:user@example.org"
>
<b>
user@example.org
</b>
</a>
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
user
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
<select />
</td>
<td
class="ds-table-col ds-table-col-right"
>
<nuxt-link-stub
to="[object Object]"
>
admin.users.table.edit
</nuxt-link-stub>
</td>
</tr>
<tr>
<td
class="ds-table-col"
>
NaN.
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
User
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
<a
href="mailto:user2@example.org"
>
<b>
user2@example.org
</b>
</a>
</td>
<td
class="ds-table-col"
>
<nuxt-link-stub
to="[object Object]"
>
<b>
user
</b>
</nuxt-link-stub>
</td>
<td
class="ds-table-col"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
</td>
<td
class="ds-table-col ds-table-col-right"
>
<select />
</td>
<td
class="ds-table-col ds-table-col-right"
>
<nuxt-link-stub
to="[object Object]"
>
admin.users.table.edit
</nuxt-link-stub>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="pagination-buttons"
>
<button
class="previous-button base-button --icon-only --circle"
data-test="previous-button"
disabled="disabled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<!---->
<button
class="next-button base-button --icon-only --circle"
data-test="next-button"
disabled="disabled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
</div>
<!---->
</article>
</div>
`;

View File

@ -10,11 +10,9 @@ const stubs = {
describe('Users', () => { describe('Users', () => {
let wrapper let wrapper
let Wrapper
let getters
const mocks = { const mocks = {
$t: jest.fn(), $t: jest.fn((t) => t),
$apollo: { $apollo: {
loading: false, loading: false,
mutate: jest mutate: jest
@ -38,116 +36,154 @@ describe('Users', () => {
}, },
} }
describe('mount', () => { const getters = {
getters = { 'auth/isAdmin': () => true,
'auth/isAdmin': () => true, 'auth/user': () => {
'auth/user': () => { return { id: 'admin' }
return { id: 'admin' } },
}, }
}
Wrapper = () => { const Wrapper = () => {
const store = new Vuex.Store({ getters }) const store = new Vuex.Store({ getters })
return mount(Users, { return mount(Users, {
mocks, mocks,
localVue, localVue,
store, store,
stubs, stubs,
}) data: () => ({
} User: [
{
id: 'user',
email: 'user@example.org',
name: 'User',
role: 'moderator',
slug: 'user',
},
{
id: 'user2',
email: 'user2@example.org',
name: 'User',
role: 'moderator',
slug: 'user',
},
],
}),
})
}
describe('given badges are enabled', () => {
beforeEach(() => {
mocks.$env = {
BADGES_ENABLED: true,
}
wrapper = Wrapper()
})
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot()
})
})
describe('given badges are disabled', () => {
beforeEach(() => {
mocks.$env = {
BADGES_ENABLED: false,
}
wrapper = Wrapper() wrapper = Wrapper()
expect(wrapper.element.tagName).toBe('DIV')
}) })
describe('search', () => { it('renders', () => {
let searchAction expect(wrapper.element).toMatchSnapshot()
beforeEach(() => { })
searchAction = (wrapper, { query }) => { })
wrapper.find('input').setValue(query)
wrapper.find('form').trigger('submit') describe('search', () => {
return wrapper let searchAction
} beforeEach(() => {
wrapper = Wrapper()
searchAction = (wrapper, { query }) => {
wrapper.find('input').setValue(query)
wrapper.find('form').trigger('submit')
return wrapper
}
})
describe('query looks like an email address', () => {
it('searches users for exact email address', async () => {
const wrapper = await searchAction(Wrapper(), { query: 'email@example.org' })
expect(wrapper.vm.email).toEqual('email@example.org')
expect(wrapper.vm.filter).toBe(null)
}) })
describe('query looks like an email address', () => { it('email address is case-insensitive', async () => {
it('searches users for exact email address', async () => { const wrapper = await searchAction(Wrapper(), { query: 'eMaiL@example.org' })
const wrapper = await searchAction(Wrapper(), { query: 'email@example.org' }) expect(wrapper.vm.email).toEqual('email@example.org')
expect(wrapper.vm.email).toEqual('email@example.org') expect(wrapper.vm.filter).toBe(null)
expect(wrapper.vm.filter).toBe(null)
})
it('email address is case-insensitive', async () => {
const wrapper = await searchAction(Wrapper(), { query: 'eMaiL@example.org' })
expect(wrapper.vm.email).toEqual('email@example.org')
expect(wrapper.vm.filter).toBe(null)
})
})
describe('query is just text', () => {
it('tries to find matching users by `name`, `slug` or `about`', async () => {
const wrapper = await searchAction(await Wrapper(), { query: 'Find me' })
const expected = {
OR: [
{ name_contains: 'Find me' },
{ slug_contains: 'Find me' },
{ about_contains: 'Find me' },
],
}
expect(wrapper.vm.email).toBe(null)
expect(wrapper.vm.filter).toEqual(expected)
})
}) })
}) })
describe('change roles', () => { describe('query is just text', () => {
beforeAll(() => { it('tries to find matching users by `name`, `slug` or `about`', async () => {
wrapper = Wrapper() const wrapper = await searchAction(await Wrapper(), { query: 'Find me' })
wrapper.setData({ const expected = {
User: [ OR: [
{ { name_contains: 'Find me' },
id: 'admin', { slug_contains: 'Find me' },
email: 'admin@example.org', { about_contains: 'Find me' },
name: 'Admin',
role: 'admin',
slug: 'admin',
},
{
id: 'user',
email: 'user@example.org',
name: 'User',
role: 'user',
slug: 'user',
},
], ],
userRoles: ['user', 'moderator', 'admin'], }
}) expect(wrapper.vm.email).toBe(null)
}) expect(wrapper.vm.filter).toEqual(expected)
it('cannot change own role', () => {
const adminRow = wrapper.findAll('tr').at(1)
expect(adminRow.find('select').exists()).toBe(false)
})
it('changes the role of another user', () => {
const userRow = wrapper.findAll('tr').at(2)
userRow.findAll('option').at(1).setSelected()
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'user',
role: 'moderator',
},
}),
)
})
it('toasts a success message after role has changed', () => {
const userRow = wrapper.findAll('tr').at(2)
userRow.findAll('option').at(1).setSelected()
expect(mocks.$toast.success).toHaveBeenCalled()
}) })
}) })
}) })
describe('change roles', () => {
beforeAll(() => {
wrapper = Wrapper()
wrapper.setData({
User: [
{
id: 'admin',
email: 'admin@example.org',
name: 'Admin',
role: 'admin',
slug: 'admin',
},
{
id: 'user',
email: 'user@example.org',
name: 'User',
role: 'user',
slug: 'user',
},
],
userRoles: ['user', 'moderator', 'admin'],
})
})
it('cannot change own role', () => {
const adminRow = wrapper.findAll('tr').at(1)
expect(adminRow.find('select').exists()).toBe(false)
})
it('changes the role of another user', () => {
const userRow = wrapper.findAll('tr').at(2)
userRow.findAll('option').at(1).setSelected()
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
id: 'user',
role: 'moderator',
},
}),
)
})
it('toasts a success message after role has changed', () => {
const userRow = wrapper.findAll('tr').at(2)
userRow.findAll('option').at(1).setSelected()
expect(mocks.$toast.success).toHaveBeenCalled()
})
})
}) })

View File

@ -120,7 +120,7 @@ export default {
currentUser: 'auth/user', currentUser: 'auth/user',
}), }),
fields() { fields() {
return { const fields = {
index: this.$t('admin.users.table.columns.number'), index: this.$t('admin.users.table.columns.number'),
name: this.$t('admin.users.table.columns.name'), name: this.$t('admin.users.table.columns.name'),
email: this.$t('admin.users.table.columns.email'), email: this.$t('admin.users.table.columns.email'),
@ -142,11 +142,16 @@ export default {
label: this.$t('admin.users.table.columns.role'), label: this.$t('admin.users.table.columns.role'),
align: 'right', align: 'right',
}, },
badges: { }
if (this.$env.BADGES_ENABLED) {
fields.badges = {
label: this.$t('admin.users.table.columns.badges'), label: this.$t('admin.users.table.columns.badges'),
align: 'right', align: 'right',
}, }
} }
return fields
}, },
}, },
apollo: { apollo: {

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,25 @@
import { mount } from '@vue/test-utils' import { render } from '@testing-library/vue'
import ProfileSlug from './_slug.vue' import ProfileSlug from './_slug.vue'
const localVue = global.localVue const localVue = global.localVue
localVue.filter('date', (d) => d) localVue.filter('date', (d) => d)
// Mock Math.random, used in Dropdown
Object.assign(Math, {
random: () => 0,
})
const stubs = { const stubs = {
'client-only': true, 'client-only': true,
'v-popover': true, 'v-popover': true,
'nuxt-link': true, 'nuxt-link': true,
'infinite-loading': true,
'follow-list': true, 'follow-list': true,
'router-link': true, 'router-link': true,
} }
describe('ProfileSlug', () => { describe('ProfileSlug', () => {
let wrapper let wrapper
let Wrapper
let mocks let mocks
beforeEach(() => { beforeEach(() => {
@ -25,7 +28,7 @@ describe('ProfileSlug', () => {
id: 'p23', id: 'p23',
name: 'It is a post', name: 'It is a post',
}, },
$t: jest.fn(), $t: jest.fn((t) => t),
// If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html // If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$route: { $route: {
params: { params: {
@ -49,49 +52,144 @@ describe('ProfileSlug', () => {
} }
}) })
describe('mount', () => { const Wrapper = (badgesEnabled, data) => {
Wrapper = () => { return render(ProfileSlug, {
return mount(ProfileSlug, { localVue,
mocks, stubs,
localVue, data: () => data,
stubs, mocks: {
}) ...mocks,
} $env: {
BADGES_ENABLED: badgesEnabled,
},
},
})
}
describe('given an authenticated user', () => { describe('given an authenticated user', () => {
beforeEach(() => { beforeEach(() => {
mocks.$filters = { mocks.$filters = {
removeLinks: (c) => c, removeLinks: (c) => c,
truncate: (a) => a, truncate: (a) => a,
} }
mocks.$store = { mocks.$store = {
getters: { getters: {
'auth/isModerator': () => false, 'auth/isModerator': () => false,
'auth/user': { 'auth/user': {
id: 'u23', id: 'u23',
},
}, },
} },
}) }
})
describe('given a user for the profile', () => { describe('given another profile user', () => {
beforeEach(() => { const user = {
wrapper = Wrapper() User: [
wrapper.setData({ {
User: [ id: 'u3',
name: 'Bob the builder',
contributionsCount: 6,
shoutedCount: 7,
commentedCount: 8,
badgeVerification: {
id: 'bv1',
icon: '/path/to/icon-bv1',
description: 'verified',
isDefault: false,
},
badgeTrophiesSelected: [
{ {
id: 'u3', id: 'bt1',
name: 'Bob the builder', icon: '/path/to/icon-bt1',
contributionsCount: 6, description: 'a trophy',
shoutedCount: 7, isDefault: false,
commentedCount: 8, },
{
id: 'bt2',
icon: '/path/to/icon-bt2',
description: 'no trophy',
isDefault: true,
}, },
], ],
}) },
],
}
describe('and badges are enabled', () => {
beforeEach(() => {
wrapper = Wrapper(true, user)
}) })
it('displays name of the user', () => { it('renders', () => {
expect(wrapper.text()).toContain('Bob the builder') expect(wrapper.container).toMatchSnapshot()
})
})
describe('and badges are disabled', () => {
beforeEach(() => {
wrapper = Wrapper(false, user)
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('given the logged in user as profile user', () => {
beforeEach(() => {
mocks.$route.params.id = 'u23'
})
const user = {
User: [
{
id: 'u23',
name: 'Bob the builder',
contributionsCount: 6,
shoutedCount: 7,
commentedCount: 8,
badgeVerification: {
id: 'bv1',
icon: '/path/to/icon-bv1',
description: 'verified',
isDefault: false,
},
badgeTrophiesSelected: [
{
id: 'bt1',
icon: '/path/to/icon-bt1',
description: 'a trophy',
isDefault: false,
},
{
id: 'bt2',
icon: '/path/to/icon-bt2',
description: 'no trophy',
isDefault: true,
},
],
},
],
}
describe('and badges are enabled', () => {
beforeEach(() => {
wrapper = Wrapper(true, user)
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
describe('and badges are disabled', () => {
beforeEach(() => {
wrapper = Wrapper(false, user)
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
}) })
}) })
}) })

View File

@ -42,8 +42,11 @@
{{ $t('profile.memberSince') }} {{ user.createdAt | date('MMMM yyyy') }} {{ $t('profile.memberSince') }} {{ user.createdAt | date('MMMM yyyy') }}
</ds-text> </ds-text>
</ds-space> </ds-space>
<ds-space v-if="user.badgeTrophies && user.badgeTrophies.length" margin="x-small"> <ds-space v-if="userBadges && userBadges.length" margin="x-small">
<hc-badges :badges="user.badgeTrophies" /> <a v-if="myProfile" href="/settings/badges" class="badge-edit-link">
<hc-badges :badges="userBadges" />
</a>
<hc-badges v-if="!myProfile" :badges="userBadges" />
</ds-space> </ds-space>
<ds-flex> <ds-flex>
<ds-flex-item> <ds-flex-item>
@ -266,6 +269,10 @@ export default {
user() { user() {
return this.User ? this.User[0] : {} return this.User ? this.User[0] : {}
}, },
userBadges() {
if (!this.$env.BADGES_ENABLED) return null
return [this.user.badgeVerification, ...(this.user.badgeTrophiesSelected || [])]
},
userName() { userName() {
const { name } = this.user || {} const { name } = this.user || {}
return name || this.$t('profile.userAnonym') return name || this.$t('profile.userAnonym')
@ -456,6 +463,12 @@ export default {
margin: auto; margin: auto;
margin-top: -60px; margin-top: -60px;
} }
.badge-edit-link {
transition: all 0.2s ease-out;
&:hover {
opacity: 0.7;
}
}
.page-name-profile-id-slug { .page-name-profile-id-slug {
.ds-flex-item:first-child .content-menu { .ds-flex-item:first-child .content-menu {
position: absolute; position: absolute;

View File

@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils' import { render } from '@testing-library/vue'
import settings from './settings.vue' import settings from './settings.vue'
const localVue = global.localVue const localVue = global.localVue
@ -17,21 +17,37 @@ describe('settings.vue', () => {
} }
}) })
describe('mount', () => { const Wrapper = () => {
const Wrapper = () => { return render(settings, {
return mount(settings, { mocks,
mocks, localVue,
localVue, stubs,
stubs, })
}) }
}
describe('given badges are enabled', () => {
beforeEach(() => { beforeEach(() => {
mocks.$env = {
BADGES_ENABLED: true,
}
wrapper = Wrapper() wrapper = Wrapper()
}) })
it('renders', () => { it('renders', () => {
expect(wrapper.element.tagName).toBe('DIV') expect(wrapper.container).toMatchSnapshot()
})
})
describe('given badges are disabled', () => {
beforeEach(() => {
mocks.$env = {
BADGES_ENABLED: false,
}
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
}) })
}) })
}) })

View File

@ -21,7 +21,7 @@
export default { export default {
computed: { computed: {
routes() { routes() {
return [ const routes = [
{ {
name: this.$t('settings.data.name'), name: this.$t('settings.data.name'),
path: `/settings`, path: `/settings`,
@ -83,6 +83,15 @@ export default {
}, },
} */ } */
] ]
if (this.$env.BADGES_ENABLED) {
routes.splice(2, 0, {
name: this.$t('settings.badges.name'),
path: `/settings/badges`,
})
}
return routes
}, },
}, },
} }

View File

@ -0,0 +1,429 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`badge settings with badges more badges available selecting an empty slot shows list with available badges 1`] = `
<div>
<article
class="base-card"
>
<h2
class="title"
>
settings.badges.name
</h2>
<p>
settings.badges.description
</p>
<div
class="ds-space ds-space-centered"
style="margin-top: 24px; margin-bottom: 16px;"
>
<div
class="presenterContainer"
>
<div
class="hc-badges"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/verification/icon"
title="Verification description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable selected"
>
<img
class="hc-badge"
src="/api/path/to/empty/icon"
title="Empty"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
<!---->
<div>
<strong>
settings.badges.click-to-use
</strong>
</div>
<!---->
<div
class="selection-info"
>
<div
class="badge-selection"
>
<button
class="badge-selection-item"
>
<div
class="badge-icon"
>
<img
alt="4"
src="/api/path/to/fourth/icon"
/>
</div>
<div
class="badge-info"
>
<div
class="badge-description"
>
Fourth description
</div>
</div>
</button>
<button
class="badge-selection-item"
>
<div
class="badge-icon"
>
<img
alt="5"
src="/api/path/to/fifth/icon"
/>
</div>
<div
class="badge-info"
>
<div
class="badge-description"
>
Fifth description
</div>
</div>
</button>
</div>
</div>
</div>
<!---->
</article>
</div>
`;
exports[`badge settings with badges no more badges available selecting an empty slot shows no more badges available message 1`] = `
<div>
<article
class="base-card"
>
<h2
class="title"
>
settings.badges.name
</h2>
<p>
settings.badges.description
</p>
<div
class="ds-space ds-space-centered"
style="margin-top: 24px; margin-bottom: 16px;"
>
<div
class="presenterContainer"
>
<div
class="hc-badges"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/verification/icon"
title="Verification description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable selected"
>
<img
class="hc-badge"
src="/api/path/to/empty/icon"
title="Empty"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
<p>
settings.badges.no-badges-available
</p>
<!---->
<!---->
<!---->
</div>
<!---->
</article>
</div>
`;
exports[`badge settings with badges renders 1`] = `
<div>
<article
class="base-card"
>
<h2
class="title"
>
settings.badges.name
</h2>
<p>
settings.badges.description
</p>
<div
class="ds-space ds-space-centered"
style="margin-top: 24px; margin-bottom: 16px;"
>
<div
class="presenterContainer"
>
<div
class="hc-badges"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/verification/icon"
title="Verification description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/empty/icon"
title="Empty"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
<!---->
<div>
<strong>
settings.badges.click-to-select
</strong>
</div>
<!---->
<!---->
</div>
<!---->
</article>
</div>
`;
exports[`badge settings with badges selecting a used badge clicking remove badge button with successful server request removes the badge 1`] = `
<div>
<article
class="base-card"
>
<h2
class="title"
>
settings.badges.name
</h2>
<p>
settings.badges.description
</p>
<div
class="ds-space"
>
<div
class="presenterContainer"
>
<div
class="hc-badges"
style="transform: scale(2);"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/verification/icon"
title="Verification description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/some/icon"
title="Some description"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/empty/icon"
title="Empty"
/>
</button>
<button
class="hc-badge-container selectable"
>
<img
class="hc-badge"
src="/api/path/to/third/icon"
title="Third description"
/>
</button>
</div>
</div>
<!---->
<!---->
<!---->
</div>
<!---->
</article>
</div>
`;
exports[`badge settings without badges renders 1`] = `
<div>
<article
class="base-card"
>
<h2
class="title"
>
settings.badges.name
</h2>
<p>
settings.badges.description
</p>
<div
class="ds-space ds-space-centered"
style="margin-top: 24px; margin-bottom: 16px;"
>
<div
class="presenterContainer"
>
<div
class="hc-badges"
>
<button
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/verification/icon"
title="Verification description"
/>
</button>
</div>
</div>
<!---->
<!---->
<!---->
<!---->
</div>
<!---->
</article>
</div>
`;

View File

@ -0,0 +1,302 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import '@testing-library/jest-dom'
import badges from './badges.vue'
const localVue = global.localVue
describe('badge settings', () => {
let mocks
const apolloMutateMock = jest.fn()
const Wrapper = () => {
return render(badges, {
localVue,
mocks,
})
}
beforeEach(() => {
mocks = {
$t: jest.fn((t) => t),
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: apolloMutateMock,
},
}
})
describe('without badges', () => {
beforeEach(() => {
mocks.$store = {
getters: {
'auth/isModerator': () => false,
'auth/user': {
id: 'u23',
badgeVerification: {
id: 'bv1',
icon: '/verification/icon',
description: 'Verification description',
isDefault: true,
},
badgeTrophiesSelected: [],
badgeTrophiesUnused: [],
},
},
}
})
it('renders', () => {
const wrapper = Wrapper()
expect(wrapper.container).toMatchSnapshot()
})
})
describe('with badges', () => {
const badgeTrophiesSelected = [
{
id: '1',
icon: '/path/to/some/icon',
isDefault: false,
description: 'Some description',
},
{
id: '2',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
]
const badgeTrophiesUnused = [
{
id: '4',
icon: '/path/to/fourth/icon',
description: 'Fourth description',
},
{
id: '5',
icon: '/path/to/fifth/icon',
description: 'Fifth description',
},
]
let wrapper
beforeEach(() => {
mocks.$store = {
getters: {
'auth/isModerator': () => false,
'auth/user': {
id: 'u23',
badgeVerification: {
id: 'bv1',
icon: '/verification/icon',
description: 'Verification description',
isDefault: false,
},
badgeTrophiesSelected,
badgeTrophiesUnused,
},
},
}
wrapper = Wrapper()
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('selecting a used badge', () => {
beforeEach(async () => {
const badge = screen.getByTitle(badgeTrophiesSelected[0].description)
await fireEvent.click(badge)
})
it('shows remove badge button', () => {
expect(screen.getByText('settings.badges.remove')).toBeInTheDocument()
})
describe('clicking remove badge button', () => {
const clickButton = async () => {
const removeButton = screen.getByText('settings.badges.remove')
await fireEvent.click(removeButton)
}
describe('with successful server request', () => {
beforeEach(() => {
apolloMutateMock.mockResolvedValue({
data: {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
{
id: '2',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
{
id: '2',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
],
},
},
})
clickButton()
})
it('calls the server', () => {
expect(apolloMutateMock).toHaveBeenCalledWith({
mutation: expect.anything(),
update: expect.anything(),
variables: {
badgeId: null,
slot: 0,
},
})
})
/* To test this, we would need a better apollo mock */
it.skip('removes the badge', async () => {
expect(wrapper.container).toMatchSnapshot()
})
it('shows a success message', () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.badges.success-update')
})
})
describe('with failed server request', () => {
beforeEach(() => {
apolloMutateMock.mockRejectedValue({ message: 'Ouch!' })
clickButton()
})
it('shows an error message', () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('settings.badges.error-update')
})
})
})
})
describe('no more badges available', () => {
beforeEach(async () => {
mocks.$store.getters['auth/user'].badgeTrophiesUnused = []
})
describe('selecting an empty slot', () => {
beforeEach(async () => {
const emptySlot = screen.getAllByTitle('Empty')[0]
await fireEvent.click(emptySlot)
})
it('shows no more badges available message', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('more badges available', () => {
describe('selecting an empty slot', () => {
beforeEach(async () => {
const emptySlot = screen.getAllByTitle('Empty')[0]
await fireEvent.click(emptySlot)
})
it('shows list with available badges', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('clicking on an available badge', () => {
const clickBadge = async () => {
const badge = screen.getByText(badgeTrophiesUnused[0].description)
await fireEvent.click(badge)
}
describe('with successful server request', () => {
beforeEach(() => {
apolloMutateMock.mockResolvedValue({
data: {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
{
id: '4',
icon: '/path/to/fourth/icon',
description: 'Fourth description',
isDefault: false,
},
{
id: '2',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
],
},
},
})
clickBadge()
})
it('calls the server', () => {
expect(apolloMutateMock).toHaveBeenCalledWith({
mutation: expect.anything(),
update: expect.anything(),
variables: {
badgeId: '4',
slot: 1,
},
})
})
/* To test this, we would need a better apollo mock */
it.skip('adds the badge', async () => {
expect(wrapper.container).toMatchSnapshot()
})
it('shows a success message', () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.badges.success-update')
})
})
describe('with failed server request', () => {
beforeEach(() => {
apolloMutateMock.mockRejectedValue({ message: 'Ouch!' })
clickBadge()
})
it('shows an error message', () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('settings.badges.error-update')
})
})
})
})
})
})
})

View File

@ -0,0 +1,176 @@
<template>
<base-card>
<h2 class="title">{{ $t('settings.badges.name') }}</h2>
<p>{{ $t('settings.badges.description') }}</p>
<ds-space centered margin-bottom="small" margin-top="base">
<div class="presenterContainer">
<badges
:badges="[currentUser.badgeVerification, ...selectedBadges]"
:selection-mode="true"
@badge-selected="handleBadgeSlotSelection"
ref="badgesComponent"
/>
</div>
<p v-if="!availableBadges.length && isEmptySlotSelected">
{{ $t('settings.badges.no-badges-available') }}
</p>
<div v-if="availableBadges.length > 0">
<strong>
{{
selectedBadgeIndex === null
? this.$t('settings.badges.click-to-select')
: isEmptySlotSelected
? this.$t('settings.badges.click-to-use')
: ''
}}
</strong>
</div>
<div v-if="selectedBadgeIndex !== null && !isEmptySlotSelected" class="badge-actions">
<base-button @click="removeBadgeFromSlot" class="remove-button">
{{ $t('settings.badges.remove') }}
</base-button>
</div>
<div
v-if="availableBadges.length && selectedBadgeIndex !== null && isEmptySlotSelected"
class="selection-info"
>
<badge-selection
:badges="availableBadges"
@badge-selected="assignBadgeToSlot"
ref="badgeSelection"
/>
</div>
</ds-space>
</base-card>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import { setTrophyBadgeSelected } from '~/graphql/User'
import scrollToContent from './scroll-to-content.js'
import Badges from '../../components/Badges.vue'
import BadgeSelection from '../../components/BadgeSelection.vue'
export default {
components: { BadgeSelection, Badges },
mixins: [scrollToContent],
data() {
return {
selectedBadgeIndex: null,
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
selectedBadges() {
return this.currentUser.badgeTrophiesSelected
},
availableBadges() {
return this.currentUser.badgeTrophiesUnused
},
isEmptySlotSelected() {
return this.selectedBadges[this.selectedBadgeIndex]?.isDefault ?? false
},
},
created() {
this.userBadges = [...(this.currentUser.badgeTrophiesSelected || [])]
},
methods: {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
handleBadgeSlotSelection(index) {
if (index === 0) {
this.$toast.info(this.$t('settings.badges.verification'))
this.$refs.badgesComponent.resetSelection()
return
}
this.selectedBadgeIndex = index === null ? null : index - 1 // The first badge in badges component is the verification badge
},
async setSlot(badge, slot) {
await this.$apollo.mutate({
mutation: setTrophyBadgeSelected,
variables: {
badgeId: badge?.id ?? null,
slot,
},
update: (_, { data: { setTrophyBadgeSelected } }) => {
const { badgeTrophiesSelected, badgeTrophiesUnused } = setTrophyBadgeSelected
this.setCurrentUser({
...this.currentUser,
badgeTrophiesSelected,
badgeTrophiesUnused,
})
},
})
},
async assignBadgeToSlot(badge) {
if (!badge || this.selectedBadgeIndex === null) {
return
}
try {
await this.setSlot(badge, this.selectedBadgeIndex)
this.$toast.success(this.$t('settings.badges.success-update'))
} catch (error) {
this.$toast.error(this.$t('settings.badges.error-update'))
}
if (this.$refs.badgeSelection && this.$refs.badgeSelection.resetSelection) {
this.$refs.badgeSelection.resetSelection()
}
this.$refs.badgesComponent.resetSelection()
this.selectedBadgeIndex = null
},
async removeBadgeFromSlot() {
if (this.selectedBadgeIndex === null) return
try {
await this.setSlot(null, this.selectedBadgeIndex)
this.$toast.success(this.$t('settings.badges.success-update'))
} catch (error) {
this.$toast.error(this.$t('settings.badges.error-update'))
}
this.$refs.badgesComponent.resetSelection()
this.selectedBadgeIndex = null
},
},
}
</script>
<style scoped>
.presenterContainer {
margin-top: 20px;
padding-top: 50px;
min-height: 220px;
--badges-scale: 2;
}
@media screen and (max-width: 400px) {
.presenterContainer {
--badges-scale: 1.5;
}
}
.badge-actions {
margin-top: 20px;
display: flex;
justify-content: center;
.remove-button {
margin-top: 8px;
}
}
.selection-info {
margin-top: 20px;
padding: 16px;
}
</style>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="170mm" height="65mm" version="1.1" viewBox="0 0 170 65" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-40.481 -87.048)" fill="#f2bd27" stroke-width="1.5118">
<path transform="matrix(.30472 .0030284 -.0030284 .30472 .96713 112.56)" d="m461.32 72.567-52.541-26.955-51.991 28.001 9.3998-58.299-42.696-40.794 58.35-9.0756 25.603-53.213 26.663 52.69 58.52 7.9067-41.872 41.64z"/>
<path transform="matrix(.21871 .0021736 -.0021736 .21871 75.918 124.75)" d="m461.32 72.567-52.541-26.955-51.991 28.001 9.3998-58.299-42.696-40.794 58.35-9.0756 25.603-53.213 26.663 52.69 58.52 7.9067-41.872 41.64z"/>
<path transform="matrix(.21871 .0021736 -.0021736 .21871 -3.5617 124.75)" d="m461.32 72.567-52.541-26.955-51.991 28.001 9.3998-58.299-42.696-40.794 58.35-9.0756 25.603-53.213 26.663 52.69 58.52 7.9067-41.872 41.64z"/>
<path transform="matrix(.16908 .0016804 -.0016804 .16908 -13.934 138.16)" d="m461.32 72.567-52.541-26.955-51.991 28.001 9.3998-58.299-42.696-40.794 58.35-9.0756 25.603-53.213 26.663 52.69 58.52 7.9067-41.872 41.64z"/>
<path transform="matrix(.16908 .0016804 -.0016804 .16908 126.82 138.16)" d="m461.32 72.567-52.541-26.955-51.991 28.001 9.3998-58.299-42.696-40.794 58.35-9.0756 25.603-53.213 26.663 52.69 58.52 7.9067-41.872 41.64z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -7,6 +7,11 @@
resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf"
integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
"@adobe/css-tools@^4.4.0":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.2.tgz#c836b1bd81e6d62cd6cdf3ee4948bcdce8ea79c8"
integrity sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==
"@ampproject/remapping@^2.2.0": "@ampproject/remapping@^2.2.0":
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630"
@ -4253,6 +4258,19 @@
lz-string "^1.5.0" lz-string "^1.5.0"
pretty-format "^27.0.2" pretty-format "^27.0.2"
"@testing-library/jest-dom@^6.6.3":
version "6.6.3"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2"
integrity sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==
dependencies:
"@adobe/css-tools" "^4.4.0"
aria-query "^5.0.0"
chalk "^3.0.0"
css.escape "^1.5.1"
dom-accessibility-api "^0.6.3"
lodash "^4.17.21"
redent "^3.0.0"
"@testing-library/vue@5": "@testing-library/vue@5":
version "5.9.0" version "5.9.0"
resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-5.9.0.tgz#d33c52ae89e076808abe622f70dcbccb1b5d080c" resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-5.9.0.tgz#d33c52ae89e076808abe622f70dcbccb1b5d080c"
@ -6058,6 +6076,11 @@ aria-query@5.1.3:
dependencies: dependencies:
deep-equal "^2.0.5" deep-equal "^2.0.5"
aria-query@^5.0.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==
arr-diff@^4.0.0: arr-diff@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -8230,6 +8253,11 @@ css-what@2.1, css-what@^2.1.2:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==
csscolorparser@~1.0.3: csscolorparser@~1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b"
@ -8793,6 +8821,11 @@ dom-accessibility-api@^0.5.9:
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==
dom-accessibility-api@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8"
integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==
dom-converter@^0.2: dom-converter@^0.2:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"