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
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:
@@ -0,0 +1,133 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignFabChild'
|
||||
const CHORE_NAME = 'AssignFabChore'
|
||||
const KINDNESS_NAME = 'AssignFabKindness'
|
||||
const PENALTY_NAME = 'AssignFabPenalty'
|
||||
const REWARD_NAME = 'AssignFabReward'
|
||||
const ROUTINE_NAME = 'AssignFabRoutine'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (
|
||||
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E fab reward', cost: 10 } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (
|
||||
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/routine/list')
|
||||
for (const r of (await pre.json()).routines ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
|
||||
}
|
||||
const res = await request.put('/api/routine/add', { data: { name, points: 5 } })
|
||||
return (await res.json()).routine?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Assignment views create FAB', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindnessId = ''
|
||||
let penaltyId = ''
|
||||
let rewardId = ''
|
||||
let routineId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore')
|
||||
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
|
||||
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
|
||||
rewardId = await createReward(request, REWARD_NAME)
|
||||
routineId = await createRoutine(request, ROUTINE_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindnessId) await request.delete(`/api/task/${kindnessId}`)
|
||||
if (penaltyId) await request.delete(`/api/task/${penaltyId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
if (routineId) await request.delete(`/api/routine/${routineId}`)
|
||||
})
|
||||
|
||||
test('Chore assign view FAB navigates to chore creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/chores\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Chore' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kindness assign view FAB navigates to kindness creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/kindness\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Kindness Act' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Penalty assign view FAB navigates to penalty creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/penalties\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Penalty' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reward assign view FAB navigates to reward creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.waitForURL(/\/parent\/rewards\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Reward' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Routine assign view FAB navigates to routine creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-routines?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Routines' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Routine' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/routines\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Routine' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
const CHILD_NAME = 'DialogHelpChild'
|
||||
const CHORE_NAME = 'DialogHelpChore'
|
||||
const KINDNESS_NAME = 'DialogHelpKindness'
|
||||
const PENALTY_NAME = 'DialogHelpPenalty'
|
||||
const REWARD_NAME = 'DialogHelpReward'
|
||||
const REWARD_COST = 10
|
||||
const ROUTINE_NAME = 'DialogHelpRoutine'
|
||||
|
||||
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { enabled },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { reset: true },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/child/list`)
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`${BACKEND}/child/${c.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/child/add`, { data: { name, age: 8 } })
|
||||
const list = await request.get(`${BACKEND}/child/list`)
|
||||
return (
|
||||
(await list.json()).children?.find(
|
||||
(c: { name: string; id: string }) => c.name === name,
|
||||
)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/task/list`)
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`${BACKEND}/task/${t.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/task/add`, { data: { name, points: 5, type } })
|
||||
const list = await request.get(`${BACKEND}/task/list`)
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/reward/list`)
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`${BACKEND}/reward/${r.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/reward/add`, {
|
||||
data: { name, description: 'E2E dialog reward', cost: REWARD_COST },
|
||||
})
|
||||
const list = await request.get(`${BACKEND}/reward/list`)
|
||||
return (
|
||||
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/routine/list`)
|
||||
for (const r of (await pre.json()).routines ?? []) {
|
||||
if (r.name === name) await request.delete(`${BACKEND}/routine/${r.id}`)
|
||||
}
|
||||
const res = await request.put(`${BACKEND}/routine/add`, { data: { name, points: 5 } })
|
||||
return (await res.json()).routine?.id ?? ''
|
||||
}
|
||||
|
||||
async function assignTask(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<void> {
|
||||
const res = await request.put(`${BACKEND}/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [taskId], type },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign ${type}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assignReward(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
rewardId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.put(`${BACKEND}/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign reward: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assignRoutine(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
routineId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
|
||||
data: { routine_id: routineId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign routine: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
function helpButton(page: Page) {
|
||||
return page.getByRole('button', { name: 'Show help for this screen' })
|
||||
}
|
||||
|
||||
function modalBackdrop(page: Page) {
|
||||
return page.locator('.modal-backdrop')
|
||||
}
|
||||
|
||||
async function dismissAutoTutorialChain(page: Page): Promise<void> {
|
||||
const card = page.locator('.tutorial-root .card')
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!(await card.isVisible().catch(() => false))) return
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
|
||||
function sectionByHeading(page: Page, heading: string) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: heading }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Dialog help button and titles', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindnessId = ''
|
||||
let penaltyId = ''
|
||||
let rewardId = ''
|
||||
let routineId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await setTutorialEnabled(request, true)
|
||||
await resetTutorialProgress(request)
|
||||
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore')
|
||||
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
|
||||
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
|
||||
rewardId = await createReward(request, REWARD_NAME)
|
||||
routineId = await createRoutine(request, ROUTINE_NAME)
|
||||
|
||||
await assignTask(request, childId, choreId, 'chore')
|
||||
await assignTask(request, childId, kindnessId, 'kindness')
|
||||
await assignTask(request, childId, penaltyId, 'penalty')
|
||||
await assignReward(request, childId, rewardId)
|
||||
await assignRoutine(request, childId, routineId)
|
||||
|
||||
// Give the child enough points so the reward is ready to redeem.
|
||||
await request.put(`${BACKEND}/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`${BACKEND}/child/${childId}`)
|
||||
if (choreId) await request.delete(`${BACKEND}/task/${choreId}`)
|
||||
if (kindnessId) await request.delete(`${BACKEND}/task/${kindnessId}`)
|
||||
if (penaltyId) await request.delete(`${BACKEND}/task/${penaltyId}`)
|
||||
if (rewardId) await request.delete(`${BACKEND}/reward/${rewardId}`)
|
||||
if (routineId) await request.delete(`${BACKEND}/routine/${routineId}`)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await dismissAutoTutorialChain(page)
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button and shows "Confirm Task" for chores', async ({
|
||||
page,
|
||||
}) => {
|
||||
const card = sectionByHeading(page, 'Chores').locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Task')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button and shows "Confirm Act" for kindness acts', async ({
|
||||
page,
|
||||
}) => {
|
||||
const card = sectionByHeading(page, 'Kindness Acts')
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: KINDNESS_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Act')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button for penalties', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Penalties')
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENALTY_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Task')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Reward confirm dialog hides help button and shows "Grant Reward"', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Rewards').locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Grant Reward')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Routine confirm dialog hides help button and shows "Confirm Routine"', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Routines').locator('.item-card').filter({ hasText: ROUTINE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Routine')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -184,6 +184,16 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
|
||||
dependencies: ['setup-tutorial'],
|
||||
testMatch: [/mode_parent\/tutorial\/.+\.spec\.ts/],
|
||||
testIgnore: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: dialog help-button/title tests — depends on the tutorial bucket
|
||||
// so it reuses the isolated tutorial user without running concurrently.
|
||||
name: 'chromium-dialog',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
|
||||
dependencies: ['chromium-tutorial'],
|
||||
testMatch: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -146,6 +146,13 @@ describe('ScheduleModal Specific Days form', () => {
|
||||
expect(w.find('.default-deadline-row').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the enable-toggle row for the tutorial anchor', () => {
|
||||
const w = mountModal()
|
||||
const toggleRow = w.find('.schedule-toggle-row')
|
||||
expect(toggleRow.exists()).toBe(true)
|
||||
expect(toggleRow.attributes('data-tutorial')).toBe('schedule-enable-toggle')
|
||||
})
|
||||
|
||||
it('Save is disabled when no days selected (isDirty is false)', () => {
|
||||
const w = mountModal()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
>?
|
||||
</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">
|
||||
</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 @click="$emit('cancel')" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
<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"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { activeStep, maybeShow, tutorialEnabled, tutorialProgress, clearChainProgress, modalTutorialStepId } from './controller'
|
||||
import { activeStep, maybeShow, tutorialEnabled, clearChainProgress, modalTutorialStepId, helpButtonHidden } from './controller'
|
||||
|
||||
// Map route names to the tutorial step that should re-fire when the user taps `?`.
|
||||
// Keep this list lean — only routes that have a tutorial step actually wired.
|
||||
@@ -56,6 +56,7 @@ const targetStepId = computed<string | null>(() => {
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
if (helpButtonHidden.value) return false
|
||||
if (!tutorialEnabled.value) return false
|
||||
return targetStepId.value !== null
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import HelpButton from '../HelpButton.vue'
|
||||
import { tutorialEnabled, tutorialProgress, setHelpButtonHidden } from '../controller'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn(() => ({ name: 'ChoreView' })),
|
||||
}))
|
||||
|
||||
describe('HelpButton', () => {
|
||||
beforeEach(() => {
|
||||
tutorialEnabled.value = true
|
||||
tutorialProgress.value = {}
|
||||
setHelpButtonHidden(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setHelpButtonHidden(false)
|
||||
})
|
||||
|
||||
it('renders when tutorial is enabled and a step is mapped', () => {
|
||||
const wrapper = mount(HelpButton)
|
||||
expect(wrapper.find('.help-fab').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides while helpButtonHidden is true', async () => {
|
||||
const wrapper = mount(HelpButton)
|
||||
expect(wrapper.find('.help-fab').exists()).toBe(true)
|
||||
|
||||
setHelpButtonHidden(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.help-fab').exists()).toBe(false)
|
||||
|
||||
setHelpButtonHidden(false)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.help-fab').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,12 @@ export const isTutorialActive = computed(() => activeStep.value !== null)
|
||||
export const sessionSkipped = ref(false)
|
||||
/** When a modal is open, this holds the step ID that should fire via the `?` button. */
|
||||
export const modalTutorialStepId = ref<string | null>(null)
|
||||
/** Hide the floating help button while modal dialogs are open. */
|
||||
export const helpButtonHidden = ref(false)
|
||||
|
||||
export function setHelpButtonHidden(hidden: boolean) {
|
||||
helpButtonHidden.value = hidden
|
||||
}
|
||||
|
||||
const queue: ActiveStep[] = []
|
||||
let hydrated = false
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@
|
||||
"GITEA_HOST",
|
||||
"docker.gitea.com/gitea-mcp-server"
|
||||
],
|
||||
"env": {
|
||||
"GITEA_ACCESS_TOKEN": "<secret>",
|
||||
"GITEA_HOST": "https://git.ryankegel.com"
|
||||
"environment": {
|
||||
"GITEA_HOST": "https://git.ryankegel.com",
|
||||
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user