feat: add View-scoped quick access presentation

This commit is contained in:
2026-08-19 18:47:46 +02:00
parent cdbf2bf80b
commit 877dced738
5 changed files with 125 additions and 8 deletions
+21 -2
View File
@@ -306,12 +306,30 @@ manifest = ModuleManifest(
"so they can always be inspected and changed. The titlebar eye " "so they can always be inspected and changed. The titlebar eye "
"opens the selector and is accented while a specialized View is " "opens the selector and is accented while a specialized View is "
"active. Hidden functions remain protected by their normal " "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", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("administrator", "power_user", "workflow_designer"), audience=("administrator", "power_user", "workflow_designer"),
related_modules=("access", "admin", "policy", "workflow_engine"), 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=( links=(
DocumentationLink( DocumentationLink(
label="Views administration", label="Views administration",
@@ -358,7 +376,8 @@ manifest = ModuleManifest(
"Inherited definitions or assignments must be changed in their owning scope." "Inherited definitions or assignments must be changed in their owning scope."
" Product-area grouping, ordering, and labels are presentation metadata in the same revision; " " 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, " "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",), documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"), audience=("administrator", "power_user", "workflow_designer"),
+33 -1
View File
@@ -52,8 +52,15 @@ LOCKOUT_ADMIN_SURFACE_IDS = {
} }
_KEY_RE = re.compile(r"[^a-z0-9]+") _KEY_RE = re.compile(r"[^a-z0-9]+")
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,79}$") _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( _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 labels[area_id] = label
normalized["product_area_labels"] = labels 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: if available_product_area_ids is not None:
available = {str(item) for item in available_product_area_ids} available = {str(item) for item in available_product_area_ids}
referenced = set(normalized.get("product_area_order", ())) | set( referenced = set(normalized.get("product_area_order", ())) | set(
+10
View File
@@ -210,11 +210,17 @@ class ViewsServiceTests(unittest.TestCase):
"navigation_mode": "grouped", "navigation_mode": "grouped",
"product_area_order": ["work", "records-documents"], "product_area_order": ["work", "records-documents"],
"product_area_labels": {"work": "My work"}, "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"), available_product_area_ids=("work", "records-documents"),
) )
revision = get_revision(self.session, definition_id=definition.id) revision = get_revision(self.session, definition_id=definition.id)
self.assertEqual("grouped", revision.presentation["navigation_mode"]) self.assertEqual("grouped", revision.presentation["navigation_mode"])
self.assertEqual(
["tasks.work"],
revision.presentation["quick_access_recommended_tool_ids"],
)
compatible_revision = create_revision( compatible_revision = create_revision(
self.session, self.session,
definition, definition,
@@ -257,6 +263,10 @@ class ViewsServiceTests(unittest.TestCase):
{"product_area_order": ["unavailable"]}, {"product_area_order": ["unavailable"]},
available_product_area_ids=("work",), available_product_area_ids=("work",),
) )
with self.assertRaises(ViewsValidationError):
normalize_view_presentation(
{"quick_access_recommended_tool_ids": ["not namespaced"]}
)
def create_published_definition( def create_published_definition(
self, self,
+8 -2
View File
@@ -20,6 +20,8 @@ export type ViewRevision = {
navigation_mode?: "grouped" | "flat"; navigation_mode?: "grouped" | "flat";
product_area_order?: string[]; product_area_order?: string[];
product_area_labels?: Record<string, string>; product_area_labels?: Record<string, string>;
quick_access_recommended_tool_ids?: string[];
quick_access_focused_tool_ids?: string[];
}; };
content_hash: string; content_hash: string;
created_by?: string | null; created_by?: string | null;
@@ -264,7 +266,9 @@ function presentationFromApi(
return { return {
navigationMode: value?.navigation_mode, navigationMode: value?.navigation_mode,
productAreaOrder: value?.product_area_order ?? [], 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 { return {
navigation_mode: value.navigationMode ?? "grouped", navigation_mode: value.navigationMode ?? "grouped",
product_area_order: value.productAreaOrder ?? [], 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 ?? []
}; };
} }
+53 -3
View File
@@ -771,6 +771,46 @@ export default function ViewsAdminPanel({
/> />
)} )}
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>Quick Access focus</h4>
<p className="muted small-note">
Recommend or focus namespaced tool IDs for this View. The
active account still needs the tool's permissions and visible surface.
</p>
</div>
</div>
<FormGrid columns={2} collapseAt="standard">
<FormField label="Recommended tool IDs" help="Comma-separated, for example tasks.work, files.recent">
<input
value={(draft.presentation.quickAccessRecommendedToolIds ?? []).join(", ")}
disabled={!definitionEditable || busy}
onChange={(event) => setDraft({
...draft,
presentation: {
...draft.presentation,
quickAccessRecommendedToolIds: commaSeparatedToolIds(event.target.value)
}
})}
/>
</FormField>
<FormField label="Focused tool IDs" help="When at least one listed tool is available, other Quick Access tools are hidden for this View.">
<input
value={(draft.presentation.quickAccessFocusedToolIds ?? []).join(", ")}
disabled={!definitionEditable || busy}
onChange={(event) => setDraft({
...draft,
presentation: {
...draft.presentation,
quickAccessFocusedToolIds: commaSeparatedToolIds(event.target.value)
}
})}
/>
</FormField>
</FormGrid>
</section>
<section className="views-surface-section"> <section className="views-surface-section">
<div className="views-section-heading"> <div className="views-section-heading">
<div> <div>
@@ -1839,7 +1879,9 @@ function defaultPresentation(areas: ViewProductArea[]): ViewPresentation {
return { return {
navigationMode: "grouped", navigationMode: "grouped",
productAreaOrder: areas.map((area) => area.id), 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?.length
? value.product_area_order ? value.product_area_order
: defaults.productAreaOrder, : 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 ?? {}) Object.entries(value.productAreaLabels ?? {})
.filter(([, label]) => label.trim()) .filter(([, label]) => label.trim())
.sort(([left], [right]) => left.localeCompare(right)) .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 { function assignmentDraftKey(draft: AssignmentDraft): string {
return JSON.stringify({ return JSON.stringify({