Add product-area presentation to Views
This commit is contained in:
@@ -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))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user