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,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>
|
||||
|
||||
Reference in New Issue
Block a user