feat(tutorial): enhance help button visibility and add dialog tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s

- Introduced a mechanism to hide the help button when modal dialogs are open by adding `helpButtonHidden` state in the tutorial controller.
- Updated various components to set the help button visibility based on dialog states.
- Added tests to verify help button visibility during reward and task confirmation dialogs.
- Created new E2E tests for dialog help button functionality across different assignment views.
- Refactored existing dialog components to utilize the new help button visibility logic.
- Added unit tests for `RewardConfirmDialog` and `TaskConfirmDialog` to ensure correct titles are rendered based on task type.
- Enhanced `HelpButton` component tests to validate visibility based on tutorial state.
This commit is contained in:
2026-07-23 01:02:21 -04:00
parent 541bed3a8a
commit cc1189cd9b
22 changed files with 677 additions and 75 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
@@ -9,6 +9,7 @@ import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
import ChildRoutineOverlay from './ChildRoutineOverlay.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
import { setHelpButtonHidden } from '@/tutorial/controller'
//import '@/assets/view-shared.css'
import '@/assets/styles.css'
import type {
@@ -640,6 +641,8 @@ const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
)
watch(showRewardDialog, (newVal) => setHelpButtonHidden(newVal))
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
@@ -27,6 +27,8 @@
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
<FloatingActionButton aria-label="Create Chore" @click="goToCreate" />
</div>
</template>
@@ -35,6 +37,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
@@ -27,6 +27,8 @@
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
<FloatingActionButton aria-label="Create Kindness Act" @click="goToCreate" />
</div>
</template>
@@ -35,6 +37,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
@@ -31,6 +31,7 @@ import {
maybeShow as tutorialMaybeShow,
activeStep as tutorialActiveStep,
modalTutorialStepId,
setHelpButtonHidden,
} from '@/tutorial/controller'
import '@/assets/styles.css'
import type {
@@ -950,6 +951,10 @@ watch(showOverrideModal, async (newVal) => {
}
})
watch(showConfirm, (newVal) => setHelpButtonHidden(newVal))
watch(showRewardConfirm, (newVal) => setHelpButtonHidden(newVal))
watch(showRoutineConfirmDialog, (newVal) => setHelpButtonHidden(newVal))
async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
@@ -27,6 +27,8 @@
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
<FloatingActionButton aria-label="Create Penalty" @click="goToCreate" />
</div>
</template>
@@ -35,6 +37,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
@@ -27,6 +27,8 @@
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
<FloatingActionButton aria-label="Create Reward" @click="goToCreateReward" />
</div>
</template>
@@ -35,6 +37,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import '@/assets/styles.css'
import { REWARD_FIELDS } from '@/common/models'
@@ -60,7 +63,7 @@ async function onSubmit() {
})
if (!resp.ok) throw new Error('Failed to update rewards')
router.back()
} catch (err) {
} catch {
alert('Failed to update rewards.')
}
}
@@ -1,22 +1,21 @@
<template>
<ModalDialog v-if="reward" @backdrop-click="$emit('cancel')">
<div class="approve-dialog">
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
<p class="item-label">{{ reward.name }}</p>
<p class="subtitle">
{{ reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points' }}
</p>
<p class="message">
Redeem this reward for <strong>{{ childName }}</strong
>?
</p>
<div class="actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button v-if="reward.redeeming" @click="$emit('deny')" class="btn btn-secondary">
Reject
</button>
<button v-else @click="$emit('cancel')" class="btn btn-secondary">No</button>
</div>
<ModalDialog
v-if="reward"
title="Grant Reward"
:subtitle="reward.name"
:imageUrl="reward.image_url"
@backdrop-click="$emit('cancel')"
>
<div class="modal-message">
Redeem this reward for <span class="child-name">{{ childName }}</span
>?
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="$emit('confirm')">Yes</button>
<button v-if="reward.redeeming" class="btn btn-secondary" @click="$emit('deny')">
Reject
</button>
<button v-else class="btn btn-secondary" @click="$emit('cancel')">No</button>
</div>
</ModalDialog>
</template>
@@ -38,54 +37,14 @@ defineEmits<{
</script>
<style scoped>
.approve-dialog {
text-align: center;
padding: 0.5rem;
}
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.item-label {
font-size: 1.2rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 0.15rem;
}
.subtitle {
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.child-name {
font-weight: 600;
color: var(--dialog-child-name);
margin-bottom: 1rem;
}
.message {
font-size: 1rem;
color: var(--dialog-message);
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
color: var(--text-primary, #333);
}
</style>
@@ -31,6 +31,8 @@
{{ isLoading ? 'Saving...' : 'Submit' }}
</button>
</div>
<FloatingActionButton aria-label="Create Routine" @click="goToCreate" />
</div>
</template>
@@ -39,6 +41,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setChildRoutines } from '@/common/api'
import MessageBlock from '../shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
@@ -69,7 +72,7 @@ async function fetchRoutines() {
const routinesData = await routinesResp.json()
const rawRoutines: Routine[] = routinesData.routines || []
await Promise.all(
rawRoutines.map(async (r: any) => {
rawRoutines.map(async (r: Routine) => {
if (r.image_id) {
try {
r.image_url = await getCachedImageUrl(r.image_id)
@@ -1,7 +1,7 @@
<template>
<ModalDialog
v-if="task"
title="Confirm Task"
:title="dialogTitle"
:subtitle="task.name"
:imageUrl="task.image_url"
@backdrop-click="$emit('cancel')"
@@ -19,14 +19,19 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import type { Task } from '@/common/models'
defineProps<{
const props = defineProps<{
task: Task | null
childName?: string
}>()
const dialogTitle = computed(() =>
props.task?.type === 'kindness' ? 'Confirm Act' : 'Confirm Task',
)
defineEmits<{
confirm: []
cancel: []
@@ -3,6 +3,7 @@ import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ChildView from '../ChildView.vue'
import { eventBus } from '@/common/eventBus'
import { helpButtonHidden } from '@/tutorial/controller'
// Mock dependencies
vi.mock('vue-router', () => ({
@@ -396,6 +397,23 @@ describe('ChildView', () => {
})
})
describe('Help FAB visibility during reward redeem dialog', () => {
beforeEach(() => {
helpButtonHidden.value = false
wrapper = mount(ChildView)
})
it('hides the help button while the reward redeem dialog is open', async () => {
wrapper.vm.showRewardDialog = true
await nextTick()
expect(helpButtonHidden.value).toBe(true)
wrapper.vm.showRewardDialog = false
await nextTick()
expect(helpButtonHidden.value).toBe(false)
})
})
describe('Cancel Pending Reward Dialog', () => {
const pendingReward = {
id: 'reward-1',
@@ -3,6 +3,7 @@ import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick, defineComponent } from 'vue'
import ParentView from '../ParentView.vue'
import { eventBus } from '@/common/eventBus'
import { helpButtonHidden } from '@/tutorial/controller'
// Mock dependencies
vi.mock('vue-router', () => ({
@@ -539,6 +540,43 @@ describe('ParentView', () => {
})
})
describe('Help FAB visibility during dialogs', () => {
beforeEach(() => {
helpButtonHidden.value = false
wrapper = mount(ParentView, mountOptions)
})
it('hides the help button while the task confirm dialog is open', async () => {
wrapper.vm.showConfirm = true
await nextTick()
expect(helpButtonHidden.value).toBe(true)
wrapper.vm.showConfirm = false
await nextTick()
expect(helpButtonHidden.value).toBe(false)
})
it('hides the help button while the reward confirm dialog is open', async () => {
wrapper.vm.showRewardConfirm = true
await nextTick()
expect(helpButtonHidden.value).toBe(true)
wrapper.vm.showRewardConfirm = false
await nextTick()
expect(helpButtonHidden.value).toBe(false)
})
it('hides the help button while the routine confirm dialog is open', async () => {
wrapper.vm.showRoutineConfirmDialog = true
await nextTick()
expect(helpButtonHidden.value).toBe(true)
wrapper.vm.showRoutineConfirmDialog = false
await nextTick()
expect(helpButtonHidden.value).toBe(false)
})
})
describe('Highlight pulse animation', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import RewardConfirmDialog from '../RewardConfirmDialog.vue'
import type { RewardStatus } from '@/common/models'
const ModalDialogStub = {
template: '<div><slot /></div>',
props: ['title', 'subtitle', 'imageUrl'],
}
describe('RewardConfirmDialog', () => {
it('renders "Grant Reward" title and reward name subtitle', () => {
const reward: RewardStatus = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
image_id: '',
}
const wrapper = mount(RewardConfirmDialog, {
props: { reward, childName: 'Test Child' },
global: { stubs: { ModalDialog: ModalDialogStub } },
})
const dialog = wrapper.findComponent(ModalDialogStub)
expect(dialog.props('title')).toBe('Grant Reward')
expect(dialog.props('subtitle')).toBe('Ice Cream')
})
})
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TaskConfirmDialog from '../TaskConfirmDialog.vue'
import type { Task } from '@/common/models'
const ModalDialogStub = {
template: '<div><slot /></div>',
props: ['title', 'subtitle', 'imageUrl'],
}
describe('TaskConfirmDialog', () => {
it('renders "Confirm Task" title for chores', () => {
const task: Task = {
id: 'task-1',
name: 'Clean Room',
type: 'chore',
points: 5,
image_id: '',
}
const wrapper = mount(TaskConfirmDialog, {
props: { task, childName: 'Test Child' },
global: { stubs: { ModalDialog: ModalDialogStub } },
})
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Task')
})
it('renders "Confirm Act" title for kindness acts', () => {
const task: Task = {
id: 'task-2',
name: 'Share Toys',
type: 'kindness',
points: 3,
image_id: '',
}
const wrapper = mount(TaskConfirmDialog, {
props: { task, childName: 'Test Child' },
global: { stubs: { ModalDialog: ModalDialogStub } },
})
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Act')
})
})
@@ -1,7 +1,7 @@
<template>
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<div class="schedule-toggle-row" data-tutorial="schedule-enable-toggle">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
<button
type="button"