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
2 changes: 2 additions & 0 deletions WebUI/src/main/ts/i18n/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export const MSG = {
"perc.ui.publish.modern@Delivery type name already exists",
PUBLISH_LOCATION_SCHEME_NAME_CONFLICT:
"perc.ui.publish.modern@Location scheme name already exists",
PUBLISH_CONTEXT_NAME_CONFLICT:
"perc.ui.publish.modern@Publishing context name already exists",
PUBLISH_BADCONFIG: "perc.ui.publish.modern@Bad Server Configuration",
PUBLISH_SUCCESS: "perc.ui.publish.title@Publish Request",
PUBLISH_SELECT_SERVER: "perc.ui.publish.title@Server",
Expand Down
35 changes: 35 additions & 0 deletions WebUI/src/main/ts/publishing/contextSaveErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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 { formatApiError, isApiError } from "../api/client";
import { message, MSG } from "../i18n/message";

/**
* Map publishing-context create/update failures to operator-visible text.
* HTTP 403 → forbidden; HTTP 409 → duplicate context name.
*/
export function mapContextSaveError(err: unknown): string {
if (isApiError(err)) {
if (err.status === 403) {
return formatApiError(err, message(MSG.PUBLISH_FORBIDDEN));
}
if (err.status === 409) {
return formatApiError(err, message(MSG.PUBLISH_CONTEXT_NAME_CONFLICT));
}
}
return formatApiError(err, message(MSG.PUBLISH_ERROR));
}
49 changes: 42 additions & 7 deletions WebUI/src/main/ts/publishing/design/ContextsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
primaryButtonStyle,
toolbarStyle,
} from "../publishing.styles";
import { mapContextSaveError } from "../contextSaveErrors";
import { mapLocationSchemeSaveError } from "../locationSchemeSaveErrors";
import { useDirtyForm } from "../dirtyFormContext";
import { normalizeSchemeType } from "./designLegacyTypes";
Expand Down Expand Up @@ -110,9 +111,19 @@ export function ContextsPanel(): React.ReactElement {
function openContextEdit(c: ContextSummary | null): void {
setCtxName(c?.name ?? "");
setCtxDesc(c?.description ?? "");
setError(null);
setDirty(false);
setMode({ kind: "context-edit", context: c });
}

function closeContextEditor(): void {
if (!confirmIfDirty()) {
return;
}
setDirty(false);
setMode({ kind: "list" });
}

async function openSchemeEdit(
s: LocationSchemeSummary | null,
contextId: string,
Expand Down Expand Up @@ -152,24 +163,31 @@ export function ContextsPanel(): React.ReactElement {
}

async function saveContext(): Promise<void> {
if (mode.kind !== "context-edit") {
return;
}
if (!ctxName.trim()) {
setError("Name is required");
return;
}
setError(null);
setSaving(true);
try {
if (mode.kind === "context-edit" && mode.context?.contextId) {
if (mode.context?.contextId) {
await updateContext(mode.context.contextId, {
name: ctxName.trim(),
description: ctxDesc,
});
} else {
await createContext({ name: ctxName.trim(), description: ctxDesc });
}
setDirty(false);
setMode({ kind: "list" });
reloadContexts();
} catch (e) {
setError(e instanceof Error ? e.message : message(MSG.PUBLISH_ERROR));
setError(mapContextSaveError(e));
} finally {
setSaving(false);
}
}

Expand Down Expand Up @@ -265,15 +283,21 @@ export function ContextsPanel(): React.ReactElement {
<input
id="ctx-name"
value={ctxName}
onChange={(e) => setCtxName(e.target.value)}
onChange={(e) => {
setCtxName(e.target.value);
setDirty(true);
}}
/>
</div>
<div style={formRowStyle}>
<label htmlFor="ctx-desc">Description</label>
<input
id="ctx-desc"
value={ctxDesc}
onChange={(e) => setCtxDesc(e.target.value)}
onChange={(e) => {
setCtxDesc(e.target.value);
setDirty(true);
}}
/>
</div>
{error && (
Expand All @@ -282,10 +306,16 @@ export function ContextsPanel(): React.ReactElement {
</p>
)}
<div style={toolbarStyle}>
<button type="button" style={primaryButtonStyle} onClick={() => void saveContext()}>
<button
type="button"
style={primaryButtonStyle}
data-testid="context-save"
disabled={saving}
onClick={() => void saveContext()}
>
{message(MSG.PUBLISH_SAVE)}
</button>
<button type="button" style={buttonStyle} onClick={() => setMode({ kind: "list" })}>
<button type="button" style={buttonStyle} onClick={() => closeContextEditor()}>
{message(MSG.PUBLISH_BACK)}
</button>
</div>
Expand Down Expand Up @@ -443,7 +473,12 @@ export function ContextsPanel(): React.ReactElement {
))}
</select>
</label>
<button type="button" style={buttonStyle} onClick={() => openContextEdit(null)}>
<button
type="button"
style={buttonStyle}
data-testid="design-add-context"
onClick={() => openContextEdit(null)}
>
Add context
</button>
{selected && (
Expand Down
82 changes: 82 additions & 0 deletions WebUI/src/test/ts/publishing/ContextsPanel.contextSave.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 { describe, expect, it, vi } from "vitest";
import { ContextsPanel } from "@/publishing/design/ContextsPanel";
import { DirtyFormProvider } from "@/publishing/dirtyFormContext";

const createContext = 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: (...args: unknown[]) => createContext(...args),
updateContext: vi.fn(),
deleteContext: vi.fn(),
createScheme: vi.fn(),
updateScheme: vi.fn(),
deleteScheme: vi.fn(),
getScheme: vi.fn(),
}));

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

describe("ContextsPanel design context save", () => {
it("creates a context then returns to the list", async () => {
listContexts.mockResolvedValue([]);
listSchemesForContext.mockResolvedValue([]);
createContext.mockResolvedValue({ contextId: "12", name: "NightCtx" });
renderPanel();
await waitFor(() => expect(listContexts).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("design-add-context"));
await waitFor(() => expect(screen.getByTestId("context-editor")).toBeTruthy());
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "NightCtx" },
});
fireEvent.click(screen.getByTestId("context-save"));
await waitFor(() => expect(createContext).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByTestId("context-editor")).toBeNull());
});

it("shows 409 conflict on the context editor", async () => {
listContexts.mockResolvedValue([]);
createContext.mockRejectedValue({
status: 409,
statusText: "Conflict",
body: { message: "Publishing context name already exists" },
});
renderPanel();
await waitFor(() => expect(listContexts).toHaveBeenCalled());
fireEvent.click(screen.getByTestId("design-add-context"));
fireEvent.change(screen.getByLabelText(/Name/i), {
target: { value: "Dup" },
});
fireEvent.click(screen.getByTestId("context-save"));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Publishing context name already exists",
);
});
});
37 changes: 37 additions & 0 deletions WebUI/src/test/ts/publishing/contextSaveErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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 { mapContextSaveError } from "@/publishing/contextSaveErrors";

describe("mapContextSaveError", () => {
it("maps HTTP 403 to forbidden chrome", () => {
expect(
mapContextSaveError({ status: 403, statusText: "Forbidden", body: {} }),
).toMatch(/Forbidden|403/i);
});

it("maps HTTP 409 body message", () => {
expect(
mapContextSaveError({
status: 409,
statusText: "Conflict",
body: { message: "Publishing context name already exists" },
}),
).toBe("Publishing context name already exists");
});
});
49 changes: 49 additions & 0 deletions docs/ai-generated/code-reviews/pr-4727-erlang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!--
Copyright (c) 2026 Intersoft Data Labs, Inc.
Licensed under the Apache License, Version 2.0.
-->

# Erlang review — PR #4727

## Scope

- Persona: erlang 0.1.1
- Persona source: /home/nate/.local/share/mkd/agents/erlang
- Status: mkd-code-review 0.1.18, pack percussion, --gate advisory, --git-base origin/main
- PR: https://github.com/intersoftdatalabs-in/percussioncms/pull/4727
- Base: origin/main (15b6a186085b89ed91520a24d2dc6e7e1f67e8e3)
- Head: a76cd4fe3b31f9d494d490a2b33f4b27e6e8c095
- Files analyzed: 9

## CLI stdout (`mkd-code-review analyze --format markdown`)

## Summary

Machine analysis found **0** finding(s), **0** bug(s).

## Scope

- Base: origin/main
- Head: HEAD
- Files: 9 analyzed
- Persona: erlang 0.1.1
- Persona source: /home/nate/.local/share/mkd/agents/erlang

## Recommendation

approve

## Gate

- Blocking bugs: 0
- May commit/push: yes

## Issues

_No issues._

## Intent / change-class

Publishing Design context save: REST 403/409, ContextsPanel persist + error mapping, Vitest, Playwright, product-docs. Companions present. No rule-file diffs. No non-portable path I/O.

Recommendation: **approve**. May commit/push: yes.
Loading
Loading