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:
@@ -10,7 +10,7 @@
|
||||
<slot
|
||||
:name="`custom-field-${field.name}`"
|
||||
:modelValue="formData[field.name]"
|
||||
:update="(val) => (formData[field.name] = val)"
|
||||
:update="(val: unknown) => (formData[field.name] = val)"
|
||||
>
|
||||
<!-- Default rendering if no slot provided -->
|
||||
<input
|
||||
@@ -39,7 +39,11 @@
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
|
||||
>
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -63,34 +67,31 @@ type Field = {
|
||||
imageType?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entityLabel: string
|
||||
fields: Field[]
|
||||
initialData?: Record<string, any>
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entityLabel: string
|
||||
fields: Field[]
|
||||
initialData?: Record<string, any>
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||
|
||||
const router = useRouter()
|
||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
// Optionally focus first input
|
||||
isDirty.value = false
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
@@ -109,14 +110,36 @@ function submit() {
|
||||
|
||||
// Editable field names (exclude custom fields that are not editable)
|
||||
const editableFieldNames = props.fields
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'is_good' || f.type === 'image')
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'is_good')
|
||||
.map((f) => f.name)
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
function getFieldByName(name: string): Field | undefined {
|
||||
return props.fields.find((field) => field.name === name)
|
||||
}
|
||||
|
||||
function valuesEqualForDirtyCheck(
|
||||
fieldName: string,
|
||||
currentValue: unknown,
|
||||
initialValue: unknown,
|
||||
): boolean {
|
||||
const field = getFieldByName(fieldName)
|
||||
|
||||
if (field?.type === 'number') {
|
||||
const currentEmpty = currentValue === '' || currentValue === null || currentValue === undefined
|
||||
const initialEmpty = initialValue === '' || initialValue === null || initialValue === undefined
|
||||
if (currentEmpty && initialEmpty) return true
|
||||
if (currentEmpty !== initialEmpty) return false
|
||||
return Number(currentValue) === Number(initialValue)
|
||||
}
|
||||
|
||||
return JSON.stringify(currentValue) === JSON.stringify(initialValue)
|
||||
}
|
||||
|
||||
function checkDirty() {
|
||||
isDirty.value = editableFieldNames.some((key) => {
|
||||
return JSON.stringify(formData.value[key]) !== JSON.stringify(props.initialData?.[key])
|
||||
return !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +154,7 @@ const isValid = computed(() => {
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (value === '' || value === null || value === undefined) return false
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) return false
|
||||
if (field.min !== undefined && numValue < field.min) return false
|
||||
@@ -145,8 +169,7 @@ const isValid = computed(() => {
|
||||
|
||||
watch(
|
||||
() => ({ ...formData.value }),
|
||||
(newVal) => {
|
||||
console.log('formData changed:', newVal)
|
||||
() => {
|
||||
checkDirty()
|
||||
},
|
||||
{ deep: true },
|
||||
@@ -157,7 +180,8 @@ watch(
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
checkDirty()
|
||||
baselineData.value = { ...newVal }
|
||||
isDirty.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
|
||||
@@ -137,6 +137,11 @@ const submit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePinInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
@@ -357,6 +362,7 @@ onUnmounted(() => {
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
@input="handlePinInput"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
@@ -365,7 +371,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<div class="actions modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EntityEditForm from '../EntityEditForm.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('EntityEditForm', () => {
|
||||
it('keeps Create disabled when required number field is empty', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Create when required Name and Age are both valid', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('8')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user