feat: add View-scoped quick access presentation
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user