From 877dced738e3c7079277deca0b28dcd5921b904d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 18:47:46 +0200 Subject: [PATCH] feat: add View-scoped quick access presentation --- src/govoplan_views/backend/manifest.py | 23 +++++++- src/govoplan_views/backend/service.py | 34 +++++++++++- tests/test_views.py | 10 ++++ webui/src/api/views.ts | 10 +++- webui/src/features/views/ViewsAdminPanel.tsx | 56 ++++++++++++++++++-- 5 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index 71ad1bc..f520b3c 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -306,12 +306,30 @@ manifest = ModuleManifest( "so they can always be inspected and changed. The titlebar eye " "opens the selector and is accented while a specialized View is " "active. Hidden functions remain protected by their normal " - "permission checks." + "permission checks. A revision may recommend Quick Access tools or " + "focus the rail to a task-specific subset. These fields affect presentation " + "only: unavailable, context-incompatible, or unauthorized tools stay absent, " + "and an unusable focus falls back to the normal effective rail. Workflow uses " + "the same behavior by resolving the exact immutable View revision." ), layer="available", documentation_types=("admin", "user"), audience=("administrator", "power_user", "workflow_designer"), related_modules=("access", "admin", "policy", "workflow_engine"), + translations={ + "de": { + "title": "Aufgabenbezogene Ansichten", + "summary": "Die sichtbare Oberflaeche auf die fuer eine Aufgabe benoetigten Module und Funktionen begrenzen, ohne Berechtigungen zu aendern.", + "body": ( + "Ansichten sind versionierte Darstellungsprojektionen. Module melden ihre waehlbaren Oberflaechen ueber " + "den Plattformvertrag. Eine Revision darf Schnellzugriffswerkzeuge empfehlen oder die Leiste auf eine " + "aufgabenbezogene Teilmenge fokussieren. Das aendert nur die Darstellung: nicht verfuegbare, unpassende " + "oder unberechtigte Werkzeuge bleiben verborgen; ein nicht nutzbarer Fokus faellt auf die normale wirksame " + "Leiste zurueck. Workflow erhaelt dasselbe Verhalten durch die genaue unveraenderliche Ansichtsversion. " + "Ausgeblendete Funktionen bleiben durch ihre normalen Berechtigungspruefungen geschuetzt." + ), + } + }, links=( DocumentationLink( label="Views administration", @@ -358,7 +376,8 @@ manifest = ModuleManifest( "Inherited definitions or assignments must be changed in their owning scope." " Product-area grouping, ordering, and labels are presentation metadata in the same revision; " "they cannot expose a hidden surface or grant authority. Grouped navigation is the sensible default, " - "while flat navigation preserves the complete authorized tool rail." + "while flat navigation preserves the complete authorized tool rail. Quick Access recommendation and " + "focus ids are likewise revisioned presentation metadata and never authorize a contribution." ), documentation_types=("admin",), audience=("administrator", "power_user", "workflow_designer"), diff --git a/src/govoplan_views/backend/service.py b/src/govoplan_views/backend/service.py index 4574915..77f021b 100644 --- a/src/govoplan_views/backend/service.py +++ b/src/govoplan_views/backend/service.py @@ -52,8 +52,15 @@ LOCKOUT_ADMIN_SURFACE_IDS = { } _KEY_RE = re.compile(r"[^a-z0-9]+") _PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,79}$") +_QUICK_ACCESS_TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)+$") _PRESENTATION_KEYS = frozenset( - {"navigation_mode", "product_area_order", "product_area_labels"} + { + "navigation_mode", + "product_area_order", + "product_area_labels", + "quick_access_recommended_tool_ids", + "quick_access_focused_tool_ids", + } ) @@ -419,6 +426,31 @@ def normalize_view_presentation( labels[area_id] = label normalized["product_area_labels"] = labels + for field_name, label in ( + ("quick_access_recommended_tool_ids", "recommended Quick Access tools"), + ("quick_access_focused_tool_ids", "focused Quick Access tools"), + ): + raw_tool_ids = value.get(field_name) + if raw_tool_ids is None: + continue + if not isinstance(raw_tool_ids, list) or len(raw_tool_ids) > 100: + raise ViewsValidationError( + f"View {label} must be a list of at most 100 ids" + ) + tool_ids: list[str] = [] + for raw_id in raw_tool_ids: + tool_id = str(raw_id).strip() + if not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(tool_id): + raise ViewsValidationError( + f"Invalid Quick Access tool id in View presentation: {tool_id!r}" + ) + if tool_id in tool_ids: + raise ViewsValidationError( + f"Duplicate Quick Access tool id in View presentation: {tool_id}" + ) + tool_ids.append(tool_id) + normalized[field_name] = tool_ids + if available_product_area_ids is not None: available = {str(item) for item in available_product_area_ids} referenced = set(normalized.get("product_area_order", ())) | set( diff --git a/tests/test_views.py b/tests/test_views.py index ba136da..2518cc1 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -210,11 +210,17 @@ class ViewsServiceTests(unittest.TestCase): "navigation_mode": "grouped", "product_area_order": ["work", "records-documents"], "product_area_labels": {"work": "My work"}, + "quick_access_recommended_tool_ids": ["tasks.work"], + "quick_access_focused_tool_ids": ["tasks.work", "mail.messages"], }, available_product_area_ids=("work", "records-documents"), ) revision = get_revision(self.session, definition_id=definition.id) self.assertEqual("grouped", revision.presentation["navigation_mode"]) + self.assertEqual( + ["tasks.work"], + revision.presentation["quick_access_recommended_tool_ids"], + ) compatible_revision = create_revision( self.session, definition, @@ -257,6 +263,10 @@ class ViewsServiceTests(unittest.TestCase): {"product_area_order": ["unavailable"]}, available_product_area_ids=("work",), ) + with self.assertRaises(ViewsValidationError): + normalize_view_presentation( + {"quick_access_recommended_tool_ids": ["not namespaced"]} + ) def create_published_definition( self, diff --git a/webui/src/api/views.ts b/webui/src/api/views.ts index 3686bb7..974e7ab 100644 --- a/webui/src/api/views.ts +++ b/webui/src/api/views.ts @@ -20,6 +20,8 @@ export type ViewRevision = { navigation_mode?: "grouped" | "flat"; product_area_order?: string[]; product_area_labels?: Record; + quick_access_recommended_tool_ids?: string[]; + quick_access_focused_tool_ids?: string[]; }; content_hash: string; created_by?: string | null; @@ -264,7 +266,9 @@ function presentationFromApi( return { navigationMode: value?.navigation_mode, productAreaOrder: value?.product_area_order ?? [], - productAreaLabels: value?.product_area_labels ?? {} + productAreaLabels: value?.product_area_labels ?? {}, + quickAccessRecommendedToolIds: value?.quick_access_recommended_tool_ids ?? [], + quickAccessFocusedToolIds: value?.quick_access_focused_tool_ids ?? [] }; } @@ -274,7 +278,9 @@ export function presentationToApi( return { navigation_mode: value.navigationMode ?? "grouped", product_area_order: value.productAreaOrder ?? [], - product_area_labels: value.productAreaLabels ?? {} + product_area_labels: value.productAreaLabels ?? {}, + quick_access_recommended_tool_ids: value.quickAccessRecommendedToolIds ?? [], + quick_access_focused_tool_ids: value.quickAccessFocusedToolIds ?? [] }; } diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx index 9d38a60..330f34d 100644 --- a/webui/src/features/views/ViewsAdminPanel.tsx +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -771,6 +771,46 @@ export default function ViewsAdminPanel({ /> )} +
+
+
+

Quick Access focus

+

+ Recommend or focus namespaced tool IDs for this View. The + active account still needs the tool's permissions and visible surface. +

+
+
+ + + setDraft({ + ...draft, + presentation: { + ...draft.presentation, + quickAccessRecommendedToolIds: commaSeparatedToolIds(event.target.value) + } + })} + /> + + + setDraft({ + ...draft, + presentation: { + ...draft.presentation, + quickAccessFocusedToolIds: commaSeparatedToolIds(event.target.value) + } + })} + /> + + +
+
@@ -1839,7 +1879,9 @@ function defaultPresentation(areas: ViewProductArea[]): ViewPresentation { return { navigationMode: "grouped", productAreaOrder: areas.map((area) => area.id), - productAreaLabels: {} + productAreaLabels: {}, + quickAccessRecommendedToolIds: [], + quickAccessFocusedToolIds: [] }; } @@ -1878,7 +1920,9 @@ function revisionPresentation( value?.product_area_order?.length ? value.product_area_order : defaults.productAreaOrder, - productAreaLabels: value?.product_area_labels ?? {} + productAreaLabels: value?.product_area_labels ?? {}, + quickAccessRecommendedToolIds: value?.quick_access_recommended_tool_ids ?? [], + quickAccessFocusedToolIds: value?.quick_access_focused_tool_ids ?? [] }; } @@ -1905,10 +1949,16 @@ function presentationKey(value: ViewPresentation): string { Object.entries(value.productAreaLabels ?? {}) .filter(([, label]) => label.trim()) .sort(([left], [right]) => left.localeCompare(right)) - ) + ), + quickAccessRecommendedToolIds: value.quickAccessRecommendedToolIds ?? [], + quickAccessFocusedToolIds: value.quickAccessFocusedToolIds ?? [] }); } +function commaSeparatedToolIds(value: string): string[] { + return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; +} + function assignmentDraftKey(draft: AssignmentDraft): string { return JSON.stringify({