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
14 changes: 14 additions & 0 deletions WebUI/src/main/frontend/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ afterEach(() => {
cleanup();
});

// Vitest 5 + jsdom 30 on the Maven-pinned Node 22.22.0 throws inside
// URL.createObjectURL (Blob has no _buffer). Keep image-field previews testable.
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
const nativeCreateObjectURL = URL.createObjectURL.bind(URL);
URL.createObjectURL = (obj: Blob | MediaSource): string => {
try {
return nativeCreateObjectURL(obj);
} catch {
const token = Math.random().toString(16).slice(2);
return `blob:http://localhost/${token}`;
}
};
}

// jsdom stubs used by axe-core / chart-ish components — avoid unhandled
// "Not implemented" errors that fail the suite with exit code 1.
if (typeof HTMLCanvasElement !== "undefined") {
Expand Down
8 changes: 8 additions & 0 deletions WebUI/src/main/ts/i18n/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ export const MSG = {
PUBLISH_ERROR: "perc.ui.home.modern@Error",
PUBLISH_FORBIDDEN: "perc.ui.publish.modern@Publish Forbidden",
PUBLISH_JOB_NOT_FOUND: "perc.ui.publish.modern@Publish job not found",
PUBLISH_LOG_OPEN_EDITOR: "perc.ui.publish.modern@Open in editor",
PUBLISH_LOG_ITEM_FORBIDDEN:
"perc.ui.publish.modern@You are not allowed to open this item",
PUBLISH_LOG_ITEM_NOT_FOUND: "perc.ui.publish.modern@This item was not found",
PUBLISH_LOG_ITEM_NO_ID:
"perc.ui.publish.modern@This log row has no content id",
PUBLISH_LOG_ITEM_OPEN_FAILED:
"perc.ui.publish.modern@Could not open this item in the editor",
PUBLISH_JOB_STOP_CONFLICT:
"perc.ui.publish.modern@Publish job cannot be stopped",
PUBLISH_EDITION_NAME_CONFLICT:
Expand Down
61 changes: 60 additions & 1 deletion WebUI/src/main/ts/publishing/components/LogDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import {
filterLogItems,
type PublishLogItem,
} from "../logDetails";
import { reserveEditorWindow } from "../../editor/openEditorHost";
import {
openLogItemInEditor,
type OpenLogItemReason,
type OpenLogItemResult,
} from "../openLogItemInEditor";
import {
buttonStyle,
emptyStyle,
Expand All @@ -41,6 +47,24 @@ export interface LogDetailsPanelProps {
status?: string;
};
onClose: () => void;
/** Test seam. Production uses {@link openLogItemInEditor}. */
openItem?: (
contentId: string | number | null | undefined,
reservedWindow?: Window | null,
) => Promise<OpenLogItemResult>;
}

function openFailureMessage(reason: OpenLogItemReason): string {
if (reason === "forbidden") {
return message(MSG.PUBLISH_LOG_ITEM_FORBIDDEN);
}
if (reason === "not_found") {
return message(MSG.PUBLISH_LOG_ITEM_NOT_FOUND);
}
if (reason === "missing_id") {
return message(MSG.PUBLISH_LOG_ITEM_NO_ID);
}
return message(MSG.PUBLISH_LOG_ITEM_OPEN_FAILED);
}

/**
Expand All @@ -50,10 +74,14 @@ export function LogDetailsPanel({
details,
jobSummary,
onClose,
openItem = (contentId, reservedWindow) =>
openLogItemInEditor(contentId, { reservedWindow }),
}: LogDetailsPanelProps): React.ReactElement {
const items = useMemo(() => extractLogItems(details), [details]);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<PublishLogItem | null>(null);
const [opening, setOpening] = useState(false);
const [openError, setOpenError] = useState<string | null>(null);
const filtered = useMemo(
() => filterLogItems(items, filter),
[items, filter],
Expand Down Expand Up @@ -198,10 +226,41 @@ export function LogDetailsPanel({
<p>
<strong>Status:</strong> {String(selected.status ?? "—")}
</p>
{openError && (
<p role="alert" data-testid="publish-log-item-open-error">
{openError}
</p>
)}
<button
type="button"
style={buttonStyle}
data-testid="publish-log-open-editor"
disabled={opening}
onClick={() => {
const reserved = reserveEditorWindow();
setOpening(true);
setOpenError(null);
void openItem(selected.contentid, reserved)
.then((result) => {
if (!result.ok) {
setOpenError(openFailureMessage(result.reason));
}
})
.catch(() => {
setOpenError(message(MSG.PUBLISH_LOG_ITEM_OPEN_FAILED));
})
.finally(() => setOpening(false));
}}
>
{message(MSG.PUBLISH_LOG_OPEN_EDITOR)}
</button>
<button
type="button"
style={buttonStyle}
onClick={() => setSelected(null)}
onClick={() => {
setSelected(null);
setOpenError(null);
}}
>
{message(MSG.PUBLISH_BACK)}
</button>
Expand Down
107 changes: 107 additions & 0 deletions WebUI/src/main/ts/publishing/openLogItemInEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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.
*/

/**
* Open a publish-log item row in the React content editor (#4766).
*
* <p>Probes {@code GET …/itemmanagement/item/fields/{id}} before opening a
* window. HTTP 403 and 404 are failures — they must not look like a successful
* open.</p>
*/

import { isApiError } from "../api/client";
import { parseExplorerContentId } from "../api/contentExplorer/pathItemId";
import { fetchItemEditorFields } from "../editor/itemFieldsApi";
import {
closeReservedWindow,
openEditorHost,
type OpenEditorHostDeps,
} from "../editor/openEditorHost";

export type OpenLogItemReason =
| "opened"
| "missing_id"
| "forbidden"
| "not_found"
| "failed";

export interface OpenLogItemResult {
ok: boolean;
reason: OpenLogItemReason;
}

export interface OpenLogItemDeps {
probe?: (itemId: string) => Promise<unknown>;
/**
* Popup reserved on the click gesture ({@code about:blank}) so the later
* navigation is not blocked after the fields probe.
*/
reservedWindow?: Window | null;
open?: (
input: { id: number; mode: "edit" },
deps?: OpenEditorHostDeps,
) => Promise<boolean>;
}

/** Map REST failures so 403/404 are not a successful editor open. */
export function logItemOpenErrorReason(err: unknown): OpenLogItemReason {
if (isApiError(err)) {
if (err.status === 403) {
return "forbidden";
}
if (err.status === 404) {
return "not_found";
}
}
return "failed";
}

/**
* Probe the content id, then open the editor host. Missing ids and 403/404
* never open a window.
*/
export async function openLogItemInEditor(
contentId: string | number | null | undefined,
deps: OpenLogItemDeps = {},
): Promise<OpenLogItemResult> {
const reserved = deps.reservedWindow ?? null;
const id = parseExplorerContentId(contentId ?? undefined);
if (id == null) {
closeReservedWindow(reserved);
return { ok: false, reason: "missing_id" };
}
const probe = deps.probe ?? ((itemId: string) => fetchItemEditorFields(itemId));
try {
await probe(String(id));
} catch (err) {
closeReservedWindow(reserved);
return { ok: false, reason: logItemOpenErrorReason(err) };
}
const open = deps.open ?? openEditorHost;
let opened = false;
try {
opened = await open({ id, mode: "edit" }, { reservedWindow: reserved });
} catch {
closeReservedWindow(reserved);
return { ok: false, reason: "failed" };
}
if (!opened) {
closeReservedWindow(reserved);
return { ok: false, reason: "failed" };
}
return { ok: true, reason: "opened" };
}
111 changes: 111 additions & 0 deletions WebUI/src/test/ts/publishing/openLogItemInEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 { LogDetailsPanel } from "@/publishing/components/LogDetailsPanel";
import {
logItemOpenErrorReason,
openLogItemInEditor,
} from "@/publishing/openLogItemInEditor";

describe("openLogItemInEditor", () => {
it("maps 403 and 404 and does not open a window", async () => {
const open = vi.fn();
const forbidden = await openLogItemInEditor(42, {
probe: async () => {
throw { status: 403, statusText: "Forbidden", body: null };
},
open,
});
expect(forbidden).toEqual({ ok: false, reason: "forbidden" });
expect(open).not.toHaveBeenCalled();

const missing = await openLogItemInEditor(99, {
probe: async () => {
throw { status: 404, statusText: "Not Found", body: null };
},
open,
});
expect(missing).toEqual({ ok: false, reason: "not_found" });
expect(open).not.toHaveBeenCalled();
expect(logItemOpenErrorReason({ status: 500 })).toBe("failed");

const reserved = { closed: false, close: vi.fn() } as unknown as Window;
const closed = await openLogItemInEditor(7, {
reservedWindow: reserved,
probe: async () => {
throw { status: 403, statusText: "Forbidden", body: null };
},
open,
});
expect(closed.reason).toBe("forbidden");
expect((reserved as unknown as { close: () => void }).close).toHaveBeenCalled();
});

it("opens the editor after a successful probe", async () => {
const open = vi.fn().mockResolvedValue(true);
const result = await openLogItemInEditor("42", {
probe: async () => ({ contentId: "42" }),
open,
});
expect(result).toEqual({ ok: true, reason: "opened" });
expect(open).toHaveBeenCalledWith(
{ id: 42, mode: "edit" },
{ reservedWindow: null },
);
});

it("refuses a row with no content id", async () => {
const probe = vi.fn();
const result = await openLogItemInEditor(undefined, { probe });
expect(result.reason).toBe("missing_id");
expect(probe).not.toHaveBeenCalled();
});
});

describe("LogDetailsPanel open in editor", () => {
const details = {
SitePublishItem: [
{
contentid: 42,
status: "Success",
operation: "publish",
fileName: "index.html",
},
],
};

it("shows 404 in the item detail instead of a blank success", async () => {
vi.stubGlobal("open", vi.fn(() => null));
const openItem = vi.fn().mockResolvedValue({
ok: false,
reason: "not_found",
});
render(
<LogDetailsPanel details={details} onClose={() => undefined} openItem={openItem} />,
);
fireEvent.click(screen.getByRole("button", { name: /item details/i }));
fireEvent.click(screen.getByTestId("publish-log-open-editor"));
await waitFor(() => {
expect(screen.getByTestId("publish-log-item-open-error")).toHaveTextContent(
"This item was not found",
);
});
expect(openItem).toHaveBeenCalledWith(42, null);
});
});
25 changes: 25 additions & 0 deletions docs/ai-generated/code-reviews/pr-4780-erlang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Summary

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

## Scope

- Base: origin/main
- Head: HEAD
- Files: 8 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._

Loading
Loading