-
Notifications
You must be signed in to change notification settings - Fork 0
feat(scheduler): Clinical Scheduler audit trail + inline per-week history #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9752cf8
feat(scheduler): write audit entries within the schedule transaction
rlorenzo ff25f6a
feat(scheduler): add manage-gated audit trail page
rlorenzo 75828ee
feat(scheduler): add per-week schedule history modal
rlorenzo c9ec4a7
refactor(effort): align the audit list with the scheduler audit trail
rlorenzo 215377a
fix(scheduler): keep week-history dialog header stationary while paging
rlorenzo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
97 changes: 97 additions & 0 deletions
97
VueApp/src/ClinicalScheduler/__tests__/audit-log-page-access.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import { usePermissionsStore } from "../stores/permissions" | ||
| import { AuditLogService } from "../services/audit-log-service" | ||
| import { RotationService } from "../services/rotation-service" | ||
| import { PageDataService } from "../services/page-data-service" | ||
| import AuditLogPage from "../pages/AuditLogPage.vue" | ||
| import { | ||
| setupTest, | ||
| createTestWrapper, | ||
| waitForAsync, | ||
| createMockPermissionsStore, | ||
| createMockUserPermissions, | ||
| } from "./test-utils" | ||
| import type { UserPermissions } from "../types" | ||
|
|
||
| type MockPermissionsStore = Omit<ReturnType<typeof createMockPermissionsStore>, "userPermissions"> & { | ||
| hasManagePermission?: boolean | ||
| userPermissions: UserPermissions | null | ||
| } | ||
|
|
||
| // Mock the permissions store and the services the page calls | ||
| vi.mock("../stores/permissions") | ||
| vi.mock("../services/audit-log-service") | ||
| vi.mock("../services/rotation-service") | ||
| vi.mock("../services/page-data-service") | ||
|
|
||
| // Stub child components that hit the network or rely on named routes | ||
| vi.mock("../components/AccessDeniedCard.vue", () => ({ | ||
| default: { | ||
| name: "AccessDeniedCard", | ||
| props: ["message", "subtitle"], | ||
| template: '<div class="no-access-card"><div>Access Denied</div><div>{{message}}</div></div>', | ||
| }, | ||
| })) | ||
| vi.mock("../components/SchedulerNavigation.vue", () => ({ | ||
| default: { name: "SchedulerNavigation", template: "<div class='scheduler-nav'></div>" }, | ||
| })) | ||
|
|
||
| describe("auditLogPage - Access Control", () => { | ||
| let mockPermissionsStore: MockPermissionsStore = {} as never | ||
| let router: any = {} | ||
|
|
||
| beforeEach(() => { | ||
| const { router: testRouter, mockStore } = setupTest() | ||
| router = testRouter | ||
| mockPermissionsStore = mockStore | ||
| vi.mocked(usePermissionsStore).mockReturnValue(mockPermissionsStore as any) | ||
| vi.mocked(AuditLogService.getAuditLog).mockResolvedValue({ result: [], success: true, errors: [] }) | ||
| vi.mocked(AuditLogService.getModifiers).mockResolvedValue({ result: [], success: true, errors: [] }) | ||
| vi.mocked(AuditLogService.getPersons).mockResolvedValue({ result: [], success: true, errors: [] }) | ||
| vi.mocked(AuditLogService.getTerms).mockResolvedValue({ result: [], success: true, errors: [] }) | ||
| vi.mocked(RotationService.getRotations).mockResolvedValue({ result: [], success: true, errors: [] }) | ||
| vi.mocked(PageDataService.getPageData).mockResolvedValue({ currentGradYear: 2026, availableGradYears: [2026] }) | ||
| }) | ||
|
|
||
| it("shows access denied when user lacks the manage permission", () => { | ||
| mockPermissionsStore.hasManagePermission = false | ||
| mockPermissionsStore.userPermissions = createMockUserPermissions() | ||
|
|
||
| const wrapper = createTestWrapper({ component: AuditLogPage, router }) | ||
|
|
||
| expect(wrapper.find(".no-access-card").exists()).toBeTruthy() | ||
| // The results table (and its "Modified By" column) only renders for managers. | ||
| expect(wrapper.text()).not.toContain("Modified By") | ||
| }) | ||
|
|
||
| it("shows the audit trail and loads filter options for managers", async () => { | ||
| mockPermissionsStore.hasManagePermission = true | ||
| mockPermissionsStore.userPermissions = createMockUserPermissions({ | ||
| permissions: { | ||
| hasAdminPermission: false, | ||
| hasManagePermission: true, | ||
| hasEditClnSchedulesPermission: false, | ||
| hasEditOwnSchedulePermission: false, | ||
| servicePermissions: {}, | ||
| editableServiceCount: 0, | ||
| }, | ||
| }) | ||
|
|
||
| const wrapper = createTestWrapper({ component: AuditLogPage, router }) | ||
| await waitForAsync() | ||
|
|
||
| expect(wrapper.find(".no-access-card").exists()).toBeFalsy() | ||
| expect(wrapper.text()).toContain("Modified By") | ||
| expect(RotationService.getRotations).toHaveBeenCalledWith() | ||
| expect(AuditLogService.getModifiers).toHaveBeenCalledWith() | ||
| }) | ||
|
|
||
| it("initializes the permissions store on mount when not yet loaded", async () => { | ||
| mockPermissionsStore.hasManagePermission = true | ||
| mockPermissionsStore.userPermissions = null | ||
|
|
||
| createTestWrapper({ component: AuditLogPage, router }) | ||
| await waitForAsync() | ||
|
|
||
| expect(mockPermissionsStore.initialize).toHaveBeenCalledWith() | ||
| }) | ||
| }) |
66 changes: 66 additions & 0 deletions
66
VueApp/src/ClinicalScheduler/__tests__/audit-log-results-table.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import AuditLogResultsTable from "../components/AuditLogResultsTable.vue" | ||
| import StatusBadge from "@/components/StatusBadge.vue" | ||
| import { createTestWrapper } from "./test-utils" | ||
| import type { AuditLogEntry } from "../types/audit-types" | ||
|
|
||
| function makeEntry(overrides: Partial<AuditLogEntry> = {}): AuditLogEntry { | ||
| return { | ||
| scheduleAuditId: 1, | ||
| area: "Clinicians", | ||
| mothraId: "abc123", | ||
| personName: "Dr. Example", | ||
| action: "Added to rotation", | ||
| rotationId: 5, | ||
| rotationName: "Cardiology", | ||
| weekId: 10, | ||
| weekNum: 3, | ||
| weekStart: "2026-01-05", | ||
| term: "Spring 2026", | ||
| modifiedBy: "mgr456", | ||
| modifiedByName: "Manager Person", | ||
| timeStamp: "2026-01-06T08:30:00", | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| function mountTable(entries: AuditLogEntry[], isLoading = false) { | ||
| return createTestWrapper({ | ||
| component: AuditLogResultsTable, | ||
| props: { entries, isLoading }, | ||
| }) | ||
| } | ||
|
|
||
| describe("auditLogResultsTable", () => { | ||
| it("renders audit entries", () => { | ||
| expect.assertions(4) | ||
|
|
||
| const wrapper = mountTable([makeEntry()]) | ||
|
|
||
| expect(wrapper.text()).toContain("Dr. Example") | ||
| expect(wrapper.text()).toContain("Added to rotation") | ||
| expect(wrapper.text()).toContain("Cardiology") | ||
| expect(wrapper.text()).toContain("Manager Person") | ||
| }) | ||
|
|
||
| it("renders one row per entry", () => { | ||
| expect.assertions(3) | ||
|
|
||
| const wrapper = mountTable([ | ||
| makeEntry(), | ||
| makeEntry({ scheduleAuditId: 2, personName: "Dr. Second", action: "Removed from rotation" }), | ||
| ]) | ||
|
|
||
| // One action badge renders per entry across every responsive table variant. | ||
| expect(wrapper.findAllComponents(StatusBadge)).toHaveLength(2) | ||
| expect(wrapper.text()).toContain("Dr. Second") | ||
| expect(wrapper.text()).toContain("Removed from rotation") | ||
| }) | ||
|
|
||
| it("marks the table as loading", () => { | ||
| expect.assertions(1) | ||
|
|
||
| const wrapper = mountTable([], true) | ||
|
|
||
| expect(wrapper.findComponent({ name: "QTable" }).props("loading")).toBeTruthy() | ||
| }) | ||
| }) | ||
153 changes: 153 additions & 0 deletions
153
VueApp/src/ClinicalScheduler/__tests__/week-history-content.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import WeekHistoryContent from "../components/WeekHistoryContent.vue" | ||
| import { createTestWrapper } from "./test-utils" | ||
| import { useDateFunctions } from "@/composables/DateFunctions" | ||
| import type { AuditLogEntry } from "../types/audit-types" | ||
|
|
||
| function makeEntry(overrides: Partial<AuditLogEntry> = {}): AuditLogEntry { | ||
| return { | ||
| scheduleAuditId: 1, | ||
| area: "Clinicians", | ||
| mothraId: "abc123", | ||
| personName: "Dr. Example", | ||
| action: "Added to rotation", | ||
| rotationId: 5, | ||
| rotationName: "Cardiology", | ||
| weekId: 10, | ||
| weekNum: 3, | ||
| weekStart: "2026-01-05", | ||
| term: "Spring 2026", | ||
| modifiedBy: "mgr456", | ||
| modifiedByName: "Manager Person", | ||
| timeStamp: "2026-01-06T08:30:00", | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| function mountContent(props: Record<string, unknown> = {}) { | ||
| return createTestWrapper({ | ||
| component: WeekHistoryContent, | ||
| props: { | ||
| titleId: "week-history-title-test", | ||
| viewMode: "rotation", | ||
| weekNumber: 3, | ||
| weekDateStart: "2026-01-05", | ||
| contextLabel: "Cardiology", | ||
| entries: [], | ||
| isLoading: false, | ||
| error: null, | ||
| ...props, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| describe("weekHistoryContent", () => { | ||
| it("shows skeleton rows while loading", () => { | ||
| expect.assertions(2) | ||
|
|
||
| const wrapper = mountContent({ isLoading: true }) | ||
|
|
||
| expect(wrapper.findAll(".week-history__skeleton-row")).toHaveLength(3) | ||
| expect(wrapper.attributes("aria-busy")).toBe("true") | ||
| }) | ||
|
|
||
| it("shows the error state and emits retry", async () => { | ||
| expect.assertions(2) | ||
|
|
||
| const wrapper = mountContent({ error: "Failed to load the audit trail" }) | ||
|
|
||
| expect(wrapper.find("[role='alert']").text()).toContain("Failed to load the audit trail") | ||
|
|
||
| await wrapper.find("[role='alert'] button").trigger("click") | ||
|
|
||
| expect(wrapper.emitted("retry")).toHaveLength(1) | ||
| }) | ||
|
|
||
| it("shows an empty state when there are no entries", () => { | ||
| expect.assertions(1) | ||
|
|
||
| const wrapper = mountContent({ entries: [] }) | ||
|
|
||
| expect(wrapper.text()).toContain("No audit entries for this week.") | ||
| }) | ||
|
|
||
| it("lists entries with the clinician as subject in rotation view", () => { | ||
| expect.assertions(3) | ||
|
|
||
| const wrapper = mountContent({ viewMode: "rotation", entries: [makeEntry()] }) | ||
|
|
||
| expect(wrapper.find(".week-history__subject").text()).toBe("Dr. Example") | ||
| expect(wrapper.text()).toContain("Added to rotation") | ||
| expect(wrapper.text()).toContain("Manager Person") | ||
| }) | ||
|
|
||
| it("lists entries with the rotation as subject in clinician view", () => { | ||
| expect.assertions(1) | ||
|
|
||
| const wrapper = mountContent({ viewMode: "clinician", entries: [makeEntry()] }) | ||
|
|
||
| expect(wrapper.find(".week-history__subject").text()).toBe("Cardiology") | ||
| }) | ||
|
|
||
| it("labels the week navigation with the current week and date", () => { | ||
| expect.assertions(2) | ||
|
|
||
| const { formatDate } = useDateFunctions() | ||
| const wrapper = mountContent({ weekNumber: 3, weekDateStart: "2026-01-05" }) | ||
|
|
||
| const label = wrapper.find(".week-history__nav-label").text() | ||
| expect(label).toContain("Week 3") | ||
| expect(label).toContain(formatDate("2026-01-05")) | ||
| }) | ||
|
|
||
| it("emits prev/next when the enabled nav controls are clicked", async () => { | ||
| expect.assertions(2) | ||
|
|
||
| const wrapper = mountContent({ canPrev: true, canNext: true }) | ||
|
|
||
| await wrapper.find("[aria-label='Previous week']").trigger("click") | ||
| await wrapper.find("[aria-label='Next week']").trigger("click") | ||
|
|
||
| expect(wrapper.emitted("prev")).toHaveLength(1) | ||
| expect(wrapper.emitted("next")).toHaveLength(1) | ||
| }) | ||
|
|
||
| it("disables nav controls at the schedule ends", () => { | ||
| expect.assertions(2) | ||
|
|
||
| const wrapper = mountContent({ canPrev: false, canNext: false }) | ||
|
|
||
| expect(wrapper.find("[aria-label='Previous week']").classes()).toContain("disabled") | ||
| expect(wrapper.find("[aria-label='Next week']").classes()).toContain("disabled") | ||
| }) | ||
|
|
||
| it("shows skeleton rows only on the initial load (no entries yet)", () => { | ||
| expect.assertions(2) | ||
|
|
||
| const wrapper = mountContent({ isLoading: true, entries: [] }) | ||
|
|
||
| expect(wrapper.findAll(".week-history__skeleton-row")).toHaveLength(3) | ||
| expect(wrapper.find(".week-history__list").exists()).toBeFalsy() | ||
| }) | ||
|
|
||
| it("keeps the current rows (dimmed) instead of a skeleton while paging weeks", () => { | ||
| expect.assertions(3) | ||
|
|
||
| // Entries already present while loading = paging to another week | ||
| const wrapper = mountContent({ isLoading: true, entries: [makeEntry()] }) | ||
|
|
||
| expect(wrapper.findAll(".week-history__skeleton-row")).toHaveLength(0) | ||
| const list = wrapper.find(".week-history__list") | ||
| expect(list.exists()).toBeTruthy() | ||
| expect(list.classes()).toContain("week-history__list--loading") | ||
| }) | ||
|
|
||
| it("shows the loading progress bar only while a fetch is in flight", () => { | ||
| expect.assertions(2) | ||
|
|
||
| const loading = mountContent({ isLoading: true, entries: [makeEntry()] }) | ||
| const idle = mountContent({ isLoading: false, entries: [makeEntry()] }) | ||
|
|
||
| expect(loading.find(".week-history__progress").exists()).toBeTruthy() | ||
| expect(idle.find(".week-history__progress").exists()).toBeFalsy() | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.