feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
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:
2026-02-10 20:21:05 -05:00
parent 3dee8b80a2
commit 401c21ad82
45 changed files with 4353 additions and 441 deletions

View File

@@ -1,13 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue'
import { setChildOverride, parseErrorResponse } from '@/common/api'
import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css'
//import '@/assets/view-shared.css'
import type {
Task,
Child,
@@ -22,6 +25,8 @@ import type {
ChildModifiedEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
} from '@/common/models'
const route = useRoute()
@@ -36,10 +41,24 @@ const showConfirm = ref(false)
const selectedTask = ref<Task | null>(null)
const showRewardConfirm = ref(false)
const selectedReward = ref<Reward | null>(null)
const childChoreListRef = ref()
const childPenaltyListRef = ref()
const childRewardListRef = ref()
const showPendingRewardDialog = ref(false)
// Override editing
const showOverrideModal = ref(false)
const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null)
const overrideCustomValue = ref(0)
const isOverrideValid = ref(true)
const readyItemId = ref<string | null>(null)
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
function handleTaskTriggered(event: Event) {
console.log('Task triggered, refreshing rewards list -> ', childRewardListRef.value)
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
@@ -168,6 +187,63 @@ function handleChildModified(event: Event) {
}
}
function handleOverrideSet(event: Event) {
const payload = event.payload as ChildOverrideSetPayload
if (child.value && payload.override.child_id === child.value.id) {
// Refresh the appropriate list to show the override badge
if (payload.override.entity_type === 'task') {
childChoreListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.override.entity_type === 'reward') {
childRewardListRef.value?.refresh()
}
}
}
function handleOverrideDeleted(event: Event) {
const payload = event.payload as ChildOverrideDeletedPayload
if (child.value && payload.child_id === child.value.id) {
// Refresh the appropriate list to remove the override badge
if (payload.entity_type === 'task') {
childChoreListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.entity_type === 'reward') {
childRewardListRef.value?.refresh()
}
}
}
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
overrideEditTarget.value = { entity: item, type }
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
overrideCustomValue.value = item.custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
function validateOverrideInput() {
const val = overrideCustomValue.value
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000
}
async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
const res = await setChildOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideEditTarget.value.type,
overrideCustomValue.value,
)
if (res.ok) {
showOverrideModal.value = false
} else {
const { msg } = parseErrorResponse(res)
alert(`Error: ${msg}`)
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
@@ -194,6 +270,8 @@ onMounted(async () => {
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('child_override_set', handleOverrideSet)
eventBus.on('child_override_deleted', handleOverrideDeleted)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
@@ -223,6 +301,8 @@ onUnmounted(() => {
eventBus.off('child_reward_request', handleRewardRequest)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_override_set', handleOverrideSet)
eventBus.off('child_override_deleted', handleOverrideDeleted)
})
function getPendingRewardIds(): string[] {
@@ -354,11 +434,17 @@ function goToAssignRewards() {
<ScrollingList
title="Chores"
ref="childChoreListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:enableEdit="true"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@trigger-item="triggerTask"
@edit-item="(item) => handleEditItem(item, 'task')"
@item-ready="handleItemReady"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
@@ -373,18 +459,29 @@ function goToAssignRewards() {
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:enableEdit="true"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@trigger-item="triggerTask"
@edit-item="(item) => handleEditItem(item, 'task')"
@item-ready="handleItemReady"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
@@ -399,7 +496,12 @@ function goToAssignRewards() {
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
{{
item.custom_value !== undefined && item.custom_value !== null
? -item.custom_value
: -item.points
}}
Points
</div>
</template>
</ScrollingList>
@@ -410,7 +512,13 @@ function goToAssignRewards() {
itemKey="reward_status"
imageField="image_id"
:ids="rewards"
:enableEdit="true"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@trigger-item="triggerReward"
@edit-item="(item) => handleEditItem(item, 'reward')"
@item-ready="handleItemReady"
:getItemClass="(item) => ({ reward: true })"
>
<template #item="{ item }: { item: RewardStatus }">
@@ -439,83 +547,79 @@ function goToAssignRewards() {
</div>
<!-- Pending Reward Dialog -->
<ModalDialog v-if="showPendingRewardDialog" :title="'Warning!'">
<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?
</div>
<div class="modal-actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<PendingRewardDialog
v-if="showPendingRewardDialog"
@confirm="cancelPendingReward"
@cancel="showPendingRewardDialog = false"
/>
<!-- Override Edit Modal -->
<ModalDialog
v-if="showConfirm && selectedTask"
:title="'Confirm Task'"
:subtitle="selectedTask.name"
:imageUrl="selectedTask.image_url"
v-if="showOverrideModal && overrideEditTarget && child"
:image-url="overrideEditTarget.entity.image_url"
:title="overrideEditTarget.entity.name"
:subtitle="`Assign ${overrideEditTarget.type === 'task' ? 'new points' : 'new cost'}`"
>
<div class="modal-message">
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
{{ selectedTask.is_good ? 'to' : 'from' }}
<span class="child-name">{{ child?.name }}</span>
<div class="override-content">
<div class="input-group">
<label for="custom-value"
>{{ overrideEditTarget.type === 'task' ? 'New Points' : 'New Cost' }}:</label
>
<input
id="custom-value"
v-model.number="overrideCustomValue"
type="number"
min="0"
max="10000"
:class="{ invalid: !isOverrideValid }"
@input="validateOverrideInput"
/>
</div>
</div>
<div class="modal-actions">
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
<button class="btn-secondary" @click="showOverrideModal = false">Cancel</button>
<button class="btn-primary" :disabled="!isOverrideValid" @click="saveOverride">Save</button>
</div>
</ModalDialog>
<ModalDialog
v-if="showRewardConfirm && selectedReward"
:imageUrl="selectedReward?.image_url"
:title="selectedReward?.name"
:subtitle="
selectedReward.points_needed === 0
? 'Reward Ready!'
: selectedReward?.points_needed + ' more points'
<!-- Task Confirm Dialog -->
<TaskConfirmDialog
v-if="showConfirm"
:task="selectedTask"
:childName="child?.name"
@confirm="confirmTriggerTask"
@cancel="
() => {
showConfirm = false
selectedTask = null
}
"
>
<div class="modal-message">
Redeem this reward for <span class="child-name">{{ child?.name }}</span
>?
</div>
<div class="modal-actions">
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</ModalDialog>
/>
<!-- Reward Confirm Dialog -->
<RewardConfirmDialog
v-if="showRewardConfirm"
:reward="selectedReward"
:childName="child?.name"
@confirm="confirmTriggerReward"
@cancel="
() => {
showRewardConfirm = false
selectedReward = null
}
"
/>
</div>
</template>
<style scoped>
.layout {
display: flex;
gap: 1rem;
justify-content: center;
align-items: flex-start;
margin: 2rem 0;
}
.main {
display: flex;
flex-direction: column;
@@ -523,27 +627,13 @@ function goToAssignRewards() {
gap: 1.5rem;
width: 100%;
}
/* Responsive Adjustments */
@media (max-width: 900px) {
.layout {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 480px) {
.main {
gap: 1rem;
}
.modal {
padding: 1rem;
min-width: 0;
}
}
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
flex-wrap: wrap;
}
.item-points {
@@ -598,4 +688,37 @@ function goToAssignRewards() {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
/* Override modal styles */
.override-content {
text-align: left;
}
.input-group {
display: flex;
align-items: center;
gap: 1rem;
margin: var(--spacing-md, 1rem) 0;
}
.input-group label {
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.input-group input {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #fff);
box-sizing: border-box;
}
.input-group input.invalid {
border-color: var(--error-color, #e53e3e);
}
</style>