feat: add personal and group view ownership

This commit is contained in:
2026-07-28 22:13:15 +02:00
parent 34221b633b
commit 3de8d4aa5f
12 changed files with 833 additions and 213 deletions

View File

@@ -5,7 +5,7 @@ import {
type EffectiveViewProjection
} from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant";
export type ViewScopeType = "system" | "tenant" | "group" | "user";
export type ViewAssignmentScopeType = "system" | "tenant" | "group" | "user";
export type ViewAssignmentMode = "available" | "default" | "required";
@@ -148,12 +148,14 @@ export async function selectEffectiveView(
export async function fetchViewDefinitions(
settings: ApiSettings,
scopeType: ViewScopeType
scopeType: ViewScopeType,
scopeId?: string | null
): Promise<ViewDefinition[]> {
const response = await apiFetch<{ definitions: ViewDefinition[] }>(
settings,
apiPath("/api/v1/views/definitions", {
scope_type: scopeType,
scope_id: scopeId || undefined,
include_inherited: scopeType === "tenant"
})
);
@@ -164,6 +166,7 @@ export function createViewDefinition(
settings: ApiSettings,
payload: {
scope_type: ViewScopeType;
scope_id?: string | null;
name: string;
description?: string | null;
visible_surface_ids: string[];

View File

@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from "react";
import {
FormField,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import ViewsAdminPanel from "./ViewsAdminPanel";
import type { ViewScopeType } from "../../api/views";
type OwnerOption = {
key: string;
scopeType: Extract<ViewScopeType, "group" | "user">;
scopeId: string;
label: string;
description: string;
canWrite: boolean;
};
export default function PersonalViewsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const options = useMemo(() => ownerOptions(auth), [auth]);
const [ownerKey, setOwnerKey] = useState(options[0]?.key ?? "");
useEffect(() => {
if (!options.some((option) => option.key === ownerKey)) {
setOwnerKey(options[0]?.key ?? "");
}
}, [options, ownerKey]);
const owner = options.find((option) => option.key === ownerKey) ?? options[0];
if (!owner) {
return (
<p className="muted">
You do not have access to personal or group View definitions.
</p>
);
}
return (
<div className="views-personal-panel">
{options.length > 1 && (
<div className="views-owner-toolbar">
<FormField label="View owner">
<select
value={owner.key}
onChange={(event) => setOwnerKey(event.target.value)}
>
{options.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</FormField>
</div>
)}
<ViewsAdminPanel
key={owner.key}
settings={settings}
scopeType={owner.scopeType}
scopeId={owner.scopeId}
canWriteDefinitions={owner.canWrite}
canWriteAssignments={false}
showAssignments={false}
embedded
title={owner.label}
description={owner.description}
/>
</div>
);
}
function ownerOptions(auth: AuthInfo): OwnerOption[] {
const tenantManager = hasScope(auth, "views:definition:write");
const options: OwnerOption[] = [];
if (
tenantManager ||
hasScope(auth, "views:personal_definition:read") ||
hasScope(auth, "views:personal_definition:write")
) {
options.push({
key: `user:${auth.user.account_id}`,
scopeType: "user",
scopeId: auth.user.account_id,
label: "My Views",
description:
"Design personal interface projections. Publishing a View makes it available to this account.",
canWrite:
tenantManager ||
hasScope(auth, "views:personal_definition:write")
});
}
const canReadGroups =
tenantManager ||
hasScope(auth, "views:group_definition:read") ||
hasScope(auth, "views:group_definition:write");
if (canReadGroups) {
for (const group of auth.groups) {
options.push({
key: `group:${group.id}`,
scopeType: "group",
scopeId: group.id,
label: `Group: ${group.name}`,
description:
"Design reusable interface projections for this group. Publishing a View makes it available to all current group members.",
canWrite:
tenantManager ||
hasScope(auth, "views:group_definition:write")
});
}
}
return options;
}

View File

@@ -1,13 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Archive,
Check,
CheckSquare2,
ChevronDown,
ChevronRight,
MinusSquare,
Pencil,
Plus,
RefreshCw,
Save,
Send,
Square,
Trash2
} from "lucide-react";
import {
@@ -16,8 +19,11 @@ import {
ConfirmDialog,
Dialog,
DismissibleAlert,
ExplorerTree,
FormField,
IconButton,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
adminErrorMessage,
@@ -77,13 +83,23 @@ const LOCKOUT_SURFACES = new Set([
export default function ViewsAdminPanel({
settings,
scopeType,
scopeId,
canWriteDefinitions,
canWriteAssignments
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 { requestDiscard } = useUnsavedChanges();
@@ -133,8 +149,10 @@ export default function ViewsAdminPanel({
setError("");
try {
const [nextDefinitions, nextAssignments] = await Promise.all([
fetchViewDefinitions(settings, scopeType),
fetchViewAssignments(settings, scopeType)
fetchViewDefinitions(settings, scopeType, scopeId),
showAssignments && (scopeType === "system" || scopeType === "tenant")
? fetchViewAssignments(settings, scopeType)
: Promise.resolve([])
]);
setDefinitions(nextDefinitions);
setAssignments(nextAssignments);
@@ -161,6 +179,8 @@ export default function ViewsAdminPanel({
void load();
}, [
scopeType,
scopeId,
showAssignments,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
@@ -268,6 +288,7 @@ export default function ViewsAdminPanel({
.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
@@ -411,11 +432,26 @@ export default function ViewsAdminPanel({
}
}
const title = scopeType === "system" ? "System Views" : "Tenant Views";
const title =
titleOverride ??
({
system: "System Views",
tenant: "Tenant Views",
group: "Group Views",
user: "My Views"
} satisfies Record<ViewScopeType, string>)[scopeType];
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.";
descriptionOverride ??
({
system:
"Publish reusable interface projections and assign instance-wide defaults or requirements.",
tenant:
"Tailor the visible interface for this tenant, its groups, and individual users.",
group:
"Design reusable interface projections for members of this group.",
user:
"Design personal interface projections that remain available to this account."
} satisfies Record<ViewScopeType, string>)[scopeType];
const canCreate =
canWriteDefinitions &&
surfaces.some((surface) => surface.kind === "navigation") &&
@@ -426,6 +462,7 @@ export default function ViewsAdminPanel({
<AdminPageLayout
title={title}
description={description}
className={embedded ? "views-admin-page embedded" : "views-admin-page"}
loading={loading}
error={error}
success={success}
@@ -453,42 +490,42 @@ export default function ViewsAdminPanel({
</>
}
>
<div className="views-admin-shell">
<div className="views-management-layout">
<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">
<SelectionList
className="views-definition-list"
label="View definitions"
>
{definitions.map((definition) => (
<button
<SelectionListItem
key={definition.id}
type="button"
className={
definition.id === selectedId
? "views-definition-item active"
: "views-definition-item"
}
className="views-definition-item"
selected={definition.id === selectedId}
onClick={() => selectDefinition(definition.id)}
>
<span className="views-definition-item-main">
<strong>{definition.name}</strong>
<small>
{definition.scope_type === "system" ? "System" : "Tenant"}
{scopeLabel(definition)}
{" · "}
revision {definition.current_revision}
</small>
</span>
<StatusBadge status={definition.status} />
<ChevronRight size={16} aria-hidden="true" />
</button>
</SelectionListItem>
))}
{!definitions.length && (
<div className="views-empty-list">
No Views have been defined in this scope.
</div>
)}
</div>
</SelectionList>
</aside>
<main className="views-editor-pane">
@@ -610,17 +647,6 @@ export default function ViewsAdminPanel({
/>
</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">
@@ -629,6 +655,21 @@ export default function ViewsAdminPanel({
</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>
@@ -1026,6 +1067,7 @@ function SurfaceSelector({
onChange: (surfaceIds: string[]) => void;
}) {
const [filter, setFilter] = useState("");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const byId = useMemo(
() => new Map(surfaces.map((surface) => [surface.id, surface])),
[surfaces]
@@ -1098,6 +1140,26 @@ function SurfaceSelector({
}
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) =>
`${surface.label} ${surface.id} ${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">
@@ -1110,94 +1172,80 @@ function SurfaceSelector({
/>
</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>
);
})}
<ExplorerTree
nodes={visibleModules}
getNodeId={(surface) => surface.id}
getNodeLabel={(surface) => 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"
? `${selectedChildren}/${childIds.length} surfaces`
: `${surface.kind} · ${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">No matching surfaces.</div>
)}
</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",
@@ -1211,6 +1259,19 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
}
function scopeLabel(definition: ViewDefinition): string {
const label = {
system: "System",
tenant: "Tenant",
group: "Group",
user: "User"
} satisfies Record<ViewScopeType, string>;
return definition.scope_id && definition.scope_type !== "tenant"
? `${label[definition.scope_type]} · ${definition.scope_id}`
: label[definition.scope_type];
}
function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({
name: draft.name.trim(),

View File

@@ -3,6 +3,7 @@ import {
hasScope,
type AdminSectionsUiCapability,
type PlatformWebModule,
type SettingsSectionsUiCapability,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import ViewSelector from "./components/ViewSelector";
@@ -13,6 +14,9 @@ import "./styles/views.css";
const ViewsAdminPanel = lazy(
() => import("./features/views/ViewsAdminPanel")
);
const PersonalViewsPanel = lazy(
() => import("./features/views/PersonalViewsPanel")
);
const viewsAdminSections: AdminSectionsUiCapability = {
sections: [
@@ -63,6 +67,28 @@ const viewsRuntime: ViewsRuntimeUiCapability = {
Selector: ViewSelector
};
const viewsSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "views",
label: "Views",
group: "ui",
order: 35,
surfaceId: "views.settings.personal",
anyOf: [
"views:definition:read",
"views:definition:write",
"views:group_definition:read",
"views:group_definition:write",
"views:personal_definition:read",
"views:personal_definition:write"
],
render: ({ settings, auth }) =>
createElement(PersonalViewsPanel, { settings, auth })
}
]
};
export const viewsModule: PlatformWebModule = {
id: "views",
label: "Views",
@@ -90,13 +116,20 @@ export const viewsModule: PlatformWebModule = {
kind: "section",
label: "Tenant Views administration",
order: 30
},
{
id: "views.settings.personal",
moduleId: "views",
kind: "section",
label: "Personal and group Views",
order: 40
}
],
uiCapabilities: {
"admin.sections": viewsAdminSections,
"settings.sections": viewsSettingsSections,
"views.runtime": viewsRuntime
}
};
export default viewsModule;

View File

@@ -29,6 +29,32 @@
opacity: .78;
}
.views-management-layout {
display: grid;
gap: 18px;
}
.views-admin-page.embedded > .admin-page-heading {
position: static;
margin: 0 0 18px;
padding: 0 0 14px;
box-shadow: none;
}
.views-personal-panel {
min-width: 0;
}
.views-owner-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.views-owner-toolbar .form-field {
width: min(360px, 100%);
}
.views-admin-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
@@ -81,24 +107,7 @@
grid-template-columns: minmax(0, 1fr) auto 18px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 58px;
padding: 9px 8px 9px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.views-definition-item:hover {
background: var(--hover-tint-soft);
}
.views-definition-item.active {
background: var(--accent-hover-bg);
color: var(--text-strong);
}
.views-definition-item-main {
@@ -159,13 +168,18 @@
resize: vertical;
}
.views-surface-section,
.views-assignments-section {
.views-surface-section {
margin-top: 22px;
padding-top: 18px;
border-top: var(--border-line);
}
.views-assignments-section {
min-width: 0;
padding-top: 18px;
border-top: var(--border-line);
}
.views-section-heading {
align-items: center;
margin-bottom: 10px;
@@ -200,58 +214,25 @@
padding: 6px;
}
.views-surface-modules details {
border-bottom: var(--border-line);
}
.views-surface-modules details:last-child {
border-bottom: 0;
}
.views-surface-modules summary {
list-style: none;
}
.views-surface-modules summary::-webkit-details-marker {
display: none;
}
.views-surface-children {
display: grid;
gap: 2px;
padding: 0 0 6px 24px;
}
.views-surface-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 18px;
align-items: center;
gap: 9px;
min-height: 42px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.views-surface-row:hover {
background: var(--hover-tint-soft);
}
.views-surface-row.disabled {
.views-surface-node.disabled {
cursor: default;
opacity: .7;
}
.views-surface-row strong,
.views-surface-row small {
display: block;
letter-spacing: 0;
.views-surface-tree .explorer-tree-node-wrap {
border-radius: var(--radius-sm);
}
.views-surface-row small {
margin-top: 2px;
overflow-wrap: anywhere;
color: var(--muted);
.views-surface-tree .explorer-tree-node {
min-height: 42px;
border-radius: var(--radius-sm);
}
.views-surface-check {
display: inline-grid;
flex: 0 0 18px;
place-items: center;
color: var(--accent);
}
.views-assignment-list {