feat: enhance tutorial functionality by disabling inputs and buttons during active steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s

This commit is contained in:
2026-07-21 14:53:31 -04:00
parent f7b00fc7c4
commit 541bed3a8a
18 changed files with 418 additions and 59 deletions
@@ -8,6 +8,7 @@ import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
isTutorialActive,
} from '@/tutorial/controller'
import type {
Child,
@@ -141,10 +142,12 @@ const fetchChildren = async (): Promise<Child[]> => {
return Promise.resolve()
}),
)
children.value = childList
return childList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
children.value = []
return []
} finally {
loading.value = false
@@ -182,8 +185,7 @@ onMounted(async () => {
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
listPromise.then(() => {
maybeTriggerCreateChildTutorial()
})
// listen for outside clicks to auto-close any open kebab menu
@@ -387,6 +389,7 @@ onBeforeUnmount(() => {
<FloatingActionButton
v-if="isParentAuthenticated"
aria-label="Add Child"
:disabled="isTutorialActive"
@click="createChild"
/>
</div>
@@ -9,7 +9,7 @@
:label="field.label"
:modelValue="formData[field.name]"
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
:disabled="field.disabled"
:disabled="field.disabled || isTutorialActive"
:description="field.description"
:error="props.fieldErrors?.[field.name]"
/>
@@ -29,6 +29,7 @@
type="text"
:required="field.required"
:maxlength="field.maxlength"
:disabled="isTutorialActive"
/>
<input
v-else-if="field.type === 'number'"
@@ -40,11 +41,13 @@
:max="field.max"
inputmode="numeric"
pattern="\\d{1,3}"
:disabled="isTutorialActive"
@input="
(e) => {
if (field.maxlength && e.target.value.length > field.maxlength) {
e.target.value = e.target.value.slice(0, field.maxlength)
formData[field.name] = e.target.value
(e: Event) => {
const target = e.target as HTMLInputElement | null
if (field.maxlength && target && target.value.length > field.maxlength) {
target.value = target.value.slice(0, field.maxlength)
formData[field.name] = target.value
}
}
"
@@ -54,6 +57,7 @@
:id="field.name"
v-model="formData[field.name]"
:image-type="field.imageType || 1"
:disabled="isTutorialActive"
@add-image="onAddImage"
/>
</slot>
@@ -81,7 +85,7 @@
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import ToggleField from './ToggleField.vue'
import { useRouter } from 'vue-router'
import { isTutorialActive } from '@/tutorial/controller'
import '@/assets/styles.css'
type Field = {
@@ -118,7 +122,6 @@ const props = withDefaults(
const emit = defineEmits(['submit', 'cancel', 'add-image'])
const router = useRouter()
const formData = ref<Record<string, any>>({ ...props.initialData })
const baselineData = ref<Record<string, any>>({ ...props.initialData })
const formRef = ref<HTMLFormElement | null>(null)
@@ -1,5 +1,5 @@
<template>
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
<button class="fab" :disabled="disabled" @click="onClick" :aria-label="ariaLabel">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
@@ -8,7 +8,14 @@
</template>
<script setup lang="ts">
defineProps<{ ariaLabel?: string }>()
const props = defineProps<{ ariaLabel?: string; disabled?: boolean }>()
const emit = defineEmits<{ (e: 'click'): void }>()
function onClick() {
if (!props.disabled) {
emit('click')
}
}
</script>
<style scoped>
@@ -38,6 +45,10 @@ defineProps<{ ariaLabel?: string }>()
.fab:active {
background: var(--fab-active-bg);
}
.fab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
display: block;
}
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import EntityEditForm from '../EntityEditForm.vue'
import { activeStep } from '@/tutorial/controller'
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({
@@ -9,6 +10,69 @@ vi.mock('vue-router', () => ({
})),
}))
describe('EntityEditForm tutorial interaction', () => {
beforeEach(() => {
activeStep.value = null
})
afterEach(() => {
activeStep.value = null
})
it('disables text and number inputs while a tutorial step is active', async () => {
activeStep.value = {
def: {
id: 'edit-child-name',
title: "Child's Name",
body: 'Tutorial body',
},
anchor: null,
}
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,
},
})
await wrapper.vm.$nextTick()
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(true)
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(true)
})
it('keeps inputs enabled when no tutorial step is active', async () => {
activeStep.value = null
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,
},
})
await wrapper.vm.$nextTick()
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(false)
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(false)
})
})
describe('EntityEditForm', () => {
it('keeps Create disabled when required number field is empty', async () => {
const wrapper = mount(EntityEditForm, {
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FloatingActionButton from '../FloatingActionButton.vue'
describe('FloatingActionButton', () => {
it('emits click when enabled and clicked', async () => {
const wrapper = mount(FloatingActionButton, {
props: { ariaLabel: 'Add Child', disabled: false },
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('is disabled and does not emit click when disabled prop is true', async () => {
const wrapper = mount(FloatingActionButton, {
props: { ariaLabel: 'Add Child', disabled: true },
})
const button = wrapper.find('button')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
await button.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
})
+37 -17
View File
@@ -4,10 +4,16 @@ import { getCachedImageUrl } from '@/common/imageCache'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
const props = defineProps<{
modelValue?: string | null // selected image id or local-upload
imageType?: number // 1 or 2, default 1
}>()
const props = withDefaults(
defineProps<{
modelValue?: string | null // selected image id or local-upload
imageType?: number // 1 or 2, default 1
disabled?: boolean
}>(),
{
disabled: false,
},
)
const emit = defineEmits(['update:modelValue', 'add-image'])
const fileInput = ref<HTMLInputElement | null>(null)
@@ -28,6 +34,7 @@ const loadingImages = ref(false)
const typeParam = computed(() => props.imageType ?? 1)
const selectImage = (id: string | undefined) => {
if (props.disabled) return
if (!id) {
console.warn('selectImage called with null id')
return
@@ -36,18 +43,20 @@ const selectImage = (id: string | undefined) => {
}
const addFromLocal = () => {
if (props.disabled) return
fileInput.value?.click()
}
const onFileChange = async (event: Event) => {
if (props.disabled) return
const files = (event.target as HTMLInputElement).files
if (files && files.length > 0) {
const file = files[0]
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const { blob, url } = await resizeImageFile(file, 512)
localImageUrl.value = url
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
}
if (!files || files.length === 0) return
const file = files[0]
if (!file) return
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const { blob, url } = await resizeImageFile(file, 512)
localImageUrl.value = url
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
}
onBeforeUnmount(() => {
@@ -55,6 +64,7 @@ onBeforeUnmount(() => {
})
const addFromCamera = async () => {
if (props.disabled) return
cameraError.value = null
capturedImageUrl.value = null
showCamera.value = true
@@ -152,7 +162,9 @@ onMounted(async () => {
const idx = images.findIndex((img) => img.id === props.modelValue)
if (idx > 0) {
const [selected] = images.splice(idx, 1)
images.unshift(selected)
if (selected) {
images.unshift(selected)
}
}
}
availableImages.value = images
@@ -199,8 +211,8 @@ function updateLocalImage(url: string, file: File) {
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
if (idx === -1) {
availableImages.value.unshift({ id: 'local-upload', url })
} else {
availableImages.value[idx].url = url
} else if (availableImages.value[idx]) {
availableImages.value[idx]!.url = url
}
nextTick(() => {
@@ -224,7 +236,7 @@ function updateLocalImage(url: string, file: File) {
:key="img.id"
:src="img.url"
class="selectable-image"
:class="{ selected: modelValue === img.id }"
:class="{ selected: modelValue === img.id, disabled: props.disabled }"
:alt="`Image ${img.id}`"
@click="selectImage(img.id)"
/>
@@ -239,10 +251,10 @@ function updateLocalImage(url: string, file: File) {
@change="onFileChange"
/>
<div class="image-actions">
<button ref="addPhotoBtn" type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
<button ref="addPhotoBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromLocal" aria-label="Add from device">
<span class="icon"></span>
</button>
<button ref="cameraBtn" type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
<button ref="cameraBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromCamera" aria-label="Add from camera">
<span class="icon">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
@@ -315,6 +327,10 @@ function updateLocalImage(url: string, file: File) {
border-color: var(--selectable-image-selected);
box-shadow: 0 0 0 2px #667eea55;
}
.selectable-image.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading-images {
color: var(--loading-text);
font-size: 0.98rem;
@@ -344,6 +360,10 @@ function updateLocalImage(url: string, file: File) {
color: var(--icon-btn-color);
box-shadow: var(--icon-btn-shadow);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn svg {
width: 32px; /* Bigger camera icon */
height: 32px;
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ImagePicker from '../ImagePicker.vue'
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
revokeImageUrl: vi.fn(),
revokeAllImageUrls: vi.fn(),
}))
global.fetch = vi.fn()
async function flushPromises() {
for (let i = 0; i < 5; i++) {
await nextTick()
}
}
describe('ImagePicker disabled behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
const mockFetch = vi.fn()
global.fetch = mockFetch
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ ids: ['img1'] }),
})
})
it('renders selectable images and action buttons', async () => {
const wrapper = mount(ImagePicker, {
props: { modelValue: null, imageType: 1, disabled: false },
})
await flushPromises()
expect(wrapper.findAll('.selectable-image').length).toBeGreaterThan(0)
expect(wrapper.find('button[aria-label="Add from device"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="Add from camera"]').exists()).toBe(true)
})
it('disables image selection and action buttons when disabled is true', async () => {
const wrapper = mount(ImagePicker, {
props: { modelValue: null, imageType: 1, disabled: true },
})
await flushPromises()
const image = wrapper.find('.selectable-image')
expect(image.classes()).toContain('disabled')
const addFromDevice = wrapper.find('button[aria-label="Add from device"]')
const addFromCamera = wrapper.find('button[aria-label="Add from camera"]')
expect((addFromDevice.element as HTMLButtonElement).disabled).toBe(true)
expect((addFromCamera.element as HTMLButtonElement).disabled).toBe(true)
})
it('does not emit update:modelValue when a disabled image is clicked', async () => {
const wrapper = mount(ImagePicker, {
props: { modelValue: null, imageType: 1, disabled: true },
})
await flushPromises()
const image = wrapper.find('.selectable-image')
await image.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not open file input when Add from device is clicked while disabled', async () => {
const wrapper = mount(ImagePicker, {
props: { modelValue: null, imageType: 1, disabled: true },
})
await flushPromises()
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
expect(clickSpy).not.toHaveBeenCalled()
})
it('opens the file input when Add from device is clicked while enabled', async () => {
const wrapper = mount(ImagePicker, {
props: { modelValue: null, imageType: 1, disabled: false },
})
await flushPromises()
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
expect(clickSpy).toHaveBeenCalled()
})
})