Skip to content

Commit fcef94a

Browse files
committed
VPR-59 merge(cms): CMS migration to Development for TEST deploy
Merge resolution notes: - AuditList.vue: kept the CMS-aligned search row with its Clear Filters button, and adopted Development AuditChangeDetail in all three layouts (desktop included), superseding this branch ChangeDetailDiff, which is removed - Eval router: updated to the renamed @/shared/create-spa-router module (this branch renamed createSpaRouter; Eval landed on Development importing the old name)
2 parents c03d722 + 7852ce9 commit fcef94a

161 files changed

Lines changed: 24006 additions & 1009 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.coderabbit.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ reviews:
4646
languagetool:
4747
enabled: true
4848
level: default
49+
# Biome's strict JSON parser can't read appsettings*.json (ASP.NET parses those as
50+
# JSON-with-comments by design), so it warned on every review touching them. Its lint
51+
# coverage is already provided by CI (oxlint/oxfmt, JSCPD, Fallow, ReSharper, CodeQL).
52+
biome:
53+
enabled: false
4954

5055
chat:
5156
auto_reply: true

VueApp/src/CAHFS/router/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createSpaRouter } from "@/shared/createSpaRouter"
1+
import { createSpaRouter } from "@/shared/create-spa-router"
22
import { routes } from "./routes"
33
import { useRequireLogin } from "@/composables/RequireLogin"
44
import { checkHasOnePermission } from "@/composables/CheckPagePermission"
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import CmsHome from "@/CMS/pages/CmsHome.vue"
2+
import { useUserStore } from "@/store/UserStore"
3+
import { mountCms, flushPromises } from "./test-utils"
4+
5+
// The hub checks the trash for files nearing the purge cutoff (file managers only).
6+
const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(() =>
7+
Promise.resolve({ success: true, result: [] }),
8+
)
9+
vi.mock("@/composables/ViperFetch", () => ({
10+
useFetch: () => ({
11+
get: getMock,
12+
createUrlSearchParams: (obj: Record<string, string | number | null | undefined>) => {
13+
const params = new URLSearchParams()
14+
for (const [k, v] of Object.entries(obj)) {
15+
if (v !== null && v !== undefined) {
16+
params.append(k, v.toString())
17+
}
18+
}
19+
return params
20+
},
21+
}),
22+
}))
23+
24+
function trashedFile(friendlyName: string, purgeOn: string) {
25+
return { fileGuid: `g-${friendlyName}`, friendlyName, purgeOn, deletedOn: "2024-01-01T00:00:00" }
26+
}
27+
28+
function daysFromNow(days: number): string {
29+
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
30+
}
31+
32+
/**
33+
* CmsHome is the permission-gated hub: each tool card (and each action inside a card) only shows
34+
* when the user holds one of its permissions, the recent-activity rail mirrors the manage
35+
* permissions, and users with no CMS access get an explanatory banner instead. RecentActivity is
36+
* stubbed (it fetches its own data); these tests only assert which props gate its panels.
37+
*/
38+
39+
const recentActivityStub = {
40+
name: "RecentActivity",
41+
props: ["showBlocks", "showFiles", "showLeftNavs"],
42+
template: "<div class='recent-activity-stub' />",
43+
}
44+
45+
// MountCms seeds the full CMS admin permission set; narrower personas are applied afterwards
46+
// through the same user store the page reads reactively.
47+
async function mountHome(permissions?: string[]) {
48+
const wrapper = mountCms(CmsHome, { global: { stubs: { RecentActivity: recentActivityStub } } })
49+
if (permissions) {
50+
useUserStore().setPermissions(permissions)
51+
await flushPromises()
52+
}
53+
await flushPromises()
54+
return wrapper
55+
}
56+
57+
function actionLabels(wrapper: Awaited<ReturnType<typeof mountHome>>): string[] {
58+
return wrapper.findAllComponents({ name: "QBtn" }).map((b) => b.props("label") as string)
59+
}
60+
61+
describe("cmsHome.vue - permission-gated sections", () => {
62+
it("shows every tool card and the full activity rail for a CMS admin", async () => {
63+
const wrapper = await mountHome()
64+
65+
// One box per left-nav group, in the nav's order (Link Collections is an action
66+
// inside Content Blocks, mirroring CmsNavMenu.cs).
67+
// h2 text includes the leading icon ligature name in happy-dom, hence stringContaining.
68+
expect(wrapper.findAll("h2").map((h) => h.text())).toStrictEqual([
69+
expect.stringContaining("Content Blocks"),
70+
expect.stringContaining("Files"),
71+
expect.stringContaining("Left Navigation"),
72+
])
73+
expect(actionLabels(wrapper)).toContain("Manage Link Collections")
74+
const rail = wrapper.findComponent({ name: "RecentActivity" })
75+
expect(rail.props("showBlocks")).toBeTruthy()
76+
expect(rail.props("showFiles")).toBeTruthy()
77+
expect(rail.props("showLeftNavs")).toBeTruthy()
78+
})
79+
80+
it("shows a create-only user just the Content Blocks card with only the Add action", async () => {
81+
const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.CreateContentBlock"])
82+
83+
expect(wrapper.text()).toContain("Content Blocks")
84+
expect(wrapper.text()).not.toContain("Link Collections")
85+
expect(wrapper.text()).not.toContain("Left Navigation")
86+
// Manage/History require ManageContentBlocks even inside a visible section.
87+
expect(actionLabels(wrapper)).toStrictEqual(["Add Content Block"])
88+
// No manage permission at all: the activity rail is hidden entirely.
89+
expect(wrapper.findComponent({ name: "RecentActivity" }).exists()).toBeFalsy()
90+
})
91+
92+
it("shows a files-only user the Files card, including the pre-filtered Trash deep-link", async () => {
93+
const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.AllFiles"])
94+
95+
expect(actionLabels(wrapper)).toStrictEqual(["Manage Files", "Add File", "Audit Trail", "Trash"])
96+
const addFile = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Add File")!
97+
expect(addFile.props("to")).toStrictEqual({ name: "CmsFiles", query: { upload: "1" } })
98+
const trash = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Trash")!
99+
expect(trash.props("to")).toStrictEqual({ name: "CmsFiles", query: { status: "deleted" } })
100+
101+
const rail = wrapper.findComponent({ name: "RecentActivity" })
102+
expect(rail.props("showFiles")).toBeTruthy()
103+
expect(rail.props("showBlocks")).toBeFalsy()
104+
expect(rail.props("showLeftNavs")).toBeFalsy()
105+
})
106+
107+
it("warns file managers when trashed files purge within a week, linking to the Trash", async () => {
108+
getMock.mockResolvedValueOnce({
109+
success: true,
110+
result: [
111+
trashedFile("soon.pdf", daysFromNow(2)),
112+
trashedFile("soon2.pdf", daysFromNow(6)),
113+
trashedFile("later.pdf", daysFromNow(20)),
114+
],
115+
})
116+
const wrapper = await mountHome()
117+
118+
expect(wrapper.text()).toContain("2 trashed files will be permanently deleted within 7 days.")
119+
expect(wrapper.find("a[href*='status=deleted']").exists()).toBeTruthy()
120+
const trashFetch = getMock.mock.calls
121+
.map((c) => c[0] as unknown as string)
122+
.find((u) => u.includes("status=deleted"))!
123+
expect(trashFetch).toContain("sortBy=deletedOn")
124+
expect(trashFetch).toContain("descending=false")
125+
})
126+
127+
it("shows no purge warning when nothing in the trash purges soon", async () => {
128+
getMock.mockResolvedValueOnce({ success: true, result: [trashedFile("later.pdf", daysFromNow(20))] })
129+
const wrapper = await mountHome()
130+
131+
expect(wrapper.text()).not.toContain("permanently deleted within")
132+
})
133+
134+
it("tells a user with no CMS tool permissions that they have no access", async () => {
135+
const wrapper = await mountHome(["SVMSecure.CMS"])
136+
137+
expect(wrapper.text()).toContain("Your account does not have access to any CMS tools.")
138+
expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0)
139+
})
140+
})
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue"
2+
import { mountCms, flushPromises, createTestRouter } from "./test-utils"
3+
4+
/**
5+
* Diff additions to ContentBlockEdit: selecting a version only sets selectedHistory (it no longer
6+
* auto-loads), the "Diff vs current" / "Load into editor" buttons are disabled until a version is
7+
* selected, and diffAgainstCurrent POSTs the editor's CURRENT content to the diff endpoint, opens
8+
* ContentDiffDialog with the returned HTML, and notifies + closes on failure. Mock ViperFetch.
9+
*/
10+
11+
const mockGet = vi.fn<(...args: unknown[]) => unknown>()
12+
const mockPost = vi.fn<(...args: unknown[]) => unknown>()
13+
const mockPut = vi.fn<(...args: unknown[]) => unknown>()
14+
vi.mock("@/composables/ViperFetch", () => ({
15+
useFetch: () => ({
16+
get: (...args: unknown[]) => mockGet(...args),
17+
post: (...args: unknown[]) => mockPost(...args),
18+
put: (...args: unknown[]) => mockPut(...args),
19+
createUrlSearchParams: (obj: Record<string, string | number | null | undefined>) => {
20+
const params = new URLSearchParams()
21+
for (const [k, v] of Object.entries(obj)) {
22+
if (v !== null && v !== undefined) {
23+
params.append(k, v.toString())
24+
}
25+
}
26+
return params
27+
},
28+
}),
29+
}))
30+
31+
const BLOCK = {
32+
contentBlockId: 7,
33+
content: "<p>current editor content</p>",
34+
title: "Welcome",
35+
system: "Viper",
36+
application: null,
37+
page: null,
38+
viperSectionPath: null,
39+
blockOrder: 1,
40+
friendlyName: "welcome",
41+
allowPublicAccess: false,
42+
modifiedOn: "2024-03-01T12:00:00",
43+
modifiedBy: "editor",
44+
deletedOn: null,
45+
permissions: [],
46+
files: [],
47+
}
48+
49+
const HISTORY = [
50+
{ contentHistoryId: 91, modifiedOn: "2024-02-01T10:00:00", modifiedBy: "bob" },
51+
{ contentHistoryId: 92, modifiedOn: "2024-01-01T09:00:00", modifiedBy: "amy" },
52+
]
53+
54+
function routeGet() {
55+
mockGet.mockReset()
56+
mockPost.mockReset()
57+
mockGet.mockImplementation((...args: unknown[]) => {
58+
const url = args[0] as string
59+
if (url.includes("/section-paths")) {
60+
return Promise.resolve({ success: true, result: [] })
61+
}
62+
if (url.includes("/history")) {
63+
return Promise.resolve({ success: true, result: HISTORY })
64+
}
65+
// The single-block load (.../content/7).
66+
return Promise.resolve({ success: true, result: { ...BLOCK } })
67+
})
68+
}
69+
70+
// QEditor relies on document.execCommand and rich-text DOM that happy-dom lacks; stub it with a
71+
// minimal v-model textarea so block.content still binds without exercising the real editor.
72+
const qEditorStub = {
73+
name: "QEditor",
74+
props: ["modelValue"],
75+
emits: ["update:modelValue"],
76+
methods: {
77+
getContentEl() {
78+
return null
79+
},
80+
},
81+
template: `<textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
82+
}
83+
84+
async function mountEdit() {
85+
const router = createTestRouter()
86+
await router.push({ name: "CmsContentBlockEdit", params: { id: "7" } })
87+
await router.isReady()
88+
const wrapper = mountCms(ContentBlockEdit, { global: { stubs: { QEditor: qEditorStub } } }, router)
89+
await flushPromises()
90+
await flushPromises()
91+
return wrapper
92+
}
93+
94+
function diffButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
95+
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Diff vs current"))!
96+
}
97+
function loadButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
98+
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Load into editor"))!
99+
}
100+
function historySelect(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
101+
// The version-history select is the one labeled "Select a previous version".
102+
return wrapper.findAllComponents({ name: "QSelect" }).find((s) => s.props("label") === "Select a previous version")!
103+
}
104+
105+
describe("ContentBlockEdit.vue - history selection gating", () => {
106+
beforeEach(() => routeGet())
107+
108+
it("renders the diff/load buttons disabled until a version is selected", async () => {
109+
const wrapper = await mountEdit()
110+
expect(diffButton(wrapper).props("disable")).toBeTruthy()
111+
expect(loadButton(wrapper).props("disable")).toBeTruthy()
112+
})
113+
114+
it("selecting a version only sets selectedHistory and does not fetch that version", async () => {
115+
const wrapper = await mountEdit()
116+
const getCallsBefore = mockGet.mock.calls.length
117+
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
118+
await flushPromises()
119+
// No extra GET for the version content; the buttons just enable.
120+
expect(mockGet.mock.calls.length).toBe(getCallsBefore)
121+
expect(diffButton(wrapper).props("disable")).toBeFalsy()
122+
expect(loadButton(wrapper).props("disable")).toBeFalsy()
123+
})
124+
})
125+
126+
describe("ContentBlockEdit.vue - diffAgainstCurrent", () => {
127+
beforeEach(() => routeGet())
128+
129+
it("POSTs the current editor content to the diff endpoint and opens the dialog with the result", async () => {
130+
mockPost.mockResolvedValue({
131+
success: true,
132+
result: { content: "<ins>new</ins>", hasComparison: true, hasChanges: true },
133+
})
134+
const wrapper = await mountEdit()
135+
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
136+
await flushPromises()
137+
138+
await diffButton(wrapper).trigger("click")
139+
await flushPromises()
140+
141+
expect(mockPost).toHaveBeenCalledOnce()
142+
const [url, payload] = mockPost.mock.calls[0]!
143+
expect(url).toContain("CMS/content/7/history/91/diff")
144+
// The CURRENT editor content is posted (so unsaved edits are diffed).
145+
expect(payload).toEqual({ content: "<p>current editor content</p>" })
146+
147+
const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
148+
expect(dialog.props("modelValue")).toBeTruthy()
149+
expect(dialog.props("diffHtml")).toContain("new")
150+
expect(dialog.props("subtitle")).toContain("to your current editor content")
151+
})
152+
153+
it("notifies and closes the diff dialog when the diff POST fails", async () => {
154+
mockPost.mockResolvedValue({ success: false, errors: ["diff failed"] })
155+
const wrapper = await mountEdit()
156+
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
157+
await flushPromises()
158+
159+
await diffButton(wrapper).trigger("click")
160+
await flushPromises()
161+
162+
const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
163+
expect(dialog.props("modelValue")).toBeFalsy()
164+
expect(document.body.textContent).toContain("diff failed")
165+
})
166+
})

0 commit comments

Comments
 (0)