feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s
- Implemented PendingRewardDialog for handling pending reward requests. - Created RewardConfirmDialog for confirming reward redemption. - Developed TaskConfirmDialog for task confirmation with child name display. test: add unit tests for ChildView and ParentView components - Added comprehensive tests for ChildView including task triggering and SSE event handling. - Implemented tests for ParentView focusing on override modal and SSE event management. test: add ScrollingList component tests - Created tests for ScrollingList to verify item fetching, loading states, and custom item classes. - Included tests for two-step click interactions and edit button display logic. - Moved toward hashed passwords.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-heading">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
|
||||
@@ -19,6 +19,10 @@ defineProps<{
|
||||
title?: string
|
||||
subtitle?: string | null | undefined
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'backdrop-click': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,6 +11,9 @@ const props = defineProps<{
|
||||
isParentAuthenticated?: boolean
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
enableEdit?: boolean
|
||||
childId?: string
|
||||
readyItemId?: string | null
|
||||
}>()
|
||||
|
||||
// Compute the fetch URL with ids if present
|
||||
@@ -24,6 +27,8 @@ const fetchUrl = computed(() => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger-item', item: any): void
|
||||
(e: 'edit-item', item: any): void
|
||||
(e: 'item-ready', itemId: string): void
|
||||
}>()
|
||||
|
||||
const items = ref<any[]>([])
|
||||
@@ -32,7 +37,6 @@ const error = ref<string | null>(null)
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
|
||||
const lastCenteredItemId = ref<string | null>(null)
|
||||
const readyItemId = ref<string | null>(null)
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
@@ -112,23 +116,41 @@ const handleClicked = async (item: any) => {
|
||||
const card = itemRefs.value[item.id]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
// If this item is already ready (has edit button showing)
|
||||
if (props.readyItemId === item.id) {
|
||||
// Second click - trigger the item and reset
|
||||
emit(
|
||||
'trigger-item',
|
||||
items.value.find((i) => i.id === item.id),
|
||||
)
|
||||
emit('item-ready', '')
|
||||
lastCenteredItemId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// First click or different item clicked
|
||||
// Check if item needs to be centered
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredItemId.value !== item.id) {
|
||||
// Center the item, but don't trigger
|
||||
if (!cardFullyVisible) {
|
||||
// Center the item first
|
||||
await centerItem(item.id)
|
||||
lastCenteredItemId.value = item.id
|
||||
readyItemId.value = item.id
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'trigger-item',
|
||||
items.value.find((i) => i.id === item.id),
|
||||
)
|
||||
readyItemId.value = null
|
||||
|
||||
// Mark this item as ready (show edit button)
|
||||
lastCenteredItemId.value = item.id
|
||||
emit('item-ready', item.id)
|
||||
}
|
||||
|
||||
const handleEditClick = (item: any, event: Event) => {
|
||||
event.stopPropagation()
|
||||
emit('edit-item', item)
|
||||
// Reset the 2-step process after opening edit modal
|
||||
emit('item-ready', '')
|
||||
lastCenteredItemId.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -160,6 +182,21 @@ onBeforeUnmount(() => {
|
||||
:ref="(el) => (itemRefs[item.id] = el)"
|
||||
@click.stop="handleClicked(item)"
|
||||
>
|
||||
<button
|
||||
v-if="enableEdit && readyItemId === item.id"
|
||||
class="edit-button"
|
||||
@click="handleEditClick(item, $event)"
|
||||
title="Edit custom value"
|
||||
>
|
||||
<img src="/edit.png" alt="Edit" />
|
||||
</button>
|
||||
<span
|
||||
v-if="
|
||||
isParentAuthenticated && item.custom_value !== undefined && item.custom_value !== null
|
||||
"
|
||||
class="override-badge"
|
||||
>⭐</span
|
||||
>
|
||||
<slot name="item" :item="item">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
</slot>
|
||||
@@ -249,6 +286,45 @@ onBeforeUnmount(() => {
|
||||
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background-color: var(--btn-primary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.1s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.edit-button img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.override-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ScrollingList from '../ScrollingList.vue'
|
||||
|
||||
// Mock image cache
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn((id: string) => Promise.resolve(`/cached/${id}.png`)),
|
||||
revokeAllImageUrls: vi.fn(),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('ScrollingList', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
const defaultProps = {
|
||||
title: 'Test List',
|
||||
fetchBaseUrl: '/api/test',
|
||||
ids: ['item-1', 'item-2'],
|
||||
itemKey: 'items',
|
||||
}
|
||||
|
||||
const mockItems = [
|
||||
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1' },
|
||||
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2' },
|
||||
]
|
||||
|
||||
const mockItemsWithOverride = [
|
||||
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1', custom_value: 15 },
|
||||
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2', custom_value: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock fetch responses
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItems }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Component Mounting', () => {
|
||||
it('renders with title', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('h3').text()).toBe('Test List')
|
||||
})
|
||||
|
||||
it('fetches items on mount', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
|
||||
})
|
||||
|
||||
it('displays loading state initially', () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays items after loading', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.item-card').length).toBe(2)
|
||||
})
|
||||
|
||||
it('displays empty message when no items', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: [] }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.empty').text()).toContain('No Test List')
|
||||
})
|
||||
|
||||
it('displays error message on fetch failure', async () => {
|
||||
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.error').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Override Badge Display', () => {
|
||||
it('shows override badge when custom_value exists and isParentAuthenticated is true', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const badges = wrapper.findAll('.override-badge')
|
||||
expect(badges.length).toBe(1) // Only item-1 has custom_value
|
||||
expect(badges[0].text()).toBe('⭐')
|
||||
})
|
||||
|
||||
it('does not show badge when custom_value is null', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItems }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge when custom_value is undefined', async () => {
|
||||
const itemsWithUndefined = [{ id: 'item-1', name: 'Item One', points: 10 }]
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: itemsWithUndefined }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge in child mode even when custom_value exists', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: false } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge when isParentAuthenticated is undefined', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Two-Step Click Interaction', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: false, // No edit button for basic click test
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('emits item-ready on first click', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('item-ready')).toBeTruthy()
|
||||
expect(wrapper.emitted('item-ready')![0]).toEqual(['item-1'])
|
||||
})
|
||||
|
||||
it('emits trigger-item on second click of same item', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
|
||||
// First click - select item
|
||||
await cards[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Update prop to simulate parent setting readyItemId
|
||||
await wrapper.setProps({ readyItemId: 'item-1' })
|
||||
await nextTick()
|
||||
|
||||
// Second click - trigger item
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('trigger-item')).toBeTruthy()
|
||||
expect(wrapper.emitted('trigger-item')![0][0].id).toBe('item-1')
|
||||
})
|
||||
|
||||
it('resets ready state after triggering', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
|
||||
// First click
|
||||
await cards[0].trigger('click')
|
||||
await wrapper.setProps({ readyItemId: 'item-1' })
|
||||
|
||||
// Second click
|
||||
await cards[0].trigger('click')
|
||||
|
||||
const itemReadyEvents = wrapper.emitted('item-ready')
|
||||
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Button Display', () => {
|
||||
it('shows edit button only for readyItemId when enableEdit is true', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButtons = wrapper.findAll('.edit-button')
|
||||
expect(editButtons.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not show edit button when enableEdit is false', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: false,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.edit-button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show edit button when readyItemId is null', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: null,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.edit-button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('emits edit-item when edit button is clicked', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButton = wrapper.find('.edit-button')
|
||||
await editButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('edit-item')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit-item')![0][0].id).toBe('item-1')
|
||||
})
|
||||
|
||||
it('resets ready state after edit button click', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButton = wrapper.find('.edit-button')
|
||||
await editButton.trigger('click')
|
||||
|
||||
const itemReadyEvents = wrapper.emitted('item-ready')
|
||||
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filter Function', () => {
|
||||
it('filters items using provided filterFn', async () => {
|
||||
const filterFn = (item: any) => item.points > 15
|
||||
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
filterFn,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.item-card').length).toBe(1) // Only item-2 with 20 points
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh Method', () => {
|
||||
it('exposes refresh method', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.refresh).toBeDefined()
|
||||
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||
})
|
||||
|
||||
it('refetches items when refresh is called', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
await wrapper.vm.refresh()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Loading', () => {
|
||||
it('loads images for items with image_id', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const { getCachedImageUrl } = await import('@/common/imageCache')
|
||||
expect(getCachedImageUrl).toHaveBeenCalledWith('img1')
|
||||
expect(getCachedImageUrl).toHaveBeenCalledWith('img2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Item Classes', () => {
|
||||
it('applies custom classes from getItemClass prop', async () => {
|
||||
const getItemClass = (item: any) => ({
|
||||
good: item.points > 15,
|
||||
bad: item.points <= 15,
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
getItemClass,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
expect(cards[0].classes()).toContain('bad') // item-1 with 10 points
|
||||
expect(cards[1].classes()).toContain('good') // item-2 with 20 points
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user