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:
@@ -23,6 +23,7 @@ import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps<{ id?: string }>()
|
||||
|
||||
const isEdit = computed(() => !!props.id)
|
||||
|
||||
type Field = {
|
||||
@@ -44,7 +45,7 @@ type ChildForm = {
|
||||
|
||||
const fields: Field[] = [
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import StatusMessage from '../shared/StatusMessage.vue'
|
||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
//import '@/assets/view-shared.css'
|
||||
import '@/assets/styles.css'
|
||||
@@ -31,6 +33,9 @@ const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<RewardStatus | null>(null)
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
@@ -193,6 +198,60 @@ const triggerReward = (reward: RewardStatus) => {
|
||||
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) {
|
||||
@@ -392,6 +451,32 @@ onUnmounted(() => {
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -480,4 +565,16 @@ onUnmounted(() => {
|
||||
pointer-events: none;
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 PendingRewardDialog from './PendingRewardDialog.vue'
|
||||
import TaskConfirmDialog from './TaskConfirmDialog.vue'
|
||||
@@ -52,6 +52,9 @@ const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward'
|
||||
const overrideCustomValue = ref(0)
|
||||
const isOverrideValid = ref(true)
|
||||
const readyItemId = ref<string | null>(null)
|
||||
const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
function handleItemReady(itemId: string) {
|
||||
readyItemId.value = itemId
|
||||
@@ -214,6 +217,12 @@ function handleOverrideDeleted(event: Event) {
|
||||
}
|
||||
|
||||
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 }
|
||||
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
|
||||
overrideCustomValue.value = item.custom_value ?? defaultValue
|
||||
@@ -221,11 +230,34 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
||||
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() {
|
||||
const val = overrideCustomValue.value
|
||||
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() {
|
||||
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
||||
|
||||
@@ -549,8 +581,18 @@ function goToAssignRewards() {
|
||||
<!-- Pending Reward Dialog -->
|
||||
<PendingRewardDialog
|
||||
v-if="showPendingRewardDialog"
|
||||
@confirm="cancelPendingReward"
|
||||
@cancel="showPendingRewardDialog = false"
|
||||
:message="
|
||||
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 -->
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
|
||||
<div class="modal-message">
|
||||
There is a pending reward request. The reward must be cancelled before triggering a new
|
||||
task.<br />
|
||||
Would you like to cancel the pending reward?
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
|
||||
@@ -15,6 +13,15 @@
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
|
||||
@@ -247,6 +247,145 @@ describe('ChildView', () => {
|
||||
)
|
||||
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', () => {
|
||||
|
||||
@@ -348,4 +348,106 @@ describe('ParentView', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user