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. }, }, }