1238 lines
38 KiB
TypeScript
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)
|
|
);
|
|
}
|