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
80 changes: 80 additions & 0 deletions WebUI/src/main/ts/api/contentExplorer/listColumnsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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.
*/

/**
* Session column overlay for the current Explorer folder (#4722).
* PUT/GET {@code /services/explorer/list-columns}. Does not rewrite the
* shared display format.
*/

import { get, isApiError, put } from "../client";
import { PATHS } from "../paths";
import type { DisplayFormatColumn } from "./displayFormatsApi";

export interface ExplorerListColumns {
folderPath: string;
columns: string[];
}

export function unwrapExplorerListColumns(payload: unknown): ExplorerListColumns {
const root =
payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: {};
const wrapped = root.ExplorerListColumns;
const body =
wrapped && typeof wrapped === "object"
? (wrapped as Record<string, unknown>)
: root;
const folderPath = typeof body.folderPath === "string" ? body.folderPath : "";
const raw = body.columns;
const columns = Array.isArray(raw)
? raw.filter((c): c is string => typeof c === "string" && c.trim().length > 0)
: [];
return { folderPath, columns };
}

export function columnsToDisplayFormat(
sources: readonly string[],
): DisplayFormatColumn[] {
return sources.map((source) => ({ source, label: source }));
}

export function listColumnsHttpStatus(err: unknown): number | null {
return isApiError(err) ? err.status : null;
}

export async function getExplorerListColumns(
folderPath: string,
): Promise<ExplorerListColumns> {
const q = new URLSearchParams();
q.set("folderPath", folderPath);
const payload = await get<unknown>(
`${PATHS.EXPLORER_LIST_COLUMNS}?${q.toString()}`,
);
return unwrapExplorerListColumns(payload);
}

export async function saveExplorerListColumns(
folderPath: string,
columns: readonly string[],
): Promise<ExplorerListColumns> {
const payload = await put<unknown>(PATHS.EXPLORER_LIST_COLUMNS, {
ExplorerListColumns: { folderPath, columns },
});
return unwrapExplorerListColumns(payload);
}
4 changes: 4 additions & 0 deletions WebUI/src/main/ts/api/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,10 @@ export const PATHS = {
get DISPLAY_FORMATS() {
return `${SERVICES_ROOT}/displayformats`;
},
/** Session overlay of Explorer folder-list columns (#4722). */
get EXPLORER_LIST_COLUMNS() {
return `${SERVICES_ROOT}/explorer/list-columns`;
},
/** CX action menu design catalog (GET list/detail). */
get ACTION_MENUS() {
return `${SERVICES_ROOT}/actions/catalog`;
Expand Down
41 changes: 40 additions & 1 deletion WebUI/src/main/ts/contentExplorer/ContentExplorerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ import {
normalizeDisplayFormatColumns,
type DisplayFormat,
} from "../api/contentExplorer/displayFormatsApi";
import {
columnsToDisplayFormat,
getExplorerListColumns,
type ExplorerListColumns,
} from "../api/contentExplorer/listColumnsApi";
import { ExplorerListColumnsPanel } from "./ExplorerListColumnsPanel";
import {
getItemWorkflowTransitions,
transitionItem,
Expand Down Expand Up @@ -234,6 +240,8 @@ export interface ContentExplorerShellProps {
onFolderActivated?: (path: string, folder: PSPathItem) => void;
/** Test seam: override display-format catalog load. */
loadDisplayFormats?: () => Promise<DisplayFormat[]>;
/** Test seam: session column overlay for the current folder (#4722). */
loadListColumns?: (folderPath: string) => Promise<ExplorerListColumns>;
/** Test seam: override action menu load. */
loadMenuActions?: (item: PSPathItem | null) => Promise<MenuAction[]>;
/**
Expand Down Expand Up @@ -479,6 +487,7 @@ function ContentExplorerShellInner({
actionHandlers,
onFolderActivated,
loadDisplayFormats = defaultLoadDisplayFormats,
loadListColumns = getExplorerListColumns,
loadMenuActions = defaultLoadMenuActions,
loadWorkflowMenuActions = defaultLoadWorkflowMenuActions,
currentUserIdentities: currentUserIdentitiesProp,
Expand Down Expand Up @@ -558,6 +567,8 @@ function ContentExplorerShellInner({
useState<RevisionsPanelTab>("revisions");
const [displayFormats, setDisplayFormats] = useState<DisplayFormat[]>([]);
const [selectedFormatKey, setSelectedFormatKey] = useState<string>("");
/** Session column overlay for the current folder (#4722). Empty = format/default. */
const [sessionColumnSources, setSessionColumnSources] = useState<string[]>([]);
/** Non-fatal display-format catalog failure — selector stays mounted (#3208). */
const [displayFormatLoadError, setDisplayFormatLoadError] = useState<
string | null
Expand Down Expand Up @@ -765,6 +776,26 @@ function ContentExplorerShellInner({
};
}, [loadDisplayFormats]);

useEffect(() => {
let cancelled = false;
const path = selection.folderPath;
if (!path) {
setSessionColumnSources([]);
return;
}
loadListColumns(path)
.then((saved) => {
if (cancelled) return;
setSessionColumnSources(saved.columns ?? []);
})
.catch(() => {
if (!cancelled) setSessionColumnSources([]);
});
return () => {
cancelled = true;
};
}, [selection.folderPath, loadListColumns]);

useEffect(() => {
let cancelled = false;
async function loadMenus(): Promise<void> {
Expand Down Expand Up @@ -826,11 +857,14 @@ function ContentExplorerShellInner({
}, [displayFormats, selectedFormatKey]);

const detailDisplayFormat = useMemo(() => {
if (sessionColumnSources.length > 0) {
return toDetailDisplayFormat(columnsToDisplayFormat(sessionColumnSources));
}
if (!selectedFormat) return undefined;
return toDetailDisplayFormat(
normalizeDisplayFormatColumns(selectedFormat.columns),
);
}, [selectedFormat]);
}, [selectedFormat, sessionColumnSources]);

const displayFormatId = selectedFormat
? resolvePathmanagementDisplayFormatId(selectedFormat) || null
Expand Down Expand Up @@ -1560,6 +1594,11 @@ function ContentExplorerShellInner({
onSelectFormat={setSelectedFormatKey}
onCommand={handleMenuBarCommand}
/>
<ExplorerListColumnsPanel
folderPath={selection.folderPath}
selectedSources={sessionColumnSources}
onSaved={setSessionColumnSources}
/>
<div style={toolRowStyle}>
<ReducedActions
item={selection.item}
Expand Down
156 changes: 156 additions & 0 deletions WebUI/src/main/ts/contentExplorer/ExplorerListColumnsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* 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 React, { useMemo, useState } from "react";
import { formatApiError, isSessionRedirectError } from "../api/client";
import {
listColumnsHttpStatus,
saveExplorerListColumns,
} from "../api/contentExplorer/listColumnsApi";
import { DISPLAY_FORMAT_FIELD_CATALOG, SYS_TITLE_SOURCE } from "../developer/displayFormatColumns";
import { message } from "../i18n/message";
import { EXPLORER_MSG } from "./messages";

export interface ExplorerListColumnsPanelProps {
folderPath: string | null;
/** Sources currently applied to the list (may be empty). */
selectedSources: readonly string[];
onSaved: (sources: string[]) => void;
saveColumns?: (
folderPath: string,
columns: readonly string[],
) => Promise<{ columns: string[] }>;
}

export function ExplorerListColumnsPanel({
folderPath,
selectedSources,
onSaved,
saveColumns = saveExplorerListColumns,
}: ExplorerListColumnsPanelProps): React.ReactElement {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(() =>
selectedSources.length > 0 ? [...selectedSources] : [SYS_TITLE_SOURCE],
);
const [error, setError] = useState<string | null>(null);
const [httpStatus, setHttpStatus] = useState<number | null>(null);
const [busy, setBusy] = useState(false);

const selected = useMemo(() => new Set(draft.map((s) => s.toLowerCase())), [draft]);

function toggle(source: string): void {
if (source === SYS_TITLE_SOURCE) return;
setDraft((prev) => {
const key = source.toLowerCase();
if (prev.some((s) => s.toLowerCase() === key)) {
return prev.filter((s) => s.toLowerCase() !== key);
}
return [...prev, source];
});
}

async function apply(): Promise<void> {
setError(null);
setHttpStatus(null);
const path = folderPath?.trim() ?? "";
if (!path) {
setHttpStatus(400);
setError(message(EXPLORER_MSG.LIST_COLUMNS_NEED_FOLDER));
return;
}
setBusy(true);
try {
const saved = await saveColumns(path, draft);
const next = saved.columns.length > 0 ? saved.columns : draft;
onSaved(next);
setOpen(false);
} catch (err) {
if (isSessionRedirectError(err)) return;
const status = listColumnsHttpStatus(err);
setHttpStatus(status);
const detail = formatApiError(err, message(EXPLORER_MSG.LIST_COLUMNS_SAVE_ERROR));
const withStatus =
status != null && !detail.includes(`HTTP ${status}`)
? `${detail} (HTTP ${status})`
: detail;
setError(withStatus);
} finally {
setBusy(false);
}
}

return (
<div data-testid="explorer-list-columns">
<button
type="button"
data-testid="explorer-list-columns-open"
onClick={() => {
setDraft(selectedSources.length > 0 ? [...selectedSources] : [SYS_TITLE_SOURCE]);
setError(null);
setHttpStatus(null);
setOpen((v) => !v);
}}
>
{message(EXPLORER_MSG.LIST_COLUMNS_OPEN)}
</button>
{open ? (
<div
role="dialog"
aria-label={message(EXPLORER_MSG.LIST_COLUMNS_OPEN)}
data-testid="explorer-list-columns-dialog"
>
<ul style={{ listStyle: "none", margin: "8px 0", padding: 0 }}>
{DISPLAY_FORMAT_FIELD_CATALOG.map((field) => {
const locked = field.source === SYS_TITLE_SOURCE;
return (
<li key={field.source}>
<label>
<input
type="checkbox"
data-testid={`explorer-list-column-${field.source}`}
checked={locked || selected.has(field.source)}
disabled={locked || busy}
onChange={() => toggle(field.source)}
/>{" "}
{field.label}
</label>
</li>
);
})}
</ul>
<button
type="button"
data-testid="explorer-list-columns-apply"
disabled={busy}
onClick={() => void apply()}
>
{message(EXPLORER_MSG.LIST_COLUMNS_APPLY)}
</button>
</div>
) : null}
{error ? (
<p
role="alert"
data-testid="explorer-list-columns-error"
data-http-status={httpStatus ?? ""}
>
{error}
</p>
) : null}
</div>
);
}
4 changes: 4 additions & 0 deletions WebUI/src/main/ts/contentExplorer/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ export const EXPLORER_MSG = {
/** Non-fatal catalog load failure — selector stays mounted (#3208). */
DISPLAY_FORMAT_LOAD_ERROR:
"perc.ui.explorer@Could not load display formats",
LIST_COLUMNS_OPEN: "perc.ui.explorer@Columns",
LIST_COLUMNS_APPLY: "perc.ui.explorer@Apply columns",
LIST_COLUMNS_NEED_FOLDER: "perc.ui.explorer@Select a folder before changing columns (HTTP 400)",
LIST_COLUMNS_SAVE_ERROR: "perc.ui.explorer@Could not save list columns",
/** Product shell: server-driven action toolbar (US3 / #2400 / #2972). */
SERVER_ACTIONS_ARIA: "perc.ui.explorer@Server actions",
/** Visible chrome label so QA/operators can identify the toolbar region. */
Expand Down
Loading
Loading