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
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user