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
33 changes: 27 additions & 6 deletions WebUI/src/main/ts/contentExplorer/ContentExplorerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@
*
* <p>Composes DCE-style top menu bar (Content / View / Help), tree, detail
* list, reduced actions, server-driven action toolbar (with nested MENU
* dropdowns), context menu, search panel, IA relationships panel, dependency
* viewer, and display-format selector so the SPA route approaches Desktop
* Content Explorer parity (#2400 / #2731 / #2768 / #2769).</p>
* dropdowns and enablement filtering from {@code rest/actions}), item/folder
* context menu, search panel, IA relationships panel, dependency viewer, and
* display-format selector so the SPA route approaches Desktop Content Explorer
* parity (#2400 / #2407 / #2731 / #2768 / #2769 / #2849).</p>
*/

import React, {
Expand Down Expand Up @@ -55,6 +56,10 @@ import type {
} from "../api/contentExplorer/types";
import { useSpaBootstrap } from "../app/bootstrap/BootstrapContext";
import { message } from "../i18n/message";
import {
filterContextMenuActions,
filterToolbarActions,
} from "./actionEnablement";
import { ActionToolbar } from "./ActionToolbar";
import { ClipboardPanel } from "./clipboard/ClipboardPanel";
import { EMPTY_CLIPBOARD, setClipboard as buildClipboard } from "./clipboard/model";
Expand Down Expand Up @@ -216,6 +221,18 @@ function isWorkflowEligibleItem(item: PSPathItem | null | undefined): boolean {
return id.length > 0;
}

/**
* Load the server action catalog for the current selection (#2849).
*
* <p>When a content item is selected, prefer per-content-type menus from
* {@code POST /actions/find/types}. Otherwise load the full cascading tree
* from {@code GET /actions/find} (no {@code item=true} filter) so toolbar
* dropdown parents ({@code MENU}) remain available — {@code item=true}
* would keep only flat {@code MENUITEM} roots and drop nested chrome.</p>
*
* <p>Desktop-only / surface enablement is applied by the shell after load
* via {@link filterToolbarActions} / {@link filterContextMenuActions}.</p>
*/
async function defaultLoadMenuActions(
item: PSPathItem | null,
): Promise<MenuAction[]> {
Expand All @@ -226,7 +243,7 @@ async function defaultLoadMenuActions(
return mapActionMenusToMenuActions(menus);
}
}
const menus = await findActions({ item: true });
const menus = await findActions({});
return mapActionMenusToMenuActions(menus);
}

Expand Down Expand Up @@ -408,7 +425,9 @@ export function ContentExplorerShell({
workflow = null;
}
if (cancelled) return;
setMenuActions(mergeWorkflowMenuActions(base ?? [], workflow));
// Toolbar surface: drop desktop-only URLs and CONTEXTMENU roots (#2849).
const merged = mergeWorkflowMenuActions(base ?? [], workflow);
setMenuActions(filterToolbarActions(merged));
} catch {
if (!cancelled) setMenuActions([]);
}
Expand Down Expand Up @@ -530,8 +549,10 @@ export function ContentExplorerShell({
workflow = null;
}
if (requestId !== contextMenuRequestIdRef.current) return;
// Context-menu surface: keep CONTEXTMENU roots; drop desktop-only (#2849).
const merged = mergeWorkflowMenuActions(base ?? [], workflow);
setContextMenu({
actions: mergeWorkflowMenuActions(base ?? [], workflow),
actions: filterContextMenuActions(merged),
x: clientX,
y: clientY,
});
Expand Down
213 changes: 213 additions & 0 deletions WebUI/src/main/ts/contentExplorer/actionEnablement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
* 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.
*/

/**
* Server-action enablement / visibility helpers for product Explorer
* (#2849 / parent #2407 / grandparent #2400).
*
* <p>The REST {@code /actions/*} catalog includes Desktop Content Explorer
* (DCE) menu entries that are not web-executable (custom app protocols,
* {@code file:}, {@code javascript:}, empty client markers that only the
* desktop CX understands). The product SPA must not surface those as
* toolbar / context-menu affordances. These pure helpers filter
* {@link MenuAction} trees after mapping from wire {@code ActionMenu}
* DTOs — they do not invent new action types and do not execute actions.</p>
*
* <p>Rules (FR-011: hide unauthorized / non-applicable):</p>
* <ul>
* <li>Client-handled leaves (no URL, or URL sentinel {@code CLIENT}) stay —
* the shell routes them through {@code onInvoke}.</li>
* <li>Leaves with a URL must pass {@link classifyUrl} (same-origin /
* relative / http(s) whitelist) or they are treated as desktop-only
* and dropped for the SPA surface.</li>
* <li>{@code CONTEXTMENU} roots are context-menu only (not toolbar
* chrome), matching DCE menu-type semantics.</li>
* <li>Empty cascading {@code MENU} parents after filtering are dropped.</li>
* </ul>
*/

import type { MenuAction } from "../api/contentExplorer/types";
import { classifyUrl } from "../util/safeNavigate";

/** Where the filtered menu will be rendered. */
export type ActionSurface = "toolbar" | "contextmenu";

/**
* Optional selection context for enablement. Multi-select / clipboard is
* intentionally out of scope for #2849 (see #2408).
*/
export interface ActionEnablementContext {
surface: ActionSurface;
/**
* Currently selected detail-list item, or {@code null} when only a folder
* is active. Reserved for future access-level gating; filters today are
* URL/surface based so folder-level catalog menus still show.
*/
selectionItem?: { type?: string; accessLevel?: string } | null;
/**
* Synthetic base URL for {@link classifyUrl} (tests pass an absolute
* origin; production callers may omit and use {@code window.location}).
*/
baseHref?: string;
}

/**
* DCE / legacy CX marks pure client actions with the literal URL token
* {@code CLIENT} (see ContentExplorerMenu.xml). That is not a navigable
* href — the SPA treats it as "client-handled" like a missing URL.
*/
const CLIENT_URL_SENTINELS: ReadonlySet<string> = new Set([
"client",
"clientaction",
"client-action",
]);

/**
* True when the action has no navigable URL and should be delegated to
* the shell {@code onInvoke} path (or is a pure cascade parent).
*/
export function isClientHandledAction(action: MenuAction): boolean {
const raw = action.url;
if (raw == null) return true;
const trimmed = String(raw).trim();
if (trimmed.length === 0) return true;
return CLIENT_URL_SENTINELS.has(trimmed.toLowerCase());
}

/**
* True when the action URL cannot run in the product SPA (desktop-only
* protocol, different origin, or known-dangerous scheme). Client-handled
* actions are never desktop-only.
*/
export function isDesktopOnlyActionUrl(
url: string | undefined | null,
baseHref?: string,
): boolean {
if (url == null) return false;
const trimmed = String(url).trim();
if (trimmed.length === 0) return false;
if (CLIENT_URL_SENTINELS.has(trimmed.toLowerCase())) return false;
const result = classifyUrl(trimmed, baseHref);
return !result.ok;
}

/**
* True when a leaf action is usable in the SPA: client-handled or a
* web-safe URL. Cascade parents with children are evaluated separately.
*/
export function isWebExecutableLeaf(
action: MenuAction,
baseHref?: string,
): boolean {
if (isClientHandledAction(action)) {
return true;
}
return !isDesktopOnlyActionUrl(action.url, baseHref);
}

/**
* Whether this action (as a root or nested entry) may appear on the
* given surface before child filtering.
*
* <p>{@code CONTEXTMENU} menu types are DCE context-popup roots; they are
* kept for the context-menu surface and hidden from the horizontal
* toolbar so product chrome does not dump the entire popup tree as
* buttons.</p>
*/
export function isActionAllowedOnSurface(
action: MenuAction,
surface: ActionSurface,
): boolean {
const type = (action.menuType ?? "MENUITEM").toUpperCase();
if (surface === "toolbar" && type === "CONTEXTMENU") {
return false;
}
return true;
}

function hasChildren(action: MenuAction): boolean {
return (action.children?.length ?? 0) > 0;
}

/**
* Recursively filter a {@link MenuAction} tree for product Explorer
* surfaces. Pure: does not mutate the input array or child arrays.
*
* @param actions Root actions from {@code mapActionMenusToMenuActions}
* @param ctx Surface + optional base URL for URL classification
* @returns New array of enabled actions (may be empty)
*/
export function filterEnabledMenuActions(
actions: MenuAction[] | null | undefined,
ctx: ActionEnablementContext,
): MenuAction[] {
if (actions == null || actions.length === 0) {
return [];
}
const baseHref = ctx.baseHref;
const out: MenuAction[] = [];
for (const action of actions) {
if (!action || !action.name) {
continue;
}
if (!isActionAllowedOnSurface(action, ctx.surface)) {
continue;
}

if (hasChildren(action)) {
const filteredChildren = filterEnabledMenuActions(action.children, ctx);
if (filteredChildren.length === 0) {
// Cascade parent with no web-usable children: drop entirely.
continue;
}
out.push({
...action,
children: filteredChildren,
});
continue;
}

// Leaf
if (!isWebExecutableLeaf(action, baseHref)) {
continue;
}
out.push(action);
}
return out;
}

/**
* Convenience: filter for the horizontal server action toolbar.
*/
export function filterToolbarActions(
actions: MenuAction[] | null | undefined,
baseHref?: string,
): MenuAction[] {
return filterEnabledMenuActions(actions, { surface: "toolbar", baseHref });
}

/**
* Convenience: filter for the item/folder context menu popup.
*/
export function filterContextMenuActions(
actions: MenuAction[] | null | undefined,
baseHref?: string,
): MenuAction[] {
return filterEnabledMenuActions(actions, {
surface: "contextmenu",
baseHref,
});
}
95 changes: 95 additions & 0 deletions WebUI/src/test/ts/contentExplorer/ContentExplorerShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,101 @@ describe("ContentExplorerShell product composition (#2400)", () => {
expect(screen.queryByTestId("context-menu-item-open-slow")).toBeNull();
});

it("filters desktop-only actions from toolbar and mounts context menu for items (#2849)", async () => {
const loadMenuActions = vi.fn(async () => [
{
name: "open",
label: "Open",
sortRank: 1,
menuType: "MENUITEM" as const,
},
{
name: "desktop-cx",
label: "Desktop only",
sortRank: 2,
menuType: "MENUITEM" as const,
url: "rxapp://launch-cx",
},
{
name: "cx-popup",
label: "CX popup",
sortRank: 3,
menuType: "CONTEXTMENU" as const,
children: [
{
name: "cx-edit",
label: "Edit in CX",
sortRank: 1,
menuType: "MENUITEM" as const,
},
],
},
]);

mockFetch(async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.includes("paginatedFolder") || url.includes("/folder/")) {
return new Response(
JSON.stringify({
PagedItemList: {
childrenInPage: [
{
id: "501",
name: "page-five",
path: "/Sites/page-five",
type: "page",
accessLevel: "WRITE",
},
],
childrenCount: 1,
startIndex: 1,
},
PathItem: [],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json" },
});
});

renderShell(
<ContentExplorerShell
initialPath="/Sites"
loadDisplayFormats={async () => []}
loadMenuActions={loadMenuActions}
loadWorkflowMenuActions={async () => null}
/>,
);

await waitFor(() => {
expect(screen.getByTestId("action-toolbar")).toBeInTheDocument();
expect(screen.getByTestId("action-toolbar-item-open")).toBeInTheDocument();
});
// Desktop-only URL and CONTEXTMENU roots must not appear on the toolbar.
expect(screen.queryByTestId("action-toolbar-item-desktop-cx")).toBeNull();
expect(screen.queryByTestId("action-toolbar-item-cx-popup")).toBeNull();

await waitFor(() => {
expect(screen.getByTestId("detail-row-501")).toBeInTheDocument();
});
fireEvent.contextMenu(screen.getByTestId("detail-row-501"), {
clientX: 12,
clientY: 24,
});

await waitFor(() => {
expect(screen.getByTestId("context-menu")).toBeInTheDocument();
expect(screen.getByTestId("context-menu-item-open")).toBeInTheDocument();
// CONTEXTMENU roots are allowed on the context-menu surface.
expect(screen.getByTestId("context-menu-item-cx-popup")).toBeInTheDocument();
});
// Desktop-only URL still filtered from context menu.
expect(screen.queryByTestId("context-menu-item-desktop-cx")).toBeNull();
});

it("merges workflow transition group into toolbar and invokes transition (#2732)", async () => {
const runWorkflowTransition = vi.fn(async () => undefined);
const loadWorkflowMenuActions = vi.fn(async (item) => {
Expand Down
Loading
Loading