feat: add searchable view assignment targets
This commit is contained in:
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_views.backend.manifest import (
|
||||
@@ -31,6 +32,8 @@ from govoplan_views.backend.schemas import (
|
||||
ViewAssignmentCreateRequest,
|
||||
ViewAssignmentListResponse,
|
||||
ViewAssignmentResponse,
|
||||
ViewAssignmentTargetListResponse,
|
||||
ViewAssignmentTargetResponse,
|
||||
ViewAssignmentUpdateRequest,
|
||||
ViewDefinitionCreateRequest,
|
||||
ViewDefinitionListResponse,
|
||||
@@ -77,6 +80,168 @@ def _catalogue() -> tuple[ViewSurface, ...]:
|
||||
return get_registry().view_surfaces()
|
||||
|
||||
|
||||
def _optional_access_directory() -> AccessDirectory | None:
|
||||
registry = get_registry()
|
||||
if not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
if not isinstance(capability, AccessDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Access directory capability is invalid.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _all_assignment_targets(
|
||||
directory: AccessDirectory,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
) -> list[ViewAssignmentTargetResponse]:
|
||||
if scope_type == "user":
|
||||
by_account_id: dict[str, ViewAssignmentTargetResponse] = {}
|
||||
for user in directory.users_for_tenant(tenant_id):
|
||||
label = (user.display_name or user.email or user.account_id).strip()
|
||||
detail = user.email if user.email and user.email != label else None
|
||||
target = ViewAssignmentTargetResponse(
|
||||
id=user.account_id,
|
||||
scope_type="user",
|
||||
label=label,
|
||||
detail=detail,
|
||||
disabled=user.status != "active",
|
||||
disabled_reason=(
|
||||
"This user is inactive." if user.status != "active" else None
|
||||
),
|
||||
)
|
||||
current = by_account_id.get(user.account_id)
|
||||
if current is None or (current.disabled and not target.disabled):
|
||||
by_account_id[user.account_id] = target
|
||||
targets = list(by_account_id.values())
|
||||
elif scope_type == "group":
|
||||
targets = [
|
||||
ViewAssignmentTargetResponse(
|
||||
id=group.id,
|
||||
scope_type="group",
|
||||
label=group.name,
|
||||
disabled=group.status != "active",
|
||||
disabled_reason=(
|
||||
"This group is inactive." if group.status != "active" else None
|
||||
),
|
||||
)
|
||||
for group in directory.groups_for_tenant(tenant_id)
|
||||
]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unsupported View assignment target scope: {scope_type}",
|
||||
)
|
||||
return sorted(
|
||||
targets,
|
||||
key=lambda item: (item.disabled, item.label.casefold(), item.id),
|
||||
)
|
||||
|
||||
|
||||
def _search_assignment_targets(
|
||||
directory: AccessDirectory,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> list[ViewAssignmentTargetResponse]:
|
||||
needle = query.strip().casefold()
|
||||
targets = _all_assignment_targets(
|
||||
directory,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
)
|
||||
if needle:
|
||||
targets = [
|
||||
target
|
||||
for target in targets
|
||||
if needle
|
||||
in " ".join(
|
||||
(
|
||||
target.label,
|
||||
target.detail or "",
|
||||
target.id,
|
||||
)
|
||||
).casefold()
|
||||
]
|
||||
return targets[:limit]
|
||||
|
||||
|
||||
def _assignment_target_lookup(
|
||||
directory: AccessDirectory | None,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
assignments: list[object],
|
||||
) -> dict[tuple[str, str], ViewAssignmentTargetResponse]:
|
||||
if directory is None or tenant_id is None:
|
||||
return {}
|
||||
scope_types = {
|
||||
str(getattr(assignment, "scope_type", ""))
|
||||
for assignment in assignments
|
||||
if getattr(assignment, "scope_id", None)
|
||||
}
|
||||
result: dict[tuple[str, str], ViewAssignmentTargetResponse] = {}
|
||||
for scope_type in scope_types.intersection({"group", "user"}):
|
||||
for target in _all_assignment_targets(
|
||||
directory,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
):
|
||||
result[(scope_type, target.id)] = target
|
||||
return result
|
||||
|
||||
|
||||
def _assignment_response(
|
||||
assignment: object,
|
||||
*,
|
||||
targets: dict[tuple[str, str], ViewAssignmentTargetResponse],
|
||||
) -> ViewAssignmentResponse:
|
||||
payload = assignment_payload(assignment)
|
||||
scope_type = str(payload.get("scope_type") or "")
|
||||
scope_id = str(payload.get("scope_id") or "")
|
||||
target = targets.get((scope_type, scope_id))
|
||||
if target is not None:
|
||||
payload["scope_label"] = target.label
|
||||
payload["scope_detail"] = target.detail
|
||||
return ViewAssignmentResponse.model_validate(payload)
|
||||
|
||||
|
||||
def _validate_assignment_target(
|
||||
directory: AccessDirectory | None,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> None:
|
||||
if directory is None or tenant_id is None or scope_type not in {"group", "user"}:
|
||||
return
|
||||
target = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in _all_assignment_targets(
|
||||
directory,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
)
|
||||
if candidate.id == scope_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
raise ViewsValidationError(
|
||||
f"The selected {scope_type} does not exist in the current tenant."
|
||||
)
|
||||
if target.disabled:
|
||||
raise ViewsValidationError(
|
||||
f"The selected {scope_type} is inactive and cannot receive a View assignment."
|
||||
)
|
||||
|
||||
|
||||
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if any(has_scope(principal, scope) for scope in scopes):
|
||||
return
|
||||
@@ -697,6 +862,42 @@ def api_archive_definition(
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assignment-targets",
|
||||
response_model=ViewAssignmentTargetListResponse,
|
||||
)
|
||||
def api_list_assignment_targets(
|
||||
scope_type: str = Query(pattern="^(group|user)$"),
|
||||
query: str = Query(default="", max_length=200),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ViewAssignmentTargetListResponse:
|
||||
_require_assignment_read(principal, "tenant")
|
||||
if principal.tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="User and group View assignments require an active tenant.",
|
||||
)
|
||||
directory = _optional_access_directory()
|
||||
if directory is None:
|
||||
return ViewAssignmentTargetListResponse(
|
||||
directory_available=False,
|
||||
unavailable_reason=(
|
||||
"User and group selection requires an enabled Access directory."
|
||||
),
|
||||
)
|
||||
return ViewAssignmentTargetListResponse(
|
||||
directory_available=True,
|
||||
targets=_search_assignment_targets(
|
||||
directory,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
query=query,
|
||||
limit=limit,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assignments", response_model=ViewAssignmentListResponse)
|
||||
def api_list_assignments(
|
||||
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
|
||||
@@ -705,15 +906,22 @@ def api_list_assignments(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ViewAssignmentListResponse:
|
||||
_require_assignment_read(principal, scope_type)
|
||||
assignments = list_assignments(
|
||||
session,
|
||||
assignments = list(
|
||||
list_assignments(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
include_inherited=include_inherited,
|
||||
)
|
||||
)
|
||||
targets = _assignment_target_lookup(
|
||||
_optional_access_directory(),
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
include_inherited=include_inherited,
|
||||
assignments=assignments,
|
||||
)
|
||||
return ViewAssignmentListResponse(
|
||||
assignments=[
|
||||
ViewAssignmentResponse.model_validate(assignment_payload(assignment))
|
||||
_assignment_response(assignment, targets=targets)
|
||||
for assignment in assignments
|
||||
]
|
||||
)
|
||||
@@ -731,6 +939,13 @@ def api_create_assignment(
|
||||
) -> ViewAssignmentResponse:
|
||||
_require_assignment_write(principal, payload.scope_type)
|
||||
try:
|
||||
directory = _optional_access_directory()
|
||||
_validate_assignment_target(
|
||||
directory,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
definition = get_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
@@ -764,7 +979,12 @@ def api_create_assignment(
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
|
||||
targets = _assignment_target_lookup(
|
||||
directory,
|
||||
tenant_id=principal.tenant_id,
|
||||
assignments=[assignment],
|
||||
)
|
||||
return _assignment_response(assignment, targets=targets)
|
||||
except ViewsError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
@@ -803,7 +1023,12 @@ def api_update_assignment(
|
||||
details={"fields": sorted(payload.model_fields_set)},
|
||||
)
|
||||
session.commit()
|
||||
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
|
||||
targets = _assignment_target_lookup(
|
||||
_optional_access_directory(),
|
||||
tenant_id=principal.tenant_id,
|
||||
assignments=[assignment],
|
||||
)
|
||||
return _assignment_response(assignment, targets=targets)
|
||||
except ViewsError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
@@ -91,6 +91,8 @@ class ViewAssignmentResponse(BaseModel):
|
||||
tenant_id: str | None = None
|
||||
scope_type: AssignmentScopeType
|
||||
scope_id: str | None = None
|
||||
scope_label: str | None = None
|
||||
scope_detail: str | None = None
|
||||
definition_id: str
|
||||
revision_id: str | None = None
|
||||
mode: AssignmentMode
|
||||
@@ -107,6 +109,21 @@ class ViewAssignmentListResponse(BaseModel):
|
||||
assignments: list[ViewAssignmentResponse]
|
||||
|
||||
|
||||
class ViewAssignmentTargetResponse(BaseModel):
|
||||
id: str
|
||||
scope_type: Literal["group", "user"]
|
||||
label: str
|
||||
detail: str | None = None
|
||||
disabled: bool = False
|
||||
disabled_reason: str | None = None
|
||||
|
||||
|
||||
class ViewAssignmentTargetListResponse(BaseModel):
|
||||
directory_available: bool
|
||||
targets: list[ViewAssignmentTargetResponse] = Field(default_factory=list)
|
||||
unavailable_reason: str | None = None
|
||||
|
||||
|
||||
class ViewAssignmentCreateRequest(BaseModel):
|
||||
scope_type: AssignmentScopeType
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.access import GroupRef, UserRef
|
||||
from govoplan_views.backend.router import (
|
||||
_all_assignment_targets,
|
||||
_search_assignment_targets,
|
||||
_validate_assignment_target,
|
||||
)
|
||||
from govoplan_views.backend.service import ViewsValidationError
|
||||
|
||||
|
||||
class FakeDirectory:
|
||||
def users_for_tenant(self, tenant_id: str) -> tuple[UserRef, ...]:
|
||||
return (
|
||||
UserRef(
|
||||
id="membership-ada",
|
||||
account_id="account-ada",
|
||||
tenant_id=tenant_id,
|
||||
email="ada@example.test",
|
||||
display_name="Ada Lovelace",
|
||||
),
|
||||
UserRef(
|
||||
id="membership-inactive",
|
||||
account_id="account-inactive",
|
||||
tenant_id=tenant_id,
|
||||
email="inactive@example.test",
|
||||
status="inactive",
|
||||
),
|
||||
)
|
||||
|
||||
def groups_for_tenant(self, tenant_id: str) -> tuple[GroupRef, ...]:
|
||||
return (
|
||||
GroupRef(
|
||||
id="group-finance",
|
||||
tenant_id=tenant_id,
|
||||
name="Finance",
|
||||
),
|
||||
GroupRef(
|
||||
id="group-retired",
|
||||
tenant_id=tenant_id,
|
||||
name="Retired group",
|
||||
status="inactive",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ViewAssignmentTargetTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = FakeDirectory()
|
||||
|
||||
def test_user_targets_store_account_ids_and_expose_readable_labels(self) -> None:
|
||||
targets = _all_assignment_targets(
|
||||
self.directory, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
)
|
||||
|
||||
self.assertEqual("account-ada", targets[0].id)
|
||||
self.assertEqual("Ada Lovelace", targets[0].label)
|
||||
self.assertEqual("ada@example.test", targets[0].detail)
|
||||
self.assertNotEqual("membership-ada", targets[0].id)
|
||||
|
||||
def test_search_matches_labels_and_secondary_details(self) -> None:
|
||||
by_name = _search_assignment_targets(
|
||||
self.directory, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
scope_type="group",
|
||||
query="finance",
|
||||
limit=10,
|
||||
)
|
||||
by_email = _search_assignment_targets(
|
||||
self.directory, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
query="ada@example",
|
||||
limit=10,
|
||||
)
|
||||
|
||||
self.assertEqual(["group-finance"], [target.id for target in by_name])
|
||||
self.assertEqual(["account-ada"], [target.id for target in by_email])
|
||||
|
||||
def test_inactive_and_unknown_targets_cannot_receive_new_assignments(self) -> None:
|
||||
with self.assertRaisesRegex(ViewsValidationError, "inactive"):
|
||||
_validate_assignment_target(
|
||||
self.directory, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id="account-inactive",
|
||||
)
|
||||
with self.assertRaisesRegex(ViewsValidationError, "does not exist"):
|
||||
_validate_assignment_target(
|
||||
self.directory, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
scope_type="group",
|
||||
scope_id="group-missing",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-2
@@ -18,7 +18,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
@@ -26,4 +26,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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