feat: add searchable view assignment targets
This commit is contained in:
@@ -45,6 +45,8 @@ export type ViewAssignment = {
|
||||
tenant_id?: string | null;
|
||||
scope_type: ViewAssignmentScopeType;
|
||||
scope_id?: string | null;
|
||||
scope_label?: string | null;
|
||||
scope_detail?: string | null;
|
||||
definition_id: string;
|
||||
revision_id?: string | null;
|
||||
mode: ViewAssignmentMode;
|
||||
@@ -57,6 +59,21 @@ export type ViewAssignment = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ViewAssignmentTarget = {
|
||||
id: string;
|
||||
scope_type: "group" | "user";
|
||||
label: string;
|
||||
detail?: string | null;
|
||||
disabled: boolean;
|
||||
disabled_reason?: string | null;
|
||||
};
|
||||
|
||||
export type ViewAssignmentTargetList = {
|
||||
directory_available: boolean;
|
||||
targets: ViewAssignmentTarget[];
|
||||
unavailable_reason?: string | null;
|
||||
};
|
||||
|
||||
type EffectiveViewApiResponse = {
|
||||
active_view_id?: string | null;
|
||||
active_revision_id?: string | null;
|
||||
@@ -239,6 +256,24 @@ export async function fetchViewAssignments(
|
||||
return response.assignments;
|
||||
}
|
||||
|
||||
export function fetchViewAssignmentTargets(
|
||||
settings: ApiSettings,
|
||||
scopeType: "group" | "user",
|
||||
query: string,
|
||||
limit: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<ViewAssignmentTargetList> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
apiPath("/api/v1/views/assignment-targets", {
|
||||
scope_type: scopeType,
|
||||
query: query || undefined,
|
||||
limit
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function createViewAssignment(
|
||||
settings: ApiSettings,
|
||||
payload: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -261,11 +261,13 @@
|
||||
}
|
||||
|
||||
.views-assignment-view strong,
|
||||
.views-assignment-view span {
|
||||
.views-assignment-view span,
|
||||
.views-assignment-view small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.views-assignment-view span,
|
||||
.views-assignment-view small,
|
||||
.views-assignment-priority,
|
||||
.views-assignment-revision {
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user