From 9ff74b42199a45233d93634ef40835239d600e29 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Sat, 21 Feb 2026 20:42:44 +0100 Subject: [PATCH] fix(webapp): fix badge select + drag&drop for badges on desktop devices (#9287) --- webapp/components/BadgeSelection.spec.js | 75 ++++- webapp/components/BadgeSelection.vue | 70 ++++- webapp/components/Badges.spec.js | 92 ++++++ webapp/components/Badges.vue | 74 ++++- .../UserTeaserPopover.spec.js.snap | 20 ++ .../__snapshots__/BadgeSelection.spec.js.snap | 3 + .../__snapshots__/Badges.spec.js.snap | 12 + webapp/locales/de.json | 5 + webapp/locales/en.json | 5 + webapp/locales/es.json | 21 +- webapp/locales/fr.json | 21 +- webapp/locales/it.json | 21 +- webapp/locales/nl.json | 21 +- webapp/locales/pl.json | 21 +- webapp/locales/pt.json | 21 +- webapp/locales/ru.json | 21 +- .../_id/__snapshots__/_slug.spec.js.snap | 6 + .../__snapshots__/badges.spec.js.snap | 132 ++++++++- webapp/pages/settings/badges.spec.js | 268 ++++++++++++++++++ webapp/pages/settings/badges.vue | 162 ++++++++++- 20 files changed, 1000 insertions(+), 71 deletions(-) diff --git a/webapp/components/BadgeSelection.spec.js b/webapp/components/BadgeSelection.spec.js index 78f00b87a..1d0a16c7e 100644 --- a/webapp/components/BadgeSelection.spec.js +++ b/webapp/components/BadgeSelection.spec.js @@ -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') + }) + }) + }) }) }) diff --git a/webapp/components/BadgeSelection.vue b/webapp/components/BadgeSelection.vue index a24c06fac..13ef701a8 100644 --- a/webapp/components/BadgeSelection.vue +++ b/webapp/components/BadgeSelection.vue @@ -1,10 +1,21 @@ @@ -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. }, }, }