feat: enhance child edit and view components with improved form handling and validation
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s

- Added `requireDirty` prop to `EntityEditForm` for dirty state management.
- Updated `ChildEditView` to handle initial data loading and image selection more robustly.
- Refactored `ChildView` to remove unused reward dialog logic and prevent API calls in child mode.
- Improved type definitions for form fields and initial data in `ChildEditView`.
- Enhanced error handling in form submissions across components.
- Implemented cross-tab logout synchronization on password reset in the auth store.
- Added tests for login and entity edit form functionalities to ensure proper behavior.
- Introduced global fetch interceptor for handling unauthorized responses.
- Documented password reset flow and its implications on session management.
This commit is contained in:
2026-02-17 17:18:03 -05:00
parent 5e22e5e0ee
commit 31ea76f013
29 changed files with 1000 additions and 164 deletions

View File

@@ -5,6 +5,7 @@
:fields="fields"
:initialData="initialData"
:isEdit="isEdit"
:requireDirty="isEdit"
:loading="loading"
:error="error"
@submit="handleSubmit"
@@ -16,22 +17,38 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/styles.css'
const route = useRoute()
const router = useRouter()
const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id)
const fields = [
type Field = {
name: string
label: string
type: 'text' | 'number' | 'image' | 'custom'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}
type ChildForm = {
name: string
age: number | null
image_id: string | null
}
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: 'image_id', label: 'Image', type: 'image', imageType: 1 },
]
const initialData = ref({ name: '', age: null, image_id: null })
const initialData = ref<ChildForm>({ name: '', age: null, image_id: null })
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
@@ -45,15 +62,31 @@ onMounted(async () => {
const data = await resp.json()
initialData.value = {
name: data.name ?? '',
age: Number(data.age) ?? null,
age: data.age === null || data.age === undefined ? null : Number(data.age),
image_id: data.image_id ?? null,
}
} catch (e) {
} catch {
error.value = 'Could not load child.'
} finally {
loading.value = false
await nextTick()
}
} else {
try {
const resp = await fetch('/api/image/list?type=1')
if (resp.ok) {
const data = await resp.json()
const ids = data.ids || []
if (ids.length > 0) {
initialData.value = {
...initialData.value,
image_id: ids[0],
}
}
}
} catch {
// Ignore default image lookup failures and keep existing behavior.
}
}
})
@@ -63,7 +96,7 @@ function handleAddImage({ id, file }: { id: string; file: File }) {
}
}
async function handleSubmit(form: any) {
async function handleSubmit(form: ChildForm) {
let imageId = form.image_id
error.value = null
if (!form.name.trim()) {
@@ -90,7 +123,7 @@ async function handleSubmit(form: any) {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
} catch {
error.value = 'Failed to upload image.'
loading.value = false
return
@@ -123,7 +156,7 @@ async function handleSubmit(form: any) {
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' })
} catch (err) {
} catch {
error.value = 'Failed to save child.'
}
loading.value = false

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
@@ -12,7 +11,6 @@ import type {
Child,
Event,
Task,
Reward,
RewardStatus,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
@@ -32,9 +30,6 @@ const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref()
function handleTaskTriggered(event: Event) {
@@ -179,21 +174,7 @@ const triggerTask = async (task: Task) => {
}
}
// Trigger the task via API
if (child.value?.id && task.id) {
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: task.id }),
})
if (!resp.ok) {
console.error('Failed to trigger task')
}
} catch (err) {
console.error('Error triggering task:', err)
}
}
// Child mode is speech-only; point changes are handled in parent mode.
}
const triggerReward = (reward: RewardStatus) => {
@@ -211,60 +192,6 @@ const triggerReward = (reward: RewardStatus) => {
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
return // Do not allow redeeming if already pending
}
if (reward.points_needed <= 0) {
dialogReward.value = reward
showRewardDialog.value = true
}
}
}
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
}
}
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
}
}
@@ -464,35 +391,6 @@ onUnmounted(() => {
</ScrollingList>
</div>
</div>
<ModalDialog
v-if="showRewardDialog && dialogReward"
:imageUrl="dialogReward?.image_url"
:title="dialogReward.name"
:subtitle="`${dialogReward.cost} pts`"
>
<div class="modal-message">Would you like to redeem this reward?</div>
<div class="modal-actions">
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<ModalDialog
v-if="showCancelDialog && dialogReward"
:imageUrl="dialogReward?.image_url"
:title="dialogReward.name"
:subtitle="`${dialogReward.cost} pts`"
>
<div class="modal-message">
This reward is pending.<br />
Would you like to cancel the pending reward 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>
</div>
</template>

View File

@@ -191,6 +191,16 @@ describe('ChildView', () => {
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call trigger-task API in child mode', async () => {
await wrapper.vm.triggerTask(mockChore)
expect(
(global.fetch as any).mock.calls.some((call: [string]) =>
call[0].includes('/trigger-task'),
),
).toBe(false)
})
it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis
@@ -202,6 +212,43 @@ describe('ChildView', () => {
})
})
describe('Reward Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('speaks reward text when triggered', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call reward request/cancel APIs in child mode', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
const requestCalls = (global.fetch as any).mock.calls.filter(
(call: [string]) =>
call[0].includes('/request-reward') || call[0].includes('/cancel-request-reward'),
)
expect(requestCalls.length).toBe(0)
})
})
describe('SSE Event Handlers', () => {
beforeEach(async () => {
wrapper = mount(ChildView)