fix(webapp): fix badge select + drag&drop for badges on desktop devices (#9287)

This commit is contained in:
Ulf Gebhardt 2026-02-21 20:42:44 +01:00 committed by GitHub
parent ccf10610c8
commit 9ff74b4219
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1000 additions and 71 deletions

View File

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import { render, screen, fireEvent, within } from '@testing-library/vue'
import BadgeSelection from './BadgeSelection.vue'
const localVue = global.localVue
@ -72,5 +72,78 @@ describe('Badges.vue', () => {
expect(wrapper.emitted()['badge-selected']).toEqual([[badges[1]], [null]])
})
})
describe('with drag enabled', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges, dragEnabled: true })
})
it('items are draggable', () => {
const items = wrapper.container.querySelectorAll('.badge-selection-item')
items.forEach((item) => {
expect(item.getAttribute('draggable')).toBe('true')
})
})
describe('dragstart on an item', () => {
it('sets dataTransfer data', async () => {
const item = within(wrapper.container)
.getByText(badges[0].description)
.closest('.badge-selection-item')
const setData = jest.fn()
await fireEvent.dragStart(item, {
dataTransfer: { setData, effectAllowed: '' },
})
expect(setData).toHaveBeenCalledWith(
'application/json',
JSON.stringify({ source: 'reserve', badge: badges[0] }),
)
})
})
describe('drop on container with hex badge data', () => {
it('emits badge-returned', async () => {
const container = wrapper.container.querySelector('.badge-selection')
const hexData = JSON.stringify({ source: 'hex', index: 2, badge: { id: '10' } })
await fireEvent.drop(container, {
dataTransfer: { getData: () => hexData },
})
expect(wrapper.emitted()['badge-returned']).toBeTruthy()
expect(wrapper.emitted()['badge-returned'][0][0]).toEqual({
source: 'hex',
index: 2,
badge: { id: '10' },
})
})
})
describe('drop on container with reserve badge data', () => {
it('does not emit badge-returned', async () => {
const container = wrapper.container.querySelector('.badge-selection')
const reserveData = JSON.stringify({ source: 'reserve', badge: badges[0] })
await fireEvent.drop(container, {
dataTransfer: { getData: () => reserveData },
})
expect(wrapper.emitted()['badge-returned']).toBeFalsy()
})
})
})
describe('with drag disabled', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges, dragEnabled: false })
})
it('items are not draggable', () => {
const items = wrapper.container.querySelectorAll('.badge-selection-item')
items.forEach((item) => {
expect(item.getAttribute('draggable')).toBe('false')
})
})
})
})
})

View File

@ -1,10 +1,21 @@
<template>
<div class="badge-selection">
<div
class="badge-selection"
:class="{ 'reserve-drag-over': reserveDragOver }"
@dragover.prevent
@dragenter="handleContainerDragEnter"
@dragleave="handleContainerDragLeave"
@drop.prevent="handleContainerDrop"
>
<button
v-for="(badge, index) in badges"
:key="badge.id"
class="badge-selection-item"
:class="{ dragging: draggingIndex === index }"
:draggable="dragEnabled"
@click="handleBadgeClick(badge, index)"
@dragstart="handleItemDragStart($event, badge, index)"
@dragend="handleItemDragEnd"
>
<div class="badge-icon">
<img :src="backendPath(badge.icon)" :alt="badge.id" />
@ -25,10 +36,17 @@ export default {
type: Array,
default: () => [],
},
dragEnabled: {
type: Boolean,
default: false,
},
},
data() {
return {
selectedIndex: null,
draggingIndex: null,
reserveDragOver: false,
dragEnterCount: 0,
}
},
methods: {
@ -43,6 +61,37 @@ export default {
this.selectedIndex = index
this.$emit('badge-selected', badge)
},
handleItemDragStart(event, badge, index) {
this.draggingIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/json', JSON.stringify({ source: 'reserve', badge }))
},
handleItemDragEnd() {
this.draggingIndex = null
},
handleContainerDragEnter() {
this.dragEnterCount++
this.reserveDragOver = true
},
handleContainerDragLeave() {
this.dragEnterCount--
if (this.dragEnterCount <= 0) {
this.dragEnterCount = 0
this.reserveDragOver = false
}
},
handleContainerDrop(event) {
this.dragEnterCount = 0
this.reserveDragOver = false
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'))
if (data.source === 'hex') {
this.$emit('badge-returned', data)
}
} catch {
// ignore invalid drag data
}
},
resetSelection() {
this.selectedIndex = null
},
@ -55,6 +104,17 @@ export default {
width: 100%;
max-width: 600px;
margin: 0 auto;
border: 2px solid transparent;
border-radius: 12px;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&.reserve-drag-over {
border-color: $color-success;
border-style: dashed;
background-color: rgba($color-success, 0.05);
}
.badge-selection-item {
display: flex;
@ -62,6 +122,10 @@ export default {
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
&:last-child {
margin-bottom: 0;
}
background-color: #f5f5f5;
transition: all 0.2s ease;
width: 100%;
@ -72,6 +136,10 @@ export default {
background-color: #e0e0e0;
}
&.dragging {
opacity: 0.4;
}
.badge-icon {
flex-shrink: 0;
width: 40px;

View File

@ -111,5 +111,97 @@ describe('Badges.vue', () => {
})
})
})
describe('with drag enabled', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges, selectionMode: true, dragEnabled: true })
})
it('renders draggable badges except first and default ones', () => {
const containers = wrapper.container.querySelectorAll('.hc-badge-container')
expect(containers[0].getAttribute('draggable')).toBe('false')
expect(containers[1].getAttribute('draggable')).toBe('false') // isDefault: true
expect(containers[2].getAttribute('draggable')).toBe('true')
})
it('first badge (index 0) is never draggable', () => {
const firstBadge = wrapper.container.querySelector('.hc-badge-container')
expect(firstBadge.getAttribute('draggable')).toBe('false')
})
describe('dragstart on non-default badge', () => {
it('emits drag-start with badge data', async () => {
const badge = screen.getByTitle(badges[2].description)
const container = badge.closest('.hc-badge-container')
await fireEvent.dragStart(container, {
dataTransfer: {
setData: jest.fn(),
effectAllowed: '',
},
})
expect(wrapper.emitted()['drag-start']).toBeTruthy()
expect(wrapper.emitted()['drag-start'][0][0]).toEqual({
source: 'hex',
index: 2,
badge: badges[2],
})
})
})
describe('drop on a slot', () => {
it('emits badge-drop with parsed source and target data', async () => {
const targetBadge = screen.getByTitle(badges[2].description)
const container = targetBadge.closest('.hc-badge-container')
const sourceData = JSON.stringify({ source: 'reserve', badge: { id: '99', icon: '/x' } })
await fireEvent.drop(container, {
dataTransfer: {
getData: () => sourceData,
},
})
expect(wrapper.emitted()['badge-drop']).toBeTruthy()
const emitted = wrapper.emitted()['badge-drop'][0][0]
expect(emitted.targetIndex).toBe(2)
expect(emitted.targetBadge).toEqual(badges[2])
expect(emitted.source).toEqual({ source: 'reserve', badge: { id: '99', icon: '/x' } })
})
it('does not emit badge-drop when dropping on index 0', async () => {
const firstBadge = screen.getByTitle(badges[0].description)
const container = firstBadge.closest('.hc-badge-container')
await fireEvent.drop(container, {
dataTransfer: {
getData: () => '{}',
},
})
expect(wrapper.emitted()['badge-drop']).toBeFalsy()
})
})
describe('dragend', () => {
it('emits drag-end', async () => {
const badge = screen.getByTitle(badges[2].description)
const container = badge.closest('.hc-badge-container')
await fireEvent.dragEnd(container)
expect(wrapper.emitted()['drag-end']).toBeTruthy()
})
})
})
describe('with drag disabled', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ badges, selectionMode: true, dragEnabled: false })
})
it('no badges are draggable', () => {
const containers = wrapper.container.querySelectorAll('.hc-badge-container')
containers.forEach((container) => {
expect(container.getAttribute('draggable')).toBe('false')
})
})
})
})
})

View File

@ -5,8 +5,19 @@
class="hc-badge-container"
v-for="(badge, index) in badges"
:key="index"
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
:class="{
selectable: selectionMode && index > 0,
selected: selectedIndex === index,
dragging: draggingIndex === index,
'drag-over': dragOverIndex === index,
}"
:draggable="isDraggable(index, badge)"
@click="handleBadgeClick(index)"
@dragstart="handleDragStart($event, index, badge)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver(index)"
@dragleave="handleDragLeave(index)"
@drop.prevent="handleDrop($event, index)"
>
<img :title="badge.description" :src="backendPath(badge.icon)" class="hc-badge" />
</component>
@ -26,14 +37,25 @@ export default {
type: Boolean,
default: false,
},
dragEnabled: {
type: Boolean,
default: false,
},
},
data() {
return {
selectedIndex: null,
draggingIndex: null,
dragOverIndex: null,
}
},
methods: {
backendPath,
isDraggable(index, badge) {
if (!this.dragEnabled || index === 0) return false
if (badge.isDefault) return false
return true
},
handleBadgeClick(index) {
if (!this.selectionMode || index === 0) {
return
@ -47,6 +69,46 @@ export default {
this.selectedIndex = index
this.$emit('badge-selected', index)
},
handleDragStart(event, index, badge) {
if (!this.isDraggable(index, badge)) {
event.preventDefault()
return
}
this.draggingIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(
'application/json',
JSON.stringify({ source: 'hex', index, badge }),
)
this.$emit('drag-start', { source: 'hex', index, badge })
},
handleDragEnd() {
this.draggingIndex = null
this.dragOverIndex = null
this.$emit('drag-end')
},
handleDragOver(index) {
if (index === 0) return
this.dragOverIndex = index
},
handleDragLeave(index) {
if (this.dragOverIndex === index) {
this.dragOverIndex = null
}
},
handleDrop(event, index) {
if (index === 0) return
this.dragOverIndex = null
const badge = this.badges[index]
let source = null
try {
source = JSON.parse(event.dataTransfer.getData('application/json'))
} catch {
return
}
if (!source) return
this.$emit('badge-drop', { source, targetIndex: index, targetBadge: badge })
},
resetSelection() {
this.selectedIndex = null
},
@ -99,6 +161,16 @@ export default {
opacity: 0.6;
}
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
filter: drop-shadow(0 0 4px $color-success);
transform: scale(1.15);
transition: transform 0.15s ease;
}
}
.hc-badge {

View File

@ -82,6 +82,7 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -91,6 +92,7 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -100,6 +102,7 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -109,6 +112,7 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -193,6 +197,7 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -202,6 +207,7 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -211,6 +217,7 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -220,6 +227,7 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -304,6 +312,7 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -313,6 +322,7 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -322,6 +332,7 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -331,6 +342,7 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -415,6 +427,7 @@ exports[`UserTeaserPopover renders correctly for a fresh user with zero counts 1
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -424,6 +437,7 @@ exports[`UserTeaserPopover renders correctly for a fresh user with zero counts 1
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -433,6 +447,7 @@ exports[`UserTeaserPopover renders correctly for a fresh user with zero counts 1
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -442,6 +457,7 @@ exports[`UserTeaserPopover renders correctly for a fresh user with zero counts 1
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -526,6 +542,7 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -535,6 +552,7 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -544,6 +562,7 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -553,6 +572,7 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"

View File

@ -7,6 +7,7 @@ exports[`Badges.vue with badges renders 1`] = `
>
<button
class="badge-selection-item"
draggable="false"
>
<div
class="badge-icon"
@ -29,6 +30,7 @@ exports[`Badges.vue with badges renders 1`] = `
</button>
<button
class="badge-selection-item"
draggable="false"
>
<div
class="badge-icon"
@ -51,6 +53,7 @@ exports[`Badges.vue with badges renders 1`] = `
</button>
<button
class="badge-selection-item"
draggable="false"
>
<div
class="badge-icon"

View File

@ -8,6 +8,7 @@ exports[`Badges.vue with badges in presentation mode renders 1`] = `
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -17,6 +18,7 @@ exports[`Badges.vue with badges in presentation mode renders 1`] = `
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -26,6 +28,7 @@ exports[`Badges.vue with badges in presentation mode renders 1`] = `
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -45,6 +48,7 @@ exports[`Badges.vue with badges in selection mode clicking on second badge selec
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -54,6 +58,7 @@ exports[`Badges.vue with badges in selection mode clicking on second badge selec
</div>
<button
class="hc-badge-container selectable selected"
draggable="false"
>
<img
class="hc-badge"
@ -63,6 +68,7 @@ exports[`Badges.vue with badges in selection mode clicking on second badge selec
</button>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"
@ -82,6 +88,7 @@ exports[`Badges.vue with badges in selection mode clicking twice on second badge
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -91,6 +98,7 @@ exports[`Badges.vue with badges in selection mode clicking twice on second badge
</div>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"
@ -100,6 +108,7 @@ exports[`Badges.vue with badges in selection mode clicking twice on second badge
</button>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"
@ -119,6 +128,7 @@ exports[`Badges.vue with badges in selection mode renders 1`] = `
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -128,6 +138,7 @@ exports[`Badges.vue with badges in selection mode renders 1`] = `
</div>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"
@ -137,6 +148,7 @@ exports[`Badges.vue with badges in selection mode renders 1`] = `
</button>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"

View File

@ -1050,10 +1050,15 @@
"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.",
"drag-instruction": "Badges per Drag & Drop verschieben.",
"drop-to-remove": "Hier ablegen, um die Badge zu entfernen.",
"error-update": "Beim Aktualisieren deiner Badges ist ein Fehler aufgetreten.",
"name": "Badges",
"no-badges-available": "Im Moment stehen dir keine Badges zur Verfügung, die du hinzufügen könntest.",
"remove": "Badge entfernen",
"reserve-title": "Verfügbare Badges",
"success-update": "Deine Badges wurden erfolgreich gespeichert.",
"swap-partial-error": "Der Badge-Tausch konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"verification": "Dies ist deine Verifikations-Badge und kann nicht geändert werden."
},
"blocked-users": {

View File

@ -1050,10 +1050,15 @@
"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.",
"drag-instruction": "Drag and drop badges to rearrange them.",
"drop-to-remove": "Drop here to remove the badge.",
"error-update": "An error occurred while updating your badges.",
"name": "Badges",
"no-badges-available": "You currently don't have any badges available to add.",
"remove": "Remove Badge",
"reserve-title": "Available Badges",
"success-update": "Your badges have been updated successfully.",
"swap-partial-error": "The badge swap could not be completed. Please try again.",
"verification": "This is your verification badge and cannot be changed."
},
"blocked-users": {

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Haz clic en un espacio vacío para añadir una insignia.",
"click-to-use": "Haz clic en una insignia para colocarla en el espacio seleccionado.",
"description": "Aquí puedes elegir cómo mostrar tus insignias obtenidas en tu perfil.",
"drag-instruction": "Arrastra y suelta las insignias para reorganizarlas.",
"drop-to-remove": "Suelta aquí para quitar la insignia.",
"error-update": "Se produjo un error al actualizar tus insignias.",
"name": "Insignias",
"no-badges-available": "Actualmente no tienes insignias disponibles para añadir.",
"remove": "Quitar insignia",
"reserve-title": "Insignias disponibles",
"success-update": "Tus insignias se han actualizado correctamente.",
"swap-partial-error": "El intercambio de insignias no se pudo completar. Inténtalo de nuevo.",
"verification": "Esta es tu insignia de verificación y no se puede cambiar."
},
"blocked-users": {
"block": "Bloquear usuario",

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Cliquez sur un emplacement vide pour ajouter un badge.",
"click-to-use": "Cliquez sur un badge pour le placer dans l'emplacement sélectionné.",
"description": "Ici, vous pouvez choisir comment afficher vos badges obtenus sur votre profil.",
"drag-instruction": "Glissez-déposez les badges pour les réorganiser.",
"drop-to-remove": "Déposez ici pour retirer le badge.",
"error-update": "Une erreur est survenue lors de la mise à jour de vos badges.",
"name": "Badges",
"no-badges-available": "Vous n'avez actuellement aucun badge disponible à ajouter.",
"remove": "Retirer le badge",
"reserve-title": "Badges disponibles",
"success-update": "Vos badges ont été mis à jour avec succès.",
"swap-partial-error": "L'échange de badges n'a pas pu être effectué. Veuillez réessayer.",
"verification": "Ceci est votre badge de vérification et ne peut pas être modifié."
},
"blocked-users": {
"block": "Bloquer l'utilisateur",

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Clicca su uno spazio vuoto per aggiungere un badge.",
"click-to-use": "Clicca su un badge per posizionarlo nello spazio selezionato.",
"description": "Qui puoi scegliere come visualizzare i tuoi badge ottenuti sul tuo profilo.",
"drag-instruction": "Trascina i badge per riorganizzarli.",
"drop-to-remove": "Rilascia qui per rimuovere il badge.",
"error-update": "Si è verificato un errore durante l'aggiornamento dei tuoi badge.",
"name": "Badge",
"no-badges-available": "Al momento non hai badge disponibili da aggiungere.",
"remove": "Rimuovi badge",
"reserve-title": "Badge disponibili",
"success-update": "I tuoi badge sono stati aggiornati con successo.",
"swap-partial-error": "Lo scambio dei badge non è stato completato. Riprova.",
"verification": "Questo è il tuo badge di verifica e non può essere modificato."
},
"blocked-users": {
"block": null,

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Klik op een lege plek om een badge toe te voegen.",
"click-to-use": "Klik op een badge om deze in de geselecteerde plek te plaatsen.",
"description": "Hier kun je kiezen hoe je verdiende badges op je profiel worden weergegeven.",
"drag-instruction": "Sleep badges om ze te herschikken.",
"drop-to-remove": "Laat hier los om de badge te verwijderen.",
"error-update": "Er is een fout opgetreden bij het bijwerken van je badges.",
"name": "Badges",
"no-badges-available": "Je hebt momenteel geen badges beschikbaar om toe te voegen.",
"remove": "Badge verwijderen",
"reserve-title": "Beschikbare badges",
"success-update": "Je badges zijn succesvol bijgewerkt.",
"swap-partial-error": "Het wisselen van badges kon niet worden voltooid. Probeer het opnieuw.",
"verification": "Dit is je verificatiebadge en kan niet worden gewijzigd."
},
"blocked-users": {
"block": null,

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Kliknij na puste miejsce, aby dodać odznakę.",
"click-to-use": "Kliknij na odznakę, aby umieścić ją w wybranym miejscu.",
"description": "Tutaj możesz wybrać, jak wyświetlać zdobyte odznaki na swoim profilu.",
"drag-instruction": "Przeciągnij i upuść odznaki, aby je uporządkować.",
"drop-to-remove": "Upuść tutaj, aby usunąć odznakę.",
"error-update": "Wystąpił błąd podczas aktualizacji odznak.",
"name": "Odznaki",
"no-badges-available": "Obecnie nie masz żadnych dostępnych odznak do dodania.",
"remove": "Usuń odznakę",
"reserve-title": "Dostępne odznaki",
"success-update": "Twoje odznaki zostały pomyślnie zaktualizowane.",
"swap-partial-error": "Zamiana odznak nie mogła zostać ukończona. Spróbuj ponownie.",
"verification": "To jest Twoja odznaka weryfikacyjna i nie można jej zmienić."
},
"blocked-users": {
"block": null,

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Clique num espaço vazio para adicionar uma medalha.",
"click-to-use": "Clique numa medalha para a colocar no espaço selecionado.",
"description": "Aqui pode escolher como exibir as suas medalhas conquistadas no seu perfil.",
"drag-instruction": "Arraste e solte as medalhas para reorganizá-las.",
"drop-to-remove": "Solte aqui para remover a medalha.",
"error-update": "Ocorreu um erro ao atualizar as suas medalhas.",
"name": "Medalhas",
"no-badges-available": "De momento não tem medalhas disponíveis para adicionar.",
"remove": "Remover medalha",
"reserve-title": "Medalhas disponíveis",
"success-update": "As suas medalhas foram atualizadas com sucesso.",
"swap-partial-error": "A troca de medalhas não pôde ser concluída. Tente novamente.",
"verification": "Esta é a sua medalha de verificação e não pode ser alterada."
},
"blocked-users": {
"block": "Bloquear usuário",

View File

@ -1047,14 +1047,19 @@
},
"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
"click-to-select": "Нажмите на пустое место, чтобы добавить награду.",
"click-to-use": "Нажмите на награду, чтобы поместить её в выбранное место.",
"description": "Здесь вы можете выбрать, как отображать полученные награды в вашем профиле.",
"drag-instruction": "Перетаскивайте награды, чтобы изменить их расположение.",
"drop-to-remove": "Перетащите сюда, чтобы убрать награду.",
"error-update": "При обновлении наград произошла ошибка.",
"name": "Награды",
"no-badges-available": "В настоящее время у вас нет доступных наград для добавления.",
"remove": "Убрать награду",
"reserve-title": "Доступные награды",
"success-update": "Ваши награды были успешно обновлены.",
"swap-partial-error": "Обмен наградами не удалось завершить. Попробуйте ещё раз.",
"verification": "Это ваша награда за верификацию, её нельзя изменить."
},
"blocked-users": {
"block": "Блокировать",

View File

@ -970,6 +970,7 @@ exports[`ProfileSlug given an authenticated user given another profile user and
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -979,6 +980,7 @@ exports[`ProfileSlug given an authenticated user given another profile user and
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -988,6 +990,7 @@ exports[`ProfileSlug given an authenticated user given another profile user and
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -2213,6 +2216,7 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -2222,6 +2226,7 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -2231,6 +2236,7 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
</div>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"

View File

@ -16,7 +16,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
</p>
<div
class="ds-mb-small ds-mt-base ds-space-centered"
class="ds-mb-small ds-mt-base badge-content"
>
<div
class="presenterContainer"
@ -26,6 +26,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -35,6 +36,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
</div>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -44,6 +46,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
</button>
<button
class="hc-badge-container selectable selected"
draggable="false"
>
<img
class="hc-badge"
@ -53,6 +56,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
</button>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -63,6 +67,14 @@ exports[`badge settings with badges more badges available selecting an empty slo
</div>
</div>
<p
class="drag-instruction"
>
settings.badges.drag-instruction
</p>
<!---->
<div>
@ -78,11 +90,20 @@ exports[`badge settings with badges more badges available selecting an empty slo
<div
class="selection-info"
>
<h3
class="reserve-title"
>
settings.badges.reserve-title
</h3>
<div
class="badge-selection"
>
<button
class="badge-selection-item"
draggable="true"
>
<div
class="badge-icon"
@ -105,6 +126,7 @@ exports[`badge settings with badges more badges available selecting an empty slo
</button>
<button
class="badge-selection-item"
draggable="true"
>
<div
class="badge-icon"
@ -127,6 +149,8 @@ exports[`badge settings with badges more badges available selecting an empty slo
</button>
</div>
</div>
<!---->
</div>
</div>
</div>
@ -148,7 +172,7 @@ exports[`badge settings with badges no more badges available selecting an empty
</p>
<div
class="ds-mb-small ds-mt-base ds-space-centered"
class="ds-mb-small ds-mt-base badge-content"
>
<div
class="presenterContainer"
@ -158,6 +182,7 @@ exports[`badge settings with badges no more badges available selecting an empty
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -167,6 +192,7 @@ exports[`badge settings with badges no more badges available selecting an empty
</div>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -176,6 +202,7 @@ exports[`badge settings with badges no more badges available selecting an empty
</button>
<button
class="hc-badge-container selectable selected"
draggable="false"
>
<img
class="hc-badge"
@ -185,6 +212,7 @@ exports[`badge settings with badges no more badges available selecting an empty
</button>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -195,6 +223,14 @@ exports[`badge settings with badges no more badges available selecting an empty
</div>
</div>
<p
class="drag-instruction"
>
settings.badges.drag-instruction
</p>
<p>
settings.badges.no-badges-available
@ -206,6 +242,8 @@ exports[`badge settings with badges no more badges available selecting an empty
<!---->
<!---->
<!---->
</div>
</div>
</div>
@ -227,7 +265,7 @@ exports[`badge settings with badges renders 1`] = `
</p>
<div
class="ds-mb-small ds-mt-base ds-space-centered"
class="ds-mb-small ds-mt-base badge-content"
>
<div
class="presenterContainer"
@ -237,6 +275,7 @@ exports[`badge settings with badges renders 1`] = `
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -246,6 +285,7 @@ exports[`badge settings with badges renders 1`] = `
</div>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -255,6 +295,7 @@ exports[`badge settings with badges renders 1`] = `
</button>
<button
class="hc-badge-container selectable"
draggable="false"
>
<img
class="hc-badge"
@ -264,6 +305,7 @@ exports[`badge settings with badges renders 1`] = `
</button>
<button
class="hc-badge-container selectable"
draggable="true"
>
<img
class="hc-badge"
@ -274,6 +316,14 @@ exports[`badge settings with badges renders 1`] = `
</div>
</div>
<p
class="drag-instruction"
>
settings.badges.drag-instruction
</p>
<!---->
<div>
@ -286,6 +336,69 @@ exports[`badge settings with badges renders 1`] = `
<!---->
<div
class="selection-info"
>
<h3
class="reserve-title"
>
settings.badges.reserve-title
</h3>
<div
class="badge-selection"
>
<button
class="badge-selection-item"
draggable="true"
>
<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"
draggable="true"
>
<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>
</div>
@ -308,7 +421,7 @@ exports[`badge settings without badges renders 1`] = `
</p>
<div
class="ds-mb-small ds-mt-base ds-space-centered"
class="ds-mb-small ds-mt-base badge-content"
>
<div
class="presenterContainer"
@ -318,6 +431,7 @@ exports[`badge settings without badges renders 1`] = `
>
<div
class="hc-badge-container"
draggable="false"
>
<img
class="hc-badge"
@ -328,6 +442,16 @@ exports[`badge settings without badges renders 1`] = `
</div>
</div>
<p
class="drag-instruction"
>
settings.badges.drag-instruction
</p>
<!---->
<!---->
<!---->

View File

@ -23,6 +23,7 @@ describe('badge settings', () => {
$toast: {
success: jest.fn(),
error: jest.fn(),
info: jest.fn(),
},
$apollo: {
mutate: apolloMutateMock,
@ -158,6 +159,23 @@ describe('badge settings', () => {
description: 'Third description',
},
],
badgeTrophiesUnused: [
{
id: '1',
icon: '/path/to/some/icon',
description: 'Some description',
},
{
id: '4',
icon: '/path/to/fourth/icon',
description: 'Fourth description',
},
{
id: '5',
icon: '/path/to/fifth/icon',
description: 'Fifth description',
},
],
},
}
@ -188,6 +206,7 @@ describe('badge settings', () => {
id: 'u23',
badgeTrophiesSelected:
removedResponseData.setTrophyBadgeSelected.badgeTrophiesSelected,
badgeTrophiesUnused: removedResponseData.setTrophyBadgeSelected.badgeTrophiesUnused,
}),
)
})
@ -320,5 +339,254 @@ describe('badge settings', () => {
})
})
})
describe('drag and drop', () => {
const makeDropData = (source) => JSON.stringify(source)
describe('assign badge from reserve to empty slot via DnD', () => {
const assignResponseData = {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
badgeTrophiesSelected[0],
{
id: '4',
icon: '/path/to/fourth/icon',
isDefault: false,
description: 'Fourth description',
},
badgeTrophiesSelected[2],
],
badgeTrophiesUnused: [badgeTrophiesUnused[1]],
},
}
beforeEach(async () => {
apolloMutateMock.mockImplementation(({ update }) => {
const result = { data: assignResponseData }
if (update) update(null, result)
return Promise.resolve(result)
})
// Simulate dropping a reserve badge on the empty hex slot (index 2 in Badges = slot 1)
const emptySlot = screen.getAllByTitle('Empty')[0]
const container = emptySlot.closest('.hc-badge-container')
const sourceData = makeDropData({
source: 'reserve',
badge: badgeTrophiesUnused[0],
})
await fireEvent.drop(container, {
dataTransfer: { getData: () => sourceData },
})
})
it('calls the server with correct badge and slot', () => {
expect(apolloMutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
badgeId: '4',
slot: 1,
},
}),
)
})
it('shows success message', () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.badges.success-update')
})
})
describe('remove badge from hex to reserve via DnD', () => {
const removeResponseData = {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
{
id: 'empty-0',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
badgeTrophiesSelected[1],
badgeTrophiesSelected[2],
],
badgeTrophiesUnused: [badgeTrophiesSelected[0], ...badgeTrophiesUnused],
},
}
beforeEach(async () => {
apolloMutateMock.mockImplementation(({ update }) => {
const result = { data: removeResponseData }
if (update) update(null, result)
return Promise.resolve(result)
})
// Simulate dropping a hex badge on the reserve container
const reserveContainer = wrapper.container.querySelector('.badge-selection')
const hexData = makeDropData({
source: 'hex',
index: 1,
badge: badgeTrophiesSelected[0],
})
await fireEvent.drop(reserveContainer, {
dataTransfer: { getData: () => hexData },
})
})
it('calls the server to remove badge', () => {
expect(apolloMutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
badgeId: null,
slot: 0,
},
}),
)
})
it('shows success message', () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.badges.success-update')
})
})
describe('swap two badges via DnD', () => {
const swapResponse1 = {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
badgeTrophiesSelected[1],
{
id: 'empty-temp',
icon: '/path/to/empty/icon',
isDefault: true,
description: 'Empty',
},
],
badgeTrophiesUnused: [badgeTrophiesSelected[0], ...badgeTrophiesUnused],
},
}
const swapResponse2 = {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: [
{
id: '3',
icon: '/path/to/third/icon',
isDefault: false,
description: 'Third description',
},
badgeTrophiesSelected[1],
{
id: '1',
icon: '/path/to/some/icon',
isDefault: false,
description: 'Some description',
},
],
badgeTrophiesUnused,
},
}
beforeEach(async () => {
let callCount = 0
apolloMutateMock.mockImplementation(({ update }) => {
callCount++
const responseData = callCount === 1 ? swapResponse1 : swapResponse2
const result = { data: responseData }
if (update) update(null, result)
return Promise.resolve(result)
})
// Simulate dragging badge at index 1 onto badge at index 3 (both occupied)
const targetBadge = screen.getByTitle(badgeTrophiesSelected[2].description)
const container = targetBadge.closest('.hc-badge-container')
const sourceData = makeDropData({
source: 'hex',
index: 1,
badge: badgeTrophiesSelected[0],
})
await fireEvent.drop(container, {
dataTransfer: { getData: () => sourceData },
})
})
it('calls the server twice for swap', () => {
expect(apolloMutateMock).toHaveBeenCalledTimes(2)
})
it('first mutation moves source to target slot', () => {
expect(apolloMutateMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
variables: {
badgeId: '1',
slot: 2,
},
}),
)
})
it('second mutation moves former target to source slot', () => {
expect(apolloMutateMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
variables: {
badgeId: '3',
slot: 0,
},
}),
)
})
it('shows success message', () => {
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.badges.success-update')
})
})
describe('swap with partial failure', () => {
beforeEach(async () => {
let callCount = 0
apolloMutateMock.mockImplementation(({ update }) => {
callCount++
if (callCount === 1) {
const result = {
data: {
setTrophyBadgeSelected: {
id: 'u23',
badgeTrophiesSelected: badgeTrophiesSelected,
badgeTrophiesUnused: badgeTrophiesUnused,
},
},
}
if (update) update(null, result)
return Promise.resolve(result)
}
return Promise.reject(new Error('Server error'))
})
const targetBadge = screen.getByTitle(badgeTrophiesSelected[2].description)
const container = targetBadge.closest('.hc-badge-container')
const sourceData = makeDropData({
source: 'hex',
index: 1,
badge: badgeTrophiesSelected[0],
})
await fireEvent.drop(container, {
dataTransfer: { getData: () => sourceData },
})
})
it('shows swap partial error', () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('settings.badges.swap-partial-error')
})
})
})
})
})

View File

@ -2,16 +2,24 @@
<os-card>
<h2 class="title">{{ $t('settings.badges.name') }}</h2>
<p>{{ $t('settings.badges.description') }}</p>
<div class="ds-mb-small ds-mt-base ds-space-centered">
<div class="ds-mb-small ds-mt-base badge-content">
<div class="presenterContainer">
<badges
:badges="[currentUser.badgeVerification, ...selectedBadges]"
:selection-mode="true"
:drag-enabled="dragSupported"
@badge-selected="handleBadgeSlotSelection"
@drag-start="handleHexDragStart"
@drag-end="handleHexDragEnd"
@badge-drop="handleBadgeDrop"
ref="badgesComponent"
/>
</div>
<p v-if="dragSupported" class="drag-instruction">
{{ $t('settings.badges.drag-instruction') }}
</p>
<p v-if="!availableBadges.length && isEmptySlotSelected">
{{ $t('settings.badges.no-badges-available') }}
</p>
@ -39,16 +47,30 @@
</os-button>
</div>
<div
v-if="availableBadges.length && selectedBadgeIndex !== null && isEmptySlotSelected"
class="selection-info"
>
<div v-if="availableBadges.length > 0" class="selection-info">
<h3 v-if="dragSupported || selectedBadgeIndex !== null" class="reserve-title">
{{ $t('settings.badges.reserve-title') }}
</h3>
<badge-selection
v-if="dragSupported || (selectedBadgeIndex !== null && isEmptySlotSelected)"
:badges="availableBadges"
:drag-enabled="dragSupported"
@badge-selected="assignBadgeToSlot"
@badge-returned="handleBadgeReturn"
ref="badgeSelection"
/>
</div>
<div
v-if="isDraggingFromHex && !availableBadges.length"
class="empty-reserve-drop-zone"
@dragover.prevent="emptyReserveDragOver = true"
@dragleave="emptyReserveDragOver = false"
@drop.prevent="handleEmptyReserveDrop"
:class="{ 'reserve-drag-over': emptyReserveDragOver }"
>
{{ $t('settings.badges.drop-to-remove') }}
</div>
</div>
</os-card>
</template>
@ -67,6 +89,9 @@ export default {
data() {
return {
selectedBadgeIndex: null,
isDraggingFromHex: false,
isProcessingDrop: false,
emptyReserveDragOver: false,
}
},
computed: {
@ -85,11 +110,19 @@ export default {
},
created() {
this.userBadges = [...(this.currentUser.badgeTrophiesSelected || [])]
this.dragSupported = this.detectDragSupport()
},
methods: {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
detectDragSupport() {
if (typeof window === 'undefined') return false
if (!window.matchMedia) return !('ontouchstart' in window)
const hasFinePointer = window.matchMedia('(pointer: fine)').matches
const isWideScreen = window.matchMedia('(min-width: 640px)').matches
return hasFinePointer && isWideScreen
},
handleBadgeSlotSelection(index) {
if (index === 0) {
this.$toast.info(this.$t('settings.badges.verification'))
@ -99,6 +132,83 @@ export default {
this.selectedBadgeIndex = index === null ? null : index - 1 // The first badge in badges component is the verification badge
},
handleHexDragStart() {
this.isDraggingFromHex = true
},
handleHexDragEnd() {
this.isDraggingFromHex = false
},
async handleBadgeDrop(dropData) {
if (this.isProcessingDrop) return
this.isProcessingDrop = true
try {
const source = dropData.source
const targetIndex = dropData.targetIndex - 1 // adjust for verification badge offset
const targetBadge = dropData.targetBadge
if (source && source.source === 'reserve') {
// Assign: Reserve Slot
await this.setSlot(source.badge, targetIndex)
this.$toast.success(this.$t('settings.badges.success-update'))
} else if (source && source.source === 'hex') {
const sourceIndex = source.index - 1 // adjust for verification badge offset
if (targetBadge.isDefault) {
// Move to empty slot: 1 mutation
await this.setSlot(source.badge, targetIndex)
this.$toast.success(this.$t('settings.badges.success-update'))
} else {
// Swap: 2 mutations
await this.swapBadges(source.badge, sourceIndex, targetBadge, targetIndex)
}
}
} catch (error) {
this.$toast.error(this.$t('settings.badges.error-update'))
} finally {
this.isProcessingDrop = false
this.isDraggingFromHex = false
this.$refs.badgesComponent.resetSelection()
this.selectedBadgeIndex = null
}
},
async handleBadgeReturn(data) {
if (this.isProcessingDrop) return
this.isProcessingDrop = true
try {
const sourceIndex = data.index - 1 // adjust for verification badge offset
await this.setSlot(null, sourceIndex)
this.$toast.success(this.$t('settings.badges.success-update'))
} catch (error) {
this.$toast.error(this.$t('settings.badges.error-update'))
} finally {
this.isProcessingDrop = false
this.isDraggingFromHex = false
}
},
async handleEmptyReserveDrop(event) {
this.emptyReserveDragOver = false
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'))
if (data.source === 'hex') {
await this.handleBadgeReturn(data)
}
} catch {
// ignore invalid drag data
}
},
async swapBadges(sourceBadge, sourceIndex, targetBadge, targetIndex) {
// Mutation 1: Move source badge to target slot (target badge goes to unused)
await this.setSlot(sourceBadge, targetIndex)
try {
// Mutation 2: Move former target badge to now-empty source slot
await this.setSlot(targetBadge, sourceIndex)
} catch {
this.$toast.error(this.$t('settings.badges.swap-partial-error'))
return
}
this.$toast.success(this.$t('settings.badges.success-update'))
},
async setSlot(badge, slot) {
await this.$apollo.mutate({
mutation: setTrophyBadgeSelected,
@ -143,15 +253,21 @@ export default {
} catch (error) {
this.$toast.error(this.$t('settings.badges.error-update'))
}
this.$refs.badgesComponent.resetSelection()
this.selectedBadgeIndex = null
// Keep the slot selected so the (now empty) slot immediately shows
// available badges, including the one that was just removed.
},
},
}
</script>
<style scoped>
.badge-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.presenterContainer {
margin-top: 20px;
padding-top: 50px;
@ -179,4 +295,34 @@ export default {
margin-top: 20px;
padding: 16px;
}
.drag-instruction {
font-size: 13px;
color: #888;
margin-top: 0;
margin-bottom: 16px;
}
.reserve-title {
font-size: 16px;
margin-bottom: 12px;
}
.empty-reserve-drop-zone {
margin-top: 20px;
padding: 24px;
border: 2px dashed #ccc;
border-radius: 12px;
text-align: center;
color: #888;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
.empty-reserve-drop-zone.reserve-drag-over {
border-color: $color-success;
background-color: rgba($color-success, 0.05);
color: $color-success;
}
</style>