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
+229 -4
View File
@@ -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(
list_assignments(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
scope_type=scope_type, scope_type=scope_type,
include_inherited=include_inherited, include_inherited=include_inherited,
) )
)
targets = _assignment_target_lookup(
_optional_access_directory(),
tenant_id=principal.tenant_id,
assignments=assignments,
)
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
+17
View File
@@ -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)
+102
View File
@@ -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
View File
@@ -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 @@
} }
} }
} }
+35
View File
@@ -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: {
+133 -18
View File
@@ -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",
+3 -1
View File
@@ -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);