Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions WebUI/src/main/ts/publishing/contentListSaveErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,24 @@ export function mapContentListSaveError(err: unknown): string {
}
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}

/**
* Map content-list delete failures to operator-visible text.
* HTTP 400 → bad id; HTTP 403 → forbidden; HTTP 409 → still associated with an
* edition. Plain {@link ApiError} objects are not {@code Error} instances — do
* not use {@code e.message} or a failed delete looks like a generic miss.
*/
export function mapContentListDeleteError(err: unknown): string {
if (isApiError(err)) {
if (err.status === 403) {
return formatApiError(err, message(MSG.PUBLISH_FORBIDDEN));
}
if (err.status === 409) {
return formatApiError(err, "Content list is in use");
}
if (err.status === 400) {
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}
}
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}
10 changes: 8 additions & 2 deletions WebUI/src/main/ts/publishing/design/ContentListEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
type ContentListSummary,
} from "../../api/publishing/designApi";
import { message, MSG } from "../../i18n/message";
import { mapContentListSaveError } from "../contentListSaveErrors";
import {
mapContentListDeleteError,
mapContentListSaveError,
} from "../contentListSaveErrors";
import { useDirtyForm } from "../dirtyFormContext";
import {
buttonStyle,
Expand Down Expand Up @@ -110,11 +113,13 @@ export function ContentListEditor({
return;
}
setSaving(true);
setError(null);
try {
await deleteContentList(contentList.contentListId);
setDirty(false);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : message(MSG.PUBLISH_ERROR));
setError(mapContentListDeleteError(e));
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -209,6 +214,7 @@ export function ContentListEditor({
{contentList?.contentListId && (
<button
type="button"
data-testid="contentlist-delete"
style={buttonStyle}
disabled={saving}
onClick={() => void handleDelete()}
Expand Down
5 changes: 5 additions & 0 deletions WebUI/src/main/ts/publishing/sections/DesignSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ export function DesignSection(): React.ReactElement {
<li key={c.contentListId ?? c.name} style={listItemStyle}>
<button
type="button"
data-testid={
c.contentListId
? `design-content-list-${c.contentListId}`
: "design-content-list-unnamed"
}
style={buttonStyle}
onClick={() => setClEdit(c)}
>
Expand Down
130 changes: 130 additions & 0 deletions WebUI/src/test/ts/publishing/ContentListEditor.delete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2026 Intersoft Data Labs, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContentListEditor } from "@/publishing/design/ContentListEditor";

const deleteContentList = vi.fn();

vi.mock("@/api/publishing/designApi", () => ({
createContentList: vi.fn(),
updateContentList: vi.fn(),
deleteContentList: (...args: unknown[]) => deleteContentList(...args),
}));

const contentList = {
contentListId: "5",
name: "NightCl",
listType: "modern",
};

function renderEditor(onSaved = vi.fn()) {
render(
<ContentListEditor
contentList={contentList}
onSaved={onSaved}
onCancel={() => undefined}
/>,
);
return { onSaved };
}

describe("ContentListEditor delete", () => {
beforeEach(() => {
deleteContentList.mockReset();
vi.restoreAllMocks();
});

it("deletes after confirm and reports saved", async () => {
deleteContentList.mockResolvedValue(undefined);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
const { onSaved } = renderEditor();
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(deleteContentList).toHaveBeenCalledWith("5"));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
});

it("does not delete when confirm is dismissed", () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const { onSaved } = renderEditor();
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(deleteContentList).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
expect(screen.getByTestId("contentlist-editor")).toBeInTheDocument();
});

it("shows 409 conflict and stays on the editor", async () => {
deleteContentList.mockRejectedValue({
status: 409,
statusText: "Conflict",
body: { message: "Content list is in use" },
});
vi.spyOn(window, "confirm").mockReturnValue(true);
const { onSaved } = renderEditor();
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Content list is in use",
);
expect(onSaved).not.toHaveBeenCalled();
expect(screen.getByTestId("contentlist-editor")).toBeInTheDocument();
});

it("shows 403 without treating the delete as success", async () => {
deleteContentList.mockRejectedValue({
status: 403,
statusText: "Forbidden",
body: {
message: "Admin or Designer role required to save a publish edition",
},
});
vi.spyOn(window, "confirm").mockReturnValue(true);
const { onSaved } = renderEditor();
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(await screen.findByRole("alert")).toHaveTextContent(
/Admin or Designer|403|Forbidden/i,
);
expect(onSaved).not.toHaveBeenCalled();
});

it("shows 400 without treating the delete as success", async () => {
deleteContentList.mockRejectedValue({
status: 400,
statusText: "Bad Request",
body: { message: "contentListId is required" },
});
vi.spyOn(window, "confirm").mockReturnValue(true);
const { onSaved } = renderEditor();
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(await screen.findByRole("alert")).toHaveTextContent(
"contentListId is required",
);
expect(onSaved).not.toHaveBeenCalled();
});

it("hides delete on a new content list", () => {
render(
<ContentListEditor
contentList={null}
onSaved={() => undefined}
onCancel={() => undefined}
/>,
);
expect(screen.queryByTestId("contentlist-delete")).not.toBeInTheDocument();
});
});
106 changes: 106 additions & 0 deletions WebUI/src/test/ts/publishing/DesignSection.deleteContentList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Intersoft Data Labs, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DesignSection } from "@/publishing/sections/DesignSection";

const listContentLists = vi.fn();
const deleteContentList = vi.fn();

vi.mock("@/api/home/homeApi", () => ({
fetchSites: vi.fn().mockResolvedValue([{ name: "SiteA", siteId: "1" }]),
}));

vi.mock("@/api/publishing/designApi", () => ({
listEditionsBySite: vi.fn().mockResolvedValue([]),
createEdition: vi.fn(),
updateEdition: vi.fn(),
deleteEdition: vi.fn(),
copyEdition: vi.fn(),
listEditionContentLists: vi.fn().mockResolvedValue([]),
listContentLists: (...args: unknown[]) => listContentLists(...args),
createContentList: vi.fn(),
updateContentList: vi.fn(),
deleteContentList: (...args: unknown[]) => deleteContentList(...args),
listContexts: vi.fn().mockResolvedValue([]),
associateContentList: vi.fn(),
disassociateContentList: vi.fn(),
listDeliveryTypes: vi.fn().mockResolvedValue([]),
listSchemesForContext: vi.fn().mockResolvedValue([]),
listDesignSites: vi
.fn()
.mockResolvedValue([{ siteId: "1", name: "S1", folderRoot: "//Sites/S1" }]),
listSiteProperties: vi.fn().mockResolvedValue([]),
deleteSiteProperty: vi.fn(),
putSiteProperty: vi.fn(),
}));

describe("DesignSection delete content list", () => {
beforeEach(() => {
listContentLists.mockReset();
deleteContentList.mockReset();
vi.restoreAllMocks();
});

it("removes a deleted content list from the list", async () => {
let deleted = false;
listContentLists.mockImplementation(async () => {
if (deleted) {
return [];
}
return [{ contentListId: "5", name: "NightCl", listType: "modern" }];
});
deleteContentList.mockImplementation(async () => {
deleted = true;
});
vi.spyOn(window, "confirm").mockReturnValue(true);

render(<DesignSection />);
fireEvent.click(screen.getByRole("tab", { name: /Content lists/i }));
fireEvent.click(await screen.findByTestId("design-content-list-5"));
fireEvent.click(screen.getByTestId("contentlist-delete"));

expect(await screen.findByText("No content lists.")).toBeInTheDocument();
await waitFor(() => expect(deleteContentList).toHaveBeenCalledWith("5"));
expect(screen.queryByTestId("design-content-list-5")).not.toBeInTheDocument();
});

it("keeps the content list when delete is in use", async () => {
listContentLists.mockResolvedValue([
{ contentListId: "5", name: "NightCl", listType: "modern" },
]);
deleteContentList.mockRejectedValue({
status: 409,
statusText: "Conflict",
body: { message: "Content list is in use" },
});
vi.spyOn(window, "confirm").mockReturnValue(true);

render(<DesignSection />);
fireEvent.click(screen.getByRole("tab", { name: /Content lists/i }));
fireEvent.click(await screen.findByTestId("design-content-list-5"));
fireEvent.click(screen.getByTestId("contentlist-delete"));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Content list is in use",
);
fireEvent.click(screen.getByRole("button", { name: /Back/i }));
expect(await screen.findByTestId("design-content-list-5")).toHaveTextContent(
"NightCl",
);
});
});
45 changes: 44 additions & 1 deletion WebUI/src/test/ts/publishing/contentListSaveErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
*/

import { describe, expect, it } from "vitest";
import { mapContentListSaveError } from "@/publishing/contentListSaveErrors";
import {
mapContentListDeleteError,
mapContentListSaveError,
} from "@/publishing/contentListSaveErrors";

describe("mapContentListSaveError", () => {
it("maps HTTP 403 to forbidden chrome", () => {
Expand All @@ -35,3 +38,43 @@ describe("mapContentListSaveError", () => {
).toBe("Content list name already exists");
});
});

describe("mapContentListDeleteError", () => {
it("maps HTTP 409 to the content-list-in-use message", () => {
expect(
mapContentListDeleteError({
status: 409,
statusText: "Conflict",
body: { message: "Content list is in use" },
}),
).toBe("Content list is in use");
});

it("maps HTTP 409 without a body to content list in use", () => {
expect(
mapContentListDeleteError({ status: 409, statusText: "Conflict", body: {} }),
).toMatch(/Content list is in use|409/i);
});

it("maps HTTP 403 to forbidden chrome", () => {
expect(
mapContentListDeleteError({
status: 403,
statusText: "Forbidden",
body: {
message: "Admin or Designer role required to save a publish edition",
},
}),
).toMatch(/Admin or Designer|403|Forbidden/i);
});

it("maps HTTP 400 body message", () => {
expect(
mapContentListDeleteError({
status: 400,
statusText: "Bad Request",
body: { message: "contentListId is required" },
}),
).toBe("contentListId is required");
});
});
Loading
Loading