feat: add configurable view-aware dashboards
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Settings2,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
IconButton,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DashboardWidgetContribution,
|
||||
type EffectiveViewProjection,
|
||||
type PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
dashboardMasonryRowSpan,
|
||||
defaultWidgetSize,
|
||||
widgetIsConfigurable,
|
||||
type DashboardWidgetPlacement
|
||||
} from "./dashboardLayout";
|
||||
import {
|
||||
dashboardGridPreview,
|
||||
type DashboardDragItem,
|
||||
type DashboardDropTarget
|
||||
} from "./dashboardEditorTypes";
|
||||
|
||||
type DashboardGridProps = {
|
||||
placements: DashboardWidgetPlacement[];
|
||||
widgetById: Map<string, DashboardWidgetContribution>;
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
modules: PlatformWebModule[];
|
||||
effectiveView: EffectiveViewProjection | null;
|
||||
refreshKey: number;
|
||||
configuring: boolean;
|
||||
dragItem: DashboardDragItem | null;
|
||||
dropTarget: DashboardDropTarget | null;
|
||||
onDragStart: (event: ReactDragEvent, item: DashboardDragItem) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (event: ReactDragEvent<HTMLElement>, instanceId: string) => void;
|
||||
onDragOverEnd: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDrop: (
|
||||
event: ReactDragEvent<HTMLElement>,
|
||||
placement: DashboardWidgetPlacement
|
||||
) => void;
|
||||
onDropPreview: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDropAtEnd: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onRemove: (instanceId: string) => void;
|
||||
onConfigure: (instanceId: string) => void;
|
||||
};
|
||||
|
||||
export default function DashboardGrid({
|
||||
placements,
|
||||
widgetById,
|
||||
settings,
|
||||
auth,
|
||||
modules,
|
||||
effectiveView,
|
||||
refreshKey,
|
||||
configuring,
|
||||
dragItem,
|
||||
dropTarget,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragOverEnd,
|
||||
onDrop,
|
||||
onDropPreview,
|
||||
onDropAtEnd,
|
||||
onRemove,
|
||||
onConfigure
|
||||
}: DashboardGridProps) {
|
||||
const showingEmptyPreview =
|
||||
configuring
|
||||
&& dragItem?.kind === "catalogue"
|
||||
&& dropTarget !== null;
|
||||
if (!placements.length && !showingEmptyPreview) {
|
||||
return (
|
||||
<div
|
||||
className={`dashboard-empty-state${configuring ? " is-drop-target" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
if (!configuring) return;
|
||||
onDragOverEnd(event);
|
||||
}}
|
||||
onDrop={configuring ? onDropAtEnd : undefined}
|
||||
>
|
||||
<h2>No widgets on this Dashboard</h2>
|
||||
<p className="muted">
|
||||
{configuring
|
||||
? "Drag a widget here from the library."
|
||||
: "Open Configure to add widgets provided by active modules."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const catalogueWidget =
|
||||
dragItem?.kind === "catalogue"
|
||||
? widgetById.get(dragItem.widgetId)
|
||||
: null;
|
||||
const previewItems = dashboardGridPreview(
|
||||
placements,
|
||||
configuring ? dragItem : null,
|
||||
configuring ? dropTarget : null,
|
||||
catalogueWidget ? defaultWidgetSize(catalogueWidget) : "medium"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
if (!configuring || event.target !== event.currentTarget) return;
|
||||
onDragOverEnd(event);
|
||||
}}
|
||||
onDrop={configuring ? onDropAtEnd : undefined}
|
||||
>
|
||||
{previewItems.map((item) => {
|
||||
if (item.kind === "catalogue") {
|
||||
const widget = widgetById.get(item.widgetId);
|
||||
return (
|
||||
<DashboardDropPlaceholder
|
||||
key={`catalogue-placeholder:${item.widgetId}`}
|
||||
title={widget?.title ?? "New widget"}
|
||||
size={item.size}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDropPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const placement = item.placement;
|
||||
const widget = widgetById.get(placement.widgetId);
|
||||
if (!widget) return null;
|
||||
if (item.placeholder) {
|
||||
return (
|
||||
<DashboardDropPlaceholder
|
||||
key={placement.instanceId}
|
||||
title={widget.title}
|
||||
size={placement.size}
|
||||
preserveHeight
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDropPreview}
|
||||
>
|
||||
<Card title={widget.title}>
|
||||
<DashboardWidgetContent
|
||||
widget={widget}
|
||||
placement={placement}
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
modules={modules}
|
||||
effectiveView={effectiveView}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</Card>
|
||||
</DashboardDropPlaceholder>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DashboardMasonryItem
|
||||
key={placement.instanceId}
|
||||
className={`dashboard-widget dashboard-widget-${placement.size}`}
|
||||
onDragOver={
|
||||
configuring
|
||||
? (event) => onDragOver(event, placement.instanceId)
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
configuring
|
||||
? (event) => onDrop(event, placement)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Card
|
||||
title={widget.title}
|
||||
collapsible={!configuring}
|
||||
collapseKey={`dashboard-widget:${placement.instanceId}`}
|
||||
actions={
|
||||
configuring ? (
|
||||
<div className="dashboard-widget-actions">
|
||||
<IconButton
|
||||
label={`Drag ${widget.title}`}
|
||||
icon={<GripVertical size={16} />}
|
||||
variant="ghost"
|
||||
className="dashboard-widget-drag-handle"
|
||||
draggable
|
||||
onDragStart={(event) =>
|
||||
onDragStart(event, {
|
||||
kind: "placement",
|
||||
instanceId: placement.instanceId
|
||||
})
|
||||
}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
{widgetIsConfigurable(widget) && (
|
||||
<IconButton
|
||||
label={`Configure ${widget.title}`}
|
||||
icon={<Settings2 size={16} />}
|
||||
variant="ghost"
|
||||
onClick={() => onConfigure(placement.instanceId)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
label={`Remove ${widget.title}`}
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="ghost"
|
||||
onClick={() => onRemove(placement.instanceId)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DashboardWidgetContent
|
||||
widget={widget}
|
||||
placement={placement}
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
modules={modules}
|
||||
effectiveView={effectiveView}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</Card>
|
||||
</DashboardMasonryItem>
|
||||
);
|
||||
})}
|
||||
{configuring && (
|
||||
<div
|
||||
className="dashboard-grid-end-drop-target"
|
||||
onDragOver={onDragOverEnd}
|
||||
onDrop={onDropAtEnd}
|
||||
>
|
||||
Drop here to place the widget last
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardDropPlaceholder({
|
||||
title,
|
||||
size,
|
||||
preserveHeight = false,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
size: DashboardWidgetPlacement["size"];
|
||||
preserveHeight?: boolean;
|
||||
onDragOver: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DashboardMasonryItem
|
||||
className={[
|
||||
"dashboard-widget",
|
||||
`dashboard-widget-${size}`,
|
||||
"dashboard-widget-drop-placeholder",
|
||||
preserveHeight ? "preserves-widget-height" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
aria-label={`Place ${title} here`}
|
||||
>
|
||||
{children && (
|
||||
<div className="dashboard-widget-placeholder-content">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="dashboard-widget-placeholder-surface">
|
||||
<GripVertical size={18} aria-hidden="true" />
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
</DashboardMasonryItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardMasonryItem({
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement>) {
|
||||
const itemRef = useRef<HTMLElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const item = itemRef.current;
|
||||
const grid = item?.parentElement;
|
||||
if (!item || !grid) return undefined;
|
||||
|
||||
const updateSpan = () => {
|
||||
const gridStyles = window.getComputedStyle(grid);
|
||||
const itemStyles = window.getComputedStyle(item);
|
||||
const rowHeight = Number.parseFloat(gridStyles.gridAutoRows);
|
||||
const bottomGap = Number.parseFloat(itemStyles.marginBottom);
|
||||
const nextSpan = dashboardMasonryRowSpan(
|
||||
item.getBoundingClientRect().height,
|
||||
rowHeight,
|
||||
bottomGap
|
||||
);
|
||||
const gridRowEnd = `span ${nextSpan}`;
|
||||
if (item.style.gridRowEnd !== gridRowEnd) {
|
||||
item.style.gridRowEnd = gridRowEnd;
|
||||
}
|
||||
};
|
||||
|
||||
updateSpan();
|
||||
if (typeof ResizeObserver === "undefined") return undefined;
|
||||
const observer = new ResizeObserver(updateSpan);
|
||||
observer.observe(item);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={itemRef} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardWidgetContent({
|
||||
widget,
|
||||
placement,
|
||||
settings,
|
||||
auth,
|
||||
modules,
|
||||
effectiveView,
|
||||
refreshKey
|
||||
}: {
|
||||
widget: DashboardWidgetContribution;
|
||||
placement: DashboardWidgetPlacement;
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
modules: PlatformWebModule[];
|
||||
effectiveView: EffectiveViewProjection | null;
|
||||
refreshKey: number;
|
||||
}) {
|
||||
const [scheduledRefreshKey, setScheduledRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widget.refreshIntervalMs) return undefined;
|
||||
const interval = window.setInterval(
|
||||
() => setScheduledRefreshKey((value) => value + 1),
|
||||
Math.max(widget.refreshIntervalMs, 5_000)
|
||||
);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [widget.refreshIntervalMs]);
|
||||
|
||||
return widget.render({
|
||||
settings,
|
||||
auth,
|
||||
modules,
|
||||
effectiveView,
|
||||
instanceId: placement.instanceId,
|
||||
widgetId: widget.id,
|
||||
refreshKey: refreshKey + scheduledRefreshKey,
|
||||
size: placement.size,
|
||||
configuration: placement.configuration
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user