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.audit.logging import audit_event
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
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.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_views.backend.manifest import (
|
from govoplan_views.backend.manifest import (
|
||||||
@@ -31,6 +32,8 @@ from govoplan_views.backend.schemas import (
|
|||||||
ViewAssignmentCreateRequest,
|
ViewAssignmentCreateRequest,
|
||||||
ViewAssignmentListResponse,
|
ViewAssignmentListResponse,
|
||||||
ViewAssignmentResponse,
|
ViewAssignmentResponse,
|
||||||
|
ViewAssignmentTargetListResponse,
|
||||||
|
ViewAssignmentTargetResponse,
|
||||||
ViewAssignmentUpdateRequest,
|
ViewAssignmentUpdateRequest,
|
||||||
ViewDefinitionCreateRequest,
|
ViewDefinitionCreateRequest,
|
||||||
ViewDefinitionListResponse,
|
ViewDefinitionListResponse,
|
||||||
@@ -77,6 +80,168 @@ def _catalogue() -> tuple[ViewSurface, ...]:
|
|||||||
return get_registry().view_surfaces()
|
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:
|
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
if any(has_scope(principal, scope) for scope in scopes):
|
if any(has_scope(principal, scope) for scope in scopes):
|
||||||
return
|
return
|
||||||
@@ -697,6 +862,42 @@ def api_archive_definition(
|
|||||||
raise _http_error(exc) from exc
|
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)
|
@router.get("/assignments", response_model=ViewAssignmentListResponse)
|
||||||
def api_list_assignments(
|
def api_list_assignments(
|
||||||
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
|
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
|
||||||
@@ -705,15 +906,22 @@ def api_list_assignments(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> ViewAssignmentListResponse:
|
) -> ViewAssignmentListResponse:
|
||||||
_require_assignment_read(principal, scope_type)
|
_require_assignment_read(principal, scope_type)
|
||||||
assignments = list_assignments(
|
assignments = list(
|
||||||
session,
|
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,
|
tenant_id=principal.tenant_id,
|
||||||
scope_type=scope_type,
|
assignments=assignments,
|
||||||
include_inherited=include_inherited,
|
|
||||||
)
|
)
|
||||||
return ViewAssignmentListResponse(
|
return ViewAssignmentListResponse(
|
||||||
assignments=[
|
assignments=[
|
||||||
ViewAssignmentResponse.model_validate(assignment_payload(assignment))
|
_assignment_response(assignment, targets=targets)
|
||||||
for assignment in assignments
|
for assignment in assignments
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -731,6 +939,13 @@ def api_create_assignment(
|
|||||||
) -> ViewAssignmentResponse:
|
) -> ViewAssignmentResponse:
|
||||||
_require_assignment_write(principal, payload.scope_type)
|
_require_assignment_write(principal, payload.scope_type)
|
||||||
try:
|
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(
|
definition = get_definition(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -764,7 +979,12 @@ def api_create_assignment(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
session.commit()
|
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:
|
except ViewsError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
@@ -803,7 +1023,12 @@ def api_update_assignment(
|
|||||||
details={"fields": sorted(payload.model_fields_set)},
|
details={"fields": sorted(payload.model_fields_set)},
|
||||||
)
|
)
|
||||||
session.commit()
|
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:
|
except ViewsError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class ViewAssignmentResponse(BaseModel):
|
|||||||
tenant_id: str | None = None
|
tenant_id: str | None = None
|
||||||
scope_type: AssignmentScopeType
|
scope_type: AssignmentScopeType
|
||||||
scope_id: str | None = None
|
scope_id: str | None = None
|
||||||
|
scope_label: str | None = None
|
||||||
|
scope_detail: str | None = None
|
||||||
definition_id: str
|
definition_id: str
|
||||||
revision_id: str | None = None
|
revision_id: str | None = None
|
||||||
mode: AssignmentMode
|
mode: AssignmentMode
|
||||||
@@ -107,6 +109,21 @@ class ViewAssignmentListResponse(BaseModel):
|
|||||||
assignments: list[ViewAssignmentResponse]
|
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):
|
class ViewAssignmentCreateRequest(BaseModel):
|
||||||
scope_type: AssignmentScopeType
|
scope_type: AssignmentScopeType
|
||||||
scope_id: str | None = Field(default=None, max_length=255)
|
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",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -26,4 +26,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type ViewAssignment = {
|
|||||||
tenant_id?: string | null;
|
tenant_id?: string | null;
|
||||||
scope_type: ViewAssignmentScopeType;
|
scope_type: ViewAssignmentScopeType;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
|
scope_label?: string | null;
|
||||||
|
scope_detail?: string | null;
|
||||||
definition_id: string;
|
definition_id: string;
|
||||||
revision_id?: string | null;
|
revision_id?: string | null;
|
||||||
mode: ViewAssignmentMode;
|
mode: ViewAssignmentMode;
|
||||||
@@ -57,6 +59,21 @@ export type ViewAssignment = {
|
|||||||
updated_at: string;
|
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 = {
|
type EffectiveViewApiResponse = {
|
||||||
active_view_id?: string | null;
|
active_view_id?: string | null;
|
||||||
active_revision_id?: string | null;
|
active_revision_id?: string | null;
|
||||||
@@ -239,6 +256,24 @@ export async function fetchViewAssignments(
|
|||||||
return response.assignments;
|
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(
|
export function createViewAssignment(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
CheckSquare2,
|
CheckSquare2,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ExplorerTree,
|
ExplorerTree,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
SearchableSelect,
|
||||||
SelectionList,
|
SelectionList,
|
||||||
SelectionListItem,
|
SelectionListItem,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -32,7 +33,8 @@ import {
|
|||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
useViewSurfaces,
|
useViewSurfaces,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type PlatformViewSurface
|
type PlatformViewSurface,
|
||||||
|
type SearchableSelectOption
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
archiveViewDefinition,
|
archiveViewDefinition,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
createViewDefinition,
|
createViewDefinition,
|
||||||
createViewRevision,
|
createViewRevision,
|
||||||
deleteViewAssignment,
|
deleteViewAssignment,
|
||||||
|
fetchViewAssignmentTargets,
|
||||||
fetchViewAssignments,
|
fetchViewAssignments,
|
||||||
fetchViewDefinitions,
|
fetchViewDefinitions,
|
||||||
publishViewRevision,
|
publishViewRevision,
|
||||||
@@ -62,6 +65,8 @@ type DefinitionDraft = {
|
|||||||
type AssignmentDraft = {
|
type AssignmentDraft = {
|
||||||
scopeType: ViewAssignmentScopeType;
|
scopeType: ViewAssignmentScopeType;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
|
scopeLabel: string;
|
||||||
|
scopeDetail: string;
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
mode: ViewAssignmentMode;
|
mode: ViewAssignmentMode;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -337,6 +342,14 @@ export default function ViewsAdminPanel({
|
|||||||
setAssignmentDraft({
|
setAssignmentDraft({
|
||||||
scopeType: assignment.scope_type,
|
scopeType: assignment.scope_type,
|
||||||
scopeId: assignment.scope_id ?? "",
|
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,
|
definitionId: assignment.definition_id,
|
||||||
mode: assignment.mode,
|
mode: assignment.mode,
|
||||||
priority: assignment.priority,
|
priority: assignment.priority,
|
||||||
@@ -729,6 +742,7 @@ export default function ViewsAdminPanel({
|
|||||||
editing={assignmentEditor}
|
editing={assignmentEditor}
|
||||||
draft={assignmentDraft}
|
draft={assignmentDraft}
|
||||||
definitions={definitions}
|
definitions={definitions}
|
||||||
|
settings={settings}
|
||||||
scopeType={scopeType}
|
scopeType={scopeType}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onChange={setAssignmentDraft}
|
onChange={setAssignmentDraft}
|
||||||
@@ -814,6 +828,14 @@ function AssignmentsSection({
|
|||||||
{assignments.map((assignment) => {
|
{assignments.map((assignment) => {
|
||||||
const inherited =
|
const inherited =
|
||||||
scopeType === "tenant" && assignment.tenant_id == null;
|
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 (
|
return (
|
||||||
<div key={assignment.id} className="views-assignment-row">
|
<div key={assignment.id} className="views-assignment-row">
|
||||||
<div className="views-assignment-view">
|
<div className="views-assignment-view">
|
||||||
@@ -822,9 +844,10 @@ function AssignmentsSection({
|
|||||||
assignment.definition_id}
|
assignment.definition_id}
|
||||||
</strong>
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
{assignment.scope_type}
|
{assignmentScopeLabel(assignment.scope_type)}
|
||||||
{assignment.scope_id ? ` · ${assignment.scope_id}` : ""}
|
{assignment.scope_id ? ` · ${targetLabel}` : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{targetDetail && <small>{targetDetail}</small>}
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={assignment.mode} />
|
<StatusBadge status={assignment.mode} />
|
||||||
<span className="views-assignment-priority">
|
<span className="views-assignment-priority">
|
||||||
@@ -873,6 +896,7 @@ function AssignmentDialog({
|
|||||||
editing,
|
editing,
|
||||||
draft,
|
draft,
|
||||||
definitions,
|
definitions,
|
||||||
|
settings,
|
||||||
scopeType,
|
scopeType,
|
||||||
busy,
|
busy,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -883,12 +907,17 @@ function AssignmentDialog({
|
|||||||
editing: ViewAssignment | "new" | null;
|
editing: ViewAssignment | "new" | null;
|
||||||
draft: AssignmentDraft;
|
draft: AssignmentDraft;
|
||||||
definitions: ViewDefinition[];
|
definitions: ViewDefinition[];
|
||||||
|
settings: ApiSettings;
|
||||||
scopeType: ViewScopeType;
|
scopeType: ViewScopeType;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onChange: (draft: AssignmentDraft) => void;
|
onChange: (draft: AssignmentDraft) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [directoryIssue, setDirectoryIssue] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setDirectoryIssue(null);
|
||||||
|
}, [draft.scopeType, open]);
|
||||||
const definition = definitions.find(
|
const definition = definitions.find(
|
||||||
(item) => item.id === draft.definitionId
|
(item) => item.id === draft.definitionId
|
||||||
);
|
);
|
||||||
@@ -914,6 +943,53 @@ function AssignmentDialog({
|
|||||||
: [];
|
: [];
|
||||||
const targetRequired =
|
const targetRequired =
|
||||||
draft.scopeType === "group" || draft.scopeType === "user";
|
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(
|
const canSave = Boolean(
|
||||||
definition?.published_revision &&
|
definition?.published_revision &&
|
||||||
(!targetRequired || draft.scopeId.trim()) &&
|
(!targetRequired || draft.scopeId.trim()) &&
|
||||||
@@ -949,7 +1025,9 @@ function AssignmentDialog({
|
|||||||
onChange({
|
onChange({
|
||||||
...draft,
|
...draft,
|
||||||
scopeType: event.target.value as ViewAssignmentScopeType,
|
scopeType: event.target.value as ViewAssignmentScopeType,
|
||||||
scopeId: ""
|
scopeId: "",
|
||||||
|
scopeLabel: "",
|
||||||
|
scopeDetail: ""
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -964,21 +1042,44 @@ function AssignmentDialog({
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Target ID">
|
<FormField label="Target">
|
||||||
<input
|
{targetRequired ? (
|
||||||
value={
|
<>
|
||||||
draft.scopeType === "system" || draft.scopeType === "tenant"
|
<SearchableSelect
|
||||||
? ""
|
id="view-assignment-target"
|
||||||
: draft.scopeId
|
aria-label={`Select ${draft.scopeType}`}
|
||||||
}
|
value={draft.scopeId}
|
||||||
placeholder={
|
selectedOption={selectedTarget}
|
||||||
targetRequired ? `${draft.scopeType} identifier` : "Current scope"
|
loadOptions={loadTargets}
|
||||||
}
|
placeholder={`Select a ${draft.scopeType}`}
|
||||||
disabled={editing !== "new" || !targetRequired}
|
searchPlaceholder={`Search ${draft.scopeType}s`}
|
||||||
onChange={(event) =>
|
emptyText={`No matching ${draft.scopeType}s.`}
|
||||||
onChange({ ...draft, scopeId: event.target.value })
|
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>
|
||||||
<FormField label="View">
|
<FormField label="View">
|
||||||
<select
|
<select
|
||||||
@@ -1250,6 +1351,8 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
|
|||||||
return {
|
return {
|
||||||
scopeType: scopeType === "system" ? "system" : "tenant",
|
scopeType: scopeType === "system" ? "system" : "tenant",
|
||||||
scopeId: "",
|
scopeId: "",
|
||||||
|
scopeLabel: "",
|
||||||
|
scopeDetail: "",
|
||||||
definitionId: "",
|
definitionId: "",
|
||||||
mode: "available",
|
mode: "available",
|
||||||
priority: 0,
|
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 {
|
function scopeLabel(definition: ViewDefinition): string {
|
||||||
const label = {
|
const label = {
|
||||||
system: "System",
|
system: "System",
|
||||||
|
|||||||
@@ -261,11 +261,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.views-assignment-view strong,
|
.views-assignment-view strong,
|
||||||
.views-assignment-view span {
|
.views-assignment-view span,
|
||||||
|
.views-assignment-view small {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.views-assignment-view span,
|
.views-assignment-view span,
|
||||||
|
.views-assignment-view small,
|
||||||
.views-assignment-priority,
|
.views-assignment-priority,
|
||||||
.views-assignment-revision {
|
.views-assignment-revision {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user