Add product-area presentation to Views

This commit is contained in:
2026-08-06 19:02:54 +02:00
parent 013f05839b
commit b9a92c79f9
12 changed files with 650 additions and 20 deletions
+36 -3
View File
@@ -2,7 +2,8 @@ import {
apiFetch,
apiPath,
type ApiSettings,
type EffectiveViewProjection
type EffectiveViewProjection,
type ViewPresentation
} from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant" | "group" | "user";
@@ -15,6 +16,11 @@ export type ViewRevision = {
revision: number;
surface_contract_version: string;
visible_surface_ids: string[];
presentation: {
navigation_mode?: "grouped" | "flat";
product_area_order?: string[];
product_area_labels?: Record<string, string>;
};
content_hash: string;
created_by?: string | null;
created_at: string;
@@ -79,6 +85,7 @@ type EffectiveViewApiResponse = {
active_revision_id?: string | null;
active_view_name?: string | null;
visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
locked: boolean;
available_views: Array<{
id: string;
@@ -113,6 +120,7 @@ function projection(response: EffectiveViewApiResponse): EffectiveViewProjection
activeRevisionId: response.active_revision_id ?? null,
activeViewName: response.active_view_name ?? null,
visibleSurfaceIds: response.visible_surface_ids,
presentation: presentationFromApi(response.presentation),
locked: response.locked,
availableViews: response.available_views.map((view) => ({
id: view.id,
@@ -211,6 +219,7 @@ export function createViewDefinition(
name: string;
description?: string | null;
visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
}
): Promise<ViewDefinition> {
return apiFetch(settings, "/api/v1/views/definitions", {
@@ -233,18 +242,42 @@ export function updateViewDefinition(
export function createViewRevision(
settings: ApiSettings,
definitionId: string,
visibleSurfaceIds: string[]
visibleSurfaceIds: string[],
presentation: ViewPresentation
): Promise<ViewDefinition> {
return apiFetch(
settings,
`/api/v1/views/definitions/${definitionId}/revisions`,
{
method: "POST",
...jsonBody({ visible_surface_ids: visibleSurfaceIds })
...jsonBody({
visible_surface_ids: visibleSurfaceIds,
presentation: presentationToApi(presentation)
})
}
);
}
function presentationFromApi(
value: ViewRevision["presentation"] | undefined
): ViewPresentation {
return {
navigationMode: value?.navigation_mode,
productAreaOrder: value?.product_area_order ?? [],
productAreaLabels: value?.product_area_labels ?? {}
};
}
export function presentationToApi(
value: ViewPresentation
): ViewRevision["presentation"] {
return {
navigation_mode: value.navigationMode ?? "grouped",
product_area_order: value.productAreaOrder ?? [],
product_area_labels: value.productAreaLabels ?? {}
};
}
export function publishViewRevision(
settings: ApiSettings,
definitionId: string,
+286 -8
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
ArrowDown,
ArrowUp,
CheckSquare2,
ChevronDown,
ChevronRight,
@@ -25,6 +27,7 @@ import {
FormField,
IconButton,
SearchableSelect,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
@@ -33,12 +36,15 @@ import {
dispatchPlatformViewChanged,
i18nMessage,
usePlatformLanguage,
usePlatformModules,
useUnsavedChanges,
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type PlatformWebModule,
type PlatformViewSurface,
type SearchableSelectOption
type SearchableSelectOption,
type ViewPresentation
} from "@govoplan/core-webui";
import {
archiveViewDefinition,
@@ -50,6 +56,7 @@ import {
fetchViewAssignments,
fetchViewDefinitions,
publishViewRevision,
presentationToApi,
updateViewAssignment,
updateViewDefinition,
type ViewAssignment,
@@ -70,6 +77,15 @@ type DefinitionDraft = {
name: string;
description: string;
surfaceIds: string[];
presentation: ViewPresentation;
};
type ViewProductArea = {
id: string;
label: string;
description?: string | null;
order: number;
surfaceIds: string[];
};
type AssignmentDraft = {
@@ -117,6 +133,8 @@ export default function ViewsAdminPanel({
description?: string;
}) {
const surfaces = useViewSurfaces();
const modules = usePlatformModules();
const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]);
const { requestDiscard } = useUnsavedChanges();
const { translateText } = usePlatformLanguage();
const [definitions, setDefinitions] = useState<ViewDefinition[]>([]);
@@ -125,7 +143,8 @@ export default function ViewsAdminPanel({
const [draft, setDraft] = useState<DefinitionDraft>({
name: "",
description: "",
surfaceIds: []
surfaceIds: [],
presentation: defaultPresentation([])
});
const [savedDraftKey, setSavedDraftKey] = useState("");
const [loading, setLoading] = useState(true);
@@ -225,9 +244,18 @@ export default function ViewsAdminPanel({
? {
name: definition.name,
description: definition.description ?? "",
surfaceIds: definition.latest_revision.visible_surface_ids
surfaceIds: definition.latest_revision.visible_surface_ids,
presentation: revisionPresentation(
definition.latest_revision.presentation,
productAreas
)
}
: { name: "", description: "", surfaceIds: [] };
: {
name: "",
description: "",
surfaceIds: [],
presentation: defaultPresentation(productAreas)
};
setDraft(next);
setSavedDraftKey(definitionDraftKey(next));
}
@@ -267,12 +295,17 @@ export default function ViewsAdminPanel({
}
if (
surfaceSetKey(draft.surfaceIds) !==
surfaceSetKey(next.latest_revision.visible_surface_ids)
surfaceSetKey(next.latest_revision.visible_surface_ids) ||
presentationKey(draft.presentation) !==
presentationKey(
revisionPresentation(next.latest_revision.presentation, productAreas)
)
) {
next = await createViewRevision(
settings,
selected.id,
draft.surfaceIds
draft.surfaceIds,
draft.presentation
);
}
await load(selected.id);
@@ -328,7 +361,8 @@ export default function ViewsAdminPanel({
scope_id: scopeId || null,
name: createDraft.name.trim(),
description: createDraft.description.trim() || null,
visible_surface_ids: visibleSurfaceIds
visible_surface_ids: visibleSurfaceIds,
presentation: presentationToApi(defaultPresentation(productAreas))
});
closeCreate();
setSuccess("i18n:govoplan-views.draft_created");
@@ -727,6 +761,16 @@ export default function ViewsAdminPanel({
</FormField>
</div>
{productAreas.length > 0 && (
<ProductAreaEditor
areas={productAreas}
surfaces={surfaces}
draft={draft}
disabled={!definitionEditable || busy}
onChange={setDraft}
/>
)}
<section className="views-surface-section">
<div className="views-section-heading">
<div>
@@ -1379,6 +1423,136 @@ function AssignmentDialog({
}
function ProductAreaEditor({
areas,
surfaces,
draft,
disabled,
onChange
}: {
areas: ViewProductArea[];
surfaces: PlatformViewSurface[];
draft: DefinitionDraft;
disabled: boolean;
onChange: (draft: DefinitionDraft) => void;
}) {
const { translateText } = usePlatformLanguage();
const ordered = orderedProductAreas(areas, draft.presentation.productAreaOrder);
const requiredSurfaceIds = new Set(
surfaces.filter((surface) => surface.required).map((surface) => surface.id)
);
function updatePresentation(presentation: ViewPresentation) {
onChange({ ...draft, presentation });
}
function move(areaId: string, direction: -1 | 1) {
const order = ordered.map((area) => area.id);
const index = order.indexOf(areaId);
const target = index + direction;
if (index < 0 || target < 0 || target >= order.length) return;
[order[index], order[target]] = [order[target], order[index]];
updatePresentation({ ...draft.presentation, productAreaOrder: order });
}
function setLabel(areaId: string, label: string) {
const labels = { ...(draft.presentation.productAreaLabels ?? {}) };
if (label.trim()) labels[areaId] = label;
else delete labels[areaId];
updatePresentation({ ...draft.presentation, productAreaLabels: labels });
}
function setAreaVisible(area: ViewProductArea, visible: boolean) {
const selected = new Set(draft.surfaceIds);
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
if (visible) affectedSurfaceIds.forEach((id) => selected.add(id));
else affectedSurfaceIds.forEach((id) => selected.delete(id));
onChange({ ...draft, surfaceIds: [...selected] });
}
return (
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>i18n:govoplan-views.product_areas</h4>
<p className="muted small-note">
i18n:govoplan-views.product_areas_help
</p>
</div>
<SegmentedControl<"grouped" | "flat">
ariaLabel={translateText("i18n:govoplan-views.navigation_layout")}
role="group"
value={draft.presentation.navigationMode ?? "grouped"}
disabled={disabled}
onChange={(navigationMode) =>
updatePresentation({ ...draft.presentation, navigationMode })
}
options={[
{ id: "grouped", label: "i18n:govoplan-views.grouped" },
{ id: "flat", label: "i18n:govoplan-views.flat" }
]}
/>
</div>
<div className="views-product-area-list">
{ordered.map((area, index) => {
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
const visible = affectedSurfaceIds.some((id) =>
draft.surfaceIds.includes(id)
);
const required = affectedSurfaceIds.some((id) =>
requiredSurfaceIds.has(id)
);
return (
<div className="views-product-area-row" key={area.id}>
<div className="views-product-area-copy">
<strong>{translateText(area.label)}</strong>
<small>{translateText(area.description ?? area.id)}</small>
</div>
<input
value={draft.presentation.productAreaLabels?.[area.id] ?? ""}
placeholder={translateText(area.label)}
aria-label={i18nMessage(
"i18n:govoplan-views.custom_area_label_value",
{ value0: translateText(area.label) }
)}
maxLength={200}
disabled={disabled}
onChange={(event) => setLabel(area.id, event.target.value)}
/>
<ToggleSwitch
label={area.label}
inactiveLabel="i18n:govoplan-views.hidden"
activeLabel="i18n:govoplan-views.visible"
checked={visible}
disabled={disabled || required}
help={required ? "i18n:govoplan-views.required_area_help" : undefined}
onChange={(checked) => setAreaVisible(area, checked)}
/>
<div className="views-product-area-order">
<IconButton
label="i18n:govoplan-views.move_up"
icon={<ArrowUp size={16} />}
variant="ghost"
disabled={disabled || index === 0}
onClick={() => move(area.id, -1)}
/>
<IconButton
label="i18n:govoplan-views.move_down"
icon={<ArrowDown size={16} />}
variant="ghost"
disabled={disabled || index === ordered.length - 1}
onClick={() => move(area.id, 1)}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
function SurfaceSelector({
surfaces,
selected,
@@ -1627,7 +1801,111 @@ function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({
name: draft.name.trim(),
description: draft.description.trim(),
surfaces: [...new Set(draft.surfaceIds)].sort()
surfaces: [...new Set(draft.surfaceIds)].sort(),
presentation: presentationKey(draft.presentation)
});
}
function aggregateProductAreas(modules: PlatformWebModule[]): ViewProductArea[] {
const result = new Map<string, ViewProductArea>();
for (const contribution of modules.flatMap(
(module) => module.productAreas ?? []
)) {
const existing = result.get(contribution.id);
if (existing) {
existing.surfaceIds = [
...new Set([...existing.surfaceIds, ...contribution.surfaceIds])
];
existing.order = Math.min(existing.order, contribution.order ?? 100);
continue;
}
result.set(contribution.id, {
id: contribution.id,
label: contribution.label,
description: contribution.description,
order: contribution.order ?? 100,
surfaceIds: [...new Set(contribution.surfaceIds)]
});
}
return [...result.values()].sort(
(left, right) =>
left.order - right.order || left.label.localeCompare(right.label)
);
}
function defaultPresentation(areas: ViewProductArea[]): ViewPresentation {
return {
navigationMode: "grouped",
productAreaOrder: areas.map((area) => area.id),
productAreaLabels: {}
};
}
function productAreaSurfaceIds(
area: ViewProductArea,
surfaces: PlatformViewSurface[]
): string[] {
const affected = new Set(area.surfaceIds);
let changed = true;
while (changed) {
changed = false;
for (const surface of surfaces) {
if (
surface.parentId &&
affected.has(surface.parentId) &&
!affected.has(surface.id)
) {
affected.add(surface.id);
changed = true;
}
}
}
return [...affected];
}
function revisionPresentation(
value: ViewDefinition["latest_revision"]["presentation"] | undefined,
areas: ViewProductArea[]
): ViewPresentation {
const defaults = defaultPresentation(areas);
return {
navigationMode: value?.navigation_mode ?? defaults.navigationMode,
productAreaOrder:
value?.product_area_order?.length
? value.product_area_order
: defaults.productAreaOrder,
productAreaLabels: value?.product_area_labels ?? {}
};
}
function orderedProductAreas(
areas: ViewProductArea[],
configuredOrder: string[] | undefined
): ViewProductArea[] {
const rank = new Map((configuredOrder ?? []).map((id, index) => [id, index]));
return [...areas].sort(
(left, right) =>
(rank.get(left.id) ?? 10_000) - (rank.get(right.id) ?? 10_000) ||
left.order - right.order ||
left.label.localeCompare(right.label)
);
}
function presentationKey(value: ViewPresentation): string {
return JSON.stringify({
navigationMode: value.navigationMode ?? "grouped",
productAreaOrder: value.productAreaOrder ?? [],
productAreaLabels: Object.fromEntries(
Object.entries(value.productAreaLabels ?? {})
.filter(([, label]) => label.trim())
.sort(([left], [right]) => left.localeCompare(right))
)
});
}
+24 -2
View File
@@ -129,7 +129,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-views.filter_view_surfaces": "Filter View surfaces",
"i18n:govoplan-views.surface_count": "{value0}/{value1} surfaces",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}",
"i18n:govoplan-views.no_matching_surfaces": "No matching surfaces."
"i18n:govoplan-views.no_matching_surfaces": "No matching surfaces.",
"i18n:govoplan-views.product_areas": "Product areas",
"i18n:govoplan-views.product_areas_help": "Choose the outcome-based navigation groups, their order, and optional labels for this View.",
"i18n:govoplan-views.navigation_layout": "Navigation layout",
"i18n:govoplan-views.grouped": "Grouped",
"i18n:govoplan-views.flat": "Flat",
"i18n:govoplan-views.hidden": "Hidden",
"i18n:govoplan-views.visible": "Visible",
"i18n:govoplan-views.required_area_help": "This area contains a required surface and cannot be hidden.",
"i18n:govoplan-views.custom_area_label_value": "Custom label for {value0}",
"i18n:govoplan-views.move_up": "Move up",
"i18n:govoplan-views.move_down": "Move down"
},
de: {
"i18n:govoplan-views.views": "Ansichten",
@@ -259,6 +270,17 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-views.filter_view_surfaces": "Ansichtsoberflächen filtern",
"i18n:govoplan-views.surface_count": "{value0}/{value1} Oberflächen",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}",
"i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen."
"i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen.",
"i18n:govoplan-views.product_areas": "Produktbereiche",
"i18n:govoplan-views.product_areas_help": "Ergebnisorientierte Navigationsgruppen, ihre Reihenfolge und optionale Bezeichnungen für diese Ansicht festlegen.",
"i18n:govoplan-views.navigation_layout": "Navigationsdarstellung",
"i18n:govoplan-views.grouped": "Gruppiert",
"i18n:govoplan-views.flat": "Flach",
"i18n:govoplan-views.hidden": "Ausgeblendet",
"i18n:govoplan-views.visible": "Sichtbar",
"i18n:govoplan-views.required_area_help": "Dieser Bereich enthält eine vorgeschriebene Oberfläche und kann nicht ausgeblendet werden.",
"i18n:govoplan-views.custom_area_label_value": "Eigene Bezeichnung für {value0}",
"i18n:govoplan-views.move_up": "Nach oben",
"i18n:govoplan-views.move_down": "Nach unten"
}
};
+56
View File
@@ -175,12 +175,59 @@
resize: vertical;
}
.views-product-area-section,
.views-surface-section {
margin-top: 22px;
padding-top: 18px;
border-top: var(--border-line);
}
.views-product-area-list {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.views-product-area-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr) auto auto;
align-items: center;
gap: 12px;
min-height: 62px;
padding: 8px 10px;
border-bottom: var(--border-line);
}
.views-product-area-row:last-child {
border-bottom: 0;
}
.views-product-area-row:hover {
background: var(--hover-tint-soft);
}
.views-product-area-copy {
min-width: 0;
}
.views-product-area-copy strong,
.views-product-area-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-product-area-copy small {
margin-top: 3px;
color: var(--muted);
}
.views-product-area-order {
display: flex;
align-items: center;
}
.views-assignments-section {
min-width: 0;
padding-top: 18px;
@@ -337,6 +384,15 @@
grid-template-columns: 1fr;
}
.views-product-area-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.views-product-area-row > input {
grid-column: 1 / -1;
grid-row: 2;
}
.views-editor-heading,
.views-section-heading,
.views-stale-surface-warning {