Files
govoplan-views/webui/src/features/views/ViewsAdminPanel.tsx
T

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)
);
}