-
Notifications
You must be signed in to change notification settings - Fork 0
VPR-59 [3/4] CMS migration: management SPA (frontend) #253
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
Open
rlorenzo
wants to merge
3
commits into
VPR-59-cms-r2-content-nav-backend
Choose a base branch
from
VPR-59-cms-r3-frontend
base: VPR-59-cms-r2-content-nav-backend
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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
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
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,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<string, string | number | null | undefined>) => { | ||
| 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: "<div class='recent-activity-stub' />", | ||
| } | ||
|
|
||
| // 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<ReturnType<typeof mountHome>>): 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) | ||
| }) | ||
| }) |
166 changes: 166 additions & 0 deletions
166
VueApp/src/CMS/__tests__/content-block-edit-diff.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,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<string, string | number | null | undefined>) => { | ||
| 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: "<p>current editor content</p>", | ||
| 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: `<textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`, | ||
| } | ||
|
|
||
| async function mountEdit() { | ||
| const router = createTestRouter() | ||
| await router.push({ name: "CmsContentBlockEdit", params: { id: "7" } }) | ||
| await router.isReady() | ||
| const wrapper = mountCms(ContentBlockEdit, { global: { stubs: { QEditor: qEditorStub } } }, router) | ||
| await flushPromises() | ||
| await flushPromises() | ||
| return wrapper | ||
| } | ||
|
|
||
| function diffButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) { | ||
| return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Diff vs current"))! | ||
| } | ||
| function loadButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) { | ||
| return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Load into editor"))! | ||
| } | ||
| function historySelect(wrapper: Awaited<ReturnType<typeof mountEdit>>) { | ||
| // The version-history select is the one labeled "Select a previous version". | ||
| return wrapper.findAllComponents({ name: "QSelect" }).find((s) => s.props("label") === "Select a previous version")! | ||
| } | ||
|
|
||
| describe("ContentBlockEdit.vue - history selection gating", () => { | ||
| beforeEach(() => routeGet()) | ||
|
|
||
| it("renders the diff/load buttons disabled until a version is selected", async () => { | ||
| const wrapper = await mountEdit() | ||
| expect(diffButton(wrapper).props("disable")).toBeTruthy() | ||
| expect(loadButton(wrapper).props("disable")).toBeTruthy() | ||
| }) | ||
|
|
||
| it("selecting a version only sets selectedHistory and does not fetch that version", async () => { | ||
| const wrapper = await mountEdit() | ||
| const getCallsBefore = mockGet.mock.calls.length | ||
| historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0]) | ||
| await flushPromises() | ||
| // No extra GET for the version content; the buttons just enable. | ||
| expect(mockGet.mock.calls.length).toBe(getCallsBefore) | ||
| expect(diffButton(wrapper).props("disable")).toBeFalsy() | ||
| expect(loadButton(wrapper).props("disable")).toBeFalsy() | ||
| }) | ||
| }) | ||
|
|
||
| describe("ContentBlockEdit.vue - diffAgainstCurrent", () => { | ||
| beforeEach(() => routeGet()) | ||
|
|
||
| it("POSTs the current editor content to the diff endpoint and opens the dialog with the result", async () => { | ||
| mockPost.mockResolvedValue({ | ||
| success: true, | ||
| result: { content: "<ins>new</ins>", hasComparison: true, hasChanges: true }, | ||
| }) | ||
| const wrapper = await mountEdit() | ||
| historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0]) | ||
| await flushPromises() | ||
|
|
||
| await diffButton(wrapper).trigger("click") | ||
| await flushPromises() | ||
|
|
||
| expect(mockPost).toHaveBeenCalledOnce() | ||
| const [url, payload] = mockPost.mock.calls[0]! | ||
| expect(url).toContain("CMS/content/7/history/91/diff") | ||
| // The CURRENT editor content is posted (so unsaved edits are diffed). | ||
| expect(payload).toEqual({ content: "<p>current editor content</p>" }) | ||
|
|
||
| const dialog = wrapper.findComponent({ name: "ContentDiffDialog" }) | ||
| expect(dialog.props("modelValue")).toBeTruthy() | ||
| expect(dialog.props("diffHtml")).toContain("new") | ||
| expect(dialog.props("subtitle")).toContain("to your current editor content") | ||
| }) | ||
|
|
||
| it("notifies and closes the diff dialog when the diff POST fails", async () => { | ||
| mockPost.mockResolvedValue({ success: false, errors: ["diff failed"] }) | ||
| const wrapper = await mountEdit() | ||
| historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0]) | ||
| await flushPromises() | ||
|
|
||
| await diffButton(wrapper).trigger("click") | ||
| await flushPromises() | ||
|
|
||
| const dialog = wrapper.findComponent({ name: "ContentDiffDialog" }) | ||
| expect(dialog.props("modelValue")).toBeFalsy() | ||
| expect(document.body.textContent).toContain("diff failed") | ||
| }) | ||
| }) | ||
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.