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
8 changes: 5 additions & 3 deletions WebUI/src/main/ts/api/publishing/designApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,9 +525,11 @@ export async function createContext(

/**
* PUT update. A rename sends {@code name} only so the description and default
* scheme stay stored. Location schemes stay on this context id; this request
* does not create or move them. HTTP 400 overlong name; 403 non-Admin/Designer;
* 409 duplicate name.
* scheme stay stored. A description change sends {@code description} only so
* the name and default scheme stay stored. Location schemes stay on this
* context id; this request does not create or move them. An empty description
* clears the stored text. HTTP 400 overlong name or description; 403
* non-Admin/Designer; 409 duplicate name.
*/
export async function updateContext(
contextId: string | number,
Expand Down
75 changes: 75 additions & 0 deletions WebUI/src/main/ts/publishing/contextDescription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 type { ContextSummary } from "../api/publishing/designApi";

/**
* RXCONTEXT.CONTEXTDESC is VARCHAR(255). Keep in step with
* {@code PSPublishingDesignRestService.MAX_CONTEXT_DESCRIPTION_LENGTH}.
*/
export const CONTEXT_DESCRIPTION_MAX_LENGTH = 255;

export const CONTEXT_DESCRIPTION_TOO_LONG =
"Publishing context description must be 255 characters or fewer";

export type ContextDescriptionResult =
| { ok: true; description: string }
| { ok: false; error: string };

/**
* Blank (after trim) is allowed and clears the stored description.
* Overlong text is rejected before any update request.
*/
export function validateContextDescription(raw: string): ContextDescriptionResult {
const description = raw.trim();
if (description.length > CONTEXT_DESCRIPTION_MAX_LENGTH) {
return { ok: false, error: CONTEXT_DESCRIPTION_TOO_LONG };
}
return { ok: true, description };
}

/**
* Description-only {@code updateContext} body. Name and the default scheme
* are omitted so the server leaves them stored. Location schemes stay on
* this context id because the update does not create or move schemes.
* An empty string clears the description. The id is the path parameter.
*/
export function buildContextDescriptionBody(description: string): ContextSummary {
return { description };
}

/**
* List to show after a description save succeeds. Prefer the refreshed rows.
* If the refresh failed, keep every previous row and replace only the
* description so the name and default scheme stay visible. Do not call this
* when the update failed. An empty description clears the stored text.
* Location schemes are not part of this list.
*/
export function contextsAfterSuccessfulDescription(
refreshed: ContextSummary[] | null,
contextId: string,
description: string,
previous: ContextSummary[],
): ContextSummary[] {
if (refreshed) {
return refreshed;
}
const id = String(contextId);
return previous.map((row) =>
String(row.contextId ?? "") === id ? { ...row, description } : row,
);
}
149 changes: 149 additions & 0 deletions WebUI/src/main/ts/publishing/design/ContextsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ import {
contextsAfterSuccessfulRename,
validateContextRenameName,
} from "../contextRename";
import {
buildContextDescriptionBody,
contextsAfterSuccessfulDescription,
validateContextDescription,
} from "../contextDescription";
import {
contextsAfterSuccessfulDelete,
mapContextDeleteError,
Expand All @@ -77,6 +82,7 @@ type Mode =
| { kind: "context-edit"; context: ContextSummary | null }
| { kind: "context-copy"; source: ContextSummary }
| { kind: "context-rename"; source: ContextSummary }
| { kind: "context-describe"; source: ContextSummary }
| { kind: "scheme-edit"; scheme: LocationSchemeSummary | null; contextId: string }
| { kind: "scheme-copy"; source: LocationSchemeSummary; contextId: string };

Expand Down Expand Up @@ -109,6 +115,7 @@ export function ContextsPanel(): React.ReactElement {
const [saving, setSaving] = useState(false);
const [copyName, setCopyName] = useState("");
const [renameName, setRenameName] = useState("");
const [describeText, setDescribeText] = useState("");
const { setDirty, confirmIfDirty } = useDirtyForm();

function reloadContexts(): void {
Expand Down Expand Up @@ -238,6 +245,72 @@ export function ContextsPanel(): React.ReactElement {
}
}

function openContextDescribe(): void {
const source = contexts.find((row) => String(row.contextId ?? "") === selected);
if (!source?.contextId) {
return;
}
setDescribeText(source.description ?? "");
setError(null);
setDirty(false);
setMode({ kind: "context-describe", source });
}

function closeContextDescribe(): void {
if (saving) {
return;
}
if (!confirmIfDirty()) {
return;
}
setDirty(false);
setError(null);
setMode({ kind: "list" });
}

async function saveContextDescription(): Promise<void> {
if (mode.kind !== "context-describe" || !mode.source.contextId || saving) {
return;
}
const validated = validateContextDescription(describeText);
if (!validated.ok) {
setError(validated.error);
return;
}
const id = String(mode.source.contextId);
setError(null);
setSaving(true);
const previous = contexts;
try {
await updateContext(id, buildContextDescriptionBody(validated.description));
setDirty(false);
setMode({ kind: "list" });
let refreshed: ContextSummary[] | null = null;
try {
refreshed = await listContexts();
} catch {
refreshed = null;
}
const next = contextsAfterSuccessfulDescription(
refreshed,
id,
validated.description,
previous,
);
setContexts(next);
setSelected((current) => {
if (next.some((row) => String(row.contextId ?? "") === current)) {
return current;
}
return next.some((row) => String(row.contextId ?? "") === id) ? id : current;
});
} catch (e) {
setError(mapContextSaveError(e));
} finally {
setSaving(false);
}
}

async function copyContext(): Promise<void> {
if (mode.kind !== "context-copy" || !mode.source.contextId || saving) {
return;
Expand Down Expand Up @@ -564,6 +637,64 @@ export function ContextsPanel(): React.ReactElement {
);
}

if (mode.kind === "context-describe") {
return (
<div data-testid="context-describe-form">
<h3>Context description</h3>
<p>
Name:{" "}
<span data-testid="context-describe-name">{mode.source.name ?? ""}</span>
</p>
<p data-testid="context-describe-schemes-note">
Location schemes stay on this context.
</p>
<ul data-testid="context-describe-schemes" style={listStyle}>
{schemes.map((s) => (
<li key={s.schemeId ?? s.name} data-testid="context-describe-scheme">
{s.name ?? ""}
</li>
))}
</ul>
<div style={formRowStyle}>
<label htmlFor="context-describe-description">Description</label>
<input
id="context-describe-description"
value={describeText}
onChange={(e) => {
setDescribeText(e.target.value);
setDirty(true);
}}
/>
</div>
{error && (
<p style={errorStyle} role="alert">
{error}
</p>
)}
<div style={toolbarStyle}>
<button
type="button"
style={primaryButtonStyle}
data-testid="context-describe-submit"
disabled={saving}
onClick={() => void saveContextDescription()}
>
Save description
</button>
<button
type="button"
style={buttonStyle}
data-testid="context-describe-cancel"
disabled={saving}
onClick={closeContextDescribe}
>
Cancel
</button>
</div>
</div>
);
}

if (mode.kind === "context-rename") {
return (
<div data-testid="context-rename-form">
Expand Down Expand Up @@ -898,6 +1029,15 @@ export function ContextsPanel(): React.ReactElement {
>
Rename context
</button>
<button
type="button"
style={buttonStyle}
data-testid="context-describe"
disabled={saving}
onClick={openContextDescribe}
>
Description
</button>
<button
type="button"
style={buttonStyle}
Expand Down Expand Up @@ -930,6 +1070,15 @@ export function ContextsPanel(): React.ReactElement {
{!loading && contexts.length === 0 && (
<p style={emptyStyle}>No publishing contexts.</p>
)}
{selected && (
<p>
Description:{" "}
<span data-testid={`context-description-${selected}`}>
{contexts.find((row) => String(row.contextId ?? "") === selected)?.description ??
""}
</span>
</p>
)}
<h4>Location schemes</h4>
{schemes.length === 0 ? (
<p style={emptyStyle}>No schemes for this context.</p>
Expand Down
Loading
Loading