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

// 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.
// revokeObjectURL can throw the same way; swallow it so editor tests stay quiet.
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
const nativeCreateObjectURL = URL.createObjectURL.bind(URL);
URL.createObjectURL = (obj: Blob | MediaSource): string => {
Expand All @@ -30,6 +31,14 @@ if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
return `blob:http://localhost/${token}`;
}
};
const nativeRevoke = URL.revokeObjectURL?.bind(URL);
URL.revokeObjectURL = (url: string): void => {
try {
nativeRevoke?.(url);
} catch {
// ignore broken jsdom revoke
}
};
}

// jsdom stubs used by axe-core / chart-ish components — avoid unhandled
Expand Down
158 changes: 158 additions & 0 deletions WebUI/src/main/ts/editor/EditorHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "../api/contentExplorer/itemWorkflowApi";
import { deleteFolderItem, findItemById } from "../api/contentExplorer/pathApi";
import { formatApiError, isSessionRedirectError } from "../api/client";
import { MoveDestinationPickerDialog } from "../contentExplorer/MoveDestinationPickerDialog";
import { parsePositiveInt } from "../assembly/assemblyHostUrl";
import { message } from "../i18n/message";
import { mergeEditorRows, type EditorFieldRow } from "./controlKinds";
Expand All @@ -66,6 +67,14 @@ import {
parseCopyLandingContentId,
type EditorCopyKind,
} from "./editorCopy";
import {
canMoveFromEditor,
cmsFoldersEqual,
editorMoveErrorReason,
loadEditorItemPath,
moveEditorItemToFolder,
parentFolderOfItemPath,
} from "./editorMove";
import {
canRecycleFromEditor,
editorRecycleErrorReason,
Expand Down Expand Up @@ -190,6 +199,10 @@ export interface EditorHostProps {
copyPromotable?: (itemId: string) => Promise<ItemCopyResult>;
/** Test seam: confirm new copy / promotable (defaults to {@code window.confirm}). */
confirmCopy?: (body: string) => boolean;
/** Test seam: pathmanagement item path for the open content id. */
loadItemLocation?: (itemId: string) => Promise<{ path: string }>;
/** Test seam: {@code POST /rest/folders/move/item}. */
moveItem?: (itemPath: string, targetFolderPath: string) => Promise<void>;
/** Test seam: pathmanagement lookup of the open item. */
resolveRecycleTarget?: (itemId: string) => Promise<EditorRecycleTarget>;
/** Test seam: public REST item recycle ({@code DELETE /rest/folders/item}). */
Expand Down Expand Up @@ -403,6 +416,10 @@ export function EditorHost({
copyItem = createNewCopy,
copyPromotable = createPromotableVersion,
confirmCopy,
loadItemLocation = async (itemId: string) => ({
path: await loadEditorItemPath(itemId),
}),
moveItem = moveEditorItemToFolder,
resolveRecycleTarget = findItemById,
recycleItem = deleteFolderItem,
confirmRecycle,
Expand Down Expand Up @@ -454,6 +471,12 @@ export function EditorHost({
const [copyBusy, setCopyBusy] = useState(false);
const [copyErrorKey, setCopyErrorKey] = useState<string | null>(null);
const [copyErrorDetail, setCopyErrorDetail] = useState("");
const [moveOpen, setMoveOpen] = useState(false);
const [moveSourceParent, setMoveSourceParent] = useState("");
const [moveBusy, setMoveBusy] = useState(false);
const [moveDone, setMoveDone] = useState(false);
const [moveErrorKey, setMoveErrorKey] = useState<string | null>(null);
const [moveErrorDetail, setMoveErrorDetail] = useState("");
const [recycleBusy, setRecycleBusy] = useState(false);
const [recycleDone, setRecycleDone] = useState(false);
const [recycleErrorKey, setRecycleErrorKey] = useState<string | null>(null);
Expand Down Expand Up @@ -1136,6 +1159,101 @@ export function EditorHost({
}
}

function moveErrorKeyFor(
reason: ReturnType<typeof editorMoveErrorReason>,
): string {
if (reason === "forbidden") {
return EDITOR_MSG.MOVE_FORBIDDEN;
}
if (reason === "not_found") {
return EDITOR_MSG.MOVE_NOT_FOUND;
}
if (reason === "conflict") {
return EDITOR_MSG.MOVE_CONFLICT;
}
return EDITOR_MSG.MOVE_FAILED;
}

async function handleMoveOpen(): Promise<void> {
if (contentId == null) {
return;
}
if (!canMoveFromEditor(mode)) {
setMoveErrorDetail("");
setMoveErrorKey(EDITOR_MSG.MOVE_UNAVAILABLE);
return;
}
setMoveBusy(true);
setMoveDone(false);
setMoveErrorKey(null);
setMoveErrorDetail("");
try {
const located = await loadItemLocation(String(contentId));
const parent = parentFolderOfItemPath(located.path);
if (!parent) {
setMoveErrorKey(EDITOR_MSG.MOVE_NOT_FOUND);
setMoveErrorDetail("");
return;
}
setMoveSourceParent(parent);
setMoveOpen(true);
} catch (err) {
if (isSessionRedirectError(err)) {
return;
}
const reason = editorMoveErrorReason(err);
setMoveErrorKey(moveErrorKeyFor(reason));
setMoveErrorDetail(formatApiError(err, message(moveErrorKeyFor(reason))));
} finally {
setMoveBusy(false);
}
}

async function handleMovePick(targetFolderPath: string): Promise<void> {
setMoveOpen(false);
if (contentId == null || !canMoveFromEditor(mode)) {
return;
}
const target = targetFolderPath.trim();
if (!target) {
return;
}
if (cmsFoldersEqual(moveSourceParent, target)) {
setMoveErrorDetail("");
setMoveErrorKey(EDITOR_MSG.MOVE_SAME_FOLDER);
return;
}
setMoveBusy(true);
setMoveDone(false);
setMoveErrorKey(null);
setMoveErrorDetail("");
try {
const located = await loadItemLocation(String(contentId));
const source = String(located.path ?? "").trim();
const parent = parentFolderOfItemPath(source);
if (!source || !parent) {
setMoveErrorKey(EDITOR_MSG.MOVE_NOT_FOUND);
return;
}
if (cmsFoldersEqual(parent, target)) {
setMoveErrorKey(EDITOR_MSG.MOVE_SAME_FOLDER);
return;
}
await moveItem(source, target);
setMoveSourceParent(target);
setMoveDone(true);
} catch (err) {
if (isSessionRedirectError(err)) {
return;
}
const reason = editorMoveErrorReason(err);
setMoveErrorKey(moveErrorKeyFor(reason));
setMoveErrorDetail(formatApiError(err, message(moveErrorKeyFor(reason))));
} finally {
setMoveBusy(false);
}
}

function recycleErrorKeyFor(
reason: ReturnType<typeof editorRecycleErrorReason> | "folder",
): string {
Expand Down Expand Up @@ -1497,6 +1615,7 @@ export function EditorHost({
const showPublish = canPublishFromEditor(mode, publishKind);
const showPreview = canPreviewFromEditor(mode, publishKind);
const showCopy = canCopyFromEditor(mode) && contentId != null;
const showMove = canMoveFromEditor(mode) && contentId != null;
const showRecycle = canRecycleFromEditor(mode) && contentId != null;
const showRestore = canRestoreFromEditor(mode) && contentId != null;
const showCreate = canCreateFromEditor(mode);
Expand Down Expand Up @@ -1569,6 +1688,17 @@ export function EditorHost({
{message(publishBusy ? EDITOR_MSG.PUBLISHING : EDITOR_MSG.PUBLISH_NOW)}
</button>
) : null}
{showMove ? (
<button
type="button"
className={styles.button}
data-testid="editor-move"
disabled={moveBusy || loading || payload == null || saving}
onClick={() => void handleMoveOpen()}
>
{message(moveBusy ? EDITOR_MSG.MOVING : EDITOR_MSG.MOVE_TO_FOLDER)}
</button>
) : null}
{showCopy ? (
<button
type="button"
Expand Down Expand Up @@ -1795,6 +1925,25 @@ export function EditorHost({
{copyErrorDetail ? ` ${copyErrorDetail}` : ""}
</div>
) : null}
{moveDone ? (
<div
className={styles.status}
role="status"
data-testid="editor-move-done"
>
{message(EDITOR_MSG.MOVE_DONE)}
</div>
) : null}
{moveErrorKey ? (
<div
className={styles.status}
role="alert"
data-testid="editor-move-error"
>
{message(moveErrorKey)}
{moveErrorDetail ? ` ${moveErrorDetail}` : ""}
</div>
) : null}
{recycleErrorKey ? (
<div
className={styles.status}
Expand Down Expand Up @@ -1981,6 +2130,15 @@ export function EditorHost({
</>
)}
</div>
{moveOpen ? (
<MoveDestinationPickerDialog
defaultPath={moveSourceParent}
onPick={(target) => {
void handleMovePick(target);
}}
onCancel={() => setMoveOpen(false)}
/>
) : null}
</div>
);
}
134 changes: 134 additions & 0 deletions WebUI/src/main/ts/editor/editorMove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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.
*/

/**
* Move the open editor item into another folder (#4774 / parent #4532).
*
* <p>Reuses public REST {@code POST /rest/folders/move/item}
* ({@code FoldersResource#moveFolderItem}). HTTP 403, 404, and 409 are
* failures. CMS paths use {@code /}, not OS separators. View mode has no
* move action.</p>
*/

import { isApiError } from "../api/client";
import { findItemById, moveFolderItem } from "../api/contentExplorer/pathApi";
import type { EditorHostMode } from "./editorHostUrl";

export type EditorMoveErrorReason =
| "forbidden"
| "not_found"
| "conflict"
| "failed";

/** Move is available only while editing an open item. */
export function canMoveFromEditor(mode: EditorHostMode): boolean {
return mode === "edit";
}

/**
* Folder that contains {@code itemPath} (last segment stripped).
* Repository {@code //} and Finder {@code /} forms both work.
*/
export function parentFolderOfItemPath(
itemPath: string | null | undefined,
): string | null {
if (itemPath == null) {
return null;
}
let path = String(itemPath).trim().replace(/\\/g, "/");
if (!path || path === "/" || path === "//") {
return null;
}
const repo = path.startsWith("//");
path = path.replace(/\/+$/, "");
const slash = path.lastIndexOf("/");
if (slash < 0) {
return null;
}
if (repo && slash <= 1) {
return null;
}
if (!repo && slash === 0) {
return "/";
}
return path.slice(0, slash);
}

/** Compare folder paths ignoring separator style and a leading {@code //}. */
export function cmsFoldersEqual(
left: string | null | undefined,
right: string | null | undefined,
): boolean {
const norm = (raw: string | null | undefined): string => {
if (raw == null) {
return "";
}
let path = String(raw).trim().replace(/\\/g, "/").replace(/^[A-Za-z]:/, "");
while (path.startsWith("//")) {
path = path.slice(1);
}
path = path.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
if (!path) {
return "";
}
if (!path.startsWith("/")) {
path = `/${path}`;
}
return path.toLowerCase();
};
const a = norm(left);
const b = norm(right);
return a.length > 0 && a === b;
}

/** Map REST failures so 403/404/409 are not treated as a successful move. */
export function editorMoveErrorReason(err: unknown): EditorMoveErrorReason {
if (isApiError(err)) {
if (err.status === 403) {
return "forbidden";
}
if (err.status === 404) {
return "not_found";
}
if (err.status === 409) {
return "conflict";
}
}
return "failed";
}

/** Full item path for the open content id (pathmanagement by id). */
export async function loadEditorItemPath(itemId: string): Promise<string> {
const item = await findItemById(itemId);
const path = String(item?.path ?? "").trim();
if (!path) {
const missing = { status: 404, statusText: "Not Found", body: {} };
throw missing;
}
return path;
}

/** Move a non-folder item. Does not change the open content id. */
export async function moveEditorItemToFolder(
itemPath: string,
targetFolderPath: string,
): Promise<void> {
await moveFolderItem({
sourcePath: itemPath,
targetPath: targetFolderPath,
});
}
Loading
Loading