From 4fb9c95dfb3caa03c99d181b540674b5cdc0e258 Mon Sep 17 00:00:00 2001 From: trent Date: Wed, 12 Aug 2026 07:05:37 +0800 Subject: [PATCH] Add walkthrough --- .../__tests__/components/HelpPanel.test.ts | 138 ++++++++ .../__tests__/components/TourOverlay.test.ts | 66 ++++ .../composables/useHelpPanel.test.ts | 26 ++ .../src/__tests__/help/pageGuides.test.ts | 34 ++ .../src/__tests__/stores/tour.test.ts | 145 ++++++++ .../__tests__/views/CoverSheetView.test.ts | 4 + .../__tests__/views/FhirExplorerView.test.ts | 4 + .../src/__tests__/views/IntakeView.test.ts | 4 + .../src/__tests__/views/UsersView.test.ts | 4 + .../src/components/AppHeader.vue | 6 +- .../src/components/AppShell.vue | 15 +- .../src/components/ApprovalForm.vue | 1 + .../src/components/AuditTrailPanel.vue | 1 + .../src/components/EntryForm.vue | 2 +- .../src/components/HelpPanel.vue | 145 ++++++++ .../components/SeparationOfDutiesBanner.vue | 1 + .../src/components/TourHelpButton.vue | 17 + .../src/components/TourOverlay.vue | 240 ++++++++++++++ .../src/components/VerificationForm.vue | 6 +- .../src/components/WorkstationActionBar.vue | 1 + .../src/components/WorkstationLayout.vue | 7 +- .../src/composables/useHelpPanel.ts | 24 ++ vigilcare-records-web/src/help/pageGuides.ts | 185 +++++++++++ .../src/layouts/AuthenticatedLayout.vue | 26 ++ vigilcare-records-web/src/stores/tour.ts | 191 +++++++++++ .../src/tours/definitions.ts | 311 ++++++++++++++++++ vigilcare-records-web/src/tours/types.ts | 17 + .../src/views/ApprovalView.vue | 6 +- .../src/views/CoverSheetView.vue | 10 +- vigilcare-records-web/src/views/EntryView.vue | 6 +- .../src/views/FhirExplorerView.vue | 3 + .../src/views/IntakeView.vue | 16 +- .../src/views/LiveCaptureView.vue | 16 +- .../src/views/PatientHistoryView.vue | 8 +- .../src/views/QueueDashboardView.vue | 10 +- vigilcare-records-web/src/views/UsersView.vue | 3 + .../src/views/VerificationView.vue | 6 +- 37 files changed, 1682 insertions(+), 23 deletions(-) create mode 100644 vigilcare-records-web/src/__tests__/components/HelpPanel.test.ts create mode 100644 vigilcare-records-web/src/__tests__/components/TourOverlay.test.ts create mode 100644 vigilcare-records-web/src/__tests__/composables/useHelpPanel.test.ts create mode 100644 vigilcare-records-web/src/__tests__/help/pageGuides.test.ts create mode 100644 vigilcare-records-web/src/__tests__/stores/tour.test.ts create mode 100644 vigilcare-records-web/src/components/HelpPanel.vue create mode 100644 vigilcare-records-web/src/components/TourHelpButton.vue create mode 100644 vigilcare-records-web/src/components/TourOverlay.vue create mode 100644 vigilcare-records-web/src/composables/useHelpPanel.ts create mode 100644 vigilcare-records-web/src/help/pageGuides.ts create mode 100644 vigilcare-records-web/src/stores/tour.ts create mode 100644 vigilcare-records-web/src/tours/definitions.ts create mode 100644 vigilcare-records-web/src/tours/types.ts diff --git a/vigilcare-records-web/src/__tests__/components/HelpPanel.test.ts b/vigilcare-records-web/src/__tests__/components/HelpPanel.test.ts new file mode 100644 index 0000000..7d35314 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/HelpPanel.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import HelpPanel from '@/components/HelpPanel.vue' +import TourHelpButton from '@/components/TourHelpButton.vue' +import { useHelpPanel } from '@/composables/useHelpPanel' +import { useAuthStore } from '@/stores/auth' +import { useTourStore } from '@/stores/tour' + +vi.mock('@/api/client', () => ({ + post: vi.fn(), + get: vi.fn(), +})) + +vi.mock('@/router', () => ({ + default: { + push: vi.fn().mockResolvedValue(undefined), + currentRoute: { value: { path: '/entry' } }, + }, +})) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ path: '/entry' }), +})) + +let wrappers: VueWrapper[] = [] + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + wrappers = [] + useHelpPanel().closePanel() +}) + +afterEach(() => { + wrappers.forEach((w) => w.unmount()) + wrappers = [] + useHelpPanel().closePanel() +}) + +function mountPanel() { + const wrapper = mount(HelpPanel, { attachTo: document.body }) + wrappers.push(wrapper) + return wrapper +} + +describe('TourHelpButton', () => { + it('opens the help panel when clicked', async () => { + const help = useHelpPanel() + const wrapper = mount(TourHelpButton) + wrappers.push(wrapper) + await wrapper.get('[data-testid="tour-help"]').trigger('click') + expect(help.open.value).toBe(true) + }) + + it('remains enabled when role has no tour', () => { + const auth = useAuthStore() + auth.user = { + id: 'u1', + username: 'x', + fullName: 'X', + role: 'UNKNOWN', + } + + const wrapper = mount(TourHelpButton) + wrappers.push(wrapper) + expect(wrapper.get('[data-testid="tour-help"]').attributes('disabled')).toBeUndefined() + }) +}) + +describe('HelpPanel', () => { + it('renders nothing when closed', () => { + mountPanel() + expect(document.querySelector('[data-testid="help-panel"]')).toBeNull() + }) + + it('shows page guide content when opened', async () => { + const help = useHelpPanel() + mountPanel() + help.openPanel() + await nextTick() + await flushPromises() + + const panel = document.querySelector('[data-testid="help-panel"]') + expect(panel).not.toBeNull() + expect(panel?.textContent).toContain('Data Entry') + expect(panel?.textContent).toContain('Transcribe structured fields') + expect(panel?.textContent).toContain('Replay walkthrough') + }) + + it('closes on Close button click', async () => { + const help = useHelpPanel() + mountPanel() + help.openPanel() + await nextTick() + + const done = document.querySelector('[data-testid="help-panel-done"]') as HTMLButtonElement + expect(done).not.toBeNull() + done.click() + await nextTick() + + expect(help.open.value).toBe(false) + expect(document.querySelector('[data-testid="help-panel"]')).toBeNull() + }) + + it('Replay walkthrough closes panel and starts tour', async () => { + const el = document.createElement('div') + el.setAttribute('data-tour', 'entry-header') + document.body.appendChild(el) + + const auth = useAuthStore() + auth.user = { + id: 'u1', + username: 'entry1', + fullName: 'Entry One', + role: 'DATA_ENTRY_CLERK', + } + + const tour = useTourStore() + const help = useHelpPanel() + + mountPanel() + help.openPanel() + await nextTick() + + const replay = document.querySelector('[data-testid="help-panel-replay"]') as HTMLButtonElement + expect(replay).not.toBeNull() + expect(replay.disabled).toBe(false) + replay.click() + await flushPromises() + await nextTick() + + expect(help.open.value).toBe(false) + expect(tour.active).toBe(true) + expect(tour.currentStep?.id).toBe('entry-job') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/TourOverlay.test.ts b/vigilcare-records-web/src/__tests__/components/TourOverlay.test.ts new file mode 100644 index 0000000..f1e491e --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/TourOverlay.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import TourOverlay from '@/components/TourOverlay.vue' +import { useTourStore } from '@/stores/tour' + +vi.mock('@/router', () => ({ + default: { + push: vi.fn().mockResolvedValue(undefined), + currentRoute: { value: { path: '/entry' } }, + }, +})) + +beforeEach(() => { + setActivePinia(createPinia()) + document.body.innerHTML = '' +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('TourOverlay', () => { + it('renders nothing when tour is inactive', () => { + mount(TourOverlay, { attachTo: document.body }) + expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull() + }) + + it('renders title, body, Next and Skip when active', async () => { + const el = document.createElement('div') + el.setAttribute('data-tour', 'entry-header') + document.body.appendChild(el) + + const tour = useTourStore() + await tour.start('DATA_ENTRY_CLERK', 'u1') + + mount(TourOverlay, { attachTo: document.body }) + await flushPromises() + + const overlay = document.querySelector('[data-testid="tour-overlay"]') + expect(overlay).not.toBeNull() + expect(overlay?.textContent).toContain('Your job: Data Entry') + expect(overlay?.textContent).toContain('Transcribe structured fields') + expect(document.querySelector('[data-testid="tour-next"]')).not.toBeNull() + expect(document.querySelector('[data-testid="tour-skip"]')).not.toBeNull() + }) + + it('Skip closes the tour', async () => { + const el = document.createElement('div') + el.setAttribute('data-tour', 'entry-header') + document.body.appendChild(el) + + const tour = useTourStore() + await tour.start('DATA_ENTRY_CLERK', 'u1') + + mount(TourOverlay, { attachTo: document.body }) + await flushPromises() + + const skip = document.querySelector('[data-testid="tour-skip"]') as HTMLButtonElement + skip.click() + await flushPromises() + + expect(tour.active).toBe(false) + expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull() + }) +}) diff --git a/vigilcare-records-web/src/__tests__/composables/useHelpPanel.test.ts b/vigilcare-records-web/src/__tests__/composables/useHelpPanel.test.ts new file mode 100644 index 0000000..6e63ff3 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/composables/useHelpPanel.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useHelpPanel } from '@/composables/useHelpPanel' + +describe('useHelpPanel', () => { + beforeEach(() => { + const { closePanel } = useHelpPanel() + closePanel() + }) + + it('opens, closes, and toggles shared state', () => { + const a = useHelpPanel() + const b = useHelpPanel() + + expect(a.open.value).toBe(false) + a.openPanel() + expect(b.open.value).toBe(true) + + a.closePanel() + expect(b.open.value).toBe(false) + + a.togglePanel() + expect(a.open.value).toBe(true) + a.togglePanel() + expect(a.open.value).toBe(false) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/help/pageGuides.test.ts b/vigilcare-records-web/src/__tests__/help/pageGuides.test.ts new file mode 100644 index 0000000..ce04730 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/help/pageGuides.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { getPageGuide, fallbackGuide } from '@/help/pageGuides' + +describe('getPageGuide', () => { + it.each([ + ['/intake', 'intake'], + ['/cover-sheets', 'cover-sheets'], + ['/entry', 'entry'], + ['/entry/batch-uuid-123', 'entry'], + ['/verification', 'verification'], + ['/verification/abc', 'verification'], + ['/approval', 'approval'], + ['/approval/xyz', 'approval'], + ['/live-capture', 'live-capture'], + ['/dashboard', 'dashboard'], + ['/users', 'users'], + ['/fhir-explorer', 'fhir-explorer'], + ['/patients', 'patients'], + ['/patients/p1/history', 'patients'], + ])('resolves %s to guide %s', (path, expectedId) => { + expect(getPageGuide(path).id).toBe(expectedId) + }) + + it('returns fallback for unknown paths', () => { + const guide = getPageGuide('/unknown-route') + expect(guide.id).toBe(fallbackGuide.id) + expect(guide.title).toBe(fallbackGuide.title) + }) + + it('includes steps for entry and tips for verification', () => { + expect(getPageGuide('/entry').steps.length).toBeGreaterThanOrEqual(4) + expect(getPageGuide('/verification').tips?.length).toBeGreaterThan(0) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/stores/tour.test.ts b/vigilcare-records-web/src/__tests__/stores/tour.test.ts new file mode 100644 index 0000000..abb8db3 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/stores/tour.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTourStore } from '@/stores/tour' +import { tourDefinitions } from '@/tours/definitions' + +vi.mock('@/router', () => ({ + default: { + push: vi.fn().mockResolvedValue(undefined), + currentRoute: { value: { path: '/entry' } }, + }, +})) + +import router from '@/router' + +const mockedPush = vi.mocked(router.push) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + localStorage.clear() + document.body.innerHTML = '' + // Reset route path between tests + ;(router.currentRoute as { value: { path: string } }).value = { path: '/entry' } +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +function mountAnchor(tourId: string) { + const el = document.createElement('div') + el.setAttribute('data-tour', tourId) + document.body.appendChild(el) + return el +} + +describe('useTourStore', () => { + it('hasCompleted is false until marked', () => { + const tour = useTourStore() + expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false) + tour.markCompleted('u1', 'DATA_ENTRY_CLERK') + expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true) + expect(localStorage.getItem('vigilcare_tour_u1_DATA_ENTRY_CLERK')).toBe('1') + }) + + it('keys completion by userId and role', () => { + const tour = useTourStore() + tour.markCompleted('u1', 'VERIFIER') + expect(tour.hasCompleted('u1', 'VERIFIER')).toBe(true) + expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false) + expect(tour.hasCompleted('u2', 'VERIFIER')).toBe(false) + }) + + it('tryAutoStart starts when incomplete and skips when completed', async () => { + mountAnchor('entry-header') + const tour = useTourStore() + + await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(true) + expect(tour.currentStep?.id).toBe('entry-job') + + tour.complete() + expect(tour.active).toBe(false) + expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true) + + await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(false) + }) + + it('skip dismisses and prevents auto-restart', async () => { + mountAnchor('entry-header') + const tour = useTourStore() + await tour.start('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(true) + + tour.skip() + expect(tour.active).toBe(false) + expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true) + + await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(false) + }) + + it('replay restarts even after completion', async () => { + mountAnchor('entry-header') + const tour = useTourStore() + tour.markCompleted('u1', 'DATA_ENTRY_CLERK') + + await tour.replay('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(true) + expect(tour.stepIndex).toBe(0) + }) + + it('skips steps whose selector is missing', async () => { + // Only header and patient nav exist — queue / workstation steps skip + mountAnchor('entry-header') + mountAnchor('nav-patients') + const tour = useTourStore() + + await tour.start('DATA_ENTRY_CLERK', 'u1') + expect(tour.active).toBe(true) + expect(tour.currentStep?.id).toBe('entry-job') + + await tour.next() + // entry-queue missing → skips through to patient-history + expect(tour.currentStep?.id).toBe('patient-history') + }) + + it('pushes route for cover-sheets step when needed', async () => { + mountAnchor('intake-header') + mountAnchor('intake-cover-lookup') + mountAnchor('intake-upload') + mountAnchor('intake-metadata') + mountAnchor('intake-recent') + mountAnchor('cover-sheets-header') + mountAnchor('cover-sheets-generate') + mountAnchor('nav-patients') + ;(router.currentRoute as { value: { path: string } }).value = { path: '/intake' } + + const tour = useTourStore() + await tour.start('INTAKE_CLERK', 'u1') + expect(tour.currentStep?.id).toBe('intake-job') + + // Advance until cover-sheets-nav + for (let i = 0; i < 5; i++) { + await tour.next() + } + expect(mockedPush).toHaveBeenCalledWith('/cover-sheets') + expect(tour.currentStep?.id).toBe('cover-sheets-nav') + }) + + it('every role has a non-empty definition', () => { + const roles = [ + 'INTAKE_CLERK', + 'DATA_ENTRY_CLERK', + 'VERIFIER', + 'CLINICAL_APPROVER', + 'CLINICIAN', + 'ADMINISTRATOR', + ] + for (const role of roles) { + expect(tourDefinitions[role]?.steps.length).toBeGreaterThan(0) + } + }) +}) diff --git a/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts b/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts index e2ead13..1176d83 100644 --- a/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts +++ b/vigilcare-records-web/src/__tests__/views/CoverSheetView.test.ts @@ -20,6 +20,10 @@ vi.mock('@/components/AppHeader.vue', () => ({ default: { template: '
' }, })) +vi.mock('@/components/TourHelpButton.vue', () => ({ + default: { template: '