feat: add searchable view assignment targets

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 3de8d4aa5f
commit c24d1f2ca4
7 changed files with 525 additions and 30 deletions
+135 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
CheckSquare2,
@@ -22,6 +22,7 @@ import {
ExplorerTree,
FormField,
IconButton,
SearchableSelect,
SelectionList,
SelectionListItem,
StatusBadge,
@@ -32,7 +33,8 @@ import {
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type PlatformViewSurface
type PlatformViewSurface,
type SearchableSelectOption
} from "@govoplan/core-webui";
import {
archiveViewDefinition,
@@ -40,6 +42,7 @@ import {
createViewDefinition,
createViewRevision,
deleteViewAssignment,
fetchViewAssignmentTargets,
fetchViewAssignments,
fetchViewDefinitions,
publishViewRevision,
@@ -62,6 +65,8 @@ type DefinitionDraft = {
type AssignmentDraft = {
scopeType: ViewAssignmentScopeType;
scopeId: string;
scopeLabel: string;
scopeDetail: string;
definitionId: string;
mode: ViewAssignmentMode;
priority: number;
@@ -337,6 +342,14 @@ export default function ViewsAdminPanel({
setAssignmentDraft({
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,
@@ -729,6 +742,7 @@ export default function ViewsAdminPanel({
editing={assignmentEditor}
draft={assignmentDraft}
definitions={definitions}
settings={settings}
scopeType={scopeType}
busy={busy}
onChange={setAssignmentDraft}
@@ -814,6 +828,14 @@ function AssignmentsSection({
{assignments.map((assignment) => {
const inherited =
scopeType === "tenant" && assignment.tenant_id == null;
const targetLabel =
assignment.scope_label ??
(assignment.scope_id
? `Unavailable ${assignment.scope_type}`
: 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">
@@ -822,9 +844,10 @@ function AssignmentsSection({
assignment.definition_id}
</strong>
<span>
{assignment.scope_type}
{assignment.scope_id ? ` · ${assignment.scope_id}` : ""}
{assignmentScopeLabel(assignment.scope_type)}
{assignment.scope_id ? ` · ${targetLabel}` : ""}
</span>
{targetDetail && <small>{targetDetail}</small>}
</div>
<StatusBadge status={assignment.mode} />
<span className="views-assignment-priority">
@@ -873,6 +896,7 @@ function AssignmentDialog({
editing,
draft,
definitions,
settings,
scopeType,
busy,
onChange,
@@ -883,12 +907,17 @@ function AssignmentDialog({
editing: ViewAssignment | "new" | null;
draft: AssignmentDraft;
definitions: ViewDefinition[];
settings: ApiSettings;
scopeType: ViewScopeType;
busy: boolean;
onChange: (draft: AssignmentDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const [directoryIssue, setDirectoryIssue] = useState<string | null>(null);
useEffect(() => {
setDirectoryIssue(null);
}, [draft.scopeType, open]);
const definition = definitions.find(
(item) => item.id === draft.definitionId
);
@@ -914,6 +943,53 @@ function AssignmentDialog({
: [];
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 ??
"The Access directory is not available."
);
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()) &&
@@ -949,7 +1025,9 @@ function AssignmentDialog({
onChange({
...draft,
scopeType: event.target.value as ViewAssignmentScopeType,
scopeId: ""
scopeId: "",
scopeLabel: "",
scopeDetail: ""
})
}
>
@@ -964,21 +1042,44 @@ function AssignmentDialog({
)}
</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 label="Target">
{targetRequired ? (
<>
<SearchableSelect
id="view-assignment-target"
aria-label={`Select ${draft.scopeType}`}
value={draft.scopeId}
selectedOption={selectedTarget}
loadOptions={loadTargets}
placeholder={`Select a ${draft.scopeType}`}
searchPlaceholder={`Search ${draft.scopeType}s`}
emptyText={`No matching ${draft.scopeType}s.`}
disabled={editing !== "new" || busy}
required
onChange={(value, option) =>
onChange({
...draft,
scopeId: value,
scopeLabel: option?.label ?? "",
scopeDetail: option?.description ?? ""
})
}
/>
{directoryIssue && (
<p className="muted small-note">{directoryIssue}</p>
)}
</>
) : (
<input
value={
draft.scopeType === "system"
? "Current system"
: "Current tenant"
}
disabled
readOnly
/>
)}
</FormField>
<FormField label="View">
<select
@@ -1250,6 +1351,8 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
return {
scopeType: scopeType === "system" ? "system" : "tenant",
scopeId: "",
scopeLabel: "",
scopeDetail: "",
definitionId: "",
mode: "available",
priority: 0,
@@ -1259,6 +1362,18 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
}
function assignmentScopeLabel(
scopeType: ViewAssignmentScopeType
): string {
return {
system: "System",
tenant: "Tenant",
group: "Group",
user: "User"
}[scopeType];
}
function scopeLabel(definition: ViewDefinition): string {
const label = {
system: "System",