Refactor and enhance various components and tests
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m23s
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m23s
- Remove OverrideEditModal.spec.ts test file. - Update ParentPinSetup.vue to handle Enter key for code and PIN inputs. - Modify ChildEditView.vue to add maxlength for age input. - Enhance ChildView.vue with reward confirmation and cancellation dialogs. - Update ParentView.vue to handle pending rewards and confirm edits. - Revise PendingRewardDialog.vue to accept a dynamic message prop. - Expand ChildView.spec.ts to cover reward dialog interactions. - Add tests for ParentView.vue to validate pending reward handling. - Update UserProfile.vue to simplify button styles. - Adjust RewardView.vue to improve delete confirmation handling. - Modify ChildrenListView.vue to clarify child creation instructions. - Refactor EntityEditForm.vue to improve input handling and focus management. - Enhance ItemList.vue to support item selection. - Update LoginButton.vue to focus PIN input on error. - Change ScrollingList.vue empty state color for better visibility. - Remove capture attribute from ImagePicker.vue file input. - Update router/index.ts to redirect logged-in users from auth routes. - Add authGuard.spec.ts to test router authentication logic.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_VERSION = "1.0.4RC3" # update manually when releasing features
|
BASE_VERSION = "1.0.4RC4" # update manually when releasing features
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -85,6 +85,12 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: var(--btn-primary);
|
color: var(--btn-primary);
|
||||||
}
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.btn-link {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Rounded button */
|
/* Rounded button */
|
||||||
.round-btn {
|
.round-btn {
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ModalDialog v-if="isOpen" @backdrop-click="$emit('close')">
|
|
||||||
<div class="override-edit-modal">
|
|
||||||
<h3>Edit {{ entityName }}</h3>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label :for="`override-input-${entityId}`">
|
|
||||||
{{ entityType === 'task' ? 'New Points' : 'New Cost' }}:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
:id="`override-input-${entityId}`"
|
|
||||||
v-model.number="inputValue"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="10000"
|
|
||||||
:disabled="loading"
|
|
||||||
@input="validateInput"
|
|
||||||
/>
|
|
||||||
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
|
|
||||||
<div class="default-hint">Default: {{ defaultValue }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button @click="$emit('close')" :disabled="loading" class="btn-secondary">Cancel</button>
|
|
||||||
<button @click="save" :disabled="!isValid || loading" class="btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalDialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import ModalDialog from './shared/ModalDialog.vue'
|
|
||||||
import { setChildOverride, parseErrorResponse } from '@/common/api'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
isOpen: boolean
|
|
||||||
childId: string
|
|
||||||
entityId: string
|
|
||||||
entityType: 'task' | 'reward'
|
|
||||||
entityName: string
|
|
||||||
defaultValue: number
|
|
||||||
currentOverride?: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
saved: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const inputValue = ref<number>(0)
|
|
||||||
const errorMessage = ref<string>('')
|
|
||||||
const isValid = ref<boolean>(true)
|
|
||||||
const loading = ref<boolean>(false)
|
|
||||||
|
|
||||||
// Initialize input value when modal opens
|
|
||||||
watch(
|
|
||||||
() => props.isOpen,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
inputValue.value = props.currentOverride ?? props.defaultValue
|
|
||||||
validateInput()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function validateInput() {
|
|
||||||
const value = inputValue.value
|
|
||||||
|
|
||||||
if (value === null || value === undefined || isNaN(value)) {
|
|
||||||
errorMessage.value = 'Please enter a valid number'
|
|
||||||
isValid.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value < 0 || value > 10000) {
|
|
||||||
errorMessage.value = 'Value must be between 0 and 10000'
|
|
||||||
isValid.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage.value = ''
|
|
||||||
isValid.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!isValid.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await setChildOverride(
|
|
||||||
props.childId,
|
|
||||||
props.entityId,
|
|
||||||
props.entityType,
|
|
||||||
inputValue.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const { msg } = parseErrorResponse(response)
|
|
||||||
alert(`Error: ${msg}`)
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
emit('close')
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Error: ${error}`)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.override-edit-modal {
|
|
||||||
background: var(--modal-bg);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-edit-modal h3 {
|
|
||||||
margin: 0 0 var(--spacing-md) 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body input[type='number'] {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body input[type='number']:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: var(--error-color);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-hint {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions button {
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--btn-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--btn-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { mount, VueWrapper } from '@vue/test-utils'
|
|
||||||
import { nextTick } from 'vue'
|
|
||||||
import OverrideEditModal from '../OverrideEditModal.vue'
|
|
||||||
|
|
||||||
// Mock API functions
|
|
||||||
vi.mock('@/common/api', () => ({
|
|
||||||
setChildOverride: vi.fn(),
|
|
||||||
parseErrorResponse: vi.fn(() => ({ msg: 'Test error', code: 'TEST_ERROR' })),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { setChildOverride } from '@/common/api'
|
|
||||||
|
|
||||||
global.alert = vi.fn()
|
|
||||||
|
|
||||||
describe('OverrideEditModal', () => {
|
|
||||||
let wrapper: VueWrapper<any>
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
isOpen: true,
|
|
||||||
childId: 'child-123',
|
|
||||||
entityId: 'task-456',
|
|
||||||
entityType: 'task' as 'task' | 'reward',
|
|
||||||
entityName: 'Test Task',
|
|
||||||
defaultValue: 100,
|
|
||||||
currentOverride: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.unmount()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Modal Display', () => {
|
|
||||||
it('renders when isOpen is true', () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
expect(wrapper.find('.modal-backdrop').exists()).toBe(true)
|
|
||||||
expect(wrapper.text()).toContain('Test Task')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render when isOpen is false', () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: { ...defaultProps, isOpen: false } })
|
|
||||||
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays entity information correctly for tasks', () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
expect(wrapper.text()).toContain('Test Task')
|
|
||||||
expect(wrapper.text()).toContain('New Points')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays entity information correctly for rewards', () => {
|
|
||||||
wrapper = mount(OverrideEditModal, {
|
|
||||||
props: { ...defaultProps, entityType: 'reward', entityName: 'Test Reward' },
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('Test Reward')
|
|
||||||
expect(wrapper.text()).toContain('New Cost')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Input Validation', () => {
|
|
||||||
it('initializes with default value when no override exists', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
await nextTick()
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('100')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('initializes with current override value when it exists', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, {
|
|
||||||
props: { ...defaultProps, currentOverride: 150 },
|
|
||||||
})
|
|
||||||
await nextTick()
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('150')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates input within range (0-10000)', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
|
|
||||||
|
|
||||||
// Valid value
|
|
||||||
await input.setValue(5000)
|
|
||||||
await nextTick()
|
|
||||||
expect(saveButton?.attributes('disabled')).toBeUndefined()
|
|
||||||
|
|
||||||
// Zero is valid
|
|
||||||
await input.setValue(0)
|
|
||||||
await nextTick()
|
|
||||||
expect(saveButton?.attributes('disabled')).toBeUndefined()
|
|
||||||
|
|
||||||
// Max is valid
|
|
||||||
await input.setValue(10000)
|
|
||||||
await nextTick()
|
|
||||||
expect(saveButton?.attributes('disabled')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error for values outside range', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
|
|
||||||
// Above max
|
|
||||||
await input.setValue(10001)
|
|
||||||
await nextTick()
|
|
||||||
expect(wrapper.text()).toContain('Value must be between 0 and 10000')
|
|
||||||
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
|
|
||||||
expect(saveButton?.attributes('disabled')).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('User Interactions', () => {
|
|
||||||
it('emits close event when Cancel is clicked', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const cancelButton = wrapper.findAll('button').find((btn) => btn.text() === 'Cancel')
|
|
||||||
await cancelButton?.trigger('click')
|
|
||||||
expect(wrapper.emitted('close')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits close event when clicking backdrop', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
await wrapper.find('.modal-backdrop').trigger('click')
|
|
||||||
expect(wrapper.emitted('close')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not close when clicking modal dialog', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
await wrapper.find('.modal-dialog').trigger('click')
|
|
||||||
expect(wrapper.emitted('close')).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls API and emits events on successful save', async () => {
|
|
||||||
;(setChildOverride as any).mockResolvedValue({ ok: true })
|
|
||||||
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
await input.setValue(250)
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
|
|
||||||
await saveButton?.trigger('click')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(setChildOverride).toHaveBeenCalledWith('child-123', 'task-456', 'task', 250)
|
|
||||||
expect(wrapper.emitted('saved')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('close')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows alert on API error', async () => {
|
|
||||||
;(setChildOverride as any).mockResolvedValue({ ok: false, status: 400 })
|
|
||||||
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
|
|
||||||
await saveButton?.trigger('click')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(global.alert).toHaveBeenCalledWith('Error: Test error')
|
|
||||||
expect(wrapper.emitted('saved')).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not save when validation fails', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: defaultProps })
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
await input.setValue(20000)
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
|
|
||||||
await saveButton?.trigger('click')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(setChildOverride).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Modal State Updates', () => {
|
|
||||||
it('reinitializes value when modal reopens', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, { props: { ...defaultProps, isOpen: false } })
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
await wrapper.setProps({ isOpen: true })
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('100')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses updated currentOverride when modal reopens', async () => {
|
|
||||||
wrapper = mount(OverrideEditModal, {
|
|
||||||
props: { ...defaultProps, isOpen: true, currentOverride: 200 },
|
|
||||||
})
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
await wrapper.setProps({ isOpen: false })
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
await wrapper.setProps({ isOpen: true, currentOverride: 300 })
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const input = wrapper.find('input[type="number"]')
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('300')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -18,7 +18,13 @@
|
|||||||
We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for
|
We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for
|
||||||
10 minutes.
|
10 minutes.
|
||||||
</p>
|
</p>
|
||||||
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
|
<input
|
||||||
|
v-model="code"
|
||||||
|
maxlength="6"
|
||||||
|
class="code-input"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
@keyup.enter="isCodeValid && verifyCode()"
|
||||||
|
/>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button
|
<button
|
||||||
v-if="!loading"
|
v-if="!loading"
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="pin"
|
v-model="pin"
|
||||||
@input="handlePinInput"
|
@input="handlePinInput"
|
||||||
|
@keyup.enter="!loading && isPinValid && setPin()"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="\d*"
|
pattern="\d*"
|
||||||
@@ -49,6 +56,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="pin2"
|
v-model="pin2"
|
||||||
@input="handlePin2Input"
|
@input="handlePin2Input"
|
||||||
|
@keyup.enter="!loading && isPinValid && setPin()"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="\d*"
|
pattern="\d*"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import '@/assets/styles.css'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.id)
|
const isEdit = computed(() => !!props.id)
|
||||||
|
|
||||||
type Field = {
|
type Field = {
|
||||||
@@ -44,7 +45,7 @@ type ChildForm = {
|
|||||||
|
|
||||||
const fields: Field[] = [
|
const fields: Field[] = [
|
||||||
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
|
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
|
||||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120, maxlength: 3 },
|
||||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import ChildDetailCard from './ChildDetailCard.vue'
|
import ChildDetailCard from './ChildDetailCard.vue'
|
||||||
import ScrollingList from '../shared/ScrollingList.vue'
|
import ScrollingList from '../shared/ScrollingList.vue'
|
||||||
import StatusMessage from '../shared/StatusMessage.vue'
|
import StatusMessage from '../shared/StatusMessage.vue'
|
||||||
|
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||||
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
//import '@/assets/view-shared.css'
|
//import '@/assets/view-shared.css'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
@@ -31,6 +33,9 @@ const rewards = ref<string[]>([])
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const childRewardListRef = ref()
|
const childRewardListRef = ref()
|
||||||
|
const showRewardDialog = ref(false)
|
||||||
|
const showCancelDialog = ref(false)
|
||||||
|
const dialogReward = ref<RewardStatus | null>(null)
|
||||||
|
|
||||||
function handleTaskTriggered(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
@@ -193,6 +198,60 @@ const triggerReward = (reward: RewardStatus) => {
|
|||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reward.redeeming) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showCancelDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reward.points_needed <= 0) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showRewardDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRedeemReward() {
|
||||||
|
showRewardDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCancelDialog() {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRedeemReward() {
|
||||||
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) return
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to redeem reward:', err)
|
||||||
|
} finally {
|
||||||
|
showRewardDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPendingReward() {
|
||||||
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cancel pending reward:', err)
|
||||||
|
} finally {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChildData(id: string | number) {
|
async function fetchChildData(id: string | number) {
|
||||||
@@ -392,6 +451,32 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Redeem reward dialog -->
|
||||||
|
<RewardConfirmDialog
|
||||||
|
v-if="showRewardDialog"
|
||||||
|
:reward="dialogReward"
|
||||||
|
:childName="child?.name"
|
||||||
|
@confirm="confirmRedeemReward"
|
||||||
|
@cancel="cancelRedeemReward"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel pending reward dialog -->
|
||||||
|
<ModalDialog
|
||||||
|
v-if="showCancelDialog && dialogReward"
|
||||||
|
:imageUrl="dialogReward.image_url"
|
||||||
|
:title="dialogReward.name"
|
||||||
|
subtitle="Reward Pending"
|
||||||
|
@backdrop-click="closeCancelDialog"
|
||||||
|
>
|
||||||
|
<div class="modal-message">
|
||||||
|
This reward is pending.<br />Would you like to cancel the request?
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||||
|
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -480,4 +565,16 @@ onUnmounted(() => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
filter: grayscale(0.7);
|
filter: grayscale(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--modal-message-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import ModalDialog from '../shared/ModalDialog.vue'
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import PendingRewardDialog from './PendingRewardDialog.vue'
|
import PendingRewardDialog from './PendingRewardDialog.vue'
|
||||||
import TaskConfirmDialog from './TaskConfirmDialog.vue'
|
import TaskConfirmDialog from './TaskConfirmDialog.vue'
|
||||||
@@ -52,6 +52,9 @@ const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward'
|
|||||||
const overrideCustomValue = ref(0)
|
const overrideCustomValue = ref(0)
|
||||||
const isOverrideValid = ref(true)
|
const isOverrideValid = ref(true)
|
||||||
const readyItemId = ref<string | null>(null)
|
const readyItemId = ref<string | null>(null)
|
||||||
|
const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
function handleItemReady(itemId: string) {
|
function handleItemReady(itemId: string) {
|
||||||
readyItemId.value = itemId
|
readyItemId.value = itemId
|
||||||
@@ -214,6 +217,12 @@ function handleOverrideDeleted(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
||||||
|
// If editing a pending reward, warn first
|
||||||
|
if (type === 'reward' && (item as any).redeeming) {
|
||||||
|
pendingEditOverrideTarget.value = { entity: item, type }
|
||||||
|
showPendingRewardDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
overrideEditTarget.value = { entity: item, type }
|
overrideEditTarget.value = { entity: item, type }
|
||||||
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
|
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
|
||||||
overrideCustomValue.value = item.custom_value ?? defaultValue
|
overrideCustomValue.value = item.custom_value ?? defaultValue
|
||||||
@@ -221,11 +230,34 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
|||||||
showOverrideModal.value = true
|
showOverrideModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmPendingRewardAndEdit() {
|
||||||
|
if (!pendingEditOverrideTarget.value) return
|
||||||
|
const item = pendingEditOverrideTarget.value.entity as any
|
||||||
|
await cancelRewardById(item.id)
|
||||||
|
showPendingRewardDialog.value = false
|
||||||
|
const target = pendingEditOverrideTarget.value
|
||||||
|
pendingEditOverrideTarget.value = null
|
||||||
|
// Open override modal directly, bypassing the redeeming check
|
||||||
|
overrideEditTarget.value = target
|
||||||
|
const defaultValue =
|
||||||
|
target.type === 'task' ? (target.entity as Task).points : (target.entity as Reward).cost
|
||||||
|
overrideCustomValue.value = target.entity.custom_value ?? defaultValue
|
||||||
|
validateOverrideInput()
|
||||||
|
showOverrideModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function validateOverrideInput() {
|
function validateOverrideInput() {
|
||||||
const val = overrideCustomValue.value
|
const val = overrideCustomValue.value
|
||||||
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000
|
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(showOverrideModal, async (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById('custom-value')?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function saveOverride() {
|
async function saveOverride() {
|
||||||
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
||||||
|
|
||||||
@@ -549,8 +581,18 @@ function goToAssignRewards() {
|
|||||||
<!-- Pending Reward Dialog -->
|
<!-- Pending Reward Dialog -->
|
||||||
<PendingRewardDialog
|
<PendingRewardDialog
|
||||||
v-if="showPendingRewardDialog"
|
v-if="showPendingRewardDialog"
|
||||||
@confirm="cancelPendingReward"
|
:message="
|
||||||
@cancel="showPendingRewardDialog = false"
|
pendingEditOverrideTarget
|
||||||
|
? 'This reward is currently pending. Changing its cost will cancel the pending request. Would you like to proceed?'
|
||||||
|
: 'A reward is currently pending. It will be cancelled when a chore or penalty is triggered. Would you like to proceed?'
|
||||||
|
"
|
||||||
|
@confirm="pendingEditOverrideTarget ? confirmPendingRewardAndEdit() : cancelPendingReward()"
|
||||||
|
@cancel="
|
||||||
|
() => {
|
||||||
|
showPendingRewardDialog = false
|
||||||
|
pendingEditOverrideTarget = null
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Override Edit Modal -->
|
<!-- Override Edit Modal -->
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
|
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
|
||||||
<div class="modal-message">
|
<div class="modal-message">
|
||||||
There is a pending reward request. The reward must be cancelled before triggering a new
|
{{ message }}
|
||||||
task.<br />
|
|
||||||
Would you like to cancel the pending reward?
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
|
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
|
||||||
@@ -15,6 +13,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ModalDialog from '../shared/ModalDialog.vue'
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
message?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
message: 'A reward is currently pending. It will be cancelled. Would you like to proceed?',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
confirm: []
|
confirm: []
|
||||||
cancel: []
|
cancel: []
|
||||||
|
|||||||
@@ -247,6 +247,145 @@ describe('ChildView', () => {
|
|||||||
)
|
)
|
||||||
expect(requestCalls.length).toBe(0)
|
expect(requestCalls.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens redeem dialog when reward is ready and not pending', () => {
|
||||||
|
wrapper.vm.triggerReward({
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 50,
|
||||||
|
points_needed: 0,
|
||||||
|
redeeming: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(true)
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open redeem dialog when reward is not yet ready', () => {
|
||||||
|
wrapper.vm.triggerReward({
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 50,
|
||||||
|
points_needed: 10,
|
||||||
|
redeeming: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens cancel dialog when reward is already pending', () => {
|
||||||
|
wrapper.vm.triggerReward({
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 50,
|
||||||
|
points_needed: 0,
|
||||||
|
redeeming: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(true)
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Reward Redeem Dialog', () => {
|
||||||
|
const readyReward = {
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 50,
|
||||||
|
points_needed: 0,
|
||||||
|
redeeming: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper = mount(ChildView)
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
wrapper.vm.triggerReward(readyReward)
|
||||||
|
await nextTick()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes redeem dialog on cancelRedeemReward', async () => {
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(true)
|
||||||
|
wrapper.vm.cancelRedeemReward()
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls request-reward API on confirmRedeemReward', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
await wrapper.vm.confirmRedeemReward()
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/child/child-123/request-reward`,
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reward_id: 'reward-1' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes redeem dialog after confirmRedeemReward', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
await wrapper.vm.confirmRedeemReward()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel Pending Reward Dialog', () => {
|
||||||
|
const pendingReward = {
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 50,
|
||||||
|
points_needed: 0,
|
||||||
|
redeeming: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper = mount(ChildView)
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
wrapper.vm.triggerReward(pendingReward)
|
||||||
|
await nextTick()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes cancel dialog on closeCancelDialog', async () => {
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(true)
|
||||||
|
wrapper.vm.closeCancelDialog()
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls cancel-request-reward API on cancelPendingReward', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
await wrapper.vm.cancelPendingReward()
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/child/child-123/cancel-request-reward`,
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reward_id: 'reward-1' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes cancel dialog after cancelPendingReward', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
await wrapper.vm.cancelPendingReward()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showCancelDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.dialogReward).toBe(null)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SSE Event Handlers', () => {
|
describe('SSE Event Handlers', () => {
|
||||||
|
|||||||
@@ -348,4 +348,106 @@ describe('ParentView', () => {
|
|||||||
expect(true).toBe(true) // Placeholder - template logic verified
|
expect(true).toBe(true) // Placeholder - template logic verified
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Override Edit - Pending Reward Guard', () => {
|
||||||
|
const pendingReward = {
|
||||||
|
id: 'reward-1',
|
||||||
|
name: 'Ice Cream',
|
||||||
|
cost: 100,
|
||||||
|
points_needed: 0,
|
||||||
|
redeeming: true,
|
||||||
|
image_url: '/images/reward.png',
|
||||||
|
custom_value: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper = mount(ParentView)
|
||||||
|
await nextTick()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows PendingRewardDialog instead of override modal when editing a pending reward', async () => {
|
||||||
|
wrapper.vm.handleEditItem(pendingReward, 'reward')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showPendingRewardDialog).toBe(true)
|
||||||
|
expect(wrapper.vm.showOverrideModal).toBe(false)
|
||||||
|
expect(wrapper.vm.pendingEditOverrideTarget).toEqual({
|
||||||
|
entity: pendingReward,
|
||||||
|
type: 'reward',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show PendingRewardDialog when editing a non-pending reward', async () => {
|
||||||
|
wrapper.vm.handleEditItem(mockReward, 'reward')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.showOverrideModal).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show PendingRewardDialog when editing a task regardless of pending rewards', async () => {
|
||||||
|
wrapper.vm.handleEditItem(mockTask, 'task')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.showOverrideModal).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels pending reward and opens override modal on confirmPendingRewardAndEdit', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
wrapper.vm.handleEditItem(pendingReward, 'reward')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper.vm.confirmPendingRewardAndEdit()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/child/child-123/cancel-request-reward`,
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reward_id: 'reward-1' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.showOverrideModal).toBe(true)
|
||||||
|
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets overrideCustomValue to reward cost when no custom_value on confirmPendingRewardAndEdit', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
|
||||||
|
wrapper.vm.handleEditItem(pendingReward, 'reward')
|
||||||
|
await wrapper.vm.confirmPendingRewardAndEdit()
|
||||||
|
|
||||||
|
expect(wrapper.vm.overrideCustomValue).toBe(pendingReward.cost)
|
||||||
|
expect(wrapper.vm.overrideEditTarget?.entity).toEqual(pendingReward)
|
||||||
|
expect(wrapper.vm.overrideEditTarget?.type).toBe('reward')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets overrideCustomValue to custom_value when present on confirmPendingRewardAndEdit', async () => {
|
||||||
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
|
||||||
|
const pendingWithOverride = { ...pendingReward, custom_value: 75 }
|
||||||
|
|
||||||
|
wrapper.vm.handleEditItem(pendingWithOverride, 'reward')
|
||||||
|
await wrapper.vm.confirmPendingRewardAndEdit()
|
||||||
|
|
||||||
|
expect(wrapper.vm.overrideCustomValue).toBe(75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears pendingEditOverrideTarget when cancel is clicked on PendingRewardDialog', async () => {
|
||||||
|
wrapper.vm.handleEditItem(pendingReward, 'reward')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Simulate cancel
|
||||||
|
wrapper.vm.showPendingRewardDialog = false
|
||||||
|
wrapper.vm.pendingEditOverrideTarget = null
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
|
||||||
|
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
|
||||||
|
expect(wrapper.vm.showOverrideModal).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,26 +15,18 @@
|
|||||||
<template #custom-field-email="{ modelValue }">
|
<template #custom-field-email="{ modelValue }">
|
||||||
<div class="email-actions">
|
<div class="email-actions">
|
||||||
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
||||||
<button
|
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||||
type="button"
|
|
||||||
class="btn-link align-start btn-link-space"
|
|
||||||
@click="goToChangeParentPin"
|
|
||||||
>
|
|
||||||
Change Parent Pin
|
Change Parent Pin
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-link align-start btn-link-space"
|
class="btn-link btn-link-space"
|
||||||
@click="resetPassword"
|
@click="resetPassword"
|
||||||
:disabled="resetting"
|
:disabled="resetting"
|
||||||
>
|
>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
||||||
type="button"
|
|
||||||
class="btn-link align-start btn-link-space"
|
|
||||||
@click="openDeleteWarning"
|
|
||||||
>
|
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,10 +366,6 @@ function closeDeleteError() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.align-start {
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
.success-message {
|
||||||
color: var(--success, #16a34a);
|
color: var(--success, #16a34a);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
deletable
|
deletable
|
||||||
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
|
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
|
||||||
@delete="confirmDeleteReward"
|
@delete="(reward: Reward) => confirmDeleteReward(reward.id)"
|
||||||
@loading-complete="(count) => (rewardCountRef = count)"
|
@loading-complete="(count) => (rewardCountRef = count)"
|
||||||
:getItemClass="(item) => `reward`"
|
:getItemClass="(item) => `reward`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ onBeforeUnmount(() => {
|
|||||||
<div>
|
<div>
|
||||||
<MessageBlock v-if="children.length === 0" message="No children">
|
<MessageBlock v-if="children.length === 0" message="No children">
|
||||||
<span v-if="!isParentAuthenticated">
|
<span v-if="!isParentAuthenticated">
|
||||||
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
|
<button class="round-btn" @click="eventBus.emit('open-login')">Switch</button> to parent
|
||||||
child
|
mode to create a child
|
||||||
</span>
|
</span>
|
||||||
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
|
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
|
||||||
</MessageBlock>
|
</MessageBlock>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
||||||
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
||||||
<form v-else @submit.prevent="submit" class="entity-form">
|
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
|
||||||
<template v-for="field in fields" :key="field.name">
|
<template v-for="field in fields" :key="field.name">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label :for="field.name">
|
<label :for="field.name">
|
||||||
@@ -14,14 +14,31 @@
|
|||||||
>
|
>
|
||||||
<!-- Default rendering if no slot provided -->
|
<!-- Default rendering if no slot provided -->
|
||||||
<input
|
<input
|
||||||
v-if="field.type === 'text' || field.type === 'number'"
|
v-if="field.type === 'text'"
|
||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="formData[field.name]"
|
v-model="formData[field.name]"
|
||||||
:type="field.type"
|
type="text"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:maxlength="field.maxlength"
|
:maxlength="field.maxlength"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name]"
|
||||||
|
type="number"
|
||||||
|
:required="field.required"
|
||||||
:min="field.min"
|
:min="field.min"
|
||||||
:max="field.max"
|
:max="field.max"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="\\d{1,3}"
|
||||||
|
@input="
|
||||||
|
(e) => {
|
||||||
|
if (field.maxlength && e.target.value.length > field.maxlength) {
|
||||||
|
e.target.value = e.target.value.slice(0, field.maxlength)
|
||||||
|
formData[field.name] = e.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
v-else-if="field.type === 'image'"
|
v-else-if="field.type === 'image'"
|
||||||
@@ -88,12 +105,31 @@ const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||||
|
const formRef = ref<HTMLFormElement | null>(null)
|
||||||
|
|
||||||
|
async function focusFirstInput() {
|
||||||
|
await nextTick()
|
||||||
|
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea')
|
||||||
|
firstInput?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
|
if (!props.loading) {
|
||||||
|
focusFirstInput()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.loading,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
if (!newVal && oldVal === true) {
|
||||||
|
focusFirstInput()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||||
emit('add-image', { id, file })
|
emit('add-image', { id, file })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ onMounted(fetchItems)
|
|||||||
watch(() => props.fetchUrl, fetchItems)
|
watch(() => props.fetchUrl, fetchItems)
|
||||||
|
|
||||||
const handleClicked = (item: any) => {
|
const handleClicked = (item: any) => {
|
||||||
|
if (props.selectable) {
|
||||||
|
const idx = selectedItems.value.indexOf(item.id)
|
||||||
|
if (idx === -1) {
|
||||||
|
selectedItems.value.push(item.id)
|
||||||
|
} else {
|
||||||
|
selectedItems.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
emit('clicked', item)
|
emit('clicked', item)
|
||||||
props.onClicked?.(item)
|
props.onClicked?.(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ const submit = async () => {
|
|||||||
}
|
}
|
||||||
if (!data.valid) {
|
if (!data.valid) {
|
||||||
error.value = 'Incorrect PIN'
|
error.value = 'Incorrect PIN'
|
||||||
|
pin.value = ''
|
||||||
|
await nextTick()
|
||||||
|
pinInput.value?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Authenticate parent and navigate
|
// Authenticate parent and navigate
|
||||||
|
|||||||
@@ -383,6 +383,6 @@ onBeforeUnmount(() => {
|
|||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
color: #888;
|
color: #d6d6d6;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||||
capture="environment"
|
|
||||||
style="display: none"
|
style="display: none"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
|
|||||||
153
frontend/vue-app/src/router/__tests__/authGuard.spec.ts
Normal file
153
frontend/vue-app/src/router/__tests__/authGuard.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
// Use plain objects — the guard only reads `.value`, so full Vue refs are unnecessary
|
||||||
|
const { isAuthReadyMock, isUserLoggedInMock, isParentAuthenticatedMock } = vi.hoisted(() => ({
|
||||||
|
isAuthReadyMock: { value: true },
|
||||||
|
isUserLoggedInMock: { value: false },
|
||||||
|
isParentAuthenticatedMock: { value: false },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth', () => ({
|
||||||
|
isAuthReady: isAuthReadyMock,
|
||||||
|
isUserLoggedIn: isUserLoggedInMock,
|
||||||
|
isParentAuthenticated: isParentAuthenticatedMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import router AFTER mocks are in place
|
||||||
|
const { default: router } = await import('../index')
|
||||||
|
|
||||||
|
// Helper — navigate and return the resolved path
|
||||||
|
async function navigate(path: string): Promise<string> {
|
||||||
|
await router.push(path)
|
||||||
|
return router.currentRoute.value.path
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('router auth guard', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
isAuthReadyMock.value = true
|
||||||
|
// Park at /auth/reset-password as a neutral starting point:
|
||||||
|
// - it is always reachable when logged out
|
||||||
|
// - it doesn't match any route a test assertion lands on
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
await router.push('/auth/reset-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Redirect logged-in users away from /auth ──────────────────────────────
|
||||||
|
|
||||||
|
it('redirects logged-in parent user from /auth to /parent', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = true
|
||||||
|
|
||||||
|
const path = await navigate('/auth')
|
||||||
|
expect(path).toBe('/parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects logged-in child user from /auth to /child', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/auth')
|
||||||
|
expect(path).toBe('/child')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects logged-in parent user from /auth/login to /parent', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = true
|
||||||
|
|
||||||
|
const path = await navigate('/auth/login')
|
||||||
|
expect(path).toBe('/parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects logged-in child user from /auth/signup to /child', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/auth/signup')
|
||||||
|
expect(path).toBe('/child')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects logged-in child user from /auth/forgot-password to /child', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/auth/forgot-password')
|
||||||
|
expect(path).toBe('/child')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Unauthenticated users may access /auth ────────────────────────────────
|
||||||
|
|
||||||
|
it('allows unauthenticated user to access /auth', async () => {
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/auth')
|
||||||
|
expect(path).toBe('/auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows unauthenticated user to access /auth/login', async () => {
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/auth/login')
|
||||||
|
expect(path).toBe('/auth/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Unauthenticated users are redirected to /auth from protected routes ───
|
||||||
|
|
||||||
|
it('redirects unauthenticated user from /parent to /auth', async () => {
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/parent')
|
||||||
|
expect(path).toBe('/auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated user from /child to /auth', async () => {
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/child')
|
||||||
|
expect(path).toBe('/auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Authenticated users are routed to the correct section ─────────────────
|
||||||
|
|
||||||
|
it('allows parent-authenticated user to access /parent', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = true
|
||||||
|
|
||||||
|
const path = await navigate('/parent')
|
||||||
|
expect(path).toBe('/parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows child user to access /child', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/child')
|
||||||
|
expect(path).toBe('/child')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects child user away from /parent to /child', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/parent')
|
||||||
|
expect(path).toBe('/child')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects parent user away from /child to /parent', async () => {
|
||||||
|
isUserLoggedInMock.value = true
|
||||||
|
isParentAuthenticatedMock.value = true
|
||||||
|
|
||||||
|
const path = await navigate('/child')
|
||||||
|
expect(path).toBe('/parent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ParentPinSetup is always accessible ───────────────────────────────────
|
||||||
|
|
||||||
|
it('allows access to /parent/pin-setup regardless of auth state', async () => {
|
||||||
|
isUserLoggedInMock.value = false
|
||||||
|
|
||||||
|
const path = await navigate('/parent/pin-setup')
|
||||||
|
expect(path).toBe('/parent/pin-setup')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -190,6 +190,15 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already logged in and trying to access /auth, redirect to appropriate view
|
||||||
|
if (to.path.startsWith('/auth') && isUserLoggedIn.value) {
|
||||||
|
if (isParentAuthenticated.value) {
|
||||||
|
return next('/parent')
|
||||||
|
} else {
|
||||||
|
return next('/child')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always allow /auth and /parent/pin-setup
|
// Always allow /auth and /parent/pin-setup
|
||||||
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
|
||||||
return next()
|
return next()
|
||||||
|
|||||||
Reference in New Issue
Block a user