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
24 changes: 22 additions & 2 deletions WebUI/src/main/ts/publishing/design/ContextsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import {
} from "../publishing.styles";
import { mapContextSaveError } from "../contextSaveErrors";
import { mapLocationSchemeSaveError } from "../locationSchemeSaveErrors";
import {
mapLocationSchemeDeleteError,
schemesAfterSuccessfulDelete,
} from "../locationSchemeDelete";
import {
buildLocationSchemeCopyBody,
schemesAfterSuccessfulCopy,
Expand Down Expand Up @@ -322,16 +326,30 @@ export function ContextsPanel(): React.ReactElement {
}

async function removeScheme(id: string): Promise<void> {
if (saving) {
return;
}
if (!window.confirm(message(MSG.PUBLISH_CONFIRM_DELETE_DESIGN))) {
return;
}
setError(null);
setSaving(true);
const previous = schemes;
try {
await deleteScheme(id);
let refreshed: LocationSchemeSummary[] | null = null;
if (selected) {
setSchemes(await listSchemesForContext(selected));
try {
refreshed = await listSchemesForContext(selected);
} catch {
refreshed = null;
}
}
setSchemes(schemesAfterSuccessfulDelete(refreshed, id, previous));
} catch (e) {
setError(e instanceof Error ? e.message : message(MSG.PUBLISH_ERROR));
setError(mapLocationSchemeDeleteError(e));
} finally {
setSaving(false);
}
}

Expand Down Expand Up @@ -653,6 +671,8 @@ export function ContextsPanel(): React.ReactElement {
<button
type="button"
style={buttonStyle}
data-testid="location-scheme-delete"
disabled={saving}
onClick={() => void removeScheme(s.schemeId!)}
>
Delete
Expand Down
66 changes: 66 additions & 0 deletions WebUI/src/main/ts/publishing/locationSchemeDelete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 type { LocationSchemeSummary } from "../api/publishing/designApi";
import { formatApiError, isApiError } from "../api/client";
import { message, MSG } from "../i18n/message";

/**
* Map location-scheme DELETE failures to the Design error region.
* HTTP 400 → bad scheme id; HTTP 403 → forbidden; HTTP 409 → the scheme
* cannot be removed. Plain {@link ApiError} objects are not {@code Error}
* instances — do not use {@code e.message} or the shell shows a generic miss
* (or nothing that names the status).
*/
export function mapLocationSchemeDeleteError(err: unknown): string {
if (isApiError(err)) {
if (err.status === 400) {
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}
if (err.status === 403) {
return formatApiError(err, message(MSG.PUBLISH_FORBIDDEN));
}
if (err.status === 409) {
return formatApiError(err, "Location scheme is in use");
}
}
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}

function withoutScheme(
rows: LocationSchemeSummary[],
deletedId: string,
): LocationSchemeSummary[] {
return rows.filter((row) => String(row.schemeId ?? "") !== deletedId);
}

/**
* Rows to show after DELETE succeeds. Drop the deleted id from the refreshed
* list, or from the previous rows when refresh failed. Do not call this when
* DELETE failed — the row must stay.
*/
export function schemesAfterSuccessfulDelete(
refreshed: LocationSchemeSummary[] | null,
deletedId: string,
previous: LocationSchemeSummary[],
): LocationSchemeSummary[] {
const id = deletedId.trim();
if (!id) {
return refreshed ?? previous;
}
return withoutScheme(refreshed ?? previous, id);
}
150 changes: 150 additions & 0 deletions WebUI/src/test/ts/publishing/ContextsPanel.delete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* 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, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContextsPanel } from "@/publishing/design/ContextsPanel";
import { DirtyFormProvider } from "@/publishing/dirtyFormContext";

const deleteScheme = vi.fn();
const listContexts = vi.fn();
const listSchemesForContext = vi.fn();

vi.mock("@/api/publishing/designApi", () => ({
listContexts: (...args: unknown[]) => listContexts(...args),
listSchemesForContext: (...args: unknown[]) => listSchemesForContext(...args),
createContext: vi.fn(),
updateContext: vi.fn(),
deleteContext: vi.fn(),
createScheme: vi.fn(),
updateScheme: vi.fn(),
deleteScheme: (...args: unknown[]) => deleteScheme(...args),
getScheme: vi.fn(),
}));

const article = {
schemeId: "11",
name: "Article",
generator: "gen",
contextId: "3",
};
const keep = {
schemeId: "12",
name: "Keep",
generator: "gen",
contextId: "3",
};

function renderPanel(): void {
render(
<DirtyFormProvider>
<ContextsPanel />
</DirtyFormProvider>,
);
}

function deleteButton(name: string): HTMLElement {
const row = screen.getByRole("button", { name }).closest("li");
if (!row) {
throw new Error(`no row for ${name}`);
}
return within(row).getByTestId("location-scheme-delete");
}

describe("ContextsPanel location scheme delete", () => {
beforeEach(() => {
deleteScheme.mockReset();
listContexts.mockReset();
listSchemesForContext.mockReset();
listContexts.mockResolvedValue([{ contextId: "3", name: "Publish" }]);
listSchemesForContext.mockResolvedValue([article, keep]);
vi.restoreAllMocks();
});

it("removes one scheme only after delete succeeds", async () => {
let resolveDelete: () => void = () => undefined;
deleteScheme.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveDelete = resolve;
}),
);
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel();
await waitFor(() => expect(deleteButton("Article")).toBeTruthy());
const listCallsBefore = listSchemesForContext.mock.calls.length;
fireEvent.click(deleteButton("Article"));
await waitFor(() => expect(deleteScheme).toHaveBeenCalledWith("11"));
expect(deleteScheme).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Article" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Keep" })).toBeTruthy();
expect(listSchemesForContext.mock.calls.length).toBe(listCallsBefore);
listSchemesForContext.mockResolvedValueOnce([keep]);
resolveDelete();
await waitFor(() => expect(screen.queryByRole("button", { name: "Article" })).toBeNull());
expect(screen.getByRole("button", { name: "Keep" })).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});

it("cancel does not call the server", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
renderPanel();
await waitFor(() => expect(deleteButton("Article")).toBeTruthy());
fireEvent.click(deleteButton("Article"));
expect(deleteScheme).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Article" })).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});

it.each([
[400, "Bad Request", "schemeId is required"],
[403, "Forbidden", "Admin or Designer role required"],
[409, "Conflict", "Location scheme is in use"],
])("HTTP %s does not claim the scheme was deleted", async (status, statusText, message) => {
deleteScheme.mockRejectedValue({ status, statusText, body: { message } });
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel();
await waitFor(() => expect(deleteButton("Article")).toBeTruthy());
const listCallsBefore = listSchemesForContext.mock.calls.length;
fireEvent.click(deleteButton("Article"));
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(screen.getByRole("button", { name: "Article" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Keep" })).toBeTruthy();
expect(listSchemesForContext.mock.calls.length).toBe(listCallsBefore);
expect(deleteScheme).toHaveBeenCalledTimes(1);
});

it("drops the row when the refresh after success fails", async () => {
let failRefresh = false;
deleteScheme.mockImplementation(async () => {
failRefresh = true;
});
listSchemesForContext.mockImplementation(async () => {
if (failRefresh) {
throw new Error("list failed");
}
return [article, keep];
});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel();
await waitFor(() => expect(deleteButton("Article")).toBeTruthy());
fireEvent.click(deleteButton("Article"));
await waitFor(() => expect(screen.queryByRole("button", { name: "Article" })).toBeNull());
expect(screen.getByRole("button", { name: "Keep" })).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
});
104 changes: 104 additions & 0 deletions WebUI/src/test/ts/publishing/locationSchemeDelete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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 { describe, expect, it } from "vitest";
import type { LocationSchemeSummary } from "@/api/publishing/designApi";
import {
mapLocationSchemeDeleteError,
schemesAfterSuccessfulDelete,
} from "@/publishing/locationSchemeDelete";

const article: LocationSchemeSummary = {
schemeId: "11",
name: "Article",
};
const keep: LocationSchemeSummary = {
schemeId: "12",
name: "Keep",
};

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

it("maps HTTP 400 without a body to a visible status", () => {
expect(
mapLocationSchemeDeleteError({
status: 400,
statusText: "Bad Request",
body: {},
}),
).toMatch(/400|Bad Request/i);
});

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

it("maps HTTP 409 body message", () => {
expect(
mapLocationSchemeDeleteError({
status: 409,
statusText: "Conflict",
body: { message: "Location scheme is in use" },
}),
).toBe("Location scheme is in use");
});

it("maps HTTP 409 without a body to in-use chrome", () => {
expect(
mapLocationSchemeDeleteError({
status: 409,
statusText: "Conflict",
body: {},
}),
).toMatch(/Location scheme is in use|409/i);
});
});

describe("schemesAfterSuccessfulDelete", () => {
it("drops the deleted id from a refreshed list", () => {
expect(schemesAfterSuccessfulDelete([article, keep], "11", [article, keep])).toEqual([
keep,
]);
});

it("drops the deleted id when refresh failed", () => {
expect(schemesAfterSuccessfulDelete(null, "11", [article, keep])).toEqual([keep]);
});

it("drops the deleted id when refresh still returns it", () => {
expect(schemesAfterSuccessfulDelete([article, keep], "11", [article])).toEqual([keep]);
});

it("leaves the previous rows when the deleted id is blank", () => {
expect(schemesAfterSuccessfulDelete(null, " ", [article])).toEqual([article]);
});
});
Loading
Loading