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

1238 lines
38 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import {
Archive,
Check,
ChevronRight,
Pencil,
Plus,
RefreshCw,
Save,
Send,
Trash2
} from "lucide-react";
import {
AdminPageLayout,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
FormField,
IconButton,
StatusBadge,
ToggleSwitch,
adminErrorMessage,
dispatchPlatformViewChanged,
useUnsavedChanges,
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type PlatformViewSurface
} from "@govoplan/core-webui";
import {
archiveViewDefinition,
createViewAssignment,
createViewDefinition,
createViewRevision,
deleteViewAssignment,
fetchViewAssignments,
fetchViewDefinitions,
publishViewRevision,
updateViewAssignment,
updateViewDefinition,
type ViewAssignment,
type ViewAssignmentMode,
type ViewAssignmentScopeType,
type ViewDefinition,
type ViewScopeType
} from "../../api/views";
type DefinitionDraft = {
name: string;
description: string;
surfaceIds: string[];
};
type AssignmentDraft = {
scopeType: ViewAssignmentScopeType;
scopeId: 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,
canWriteDefinitions,
canWriteAssignments
}: {
settings: ApiSettings;
scopeType: ViewScopeType;
canWriteDefinitions: boolean;
canWriteAssignments: boolean;
}) {
const surfaces = useViewSurfaces();
const { requestDiscard } = useUnsavedChanges();
const [definitions, setDefinitions] = useState<ViewDefinition[]>([]);
const [assignments, setAssignments] = useState<ViewAssignment[]>([]);
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<DefinitionDraft>({
name: "",
description: "",
surfaceIds: []
});
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 [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
);
useUnsavedDraftGuard({
dirty,
onSave: saveDraft,
onDiscard: resetDraft
});
async function load(preferredId?: string) {
setLoading(true);
setError("");
try {
const [nextDefinitions, nextAssignments] = await Promise.all([
fetchViewDefinitions(settings, scopeType),
fetchViewAssignments(settings, scopeType)
]);
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,
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
}
: { name: "", description: "", surfaceIds: [] };
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)
) {
next = await createViewRevision(
settings,
selected.id,
draft.surfaceIds
);
}
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("View 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(`Revision ${persisted.latest_revision.revision} published.`);
await load(persisted.id);
dispatchPlatformViewChanged();
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
async function createDefinition() {
setBusy(true);
setError("");
try {
const visibleSurfaceIds = surfaces
.filter((surface) => surface.defaultVisible !== false)
.map((surface) => surface.id);
const created = await createViewDefinition(settings, {
scope_type: scopeType,
name: createDraft.name.trim(),
description: createDraft.description.trim() || null,
visible_surface_ids: visibleSurfaceIds
});
setCreateOpen(false);
setCreateDraft({ name: "", description: "" });
setSuccess("View draft created with all currently available surfaces.");
await load(created.id);
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
async function archiveDefinition() {
if (!archiveTarget) return;
setBusy(true);
setError("");
try {
await archiveViewDefinition(settings, archiveTarget.id);
setArchiveTarget(null);
setSuccess("View archived.");
await load();
dispatchPlatformViewChanged();
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
function openCreateAssignment() {
const firstPublished = definitions.find(
(definition) => definition.status === "published"
);
setAssignmentDraft({
...emptyAssignmentDraft(scopeType),
definitionId: firstPublished?.id ?? ""
});
setAssignmentEditor("new");
setError("");
}
function openEditAssignment(assignment: ViewAssignment) {
setAssignmentDraft({
scopeType: assignment.scope_type,
scopeId: assignment.scope_id ?? "",
definitionId: assignment.definition_id,
mode: assignment.mode,
priority: assignment.priority,
active: assignment.is_active,
pinRevision: Boolean(assignment.revision_id)
});
setAssignmentEditor(assignment);
setError("");
}
async function saveAssignment() {
const definition = definitions.find(
(item) => item.id === assignmentDraft.definitionId
);
if (!definition) return;
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
});
}
setAssignmentEditor(null);
setSuccess(
assignmentEditor === "new"
? "View assignment created."
: "View assignment updated."
);
await load(selectedId);
dispatchPlatformViewChanged();
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
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("View assignment removed.");
await load(selectedId);
dispatchPlatformViewChanged();
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
const title = scopeType === "system" ? "System Views" : "Tenant Views";
const description =
scopeType === "system"
? "Publish reusable interface projections and assign instance-wide defaults or requirements."
: "Tailor the visible interface for this tenant, its groups, and individual users.";
const canCreate =
canWriteDefinitions &&
surfaces.some((surface) => surface.kind === "navigation") &&
surfaces.some((surface) => surface.kind === "route");
return (
<>
<AdminPageLayout
title={title}
description={description}
loading={loading}
error={error}
success={success}
actions={
<>
<IconButton
label="Reload Views"
icon={<RefreshCw />}
onClick={() => requestDiscard(() => void load(selectedId))}
disabled={loading || busy}
/>
<Button
variant="primary"
onClick={() => setCreateOpen(true)}
disabled={!canCreate || busy}
disabledReason={
canWriteDefinitions
? undefined
: "You do not have permission to create Views."
}
>
<Plus size={16} aria-hidden="true" />
New View
</Button>
</>
}
>
<div className="views-admin-shell">
<aside className="views-definition-pane" aria-label="View definitions">
<div className="views-pane-heading">
<strong>Definitions</strong>
<span>{definitions.length}</span>
</div>
<div className="views-definition-list">
{definitions.map((definition) => (
<button
key={definition.id}
type="button"
className={
definition.id === selectedId
? "views-definition-item active"
: "views-definition-item"
}
onClick={() => selectDefinition(definition.id)}
>
<span className="views-definition-item-main">
<strong>{definition.name}</strong>
<small>
{definition.scope_type === "system" ? "System" : "Tenant"}
{" · "}
revision {definition.current_revision}
</small>
</span>
<StatusBadge status={definition.status} />
<ChevronRight size={16} aria-hidden="true" />
</button>
))}
{!definitions.length && (
<div className="views-empty-list">
No Views have been defined in this scope.
</div>
)}
</div>
</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="Inherited" />
)}
</div>
<p className="muted small-note">
Draft revision {selected.latest_revision.revision}
{selected.published_revision
? ` · published revision ${selected.published_revision.revision}`
: " · not published"}
</p>
</div>
{!selected.readonly && selected.status !== "archived" && (
<div className="button-row compact-actions">
<Button onClick={resetDraft} disabled={!dirty || busy}>
Discard
</Button>
<Button
onClick={() => void saveDraft()}
disabled={!dirty || busy}
>
<Save size={16} aria-hidden="true" />
Save revision
</Button>
<Button
variant="primary"
onClick={() => void publishDraft()}
disabled={busy || !draft.name.trim()}
>
<Send size={16} aria-hidden="true" />
Publish
</Button>
<IconButton
label="Archive View"
icon={<Archive />}
variant="danger"
onClick={() => setArchiveTarget(selected)}
disabled={busy}
/>
</div>
)}
</div>
<div className="views-definition-form">
<FormField label="Name">
<input
value={draft.name}
maxLength={200}
disabled={selected.readonly || selected.status === "archived"}
onChange={(event) =>
setDraft({ ...draft, name: event.target.value })
}
/>
</FormField>
<FormField label="Description">
<textarea
rows={2}
value={draft.description}
maxLength={4000}
disabled={selected.readonly || selected.status === "archived"}
onChange={(event) =>
setDraft({ ...draft, description: event.target.value })
}
/>
</FormField>
</div>
<section className="views-surface-section">
<div className="views-section-heading">
<div>
<h4>Visible surfaces</h4>
<p className="muted small-note">
Permission checks remain active even when a surface is
visible. Parent surfaces are retained automatically.
</p>
</div>
<strong>{draft.surfaceIds.length} selected</strong>
</div>
{selected.stale_surface_ids.length > 0 && (
<DismissibleAlert tone="warning" dismissible={false}>
<div className="views-stale-surface-warning">
<span>
This revision references retired or currently unavailable
surfaces: {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}
>
Remove stale references
</Button>
)}
</div>
</DismissibleAlert>
)}
<SurfaceSelector
surfaces={surfaces}
selected={draft.surfaceIds}
disabled={selected.readonly || selected.status === "archived"}
onChange={(surfaceIds) =>
setDraft({ ...draft, surfaceIds })
}
/>
</section>
<AssignmentsSection
assignments={assignments}
definitions={definitions}
scopeType={scopeType}
busy={busy}
canWrite={canWriteAssignments}
onCreate={openCreateAssignment}
onEdit={openEditAssignment}
onToggle={toggleAssignment}
onDelete={setDeleteAssignmentTarget}
/>
</>
) : (
<div className="views-empty-editor">
<h3>No View selected</h3>
<p>Create a View to define a focused interface projection.</p>
</div>
)}
</main>
</div>
</AdminPageLayout>
<Dialog
open={createOpen}
title="Create View"
onClose={() => setCreateOpen(false)}
closeDisabled={busy}
footer={
<>
<Button onClick={() => setCreateOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void createDefinition()}
disabled={busy || !createDraft.name.trim()}
>
Create
</Button>
</>
}
>
<div className="form-grid">
<FormField label="Name">
<input
autoFocus
value={createDraft.name}
maxLength={200}
onChange={(event) =>
setCreateDraft({ ...createDraft, name: event.target.value })
}
/>
</FormField>
<FormField label="Description">
<textarea
rows={4}
value={createDraft.description}
maxLength={4000}
onChange={(event) =>
setCreateDraft({
...createDraft,
description: event.target.value
})
}
/>
</FormField>
<p className="muted small-note">
The first draft starts with all current surfaces visible. Refine
the selection before publishing it.
</p>
</div>
</Dialog>
<AssignmentDialog
open={assignmentEditor !== null}
editing={assignmentEditor}
draft={assignmentDraft}
definitions={definitions}
scopeType={scopeType}
busy={busy}
onChange={setAssignmentDraft}
onClose={() => setAssignmentEditor(null)}
onSave={saveAssignment}
/>
<ConfirmDialog
open={archiveTarget !== null}
title="Archive View"
message={
archiveTarget
? `Archive "${archiveTarget.name}"? Existing optional assignments will be deactivated. Required assignments must be removed first.`
: ""
}
confirmLabel="Archive"
tone="danger"
busy={busy}
onCancel={() => setArchiveTarget(null)}
onConfirm={() => void archiveDefinition()}
/>
<ConfirmDialog
open={deleteAssignmentTarget !== null}
title="Remove View assignment"
message="The target will no longer inherit this available, default, or required View from this assignment."
confirmLabel="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 definitionById = useMemo(
() => new Map(definitions.map((definition) => [definition.id, definition])),
[definitions]
);
return (
<section className="views-assignments-section">
<div className="views-section-heading">
<div>
<h4>Assignments</h4>
<p className="muted small-note">
Higher-specificity user and group assignments take precedence over
tenant and system assignments. Required assignments cannot be left.
</p>
</div>
<Button
onClick={onCreate}
disabled={!canWrite || busy || !definitions.some((item) => item.status === "published")}
>
<Plus size={16} aria-hidden="true" />
Add assignment
</Button>
</div>
<div className="views-assignment-list">
{assignments.map((assignment) => {
const inherited =
scopeType === "tenant" && assignment.tenant_id == null;
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>
{assignment.scope_type}
{assignment.scope_id ? ` · ${assignment.scope_id}` : ""}
</span>
</div>
<StatusBadge status={assignment.mode} />
<span className="views-assignment-priority">
Priority {assignment.priority}
</span>
<span className="views-assignment-revision">
{assignment.revision_id ? "Pinned revision" : "Tracks published"}
</span>
<ToggleSwitch
label="Assignment active"
checked={assignment.is_active}
disabled={inherited || !canWrite || busy}
onChange={(active) => onToggle(assignment, active)}
/>
<div className="button-row compact-actions">
<IconButton
label="Edit assignment"
icon={<Pencil />}
onClick={() => onEdit(assignment)}
disabled={inherited || !canWrite || busy}
/>
<IconButton
label="Remove assignment"
icon={<Trash2 />}
variant="danger"
onClick={() => onDelete(assignment)}
disabled={inherited || !canWrite || busy}
/>
</div>
</div>
);
})}
{!assignments.length && (
<div className="views-empty-list">
No View assignments exist in this scope.
</div>
)}
</div>
</section>
);
}
function AssignmentDialog({
open,
editing,
draft,
definitions,
scopeType,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
editing: ViewAssignment | "new" | null;
draft: AssignmentDraft;
definitions: ViewDefinition[];
scopeType: ViewScopeType;
busy: boolean;
onChange: (draft: AssignmentDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
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 canSave = Boolean(
definition?.published_revision &&
(!targetRequired || draft.scopeId.trim()) &&
requiredMissing.length === 0
);
return (
<Dialog
open={open}
title={editing === "new" ? "Add View assignment" : "Edit View assignment"}
onClose={onClose}
closeDisabled={busy}
className="views-assignment-dialog"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !canSave}
>
Save
</Button>
</>
}
>
<div className="form-grid two responsive-form-grid">
<FormField label="Target level">
<select
value={draft.scopeType}
disabled={editing !== "new"}
onChange={(event) =>
onChange({
...draft,
scopeType: event.target.value as ViewAssignmentScopeType,
scopeId: ""
})
}
>
{scopeType === "system" ? (
<option value="system">System</option>
) : (
<>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</>
)}
</select>
</FormField>
<FormField label="Target ID">
<input
value={
draft.scopeType === "system" || draft.scopeType === "tenant"
? ""
: draft.scopeId
}
placeholder={
targetRequired ? `${draft.scopeType} identifier` : "Current scope"
}
disabled={editing !== "new" || !targetRequired}
onChange={(event) =>
onChange({ ...draft, scopeId: event.target.value })
}
/>
</FormField>
<FormField label="View">
<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} ({item.scope_type})
</option>
))}
</select>
</FormField>
<FormField label="Mode">
<select
value={draft.mode}
onChange={(event) =>
onChange({
...draft,
mode: event.target.value as ViewAssignmentMode
})
}
>
<option value="available">Available</option>
<option value="default">Default</option>
<option value="required">Required</option>
</select>
</FormField>
<FormField label="Priority">
<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="Assignment active"
checked={draft.active}
onChange={(active) => onChange({ ...draft, active })}
/>
<ToggleSwitch
label="Pin current published revision"
checked={draft.pinRevision}
onChange={(pinRevision) => onChange({ ...draft, pinRevision })}
help="Pinned assignments keep the selected revision. Unpinned assignments follow future published revisions."
/>
</div>
</div>
{draft.mode === "required" && (
<DismissibleAlert
tone={requiredMissing.length ? "danger" : "warning"}
dismissible={false}
>
{requiredMissing.length
? `This revision cannot be required because it hides: ${requiredMissing.join(", ")}.`
: "Required Views cannot be left by affected users. The selector and administration escape surfaces remain available."}
</DismissibleAlert>
)}
</Dialog>
);
}
function SurfaceSelector({
surfaces,
selected,
disabled,
onChange
}: {
surfaces: PlatformViewSurface[];
selected: string[];
disabled: boolean;
onChange: (surfaceIds: string[]) => void;
}) {
const [filter, setFilter] = useState("");
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();
return (
<div className="views-surface-selector">
<div className="views-surface-filter">
<input
value={filter}
placeholder="Filter modules and surfaces"
aria-label="Filter View surfaces"
onChange={(event) => setFilter(event.target.value)}
/>
</div>
<div className="views-surface-modules">
{modules.map((moduleSurface) => {
const moduleSurfaces = [
moduleSurface,
...descendants(moduleSurface.id)
.map((id) => byId.get(id))
.filter((item): item is PlatformViewSurface => Boolean(item))
];
const matches = !normalizedFilter || moduleSurfaces.some((surface) =>
`${surface.label} ${surface.id} ${surface.description ?? ""}`
.toLowerCase()
.includes(normalizedFilter)
);
if (!matches) return null;
const childIds = moduleSurfaces.slice(1).map((surface) => surface.id);
const selectedChildren = childIds.filter((id) => selectedSet.has(id)).length;
return (
<details key={moduleSurface.id} open={Boolean(normalizedFilter)}>
<summary>
<SurfaceCheckbox
checked={selectedSet.has(moduleSurface.id)}
indeterminate={
selectedChildren > 0 && selectedChildren < childIds.length
}
disabled={disabled || requiredIds.has(moduleSurface.id)}
label={moduleSurface.label}
secondary={`${selectedChildren}/${childIds.length}`}
onChange={(checked) => toggle(moduleSurface.id, checked)}
/>
</summary>
<div className="views-surface-children">
{moduleSurfaces.slice(1).map((surface) => (
<SurfaceCheckbox
key={surface.id}
checked={selectedSet.has(surface.id)}
disabled={disabled || requiredIds.has(surface.id)}
label={surface.label}
secondary={`${surface.kind} · ${surface.description ?? surface.id}`}
onChange={(checked) => toggle(surface.id, checked)}
/>
))}
</div>
</details>
);
})}
</div>
</div>
);
}
function SurfaceCheckbox({
checked,
indeterminate = false,
disabled,
label,
secondary,
onChange
}: {
checked: boolean;
indeterminate?: boolean;
disabled: boolean;
label: string;
secondary: string;
onChange: (checked: boolean) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className={disabled ? "views-surface-row disabled" : "views-surface-row"}>
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span>
<strong>{label}</strong>
<small>{secondary}</small>
</span>
{checked && <Check size={15} aria-hidden="true" />}
</label>
);
}
function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
return {
scopeType: scopeType === "system" ? "system" : "tenant",
scopeId: "",
definitionId: "",
mode: "available",
priority: 0,
active: true,
pinRevision: false
};
}
function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({
name: draft.name.trim(),
description: draft.description.trim(),
surfaces: [...new Set(draft.surfaceIds)].sort()
});
}
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)
);
}