feat(tutorial): enhance tutorial flow and add tests for help button functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "ND-PngBFXIuDrD3eLsaPs1mWqwzusUSI6nPw-M3JjJo",
|
"value": "5gEK_iLdGl1BAGfetT8hEf6SJK2lQVbYxQjH0BdKWwU",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1792774029.131567,
|
"expires": 1792813397.168935,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkM2E5OWI5MC1kZDdlLTQ5MjQtYTZkZS1hMWU1OTcxMTE3NDciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg4Mjl9.ey7BplN-ghoJ8OGMkdOJavVAYVM_bv_xRBrtL02ASjQ",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkODk0ZjA0OS0xODU3LTQwM2UtYjgzZC1jMTY2NTNmZDU2MmEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTd9.S7frWOrZwXC5xVgtG2RDioXGok04qK_jLCYWvfedD7s",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1785008829.130873,
|
"expires": 1785048197.167997,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1784998028750}"
|
"value": "{\"type\":\"logout\",\"at\":1785037396813}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1785170829369}"
|
"value": "{\"expiresAt\":1785210197388}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "PbjRbjt9aqRjxoNjBDVJTuDsEEzgUPoplaNgzhOOwdQ",
|
"value": "Ke6qDj_0RYyrMSI1Z9C69DPtvYo26oUUiLZr_pDXZv4",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1792774174.76082,
|
"expires": 1792813392.716776,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YzU1MWFiNi1lNmMxLTQ1MzgtOTEzNS0xNDgwN2M2NjM0OTQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NzR9._hCAKJRJgq4gcDKppcjkcWf9nX94wjBfa4J94Lh2ZlI",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3NTYwZDBlNy1iMmVlLTRiMjktYTYwNi1lZTM3NmIzN2Y3ODciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTJ9.QB4bXo88KC7jr94QSjJI3Y1eVWeU4KNKdHRi1_7zcD0",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1785008974.760293,
|
"expires": 1785048192.71579,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1784998174529}"
|
"value": "{\"type\":\"logout\",\"at\":1785037392502}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1785170974948}"
|
"value": "{\"expiresAt\":1785210192904}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,4 +285,43 @@ test.describe('Dialog help button and titles', () => {
|
|||||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('help button replays the full tutorial chain when tutorial tips are disabled', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await setTutorialEnabled(request, false)
|
||||||
|
await resetTutorialProgress(request)
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
|
||||||
|
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await helpButton(page).click()
|
||||||
|
|
||||||
|
const cardTitle = page.locator('.tutorial-root .card .title')
|
||||||
|
await expect(cardTitle).toHaveText("This is your child's page")
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Assign chores')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Assign kindness acts')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Assign rewards')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Assign routines')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Assign penalties')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(cardTitle).toHaveText('Quick actions')
|
||||||
|
|
||||||
|
await page.locator('.tutorial-root .btn-primary').click()
|
||||||
|
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,7 +49,12 @@
|
|||||||
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||||
Change Parent PIN
|
Change Parent PIN
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-link btn-link-space" @click="resetPassword" :disabled="resetting">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link btn-link-space"
|
||||||
|
@click="resetPassword"
|
||||||
|
:disabled="resetting"
|
||||||
|
>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
||||||
@@ -174,7 +179,7 @@
|
|||||||
title="Tutorial Restart"
|
title="Tutorial Restart"
|
||||||
@close="showRestartSuccess = false"
|
@close="showRestartSuccess = false"
|
||||||
>
|
>
|
||||||
<div class="modal-message">Tutorial mode has been restarted</div>
|
<div class="modal-message">Tutorial mode has been restarted.</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
|
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden } from './controller'
|
import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden, sessionSkipped } from './controller'
|
||||||
|
|
||||||
// Map route names to the tutorial step that should re-fire when the user taps `?`.
|
// 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.
|
// Keep this list lean — only routes that have a tutorial step actually wired.
|
||||||
@@ -65,7 +65,9 @@ function onClick() {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
// Clear any active step so the manual re-fire wins.
|
// Clear any active step so the manual re-fire wins.
|
||||||
activeStep.value = null
|
activeStep.value = null
|
||||||
// Temporarily clear local "seen" for the whole chain so every step replays.
|
// A previous Cancel/Skip would otherwise permanently block manual help.
|
||||||
|
sessionSkipped.value = false
|
||||||
|
// Temporarily clear local "seen" state for the whole chain so every step replays.
|
||||||
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
|
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
|
||||||
clearChainProgress(id)
|
clearChainProgress(id)
|
||||||
maybeShow(id, null, true)
|
maybeShow(id, null, true)
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import HelpButton from '../HelpButton.vue'
|
import HelpButton from '../HelpButton.vue'
|
||||||
import { tutorialEnabled, tutorialProgress, setHelpButtonHidden } from '../controller'
|
import {
|
||||||
|
activeStep,
|
||||||
|
dismissActive,
|
||||||
|
sessionSkipped,
|
||||||
|
setHelpButtonHidden,
|
||||||
|
tutorialEnabled,
|
||||||
|
tutorialProgress,
|
||||||
|
} from '../controller'
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: vi.fn(() => ({ name: 'ChoreView' })),
|
useRoute: vi.fn(() => ({ name: 'ChoreView' })),
|
||||||
@@ -12,11 +19,16 @@ describe('HelpButton', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tutorialEnabled.value = true
|
tutorialEnabled.value = true
|
||||||
tutorialProgress.value = {}
|
tutorialProgress.value = {}
|
||||||
|
sessionSkipped.value = false
|
||||||
|
activeStep.value = null
|
||||||
setHelpButtonHidden(false)
|
setHelpButtonHidden(false)
|
||||||
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setHelpButtonHidden(false)
|
setHelpButtonHidden(false)
|
||||||
|
activeStep.value = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders when tutorial is enabled and a step is mapped', () => {
|
it('renders when tutorial is enabled and a step is mapped', () => {
|
||||||
@@ -44,4 +56,54 @@ describe('HelpButton', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('.help-fab').exists()).toBe(true)
|
expect(wrapper.find('.help-fab').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setAnchorHtml() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="fab"></div>
|
||||||
|
<div class="list-item"></div>
|
||||||
|
<div class="delete-btn"></div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
it('clicking the ? FAB when tips are disabled starts the help chain', async () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
setAnchorHtml()
|
||||||
|
|
||||||
|
const wrapper = mount(HelpButton)
|
||||||
|
await wrapper.find('.help-fab').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(activeStep.value?.def.id).toBe('list-chore-help')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues through the help chain while tips are disabled', async () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
setAnchorHtml()
|
||||||
|
|
||||||
|
const wrapper = mount(HelpButton)
|
||||||
|
await wrapper.find('.help-fab').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(activeStep.value?.def.id).toBe('list-chore-help')
|
||||||
|
|
||||||
|
dismissActive(true)
|
||||||
|
await nextTick()
|
||||||
|
expect(activeStep.value?.def.id).toBe('list-edit-hint')
|
||||||
|
|
||||||
|
dismissActive(true)
|
||||||
|
await nextTick()
|
||||||
|
expect(activeStep.value?.def.id).toBe('list-delete-hint')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works even if the user previously skipped the session', async () => {
|
||||||
|
sessionSkipped.value = true
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
setAnchorHtml()
|
||||||
|
|
||||||
|
const wrapper = mount(HelpButton)
|
||||||
|
await wrapper.find('.help-fab').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(sessionSkipped.value).toBe(false)
|
||||||
|
expect(activeStep.value?.def.id).toBe('list-chore-help')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
shouldShowStep,
|
||||||
|
clearChainProgress,
|
||||||
|
tutorialEnabled,
|
||||||
|
tutorialProgress,
|
||||||
|
sessionSkipped,
|
||||||
|
} from '../controller'
|
||||||
|
|
||||||
|
describe('tutorial controller - shouldShowStep', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tutorialEnabled.value = true
|
||||||
|
tutorialProgress.value = {}
|
||||||
|
sessionSkipped.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when tips are enabled, the step is unseen, and the session is not skipped', () => {
|
||||||
|
expect(shouldShowStep('create-chore')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when tips are disabled for a normal step', () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
expect(shouldShowStep('create-penalty')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for a locally-cleared step even when tips are disabled', () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
clearChainProgress('create-reward')
|
||||||
|
expect(shouldShowStep('create-reward')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still blocks locally-cleared steps when the session has been skipped', () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
sessionSkipped.value = true
|
||||||
|
clearChainProgress('create-kindness')
|
||||||
|
expect(shouldShowStep('create-kindness')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when the step has already been seen', () => {
|
||||||
|
tutorialProgress.value = { 'notification-click': true }
|
||||||
|
expect(shouldShowStep('notification-click')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores the enabled flag when ignoreEnabled is true', () => {
|
||||||
|
tutorialEnabled.value = false
|
||||||
|
expect(shouldShowStep('status-pending', true)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -80,7 +80,7 @@ export function hydrateFromProfile(profile: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowStep(id: string, ignoreEnabled = false): boolean {
|
export function shouldShowStep(id: string, ignoreEnabled = false): boolean {
|
||||||
if (!ignoreEnabled && !tutorialEnabled.value) return false
|
if (!ignoreEnabled && !tutorialEnabled.value && !locallyCleared.has(id)) return false
|
||||||
if (sessionSkipped.value) return false
|
if (sessionSkipped.value) return false
|
||||||
if (tutorialProgress.value[id]) return false
|
if (tutorialProgress.value[id]) return false
|
||||||
return true
|
return true
|
||||||
|
|||||||
Reference in New Issue
Block a user