1991 lines
65 KiB
TypeScript
1991 lines
65 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Archive,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
CheckSquare2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
MinusSquare,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Save,
|
|
Send,
|
|
Square,
|
|
Trash2
|
|
} from "lucide-react";
|
|
import { FormGrid,
|
|
ActionBlockerHint,
|
|
AdminPageLayout,
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
DocumentationHelpLink,
|
|
ExplorerTree,
|
|
FormField,
|
|
IconButton,
|
|
SearchableSelect,
|
|
SegmentedControl,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
StatusBadge,
|
|
ToggleSwitch,
|
|
adminErrorMessage,
|
|
dispatchPlatformViewChanged,
|
|
i18nMessage,
|
|
usePlatformLanguage,
|
|
usePlatformModules,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
useViewSurfaces,
|
|
type ApiSettings,
|
|
type PlatformWebModule,
|
|
type PlatformViewSurface,
|
|
type SearchableSelectOption,
|
|
type ViewPresentation
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
archiveViewDefinition,
|
|
createViewAssignment,
|
|
createViewDefinition,
|
|
createViewRevision,
|
|
deleteViewAssignment,
|
|
fetchViewAssignmentTargets,
|
|
fetchViewAssignments,
|
|
fetchViewDefinitions,
|
|
publishViewRevision,
|
|
presentationToApi,
|
|
updateViewAssignment,
|
|
updateViewDefinition,
|
|
type ViewAssignment,
|
|
type ViewAssignmentMode,
|
|
type ViewAssignmentScopeType,
|
|
type ViewDefinition,
|
|
type ViewScopeType
|
|
} from "../../api/views";
|
|
import {
|
|
VIEWS_DOCUMENTATION,
|
|
VIEWS_FIELD_DOCUMENTATION,
|
|
VIEWS_INTERFACE_I18N,
|
|
definitionDisabledReason
|
|
} from "./interfacePatterns";
|
|
|
|
|
|
type DefinitionDraft = {
|
|
name: string;
|
|
description: string;
|
|
surfaceIds: string[];
|
|
presentation: ViewPresentation;
|
|
};
|
|
|
|
type ViewProductArea = {
|
|
id: string;
|
|
label: string;
|
|
description?: string | null;
|
|
order: number;
|
|
surfaceIds: string[];
|
|
};
|
|
|
|
type AssignmentDraft = {
|
|
scopeType: ViewAssignmentScopeType;
|
|
scopeId: string;
|
|
scopeLabel: string;
|
|
scopeDetail: string;
|
|
definitionId: string;
|
|
mode: ViewAssignmentMode;
|
|
priority: number;
|
|
active: boolean;
|
|
pinRevision: boolean;
|
|
};
|
|
|
|
const LOCKOUT_SURFACES = new Set([
|
|
"access.module",
|
|
"access.nav.admin",
|
|
"access.route.admin",
|
|
"views.module",
|
|
"views.selector",
|
|
"views.admin.system",
|
|
"views.admin.tenant"
|
|
]);
|
|
|
|
|
|
export default function ViewsAdminPanel({
|
|
settings,
|
|
scopeType,
|
|
scopeId,
|
|
canWriteDefinitions,
|
|
canWriteAssignments,
|
|
showAssignments = scopeType === "system" || scopeType === "tenant",
|
|
embedded = false,
|
|
title: titleOverride,
|
|
description: descriptionOverride
|
|
}: {
|
|
settings: ApiSettings;
|
|
scopeType: ViewScopeType;
|
|
scopeId?: string | null;
|
|
canWriteDefinitions: boolean;
|
|
canWriteAssignments: boolean;
|
|
showAssignments?: boolean;
|
|
embedded?: boolean;
|
|
title?: string;
|
|
description?: string;
|
|
}) {
|
|
const surfaces = useViewSurfaces();
|
|
const modules = usePlatformModules();
|
|
const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]);
|
|
const { requestDiscard } = useUnsavedChanges();
|
|
const { translateText } = usePlatformLanguage();
|
|
const [definitions, setDefinitions] = useState<ViewDefinition[]>([]);
|
|
const [assignments, setAssignments] = useState<ViewAssignment[]>([]);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [draft, setDraft] = useState<DefinitionDraft>({
|
|
name: "",
|
|
description: "",
|
|
surfaceIds: [],
|
|
presentation: defaultPresentation([])
|
|
});
|
|
const [savedDraftKey, setSavedDraftKey] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [createDraft, setCreateDraft] = useState({
|
|
name: "",
|
|
description: ""
|
|
});
|
|
const [assignmentEditor, setAssignmentEditor] = useState<
|
|
ViewAssignment | "new" | null
|
|
>(null);
|
|
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(
|
|
emptyAssignmentDraft(scopeType)
|
|
);
|
|
const [assignmentSavedDraftKey, setAssignmentSavedDraftKey] = useState("");
|
|
const [archiveTarget, setArchiveTarget] = useState<ViewDefinition | null>(null);
|
|
const [deleteAssignmentTarget, setDeleteAssignmentTarget] =
|
|
useState<ViewAssignment | null>(null);
|
|
|
|
const selected = definitions.find((item) => item.id === selectedId) ?? null;
|
|
const dirty = Boolean(
|
|
selected &&
|
|
!selected.readonly &&
|
|
definitionDraftKey(draft) !== savedDraftKey
|
|
);
|
|
const createDirty = Boolean(
|
|
createOpen && (createDraft.name.trim() || createDraft.description.trim())
|
|
);
|
|
const assignmentDirty = Boolean(
|
|
assignmentEditor &&
|
|
assignmentDraftKey(assignmentDraft) !== assignmentSavedDraftKey
|
|
);
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty,
|
|
onSave: saveDraft,
|
|
onDiscard: resetDraft
|
|
});
|
|
useUnsavedDraftGuard({
|
|
dirty: createDirty,
|
|
onSave: createDefinition,
|
|
onDiscard: closeCreate
|
|
});
|
|
useUnsavedDraftGuard({
|
|
dirty: assignmentDirty,
|
|
onSave: saveAssignment,
|
|
onDiscard: closeAssignment
|
|
});
|
|
|
|
async function load(preferredId?: string) {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextDefinitions, nextAssignments] = await Promise.all([
|
|
fetchViewDefinitions(settings, scopeType, scopeId),
|
|
showAssignments && (scopeType === "system" || scopeType === "tenant")
|
|
? fetchViewAssignments(settings, scopeType)
|
|
: Promise.resolve([])
|
|
]);
|
|
setDefinitions(nextDefinitions);
|
|
setAssignments(nextAssignments);
|
|
const nextSelectedId =
|
|
preferredId && nextDefinitions.some((item) => item.id === preferredId)
|
|
? preferredId
|
|
: nextDefinitions.some((item) => item.id === selectedId)
|
|
? selectedId
|
|
: nextDefinitions[0]?.id ?? "";
|
|
setSelectedId(nextSelectedId);
|
|
const nextSelected =
|
|
nextDefinitions.find((item) => item.id === nextSelectedId) ?? null;
|
|
applyDefinitionDraft(nextSelected);
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setSelectedId("");
|
|
setAssignmentDraft(emptyAssignmentDraft(scopeType));
|
|
void load();
|
|
}, [
|
|
scopeType,
|
|
scopeId,
|
|
showAssignments,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey
|
|
]);
|
|
|
|
function applyDefinitionDraft(definition: ViewDefinition | null) {
|
|
const next = definition
|
|
? {
|
|
name: definition.name,
|
|
description: definition.description ?? "",
|
|
surfaceIds: definition.latest_revision.visible_surface_ids,
|
|
presentation: revisionPresentation(
|
|
definition.latest_revision.presentation,
|
|
productAreas
|
|
)
|
|
}
|
|
: {
|
|
name: "",
|
|
description: "",
|
|
surfaceIds: [],
|
|
presentation: defaultPresentation(productAreas)
|
|
};
|
|
setDraft(next);
|
|
setSavedDraftKey(definitionDraftKey(next));
|
|
}
|
|
|
|
function resetDraft() {
|
|
applyDefinitionDraft(selected);
|
|
}
|
|
|
|
function selectDefinition(definitionId: string) {
|
|
if (definitionId === selectedId) return;
|
|
requestDiscard(() => {
|
|
const definition =
|
|
definitions.find((item) => item.id === definitionId) ?? null;
|
|
setSelectedId(definitionId);
|
|
applyDefinitionDraft(definition);
|
|
setError("");
|
|
setSuccess("");
|
|
});
|
|
}
|
|
|
|
async function persistDraft(): Promise<ViewDefinition | null> {
|
|
if (!selected || selected.readonly || selected.status === "archived") {
|
|
return selected;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
let next = selected;
|
|
if (
|
|
draft.name.trim() !== selected.name ||
|
|
draft.description.trim() !== (selected.description ?? "")
|
|
) {
|
|
next = await updateViewDefinition(settings, selected.id, {
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim() || null
|
|
});
|
|
}
|
|
if (
|
|
surfaceSetKey(draft.surfaceIds) !==
|
|
surfaceSetKey(next.latest_revision.visible_surface_ids) ||
|
|
presentationKey(draft.presentation) !==
|
|
presentationKey(
|
|
revisionPresentation(next.latest_revision.presentation, productAreas)
|
|
)
|
|
) {
|
|
next = await createViewRevision(
|
|
settings,
|
|
selected.id,
|
|
draft.surfaceIds,
|
|
draft.presentation
|
|
);
|
|
}
|
|
await load(selected.id);
|
|
return next;
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
return null;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function saveDraft(): Promise<boolean> {
|
|
const next = await persistDraft();
|
|
if (!next) return false;
|
|
setSuccess("i18n:govoplan-views.draft_saved");
|
|
return true;
|
|
}
|
|
|
|
async function publishDraft() {
|
|
const persisted = await persistDraft();
|
|
if (!persisted) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await publishViewRevision(
|
|
settings,
|
|
persisted.id,
|
|
persisted.latest_revision.id
|
|
);
|
|
setSuccess(i18nMessage("i18n:govoplan-views.revision_published_value", {
|
|
value0: persisted.latest_revision.revision
|
|
}));
|
|
await load(persisted.id);
|
|
dispatchPlatformViewChanged();
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function createDefinition(): Promise<boolean> {
|
|
if (!createDraft.name.trim()) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const visibleSurfaceIds = surfaces
|
|
.filter((surface) => surface.defaultVisible !== false)
|
|
.map((surface) => surface.id);
|
|
const created = await createViewDefinition(settings, {
|
|
scope_type: scopeType,
|
|
scope_id: scopeId || null,
|
|
name: createDraft.name.trim(),
|
|
description: createDraft.description.trim() || null,
|
|
visible_surface_ids: visibleSurfaceIds,
|
|
presentation: presentationToApi(defaultPresentation(productAreas))
|
|
});
|
|
closeCreate();
|
|
setSuccess("i18n:govoplan-views.draft_created");
|
|
await load(created.id);
|
|
return true;
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function closeCreate() {
|
|
setCreateOpen(false);
|
|
setCreateDraft({ name: "", description: "" });
|
|
}
|
|
|
|
async function archiveDefinition() {
|
|
if (!archiveTarget) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await archiveViewDefinition(settings, archiveTarget.id);
|
|
setArchiveTarget(null);
|
|
setSuccess("i18n:govoplan-views.view_archived");
|
|
await load();
|
|
dispatchPlatformViewChanged();
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openCreateAssignment() {
|
|
const firstPublished = definitions.find(
|
|
(definition) => definition.status === "published"
|
|
);
|
|
const nextDraft = {
|
|
...emptyAssignmentDraft(scopeType),
|
|
definitionId: firstPublished?.id ?? ""
|
|
};
|
|
setAssignmentDraft(nextDraft);
|
|
setAssignmentSavedDraftKey(assignmentDraftKey(nextDraft));
|
|
setAssignmentEditor("new");
|
|
setError("");
|
|
}
|
|
|
|
function openEditAssignment(assignment: ViewAssignment) {
|
|
const nextDraft = {
|
|
scopeType: assignment.scope_type,
|
|
scopeId: assignment.scope_id ?? "",
|
|
scopeLabel:
|
|
assignment.scope_label ??
|
|
(assignment.scope_id
|
|
? `Unavailable ${assignment.scope_type}`
|
|
: assignment.scope_type),
|
|
scopeDetail:
|
|
assignment.scope_detail ??
|
|
(assignment.scope_label ? "" : assignment.scope_id ?? ""),
|
|
definitionId: assignment.definition_id,
|
|
mode: assignment.mode,
|
|
priority: assignment.priority,
|
|
active: assignment.is_active,
|
|
pinRevision: Boolean(assignment.revision_id)
|
|
};
|
|
setAssignmentDraft(nextDraft);
|
|
setAssignmentSavedDraftKey(assignmentDraftKey(nextDraft));
|
|
setAssignmentEditor(assignment);
|
|
setError("");
|
|
}
|
|
|
|
async function saveAssignment(): Promise<boolean> {
|
|
const definition = definitions.find(
|
|
(item) => item.id === assignmentDraft.definitionId
|
|
);
|
|
if (!definition) return false;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const revisionId = assignmentDraft.pinRevision
|
|
? (
|
|
assignmentEditor !== "new" &&
|
|
assignmentEditor?.revision_id
|
|
? assignmentEditor.revision_id
|
|
: definition.published_revision?.id ?? null
|
|
)
|
|
: null;
|
|
if (assignmentEditor === "new") {
|
|
await createViewAssignment(settings, {
|
|
scope_type: assignmentDraft.scopeType,
|
|
scope_id: assignmentDraft.scopeId.trim() || null,
|
|
definition_id: definition.id,
|
|
revision_id: revisionId,
|
|
mode: assignmentDraft.mode,
|
|
priority: assignmentDraft.priority,
|
|
is_active: assignmentDraft.active
|
|
});
|
|
} else if (assignmentEditor) {
|
|
await updateViewAssignment(settings, assignmentEditor.id, {
|
|
revision_id: revisionId,
|
|
mode: assignmentDraft.mode,
|
|
priority: assignmentDraft.priority,
|
|
is_active: assignmentDraft.active
|
|
});
|
|
}
|
|
closeAssignment();
|
|
setSuccess(
|
|
assignmentEditor === "new"
|
|
? "i18n:govoplan-views.assignment_created"
|
|
: "i18n:govoplan-views.assignment_updated"
|
|
);
|
|
await load(selectedId);
|
|
dispatchPlatformViewChanged();
|
|
return true;
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function closeAssignment() {
|
|
setAssignmentEditor(null);
|
|
const nextDraft = emptyAssignmentDraft(scopeType);
|
|
setAssignmentDraft(nextDraft);
|
|
setAssignmentSavedDraftKey(assignmentDraftKey(nextDraft));
|
|
}
|
|
|
|
async function toggleAssignment(
|
|
assignment: ViewAssignment,
|
|
active: boolean
|
|
) {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await updateViewAssignment(settings, assignment.id, {
|
|
is_active: active
|
|
});
|
|
await load(selectedId);
|
|
dispatchPlatformViewChanged();
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function removeAssignment() {
|
|
if (!deleteAssignmentTarget) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await deleteViewAssignment(settings, deleteAssignmentTarget.id);
|
|
setDeleteAssignmentTarget(null);
|
|
setSuccess("i18n:govoplan-views.assignment_removed");
|
|
await load(selectedId);
|
|
dispatchPlatformViewChanged();
|
|
} catch (caught) {
|
|
setError(adminErrorMessage(caught));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const title =
|
|
titleOverride ??
|
|
({
|
|
system: "i18n:govoplan-views.system_views",
|
|
tenant: "i18n:govoplan-views.tenant_views",
|
|
group: "i18n:govoplan-views.group_views",
|
|
user: "i18n:govoplan-views.my_views"
|
|
} satisfies Record<ViewScopeType, string>)[scopeType];
|
|
const description =
|
|
descriptionOverride ??
|
|
({
|
|
system: "i18n:govoplan-views.system_description",
|
|
tenant: "i18n:govoplan-views.tenant_description",
|
|
group: "i18n:govoplan-views.group_scope_description",
|
|
user: "i18n:govoplan-views.user_scope_description"
|
|
} satisfies Record<ViewScopeType, string>)[scopeType];
|
|
const hasRequiredCatalogue =
|
|
surfaces.some((surface) => surface.kind === "navigation") &&
|
|
surfaces.some((surface) => surface.kind === "route");
|
|
const canCreate =
|
|
canWriteDefinitions && hasRequiredCatalogue;
|
|
const definitionEditable = Boolean(
|
|
selected && !selected.readonly && selected.status !== "archived"
|
|
);
|
|
const reloadDisabledReason = loading
|
|
? VIEWS_INTERFACE_I18N.loadingText
|
|
: busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: undefined;
|
|
const createDisabledReason = loading
|
|
? VIEWS_INTERFACE_I18N.loadingText
|
|
: busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: !canWriteDefinitions
|
|
? VIEWS_INTERFACE_I18N.definitionWriteText
|
|
: !hasRequiredCatalogue
|
|
? VIEWS_INTERFACE_I18N.missingCatalogueText
|
|
: undefined;
|
|
const editDisabledReason = selected
|
|
? definitionDisabledReason({
|
|
busy,
|
|
permitted: canWriteDefinitions,
|
|
editable: definitionEditable,
|
|
archived: selected.status === "archived"
|
|
})
|
|
: undefined;
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title={title}
|
|
description={description}
|
|
className={embedded ? "views-admin-page embedded" : "views-admin-page"}
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
actions={
|
|
<>
|
|
<DocumentationHelpLink reference={VIEWS_DOCUMENTATION} />
|
|
<IconButton
|
|
label="i18n:govoplan-views.reload_views"
|
|
icon={<RefreshCw />}
|
|
onClick={() => requestDiscard(() => void load(selectedId))}
|
|
disabled={Boolean(reloadDisabledReason)}
|
|
disabledReason={reloadDisabledReason}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => setCreateOpen(true)}
|
|
disabled={!canCreate || Boolean(createDisabledReason)}
|
|
disabledReason={createDisabledReason}
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
i18n:govoplan-views.new_view
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{!canWriteDefinitions && (!showAssignments || !canWriteAssignments) && (
|
|
<ActionBlockerHint
|
|
reason={{
|
|
summary: VIEWS_INTERFACE_I18N.readOnlySummaryText,
|
|
requiredAction: VIEWS_INTERFACE_I18N.permissionGuidanceText,
|
|
actor: VIEWS_INTERFACE_I18N.administratorText,
|
|
target: VIEWS_INTERFACE_I18N.administrationTargetText
|
|
}}
|
|
labels={{
|
|
requiredAction: VIEWS_INTERFACE_I18N.requiredActionText,
|
|
actor: VIEWS_INTERFACE_I18N.actorText,
|
|
target: VIEWS_INTERFACE_I18N.destinationText
|
|
}}
|
|
documentation={VIEWS_DOCUMENTATION}
|
|
/>
|
|
)}
|
|
<div className="views-management-layout">
|
|
<div className="views-admin-shell">
|
|
<aside
|
|
className="views-definition-pane"
|
|
aria-label={translateText("i18n:govoplan-views.view_definitions")}
|
|
>
|
|
<div className="views-pane-heading">
|
|
<strong>i18n:govoplan-views.definitions</strong>
|
|
<span>{definitions.length}</span>
|
|
</div>
|
|
<SelectionList
|
|
className="views-definition-list"
|
|
label="i18n:govoplan-views.view_definitions"
|
|
>
|
|
{definitions.map((definition) => (
|
|
<SelectionListItem
|
|
key={definition.id}
|
|
className="views-definition-item"
|
|
selected={definition.id === selectedId}
|
|
onClick={() => selectDefinition(definition.id)}
|
|
>
|
|
<span className="views-definition-item-main">
|
|
<strong>{definition.name}</strong>
|
|
<small>
|
|
{scopeLabel(definition, translateText)}
|
|
<span> · </span>
|
|
{i18nMessage("i18n:govoplan-views.revision_value", {
|
|
value0: definition.current_revision
|
|
})}
|
|
</small>
|
|
</span>
|
|
<StatusBadge status={definition.status} />
|
|
<ChevronRight size={16} aria-hidden="true" />
|
|
</SelectionListItem>
|
|
))}
|
|
{!definitions.length && (
|
|
<div className="views-empty-list">
|
|
i18n:govoplan-views.no_definitions
|
|
</div>
|
|
)}
|
|
</SelectionList>
|
|
</aside>
|
|
|
|
<main className="views-editor-pane">
|
|
{selected ? (
|
|
<>
|
|
<div className="views-editor-heading">
|
|
<div>
|
|
<div className="views-editor-title-row">
|
|
<h3>{selected.name}</h3>
|
|
{selected.readonly && (
|
|
<StatusBadge
|
|
status="inherited"
|
|
label="i18n:govoplan-views.inherited"
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="muted small-note">
|
|
{i18nMessage("i18n:govoplan-views.draft_revision_value", {
|
|
value0: selected.latest_revision.revision
|
|
})}
|
|
<span> · </span>
|
|
{selected.published_revision
|
|
? i18nMessage("i18n:govoplan-views.published_revision_value", {
|
|
value0: selected.published_revision.revision
|
|
})
|
|
: "i18n:govoplan-views.not_published"}
|
|
</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button
|
|
onClick={resetDraft}
|
|
disabled={!dirty || Boolean(editDisabledReason)}
|
|
disabledReason={editDisabledReason ?? (!dirty ? VIEWS_INTERFACE_I18N.noChangesText : undefined)}
|
|
>
|
|
i18n:govoplan-views.discard
|
|
</Button>
|
|
<Button
|
|
onClick={() => void saveDraft()}
|
|
disabled={!dirty || Boolean(editDisabledReason)}
|
|
disabledReason={editDisabledReason ?? (!dirty ? VIEWS_INTERFACE_I18N.noChangesText : undefined)}
|
|
>
|
|
<Save size={16} aria-hidden="true" />
|
|
i18n:govoplan-views.save_revision
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void publishDraft()}
|
|
disabled={Boolean(editDisabledReason) || !draft.name.trim()}
|
|
disabledReason={editDisabledReason ?? (!draft.name.trim() ? VIEWS_INTERFACE_I18N.nameRequiredText : undefined)}
|
|
>
|
|
<Send size={16} aria-hidden="true" />
|
|
i18n:govoplan-views.publish
|
|
</Button>
|
|
<IconButton
|
|
label="i18n:govoplan-views.archive_view"
|
|
icon={<Archive />}
|
|
variant="danger"
|
|
onClick={() => setArchiveTarget(selected)}
|
|
disabled={Boolean(editDisabledReason)}
|
|
disabledReason={editDisabledReason}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="views-definition-form">
|
|
<FormField
|
|
label="i18n:govoplan-views.name"
|
|
help={editDisabledReason}
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<input
|
|
value={draft.name}
|
|
maxLength={200}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={(event) =>
|
|
setDraft({ ...draft, name: event.target.value })
|
|
}
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.description"
|
|
help={editDisabledReason}
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<textarea
|
|
rows={2}
|
|
value={draft.description}
|
|
maxLength={4000}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={(event) =>
|
|
setDraft({ ...draft, description: event.target.value })
|
|
}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
|
|
{productAreas.length > 0 && (
|
|
<ProductAreaEditor
|
|
areas={productAreas}
|
|
surfaces={surfaces}
|
|
draft={draft}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={setDraft}
|
|
/>
|
|
)}
|
|
|
|
<section className="views-product-area-section">
|
|
<div className="views-section-heading">
|
|
<div>
|
|
<h4>Quick Access focus</h4>
|
|
<p className="muted small-note">
|
|
Recommend or focus namespaced tool IDs for this View. The
|
|
active account still needs the tool's permissions and visible surface.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<FormGrid columns={2} collapseAt="standard">
|
|
<FormField label="Recommended tool IDs" help="Comma-separated, for example tasks.work, files.recent">
|
|
<input
|
|
value={(draft.presentation.quickAccessRecommendedToolIds ?? []).join(", ")}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={(event) => setDraft({
|
|
...draft,
|
|
presentation: {
|
|
...draft.presentation,
|
|
quickAccessRecommendedToolIds: commaSeparatedToolIds(event.target.value)
|
|
}
|
|
})}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Focused tool IDs" help="When at least one listed tool is available, other Quick Access tools are hidden for this View.">
|
|
<input
|
|
value={(draft.presentation.quickAccessFocusedToolIds ?? []).join(", ")}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={(event) => setDraft({
|
|
...draft,
|
|
presentation: {
|
|
...draft.presentation,
|
|
quickAccessFocusedToolIds: commaSeparatedToolIds(event.target.value)
|
|
}
|
|
})}
|
|
/>
|
|
</FormField>
|
|
</FormGrid>
|
|
</section>
|
|
|
|
<section className="views-surface-section">
|
|
<div className="views-section-heading">
|
|
<div>
|
|
<h4>i18n:govoplan-views.visible_surfaces</h4>
|
|
<p className="muted small-note">
|
|
i18n:govoplan-views.surface_security_help
|
|
</p>
|
|
</div>
|
|
<strong>{i18nMessage("i18n:govoplan-views.selected_count", { value0: draft.surfaceIds.length })}</strong>
|
|
</div>
|
|
{selected.stale_surface_ids.length > 0 && (
|
|
<DismissibleAlert tone="warning" dismissible={false}>
|
|
<div className="views-stale-surface-warning">
|
|
<span>
|
|
{i18nMessage("i18n:govoplan-views.stale_surfaces_value", {
|
|
value0: selected.stale_surface_ids.join(", ")
|
|
})}
|
|
</span>
|
|
{!selected.readonly && selected.status !== "archived" && (
|
|
<Button
|
|
onClick={() =>
|
|
setDraft({
|
|
...draft,
|
|
surfaceIds: draft.surfaceIds.filter(
|
|
(id) => !selected.stale_surface_ids.includes(id)
|
|
)
|
|
})
|
|
}
|
|
disabled={busy}
|
|
disabledReason={busy ? VIEWS_INTERFACE_I18N.busyText : undefined}
|
|
>
|
|
i18n:govoplan-views.remove_stale_references
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DismissibleAlert>
|
|
)}
|
|
<SurfaceSelector
|
|
surfaces={surfaces}
|
|
selected={draft.surfaceIds}
|
|
disabled={!definitionEditable || busy}
|
|
onChange={(surfaceIds) =>
|
|
setDraft({ ...draft, surfaceIds })
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
</>
|
|
) : (
|
|
<div className="views-empty-editor">
|
|
<h3>i18n:govoplan-views.no_view_selected</h3>
|
|
<p>i18n:govoplan-views.create_projection_help</p>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
{showAssignments &&
|
|
(scopeType === "system" || scopeType === "tenant") && (
|
|
<AssignmentsSection
|
|
assignments={assignments}
|
|
definitions={definitions}
|
|
scopeType={scopeType}
|
|
busy={busy}
|
|
canWrite={canWriteAssignments}
|
|
onCreate={openCreateAssignment}
|
|
onEdit={openEditAssignment}
|
|
onToggle={toggleAssignment}
|
|
onDelete={setDeleteAssignmentTarget}
|
|
/>
|
|
)}
|
|
</div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog
|
|
open={createOpen}
|
|
title="i18n:govoplan-views.create_view"
|
|
onClose={() => createDirty ? requestDiscard(closeCreate) : closeCreate()}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<>
|
|
<Button
|
|
onClick={() => createDirty ? requestDiscard(closeCreate) : closeCreate()}
|
|
disabled={busy}
|
|
disabledReason={busy ? VIEWS_INTERFACE_I18N.busyText : undefined}
|
|
>
|
|
i18n:govoplan-views.cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void createDefinition()}
|
|
disabled={busy || !createDraft.name.trim()}
|
|
disabledReason={busy ? VIEWS_INTERFACE_I18N.busyText : !createDraft.name.trim() ? VIEWS_INTERFACE_I18N.nameRequiredText : undefined}
|
|
>
|
|
i18n:govoplan-views.create
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<FormGrid columns={1} collapseAt="standard" className="">
|
|
<FormField
|
|
label="i18n:govoplan-views.name"
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<input
|
|
autoFocus
|
|
value={createDraft.name}
|
|
maxLength={200}
|
|
onChange={(event) =>
|
|
setCreateDraft({ ...createDraft, name: event.target.value })
|
|
}
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.description"
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<textarea
|
|
rows={4}
|
|
value={createDraft.description}
|
|
maxLength={4000}
|
|
onChange={(event) =>
|
|
setCreateDraft({
|
|
...createDraft,
|
|
description: event.target.value
|
|
})
|
|
}
|
|
/>
|
|
</FormField>
|
|
<p className="muted small-note">
|
|
i18n:govoplan-views.first_draft_help
|
|
</p>
|
|
</FormGrid>
|
|
</Dialog>
|
|
|
|
<AssignmentDialog
|
|
open={assignmentEditor !== null}
|
|
editing={assignmentEditor}
|
|
draft={assignmentDraft}
|
|
definitions={definitions}
|
|
settings={settings}
|
|
scopeType={scopeType}
|
|
busy={busy}
|
|
onChange={setAssignmentDraft}
|
|
onClose={() => assignmentDirty ? requestDiscard(closeAssignment) : closeAssignment()}
|
|
onSave={saveAssignment}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={archiveTarget !== null}
|
|
title="i18n:govoplan-views.archive_view"
|
|
message={
|
|
archiveTarget
|
|
? i18nMessage("i18n:govoplan-views.archive_value_confirm", {
|
|
value0: archiveTarget.name
|
|
})
|
|
: ""
|
|
}
|
|
confirmLabel="i18n:govoplan-views.archive"
|
|
tone="danger"
|
|
busy={busy}
|
|
onCancel={() => setArchiveTarget(null)}
|
|
onConfirm={() => void archiveDefinition()}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={deleteAssignmentTarget !== null}
|
|
title="i18n:govoplan-views.remove_assignment"
|
|
message="i18n:govoplan-views.remove_assignment_confirm"
|
|
confirmLabel="i18n:govoplan-views.remove"
|
|
tone="danger"
|
|
busy={busy}
|
|
onCancel={() => setDeleteAssignmentTarget(null)}
|
|
onConfirm={() => void removeAssignment()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
function AssignmentsSection({
|
|
assignments,
|
|
definitions,
|
|
scopeType,
|
|
busy,
|
|
canWrite,
|
|
onCreate,
|
|
onEdit,
|
|
onToggle,
|
|
onDelete
|
|
}: {
|
|
assignments: ViewAssignment[];
|
|
definitions: ViewDefinition[];
|
|
scopeType: ViewScopeType;
|
|
busy: boolean;
|
|
canWrite: boolean;
|
|
onCreate: () => void;
|
|
onEdit: (assignment: ViewAssignment) => void;
|
|
onToggle: (assignment: ViewAssignment, active: boolean) => void;
|
|
onDelete: (assignment: ViewAssignment) => void;
|
|
}) {
|
|
const { translateText } = usePlatformLanguage();
|
|
const definitionById = useMemo(
|
|
() => new Map(definitions.map((definition) => [definition.id, definition])),
|
|
[definitions]
|
|
);
|
|
const hasPublishedDefinition = definitions.some(
|
|
(item) => item.status === "published"
|
|
);
|
|
const createDisabledReason = busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: !canWrite
|
|
? VIEWS_INTERFACE_I18N.assignmentWriteText
|
|
: !hasPublishedDefinition
|
|
? VIEWS_INTERFACE_I18N.publishedDefinitionText
|
|
: undefined;
|
|
|
|
return (
|
|
<section className="views-assignments-section">
|
|
<div className="views-section-heading">
|
|
<div>
|
|
<h4>i18n:govoplan-views.assignments</h4>
|
|
<p className="muted small-note">
|
|
i18n:govoplan-views.assignment_precedence_help
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={onCreate}
|
|
disabled={Boolean(createDisabledReason)}
|
|
disabledReason={createDisabledReason}
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
i18n:govoplan-views.add_assignment
|
|
</Button>
|
|
</div>
|
|
<div className="views-assignment-list">
|
|
{assignments.map((assignment) => {
|
|
const inherited =
|
|
scopeType === "tenant" && assignment.tenant_id == null;
|
|
const targetLabel =
|
|
assignment.scope_label ??
|
|
(assignment.scope_id
|
|
? translateText(i18nMessage("i18n:govoplan-views.unavailable_scope_value", {
|
|
value0: translateText(assignmentScopeLabel(assignment.scope_type))
|
|
}))
|
|
: translateText(assignmentScopeLabel(assignment.scope_type)));
|
|
const targetDetail =
|
|
assignment.scope_detail ??
|
|
(assignment.scope_label ? null : assignment.scope_id);
|
|
return (
|
|
<div key={assignment.id} className="views-assignment-row">
|
|
<div className="views-assignment-view">
|
|
<strong>
|
|
{definitionById.get(assignment.definition_id)?.name ??
|
|
assignment.definition_id}
|
|
</strong>
|
|
<span>
|
|
{assignmentScopeLabel(assignment.scope_type)}
|
|
{assignment.scope_id ? ` · ${targetLabel}` : ""}
|
|
</span>
|
|
{targetDetail && <small>{targetDetail}</small>}
|
|
</div>
|
|
<StatusBadge status={assignment.mode} />
|
|
<span className="views-assignment-priority">
|
|
{i18nMessage("i18n:govoplan-views.priority_value", {
|
|
value0: assignment.priority
|
|
})}
|
|
</span>
|
|
<span className="views-assignment-revision">
|
|
{assignment.revision_id
|
|
? "i18n:govoplan-views.pinned_revision"
|
|
: "i18n:govoplan-views.tracks_published"}
|
|
</span>
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-views.assignment_active"
|
|
checked={assignment.is_active}
|
|
disabled={inherited || !canWrite || busy}
|
|
help={inherited
|
|
? VIEWS_INTERFACE_I18N.inheritedAssignmentText
|
|
: !canWrite
|
|
? VIEWS_INTERFACE_I18N.assignmentWriteText
|
|
: busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: undefined}
|
|
onChange={(active) => onToggle(assignment, active)}
|
|
/>
|
|
<div className="button-row compact-actions">
|
|
<IconButton
|
|
label="i18n:govoplan-views.edit_assignment"
|
|
icon={<Pencil />}
|
|
onClick={() => onEdit(assignment)}
|
|
disabled={inherited || !canWrite || busy}
|
|
disabledReason={inherited
|
|
? VIEWS_INTERFACE_I18N.inheritedAssignmentText
|
|
: !canWrite
|
|
? VIEWS_INTERFACE_I18N.assignmentWriteText
|
|
: busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: undefined}
|
|
/>
|
|
<IconButton
|
|
label="i18n:govoplan-views.remove_assignment"
|
|
icon={<Trash2 />}
|
|
variant="danger"
|
|
onClick={() => onDelete(assignment)}
|
|
disabled={inherited || !canWrite || busy}
|
|
disabledReason={inherited
|
|
? VIEWS_INTERFACE_I18N.inheritedAssignmentText
|
|
: !canWrite
|
|
? VIEWS_INTERFACE_I18N.assignmentWriteText
|
|
: busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!assignments.length && (
|
|
<div className="views-empty-list">
|
|
i18n:govoplan-views.no_assignments
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
|
|
function AssignmentDialog({
|
|
open,
|
|
editing,
|
|
draft,
|
|
definitions,
|
|
settings,
|
|
scopeType,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
editing: ViewAssignment | "new" | null;
|
|
draft: AssignmentDraft;
|
|
definitions: ViewDefinition[];
|
|
settings: ApiSettings;
|
|
scopeType: ViewScopeType;
|
|
busy: boolean;
|
|
onChange: (draft: AssignmentDraft) => void;
|
|
onClose: () => void;
|
|
onSave: () => void | Promise<boolean>;
|
|
}) {
|
|
const { translateText } = usePlatformLanguage();
|
|
const [directoryIssue, setDirectoryIssue] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
setDirectoryIssue(null);
|
|
}, [draft.scopeType, open]);
|
|
const definition = definitions.find(
|
|
(item) => item.id === draft.definitionId
|
|
);
|
|
const requiredAdminSurface =
|
|
draft.scopeType === "system"
|
|
? "views.admin.system"
|
|
: "views.admin.tenant";
|
|
const validatesCurrentPublishedRevision =
|
|
editing === "new" ||
|
|
!draft.pinRevision ||
|
|
!editing?.revision_id;
|
|
const requiredMissing = draft.mode === "required"
|
|
? [...LOCKOUT_SURFACES]
|
|
.filter((id) => id !== "views.admin.system" && id !== "views.admin.tenant")
|
|
.concat(requiredAdminSurface)
|
|
.filter(
|
|
(id) =>
|
|
validatesCurrentPublishedRevision &&
|
|
definitions.length > 0 &&
|
|
definition?.published_revision &&
|
|
!definition.published_revision.visible_surface_ids.includes(id)
|
|
)
|
|
: [];
|
|
const targetRequired =
|
|
draft.scopeType === "group" || draft.scopeType === "user";
|
|
const loadTargets = useCallback(
|
|
async (
|
|
query: string,
|
|
options: { limit: number; signal: AbortSignal }
|
|
): Promise<readonly SearchableSelectOption[]> => {
|
|
if (draft.scopeType !== "group" && draft.scopeType !== "user") {
|
|
return [];
|
|
}
|
|
const response = await fetchViewAssignmentTargets(
|
|
settings,
|
|
draft.scopeType,
|
|
query,
|
|
options.limit,
|
|
options.signal
|
|
);
|
|
if (!response.directory_available) {
|
|
setDirectoryIssue(
|
|
response.unavailable_reason ??
|
|
"i18n:govoplan-views.access_directory_unavailable"
|
|
);
|
|
return [];
|
|
}
|
|
setDirectoryIssue(null);
|
|
return response.targets.map((target) => ({
|
|
value: target.id,
|
|
label: target.label,
|
|
description:
|
|
[target.detail, target.disabled_reason]
|
|
.filter(Boolean)
|
|
.join(" · ") || null,
|
|
disabled: target.disabled
|
|
}));
|
|
},
|
|
[
|
|
draft.scopeType,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey
|
|
]
|
|
);
|
|
const selectedTarget: SearchableSelectOption | null = draft.scopeId
|
|
? {
|
|
value: draft.scopeId,
|
|
label: draft.scopeLabel || draft.scopeId,
|
|
description: draft.scopeDetail || null
|
|
}
|
|
: null;
|
|
const canSave = Boolean(
|
|
definition?.published_revision &&
|
|
(!targetRequired || draft.scopeId.trim()) &&
|
|
requiredMissing.length === 0
|
|
);
|
|
const saveDisabledReason = busy
|
|
? VIEWS_INTERFACE_I18N.busyText
|
|
: !definition?.published_revision
|
|
? VIEWS_INTERFACE_I18N.publishedDefinitionText
|
|
: targetRequired && !draft.scopeId.trim()
|
|
? VIEWS_INTERFACE_I18N.targetRequiredText
|
|
: requiredMissing.length > 0
|
|
? VIEWS_INTERFACE_I18N.lockoutText
|
|
: undefined;
|
|
const scopeText = translateText(assignmentScopeLabel(draft.scopeType));
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title={editing === "new"
|
|
? "i18n:govoplan-views.add_view_assignment"
|
|
: "i18n:govoplan-views.edit_view_assignment"}
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
className="views-assignment-dialog"
|
|
footer={
|
|
<>
|
|
<Button
|
|
onClick={onClose}
|
|
disabled={busy}
|
|
disabledReason={busy ? VIEWS_INTERFACE_I18N.busyText : undefined}
|
|
>
|
|
i18n:govoplan-views.cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={!canSave || Boolean(saveDisabledReason)}
|
|
disabledReason={saveDisabledReason}
|
|
>
|
|
i18n:govoplan-views.save
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<FormField
|
|
label="i18n:govoplan-views.target_level"
|
|
help={editing !== "new" ? "i18n:govoplan-views.assignment_target_immutable" : undefined}
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<select
|
|
value={draft.scopeType}
|
|
disabled={editing !== "new"}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...draft,
|
|
scopeType: event.target.value as ViewAssignmentScopeType,
|
|
scopeId: "",
|
|
scopeLabel: "",
|
|
scopeDetail: ""
|
|
})
|
|
}
|
|
>
|
|
{scopeType === "system" ? (
|
|
<option value="system">
|
|
{translateText("i18n:govoplan-views.system")}
|
|
</option>
|
|
) : (
|
|
<>
|
|
<option value="tenant">{translateText("i18n:govoplan-views.tenant")}</option>
|
|
<option value="group">{translateText("i18n:govoplan-views.group")}</option>
|
|
<option value="user">{translateText("i18n:govoplan-views.user")}</option>
|
|
</>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.target"
|
|
help={editing !== "new" ? "i18n:govoplan-views.assignment_target_immutable" : undefined}
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
{targetRequired ? (
|
|
<>
|
|
<SearchableSelect
|
|
id="view-assignment-target"
|
|
aria-label={translateText(i18nMessage("i18n:govoplan-views.select_scope_value", { value0: scopeText }))}
|
|
value={draft.scopeId}
|
|
selectedOption={selectedTarget}
|
|
loadOptions={loadTargets}
|
|
placeholder={translateText(i18nMessage("i18n:govoplan-views.select_a_scope_value", { value0: scopeText }))}
|
|
searchPlaceholder={translateText(i18nMessage("i18n:govoplan-views.search_scope_value", { value0: scopeText }))}
|
|
emptyText={translateText(i18nMessage("i18n:govoplan-views.no_matching_scope_value", { value0: scopeText }))}
|
|
disabled={editing !== "new" || busy}
|
|
required
|
|
onChange={(value, option) =>
|
|
onChange({
|
|
...draft,
|
|
scopeId: value,
|
|
scopeLabel: option?.label ?? "",
|
|
scopeDetail: option?.description ?? ""
|
|
})
|
|
}
|
|
/>
|
|
{directoryIssue && (
|
|
<ActionBlockerHint
|
|
reason={{
|
|
summary: VIEWS_INTERFACE_I18N.directorySummaryText,
|
|
details: directoryIssue,
|
|
requiredAction: VIEWS_INTERFACE_I18N.directoryGuidanceText,
|
|
actor: VIEWS_INTERFACE_I18N.administratorText,
|
|
target: VIEWS_INTERFACE_I18N.moduleTargetText
|
|
}}
|
|
labels={{
|
|
requiredAction: VIEWS_INTERFACE_I18N.requiredActionText,
|
|
actor: VIEWS_INTERFACE_I18N.actorText,
|
|
target: VIEWS_INTERFACE_I18N.destinationText
|
|
}}
|
|
documentation={VIEWS_DOCUMENTATION}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<input
|
|
value={
|
|
draft.scopeType === "system"
|
|
? translateText("i18n:govoplan-views.current_system")
|
|
: translateText("i18n:govoplan-views.current_tenant")
|
|
}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
)}
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.view"
|
|
help={editing !== "new" ? "i18n:govoplan-views.assignment_view_immutable" : undefined}
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<select
|
|
value={draft.definitionId}
|
|
disabled={editing !== "new"}
|
|
onChange={(event) =>
|
|
onChange({ ...draft, definitionId: event.target.value })
|
|
}
|
|
>
|
|
{definitions
|
|
.filter((item) => item.status === "published")
|
|
.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.name} ({translateText(assignmentScopeLabel(item.scope_type))})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.mode"
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<select
|
|
value={draft.mode}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...draft,
|
|
mode: event.target.value as ViewAssignmentMode
|
|
})
|
|
}
|
|
>
|
|
<option value="available">{translateText("i18n:govoplan-views.available")}</option>
|
|
<option value="default">{translateText("i18n:govoplan-views.default")}</option>
|
|
<option value="required">{translateText("i18n:govoplan-views.required")}</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="i18n:govoplan-views.priority"
|
|
documentation={VIEWS_FIELD_DOCUMENTATION}
|
|
>
|
|
<input
|
|
type="number"
|
|
min={-1000}
|
|
max={1000}
|
|
value={draft.priority}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...draft,
|
|
priority: Number(event.target.value) || 0
|
|
})
|
|
}
|
|
/>
|
|
</FormField>
|
|
<div className="views-assignment-toggles">
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-views.assignment_active"
|
|
checked={draft.active}
|
|
disabled={busy}
|
|
help={busy ? VIEWS_INTERFACE_I18N.busyText : undefined}
|
|
onChange={(active) => onChange({ ...draft, active })}
|
|
/>
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-views.pin_revision"
|
|
checked={draft.pinRevision}
|
|
disabled={busy}
|
|
onChange={(pinRevision) => onChange({ ...draft, pinRevision })}
|
|
help="i18n:govoplan-views.pin_revision_help"
|
|
/>
|
|
</div>
|
|
</FormGrid>
|
|
{draft.mode === "required" && (
|
|
<DismissibleAlert
|
|
tone={requiredMissing.length ? "danger" : "warning"}
|
|
dismissible={false}
|
|
>
|
|
{requiredMissing.length
|
|
? i18nMessage("i18n:govoplan-views.required_missing_value", {
|
|
value0: requiredMissing.join(", ")
|
|
})
|
|
: "i18n:govoplan-views.required_warning"}
|
|
</DismissibleAlert>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|
|
function ProductAreaEditor({
|
|
areas,
|
|
surfaces,
|
|
draft,
|
|
disabled,
|
|
onChange
|
|
}: {
|
|
areas: ViewProductArea[];
|
|
surfaces: PlatformViewSurface[];
|
|
draft: DefinitionDraft;
|
|
disabled: boolean;
|
|
onChange: (draft: DefinitionDraft) => void;
|
|
}) {
|
|
const { translateText } = usePlatformLanguage();
|
|
const ordered = orderedProductAreas(areas, draft.presentation.productAreaOrder);
|
|
const requiredSurfaceIds = new Set(
|
|
surfaces.filter((surface) => surface.required).map((surface) => surface.id)
|
|
);
|
|
|
|
function updatePresentation(presentation: ViewPresentation) {
|
|
onChange({ ...draft, presentation });
|
|
}
|
|
|
|
function move(areaId: string, direction: -1 | 1) {
|
|
const order = ordered.map((area) => area.id);
|
|
const index = order.indexOf(areaId);
|
|
const target = index + direction;
|
|
if (index < 0 || target < 0 || target >= order.length) return;
|
|
[order[index], order[target]] = [order[target], order[index]];
|
|
updatePresentation({ ...draft.presentation, productAreaOrder: order });
|
|
}
|
|
|
|
function setLabel(areaId: string, label: string) {
|
|
const labels = { ...(draft.presentation.productAreaLabels ?? {}) };
|
|
if (label.trim()) labels[areaId] = label;
|
|
else delete labels[areaId];
|
|
updatePresentation({ ...draft.presentation, productAreaLabels: labels });
|
|
}
|
|
|
|
function setAreaVisible(area: ViewProductArea, visible: boolean) {
|
|
const selected = new Set(draft.surfaceIds);
|
|
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
|
|
if (visible) affectedSurfaceIds.forEach((id) => selected.add(id));
|
|
else affectedSurfaceIds.forEach((id) => selected.delete(id));
|
|
onChange({ ...draft, surfaceIds: [...selected] });
|
|
}
|
|
|
|
return (
|
|
<section className="views-product-area-section">
|
|
<div className="views-section-heading">
|
|
<div>
|
|
<h4>i18n:govoplan-views.product_areas</h4>
|
|
<p className="muted small-note">
|
|
i18n:govoplan-views.product_areas_help
|
|
</p>
|
|
</div>
|
|
<SegmentedControl<"grouped" | "flat">
|
|
ariaLabel={translateText("i18n:govoplan-views.navigation_layout")}
|
|
role="group"
|
|
value={draft.presentation.navigationMode ?? "grouped"}
|
|
disabled={disabled}
|
|
onChange={(navigationMode) =>
|
|
updatePresentation({ ...draft.presentation, navigationMode })
|
|
}
|
|
options={[
|
|
{ id: "grouped", label: "i18n:govoplan-views.grouped" },
|
|
{ id: "flat", label: "i18n:govoplan-views.flat" }
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="views-product-area-list">
|
|
{ordered.map((area, index) => {
|
|
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
|
|
const visible = affectedSurfaceIds.some((id) =>
|
|
draft.surfaceIds.includes(id)
|
|
);
|
|
const required = affectedSurfaceIds.some((id) =>
|
|
requiredSurfaceIds.has(id)
|
|
);
|
|
return (
|
|
<div className="views-product-area-row" key={area.id}>
|
|
<div className="views-product-area-copy">
|
|
<strong>{translateText(area.label)}</strong>
|
|
<small>{translateText(area.description ?? area.id)}</small>
|
|
</div>
|
|
<input
|
|
value={draft.presentation.productAreaLabels?.[area.id] ?? ""}
|
|
placeholder={translateText(area.label)}
|
|
aria-label={i18nMessage(
|
|
"i18n:govoplan-views.custom_area_label_value",
|
|
{ value0: translateText(area.label) }
|
|
)}
|
|
maxLength={200}
|
|
disabled={disabled}
|
|
onChange={(event) => setLabel(area.id, event.target.value)}
|
|
/>
|
|
<ToggleSwitch
|
|
label={area.label}
|
|
inactiveLabel="i18n:govoplan-views.hidden"
|
|
activeLabel="i18n:govoplan-views.visible"
|
|
checked={visible}
|
|
disabled={disabled || required}
|
|
help={required ? "i18n:govoplan-views.required_area_help" : undefined}
|
|
onChange={(checked) => setAreaVisible(area, checked)}
|
|
/>
|
|
<div className="views-product-area-order">
|
|
<IconButton
|
|
label="i18n:govoplan-views.move_up"
|
|
icon={<ArrowUp size={16} />}
|
|
variant="ghost"
|
|
disabled={disabled || index === 0}
|
|
onClick={() => move(area.id, -1)}
|
|
/>
|
|
<IconButton
|
|
label="i18n:govoplan-views.move_down"
|
|
icon={<ArrowDown size={16} />}
|
|
variant="ghost"
|
|
disabled={disabled || index === ordered.length - 1}
|
|
onClick={() => move(area.id, 1)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
|
|
function SurfaceSelector({
|
|
surfaces,
|
|
selected,
|
|
disabled,
|
|
onChange
|
|
}: {
|
|
surfaces: PlatformViewSurface[];
|
|
selected: string[];
|
|
disabled: boolean;
|
|
onChange: (surfaceIds: string[]) => void;
|
|
}) {
|
|
const { translateText } = usePlatformLanguage();
|
|
const [filter, setFilter] = useState("");
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
const byId = useMemo(
|
|
() => new Map(surfaces.map((surface) => [surface.id, surface])),
|
|
[surfaces]
|
|
);
|
|
const childrenByParent = useMemo(() => {
|
|
const result = new Map<string, PlatformViewSurface[]>();
|
|
for (const surface of surfaces) {
|
|
if (!surface.parentId) continue;
|
|
const children = result.get(surface.parentId) ?? [];
|
|
children.push(surface);
|
|
result.set(surface.parentId, children);
|
|
}
|
|
for (const children of result.values()) {
|
|
children.sort(surfaceOrder);
|
|
}
|
|
return result;
|
|
}, [surfaces]);
|
|
const modules = useMemo(
|
|
() => surfaces.filter((surface) => surface.kind === "module").sort(surfaceOrder),
|
|
[surfaces]
|
|
);
|
|
const selectedSet = new Set(selected);
|
|
const requiredIds = new Set(
|
|
surfaces.filter((surface) => surface.required).map((surface) => surface.id)
|
|
);
|
|
|
|
function descendants(surfaceId: string): string[] {
|
|
const result: string[] = [];
|
|
const visited = new Set([surfaceId]);
|
|
const pending = [...(childrenByParent.get(surfaceId) ?? [])];
|
|
while (pending.length) {
|
|
const child = pending.shift();
|
|
if (!child || visited.has(child.id)) continue;
|
|
visited.add(child.id);
|
|
result.push(child.id);
|
|
pending.push(...(childrenByParent.get(child.id) ?? []));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function withAncestors(next: Set<string>, surfaceId: string) {
|
|
const visited = new Set([surfaceId]);
|
|
let current = byId.get(surfaceId);
|
|
while (current?.parentId) {
|
|
if (visited.has(current.parentId)) break;
|
|
visited.add(current.parentId);
|
|
next.add(current.parentId);
|
|
current = byId.get(current.parentId);
|
|
}
|
|
}
|
|
|
|
function normalizeRequired(next: Set<string>) {
|
|
for (const surfaceId of requiredIds) {
|
|
next.add(surfaceId);
|
|
withAncestors(next, surfaceId);
|
|
}
|
|
}
|
|
|
|
function toggle(surfaceId: string, checked: boolean) {
|
|
const next = new Set(selectedSet);
|
|
if (checked) {
|
|
next.add(surfaceId);
|
|
withAncestors(next, surfaceId);
|
|
} else {
|
|
next.delete(surfaceId);
|
|
for (const childId of descendants(surfaceId)) next.delete(childId);
|
|
}
|
|
normalizeRequired(next);
|
|
onChange([...next]);
|
|
}
|
|
|
|
const normalizedFilter = filter.trim().toLowerCase();
|
|
const visibleModules = modules.filter((moduleSurface) => {
|
|
if (!normalizedFilter) return true;
|
|
return [moduleSurface, ...descendants(moduleSurface.id)]
|
|
.map((surface) =>
|
|
typeof surface === "string" ? byId.get(surface) : surface
|
|
)
|
|
.filter((surface): surface is PlatformViewSurface => Boolean(surface))
|
|
.some((surface) =>
|
|
`${translateText(surface.label)} ${surface.id} ${translateText(surface.description ?? "")}`
|
|
.toLowerCase()
|
|
.includes(normalizedFilter)
|
|
);
|
|
});
|
|
const effectiveExpandedIds = normalizedFilter
|
|
? new Set(
|
|
surfaces
|
|
.filter((surface) => (childrenByParent.get(surface.id) ?? []).length > 0)
|
|
.map((surface) => surface.id)
|
|
)
|
|
: expandedIds;
|
|
|
|
return (
|
|
<div className="views-surface-selector">
|
|
<div className="views-surface-filter">
|
|
<input
|
|
value={filter}
|
|
placeholder={translateText("i18n:govoplan-views.filter_surfaces")}
|
|
aria-label={translateText("i18n:govoplan-views.filter_view_surfaces")}
|
|
onChange={(event) => setFilter(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="views-surface-modules">
|
|
<ExplorerTree
|
|
nodes={visibleModules}
|
|
getNodeId={(surface) => surface.id}
|
|
getNodeLabel={(surface) => translateText(surface.label)}
|
|
getNodeChildren={(surface) => childrenByParent.get(surface.id) ?? []}
|
|
expandedIds={effectiveExpandedIds}
|
|
depth={0}
|
|
className="views-surface-tree"
|
|
onToggle={(surface) => {
|
|
setExpandedIds((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(surface.id)) next.delete(surface.id);
|
|
else next.add(surface.id);
|
|
return next;
|
|
});
|
|
}}
|
|
onOpen={(surface) => {
|
|
if (disabled || requiredIds.has(surface.id)) return;
|
|
toggle(surface.id, !selectedSet.has(surface.id));
|
|
}}
|
|
renderToggleIcon={(_surface, context) =>
|
|
context.hasChildren ? (
|
|
context.expanded ? (
|
|
<ChevronDown size={16} aria-hidden="true" />
|
|
) : (
|
|
<ChevronRight size={16} aria-hidden="true" />
|
|
)
|
|
) : null
|
|
}
|
|
getNodeButtonClassName={(surface) =>
|
|
disabled || requiredIds.has(surface.id)
|
|
? "views-surface-node disabled"
|
|
: "views-surface-node"
|
|
}
|
|
renderNodeContent={(surface) => {
|
|
const childIds = descendants(surface.id);
|
|
const selectedChildren = childIds.filter((id) =>
|
|
selectedSet.has(id)
|
|
).length;
|
|
const checked = selectedSet.has(surface.id);
|
|
const indeterminate =
|
|
selectedChildren > 0 && selectedChildren < childIds.length;
|
|
const secondary =
|
|
surface.kind === "module"
|
|
? i18nMessage("i18n:govoplan-views.surface_count", {
|
|
value0: selectedChildren,
|
|
value1: childIds.length
|
|
})
|
|
: i18nMessage("i18n:govoplan-views.surface_detail", {
|
|
value0: surface.kind,
|
|
value1: surface.description ?? surface.id
|
|
});
|
|
return (
|
|
<>
|
|
<span className="views-surface-check" aria-hidden="true">
|
|
{indeterminate ? (
|
|
<MinusSquare size={17} />
|
|
) : checked ? (
|
|
<CheckSquare2 size={17} />
|
|
) : (
|
|
<Square size={17} />
|
|
)}
|
|
</span>
|
|
<span className="explorer-tree-node-content">
|
|
<strong>{surface.label}</strong>
|
|
<small>{secondary}</small>
|
|
</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
{!visibleModules.length && (
|
|
<div className="views-empty-list">
|
|
i18n:govoplan-views.no_matching_surfaces
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
|
|
return {
|
|
scopeType: scopeType === "system" ? "system" : "tenant",
|
|
scopeId: "",
|
|
scopeLabel: "",
|
|
scopeDetail: "",
|
|
definitionId: "",
|
|
mode: "available",
|
|
priority: 0,
|
|
active: true,
|
|
pinRevision: false
|
|
};
|
|
}
|
|
|
|
|
|
function assignmentScopeLabel(
|
|
scopeType: ViewAssignmentScopeType
|
|
): string {
|
|
return {
|
|
system: "i18n:govoplan-views.system",
|
|
tenant: "i18n:govoplan-views.tenant",
|
|
group: "i18n:govoplan-views.group",
|
|
user: "i18n:govoplan-views.user"
|
|
}[scopeType];
|
|
}
|
|
|
|
|
|
function scopeLabel(
|
|
definition: ViewDefinition,
|
|
translateText: (value: string) => string
|
|
): string {
|
|
const label = {
|
|
system: "i18n:govoplan-views.system",
|
|
tenant: "i18n:govoplan-views.tenant",
|
|
group: "i18n:govoplan-views.group",
|
|
user: "i18n:govoplan-views.user"
|
|
} satisfies Record<ViewScopeType, string>;
|
|
const translatedLabel = translateText(label[definition.scope_type]);
|
|
return definition.scope_id && definition.scope_type !== "tenant"
|
|
? `${translatedLabel} · ${definition.scope_id}`
|
|
: translatedLabel;
|
|
}
|
|
|
|
|
|
function definitionDraftKey(draft: DefinitionDraft): string {
|
|
return JSON.stringify({
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim(),
|
|
surfaces: [...new Set(draft.surfaceIds)].sort(),
|
|
presentation: presentationKey(draft.presentation)
|
|
});
|
|
}
|
|
|
|
|
|
function aggregateProductAreas(modules: PlatformWebModule[]): ViewProductArea[] {
|
|
const result = new Map<string, ViewProductArea>();
|
|
for (const contribution of modules.flatMap(
|
|
(module) => module.productAreas ?? []
|
|
)) {
|
|
const existing = result.get(contribution.id);
|
|
if (existing) {
|
|
existing.surfaceIds = [
|
|
...new Set([...existing.surfaceIds, ...contribution.surfaceIds])
|
|
];
|
|
existing.order = Math.min(existing.order, contribution.order ?? 100);
|
|
continue;
|
|
}
|
|
result.set(contribution.id, {
|
|
id: contribution.id,
|
|
label: contribution.label,
|
|
description: contribution.description,
|
|
order: contribution.order ?? 100,
|
|
surfaceIds: [...new Set(contribution.surfaceIds)]
|
|
});
|
|
}
|
|
return [...result.values()].sort(
|
|
(left, right) =>
|
|
left.order - right.order || left.label.localeCompare(right.label)
|
|
);
|
|
}
|
|
|
|
|
|
function defaultPresentation(areas: ViewProductArea[]): ViewPresentation {
|
|
return {
|
|
navigationMode: "grouped",
|
|
productAreaOrder: areas.map((area) => area.id),
|
|
productAreaLabels: {},
|
|
quickAccessRecommendedToolIds: [],
|
|
quickAccessFocusedToolIds: []
|
|
};
|
|
}
|
|
|
|
|
|
function productAreaSurfaceIds(
|
|
area: ViewProductArea,
|
|
surfaces: PlatformViewSurface[]
|
|
): string[] {
|
|
const affected = new Set(area.surfaceIds);
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const surface of surfaces) {
|
|
if (
|
|
surface.parentId &&
|
|
affected.has(surface.parentId) &&
|
|
!affected.has(surface.id)
|
|
) {
|
|
affected.add(surface.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return [...affected];
|
|
}
|
|
|
|
|
|
function revisionPresentation(
|
|
value: ViewDefinition["latest_revision"]["presentation"] | undefined,
|
|
areas: ViewProductArea[]
|
|
): ViewPresentation {
|
|
const defaults = defaultPresentation(areas);
|
|
return {
|
|
navigationMode: value?.navigation_mode ?? defaults.navigationMode,
|
|
productAreaOrder:
|
|
value?.product_area_order?.length
|
|
? value.product_area_order
|
|
: defaults.productAreaOrder,
|
|
productAreaLabels: value?.product_area_labels ?? {},
|
|
quickAccessRecommendedToolIds: value?.quick_access_recommended_tool_ids ?? [],
|
|
quickAccessFocusedToolIds: value?.quick_access_focused_tool_ids ?? []
|
|
};
|
|
}
|
|
|
|
|
|
function orderedProductAreas(
|
|
areas: ViewProductArea[],
|
|
configuredOrder: string[] | undefined
|
|
): ViewProductArea[] {
|
|
const rank = new Map((configuredOrder ?? []).map((id, index) => [id, index]));
|
|
return [...areas].sort(
|
|
(left, right) =>
|
|
(rank.get(left.id) ?? 10_000) - (rank.get(right.id) ?? 10_000) ||
|
|
left.order - right.order ||
|
|
left.label.localeCompare(right.label)
|
|
);
|
|
}
|
|
|
|
|
|
function presentationKey(value: ViewPresentation): string {
|
|
return JSON.stringify({
|
|
navigationMode: value.navigationMode ?? "grouped",
|
|
productAreaOrder: value.productAreaOrder ?? [],
|
|
productAreaLabels: Object.fromEntries(
|
|
Object.entries(value.productAreaLabels ?? {})
|
|
.filter(([, label]) => label.trim())
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
),
|
|
quickAccessRecommendedToolIds: value.quickAccessRecommendedToolIds ?? [],
|
|
quickAccessFocusedToolIds: value.quickAccessFocusedToolIds ?? []
|
|
});
|
|
}
|
|
|
|
function commaSeparatedToolIds(value: string): string[] {
|
|
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
|
}
|
|
|
|
|
|
function assignmentDraftKey(draft: AssignmentDraft): string {
|
|
return JSON.stringify({
|
|
scopeType: draft.scopeType,
|
|
scopeId: draft.scopeId.trim(),
|
|
definitionId: draft.definitionId,
|
|
mode: draft.mode,
|
|
priority: draft.priority,
|
|
active: draft.active,
|
|
pinRevision: draft.pinRevision
|
|
});
|
|
}
|
|
|
|
|
|
function surfaceSetKey(surfaceIds: string[]): string {
|
|
return [...new Set(surfaceIds)].sort().join("\n");
|
|
}
|
|
|
|
|
|
function surfaceOrder(
|
|
left: PlatformViewSurface,
|
|
right: PlatformViewSurface
|
|
): number {
|
|
return (
|
|
(left.order ?? 100) - (right.order ?? 100) ||
|
|
left.label.localeCompare(right.label) ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
}
|