From 55b37ec4b98992895785634897adc1f11d02cb3d Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 2 Jul 2026 19:26:44 -0700 Subject: [PATCH 1/3] VPR-59 feat(cms): CMS management SPA Slice 3/3 of the restacked CMS migration (tip file states). The Vue 3 management UI: files, content blocks with QEditor and version diffs, left-nav editor, link collections, import and bulk-encrypt tools, the redesigned hub with the recent-activity rail, shared sortable-list / server-table / dialog components, mobile card mode, and the full frontend test suite. Completes the stack; the tree now matches the development branch tip exactly. --- VueApp/src/CAHFS/router/index.ts | 2 +- VueApp/src/CMS/__tests__/cms-home.test.ts | 140 ++++ .../__tests__/content-block-edit-diff.test.ts | 166 ++++ .../__tests__/content-block-edit-save.test.ts | 378 +++++++++ .../__tests__/content-block-history.test.ts | 229 ++++++ .../src/CMS/__tests__/content-blocks.test.ts | 285 +++++++ .../CMS/__tests__/content-diff-dialog.test.ts | 66 ++ .../CMS/__tests__/date-range-filter.test.ts | 52 ++ .../file-form-dialog-conflict.test.ts | 281 +++++++ .../CMS/__tests__/file-form-dialog.test.ts | 281 +++++++ VueApp/src/CMS/__tests__/files.test.ts | 317 ++++++++ .../CMS/__tests__/inline-file-upload.test.ts | 358 +++++++++ .../src/CMS/__tests__/left-nav-edit.test.ts | 118 +++ .../__tests__/left-nav-menu-dialog.test.ts | 100 +++ .../src/CMS/__tests__/left-nav-menus.test.ts | 176 +++++ .../CMS/__tests__/link-collections.test.ts | 300 ++++++++ VueApp/src/CMS/__tests__/link.test.ts | 137 ++++ .../src/CMS/__tests__/modified-stamp.test.ts | 60 ++ .../CMS/__tests__/permission-selector.test.ts | 86 +++ .../src/CMS/__tests__/person-selector.test.ts | 129 ++++ .../src/CMS/__tests__/recent-activity.test.ts | 299 ++++++++ .../__tests__/router-canonicalization.test.ts | 50 ++ VueApp/src/CMS/__tests__/test-utils.ts | 101 +++ VueApp/src/CMS/__tests__/url.test.ts | 107 +++ .../CMS/__tests__/use-server-table.test.ts | 149 ++++ .../__tests__/use-url-filtered-table.test.ts | 133 ++++ VueApp/src/CMS/components/ActivityRow.vue | 136 ++++ VueApp/src/CMS/components/ContentBlock.vue | 39 +- .../src/CMS/components/ContentDiffDialog.vue | 159 ++++ VueApp/src/CMS/components/DateRangeFilter.vue | 33 + .../CMS/components/DeleteRestoreButtons.vue | 37 + VueApp/src/CMS/components/EditButton.vue | 26 + VueApp/src/CMS/components/FileFormDialog.vue | 560 ++++++++++++++ .../src/CMS/components/InlineFileUpload.vue | 344 +++++++++ .../src/CMS/components/LeftNavMenuDialog.vue | 144 ++++ .../components/LeftNavMenuSettingsFields.vue | 83 ++ VueApp/src/CMS/components/Link.vue | 58 +- VueApp/src/CMS/components/LinkCollections.vue | 118 ++- VueApp/src/CMS/components/ListCard.vue | 23 + VueApp/src/CMS/components/ListCardField.vue | 14 + VueApp/src/CMS/components/ModifiedStamp.vue | 39 + VueApp/src/CMS/components/PermissionChips.vue | 62 ++ .../src/CMS/components/PermissionSelector.vue | 61 ++ VueApp/src/CMS/components/PersonSelector.vue | 86 +++ VueApp/src/CMS/components/RecentActivity.vue | 257 +++++++ VueApp/src/CMS/components/StatusIcon.vue | 19 + .../composables/use-content-diff-viewer.ts | 68 ++ .../src/CMS/composables/use-server-table.ts | 88 +++ .../CMS/composables/use-url-filtered-table.ts | 111 +++ VueApp/src/CMS/file-types.ts | 5 + VueApp/src/CMS/pages/BulkEncrypt.vue | 264 +++++++ VueApp/src/CMS/pages/CmsHome.vue | 219 +++++- VueApp/src/CMS/pages/ContentBlockEdit.vue | 726 ++++++++++++++++++ VueApp/src/CMS/pages/ContentBlockHistory.vue | 263 +++++++ VueApp/src/CMS/pages/ContentBlocks.vue | 362 +++++++++ VueApp/src/CMS/pages/FileAuditLog.vue | 220 ++++++ VueApp/src/CMS/pages/Files.vue | 540 +++++++++++++ VueApp/src/CMS/pages/ImportFiles.vue | 341 ++++++++ VueApp/src/CMS/pages/LeftNavEdit.vue | 594 ++++++++++++++ VueApp/src/CMS/pages/LeftNavMenus.vue | 268 +++++++ .../src/CMS/pages/ManageLinkCollections.vue | 694 +++++++---------- VueApp/src/CMS/router/index.ts | 12 +- VueApp/src/CMS/router/routes.ts | 84 +- VueApp/src/CMS/types/index.ts | 172 ++++- VueApp/src/CTS/router/index.ts | 2 +- VueApp/src/ClinicalScheduler/router/index.ts | 2 +- VueApp/src/Computing/router/index.ts | 2 +- .../src/Effort/components/EffortLeftNav.vue | 1 + VueApp/src/Effort/pages/AuditList.vue | 50 +- VueApp/src/Effort/router/index.ts | 2 +- VueApp/src/Students/router/index.ts | 2 +- VueApp/src/components/BreadcrumbHeading.vue | 19 + VueApp/src/components/SortableList.vue | 346 +++++++++ .../__tests__/sortable-list.test.ts | 188 +++++ VueApp/src/composables/ViperFetch.ts | 20 +- .../composables/__tests__/use-reorder.test.ts | 128 +++ VueApp/src/composables/use-reorder.ts | 91 +++ VueApp/src/composables/use-route-focus.ts | 15 +- VueApp/src/config/colors.ts | 3 +- VueApp/src/layouts/LeftNav.vue | 15 +- VueApp/src/layouts/ViperLayout.vue | 8 + .../__tests__/create-spa-router.test.ts | 127 +++ VueApp/src/shared/create-spa-router.ts | 38 + VueApp/src/shared/createSpaRouter.ts | 20 - VueApp/src/styles/base.css | 149 +++- VueApp/src/styles/colors.css | 27 + 86 files changed, 12464 insertions(+), 620 deletions(-) create mode 100644 VueApp/src/CMS/__tests__/cms-home.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-block-edit-save.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-block-history.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-blocks.test.ts create mode 100644 VueApp/src/CMS/__tests__/content-diff-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/date-range-filter.test.ts create mode 100644 VueApp/src/CMS/__tests__/file-form-dialog-conflict.test.ts create mode 100644 VueApp/src/CMS/__tests__/file-form-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/files.test.ts create mode 100644 VueApp/src/CMS/__tests__/inline-file-upload.test.ts create mode 100644 VueApp/src/CMS/__tests__/left-nav-edit.test.ts create mode 100644 VueApp/src/CMS/__tests__/left-nav-menu-dialog.test.ts create mode 100644 VueApp/src/CMS/__tests__/left-nav-menus.test.ts create mode 100644 VueApp/src/CMS/__tests__/link-collections.test.ts create mode 100644 VueApp/src/CMS/__tests__/link.test.ts create mode 100644 VueApp/src/CMS/__tests__/modified-stamp.test.ts create mode 100644 VueApp/src/CMS/__tests__/permission-selector.test.ts create mode 100644 VueApp/src/CMS/__tests__/person-selector.test.ts create mode 100644 VueApp/src/CMS/__tests__/recent-activity.test.ts create mode 100644 VueApp/src/CMS/__tests__/router-canonicalization.test.ts create mode 100644 VueApp/src/CMS/__tests__/test-utils.ts create mode 100644 VueApp/src/CMS/__tests__/url.test.ts create mode 100644 VueApp/src/CMS/__tests__/use-server-table.test.ts create mode 100644 VueApp/src/CMS/__tests__/use-url-filtered-table.test.ts create mode 100644 VueApp/src/CMS/components/ActivityRow.vue create mode 100644 VueApp/src/CMS/components/ContentDiffDialog.vue create mode 100644 VueApp/src/CMS/components/DateRangeFilter.vue create mode 100644 VueApp/src/CMS/components/DeleteRestoreButtons.vue create mode 100644 VueApp/src/CMS/components/EditButton.vue create mode 100644 VueApp/src/CMS/components/FileFormDialog.vue create mode 100644 VueApp/src/CMS/components/InlineFileUpload.vue create mode 100644 VueApp/src/CMS/components/LeftNavMenuDialog.vue create mode 100644 VueApp/src/CMS/components/LeftNavMenuSettingsFields.vue create mode 100644 VueApp/src/CMS/components/ListCard.vue create mode 100644 VueApp/src/CMS/components/ListCardField.vue create mode 100644 VueApp/src/CMS/components/ModifiedStamp.vue create mode 100644 VueApp/src/CMS/components/PermissionChips.vue create mode 100644 VueApp/src/CMS/components/PermissionSelector.vue create mode 100644 VueApp/src/CMS/components/PersonSelector.vue create mode 100644 VueApp/src/CMS/components/RecentActivity.vue create mode 100644 VueApp/src/CMS/components/StatusIcon.vue create mode 100644 VueApp/src/CMS/composables/use-content-diff-viewer.ts create mode 100644 VueApp/src/CMS/composables/use-server-table.ts create mode 100644 VueApp/src/CMS/composables/use-url-filtered-table.ts create mode 100644 VueApp/src/CMS/file-types.ts create mode 100644 VueApp/src/CMS/pages/BulkEncrypt.vue create mode 100644 VueApp/src/CMS/pages/ContentBlockEdit.vue create mode 100644 VueApp/src/CMS/pages/ContentBlockHistory.vue create mode 100644 VueApp/src/CMS/pages/ContentBlocks.vue create mode 100644 VueApp/src/CMS/pages/FileAuditLog.vue create mode 100644 VueApp/src/CMS/pages/Files.vue create mode 100644 VueApp/src/CMS/pages/ImportFiles.vue create mode 100644 VueApp/src/CMS/pages/LeftNavEdit.vue create mode 100644 VueApp/src/CMS/pages/LeftNavMenus.vue create mode 100644 VueApp/src/components/BreadcrumbHeading.vue create mode 100644 VueApp/src/components/SortableList.vue create mode 100644 VueApp/src/components/__tests__/sortable-list.test.ts create mode 100644 VueApp/src/composables/__tests__/use-reorder.test.ts create mode 100644 VueApp/src/composables/use-reorder.ts create mode 100644 VueApp/src/shared/__tests__/create-spa-router.test.ts create mode 100644 VueApp/src/shared/create-spa-router.ts delete mode 100644 VueApp/src/shared/createSpaRouter.ts diff --git a/VueApp/src/CAHFS/router/index.ts b/VueApp/src/CAHFS/router/index.ts index 1ee7424d1..bf695865a 100644 --- a/VueApp/src/CAHFS/router/index.ts +++ b/VueApp/src/CAHFS/router/index.ts @@ -1,4 +1,4 @@ -import { createSpaRouter } from "@/shared/createSpaRouter" +import { createSpaRouter } from "@/shared/create-spa-router" import { routes } from "./routes" import { useRequireLogin } from "@/composables/RequireLogin" import { checkHasOnePermission } from "@/composables/CheckPagePermission" diff --git a/VueApp/src/CMS/__tests__/cms-home.test.ts b/VueApp/src/CMS/__tests__/cms-home.test.ts new file mode 100644 index 000000000..f892de107 --- /dev/null +++ b/VueApp/src/CMS/__tests__/cms-home.test.ts @@ -0,0 +1,140 @@ +import CmsHome from "@/CMS/pages/CmsHome.vue" +import { useUserStore } from "@/store/UserStore" +import { mountCms, flushPromises } from "./test-utils" + +// The hub checks the trash for files nearing the purge cutoff (file managers only). +const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(() => + Promise.resolve({ success: true, result: [] }), +) +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: getMock, + createUrlSearchParams: (obj: Record) => { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + params.append(k, v.toString()) + } + } + return params + }, + }), +})) + +function trashedFile(friendlyName: string, purgeOn: string) { + return { fileGuid: `g-${friendlyName}`, friendlyName, purgeOn, deletedOn: "2024-01-01T00:00:00" } +} + +function daysFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() +} + +/** + * CmsHome is the permission-gated hub: each tool card (and each action inside a card) only shows + * when the user holds one of its permissions, the recent-activity rail mirrors the manage + * permissions, and users with no CMS access get an explanatory banner instead. RecentActivity is + * stubbed (it fetches its own data); these tests only assert which props gate its panels. + */ + +const recentActivityStub = { + name: "RecentActivity", + props: ["showBlocks", "showFiles", "showLeftNavs"], + template: "
", +} + +// MountCms seeds the full CMS admin permission set; narrower personas are applied afterwards +// through the same user store the page reads reactively. +async function mountHome(permissions?: string[]) { + const wrapper = mountCms(CmsHome, { global: { stubs: { RecentActivity: recentActivityStub } } }) + if (permissions) { + useUserStore().setPermissions(permissions) + await flushPromises() + } + await flushPromises() + return wrapper +} + +function actionLabels(wrapper: Awaited>): string[] { + return wrapper.findAllComponents({ name: "QBtn" }).map((b) => b.props("label") as string) +} + +describe("cmsHome.vue - permission-gated sections", () => { + it("shows every tool card and the full activity rail for a CMS admin", async () => { + const wrapper = await mountHome() + + // One box per left-nav group, in the nav's order (Link Collections is an action + // inside Content Blocks, mirroring CmsNavMenu.cs). + // h2 text includes the leading icon ligature name in happy-dom, hence stringContaining. + expect(wrapper.findAll("h2").map((h) => h.text())).toStrictEqual([ + expect.stringContaining("Content Blocks"), + expect.stringContaining("Files"), + expect.stringContaining("Left Navigation"), + ]) + expect(actionLabels(wrapper)).toContain("Manage Link Collections") + const rail = wrapper.findComponent({ name: "RecentActivity" }) + expect(rail.props("showBlocks")).toBeTruthy() + expect(rail.props("showFiles")).toBeTruthy() + expect(rail.props("showLeftNavs")).toBeTruthy() + }) + + it("shows a create-only user just the Content Blocks card with only the Add action", async () => { + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.CreateContentBlock"]) + + expect(wrapper.text()).toContain("Content Blocks") + expect(wrapper.text()).not.toContain("Link Collections") + expect(wrapper.text()).not.toContain("Left Navigation") + // Manage/History require ManageContentBlocks even inside a visible section. + expect(actionLabels(wrapper)).toStrictEqual(["Add Content Block"]) + // No manage permission at all: the activity rail is hidden entirely. + expect(wrapper.findComponent({ name: "RecentActivity" }).exists()).toBeFalsy() + }) + + it("shows a files-only user the Files card, including the pre-filtered Trash deep-link", async () => { + const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.AllFiles"]) + + expect(actionLabels(wrapper)).toStrictEqual(["Manage Files", "Add File", "Audit Trail", "Trash"]) + const addFile = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Add File")! + expect(addFile.props("to")).toStrictEqual({ name: "CmsFiles", query: { upload: "1" } }) + const trash = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Trash")! + expect(trash.props("to")).toStrictEqual({ name: "CmsFiles", query: { status: "deleted" } }) + + const rail = wrapper.findComponent({ name: "RecentActivity" }) + expect(rail.props("showFiles")).toBeTruthy() + expect(rail.props("showBlocks")).toBeFalsy() + expect(rail.props("showLeftNavs")).toBeFalsy() + }) + + it("warns file managers when trashed files purge within a week, linking to the Trash", async () => { + getMock.mockResolvedValueOnce({ + success: true, + result: [ + trashedFile("soon.pdf", daysFromNow(2)), + trashedFile("soon2.pdf", daysFromNow(6)), + trashedFile("later.pdf", daysFromNow(20)), + ], + }) + const wrapper = await mountHome() + + expect(wrapper.text()).toContain("2 trashed files will be permanently deleted within 7 days.") + expect(wrapper.find("a[href*='status=deleted']").exists()).toBeTruthy() + const trashFetch = getMock.mock.calls + .map((c) => c[0] as unknown as string) + .find((u) => u.includes("status=deleted"))! + expect(trashFetch).toContain("sortBy=deletedOn") + expect(trashFetch).toContain("descending=false") + }) + + it("shows no purge warning when nothing in the trash purges soon", async () => { + getMock.mockResolvedValueOnce({ success: true, result: [trashedFile("later.pdf", daysFromNow(20))] }) + const wrapper = await mountHome() + + expect(wrapper.text()).not.toContain("permanently deleted within") + }) + + it("tells a user with no CMS tool permissions that they have no access", async () => { + const wrapper = await mountHome(["SVMSecure.CMS"]) + + expect(wrapper.text()).toContain("Your account does not have access to any CMS tools.") + expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0) + }) +}) diff --git a/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts b/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts new file mode 100644 index 000000000..6d6418450 --- /dev/null +++ b/VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts @@ -0,0 +1,166 @@ +import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue" +import { mountCms, flushPromises, createTestRouter } from "./test-utils" + +/** + * Diff additions to ContentBlockEdit: selecting a version only sets selectedHistory (it no longer + * auto-loads), the "Diff vs current" / "Load into editor" buttons are disabled until a version is + * selected, and diffAgainstCurrent POSTs the editor's CURRENT content to the diff endpoint, opens + * ContentDiffDialog with the returned HTML, and notifies + closes on failure. Mock ViperFetch. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + createUrlSearchParams: (obj: Record) => { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + params.append(k, v.toString()) + } + } + return params + }, + }), +})) + +const BLOCK = { + contentBlockId: 7, + content: "

current editor content

", + title: "Welcome", + system: "Viper", + application: null, + page: null, + viperSectionPath: null, + blockOrder: 1, + friendlyName: "welcome", + allowPublicAccess: false, + modifiedOn: "2024-03-01T12:00:00", + modifiedBy: "editor", + deletedOn: null, + permissions: [], + files: [], +} + +const HISTORY = [ + { contentHistoryId: 91, modifiedOn: "2024-02-01T10:00:00", modifiedBy: "bob" }, + { contentHistoryId: 92, modifiedOn: "2024-01-01T09:00:00", modifiedBy: "amy" }, +] + +function routeGet() { + mockGet.mockReset() + mockPost.mockReset() + mockGet.mockImplementation((...args: unknown[]) => { + const url = args[0] as string + if (url.includes("/section-paths")) { + return Promise.resolve({ success: true, result: [] }) + } + if (url.includes("/history")) { + return Promise.resolve({ success: true, result: HISTORY }) + } + // The single-block load (.../content/7). + return Promise.resolve({ success: true, result: { ...BLOCK } }) + }) +} + +// QEditor relies on document.execCommand and rich-text DOM that happy-dom lacks; stub it with a +// minimal v-model textarea so block.content still binds without exercising the real editor. +const qEditorStub = { + name: "QEditor", + props: ["modelValue"], + emits: ["update:modelValue"], + methods: { + getContentEl() { + return null + }, + }, + template: `