Rework the dashboards onto the flow canvas
One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,20 +1,33 @@
|
||||
import type {
|
||||
DashboardDef_Output,
|
||||
PageDef_Output,
|
||||
Placement,
|
||||
SectionDef_Output,
|
||||
WidgetDef,
|
||||
} from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WidgetBody, WidgetFrame } from "./widgets"
|
||||
import "./dashboard.css"
|
||||
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
||||
|
||||
/**
|
||||
* Columns per breakpoint. A wall panel gets the full twelve, a phone gets
|
||||
* three, which is what makes a stat tile still readable at arm's length.
|
||||
* A dashboard, plus the settings the generated client does not carry yet.
|
||||
*
|
||||
* `columns` is stored by the backend but the SDK is regenerated on its own
|
||||
* schedule, so it is widened here rather than waited for.
|
||||
*/
|
||||
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
|
||||
export type Dashboard = DashboardDef_Output & { columns?: number }
|
||||
|
||||
/** One grid row, in pixels. Widget heights are multiples of this. */
|
||||
const ROW = "5rem"
|
||||
/** How many columns a wall panel is cut into, if the document does not say. */
|
||||
export const DEFAULT_COLUMNS = 12
|
||||
|
||||
/** What a grid size setting may be set to; a panel is matched to one of these. */
|
||||
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
|
||||
|
||||
/** One grid row, in pixels — the unit widget heights are multiples of. */
|
||||
export const ROW_HEIGHT = 80
|
||||
|
||||
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
|
||||
export const GRID_GAP = 12
|
||||
|
||||
/**
|
||||
* The generated client marks every list optional, because the server fills
|
||||
@@ -24,40 +37,61 @@ export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? []
|
||||
export const sectionsOf = (page: PageDef_Output) => page.sections ?? []
|
||||
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
|
||||
|
||||
function placement(widget: WidgetDef) {
|
||||
const layout = (widget.layout ?? {}) as Record<
|
||||
string,
|
||||
{ x?: number; y?: number; w?: number; h?: number }
|
||||
>
|
||||
export const columnsOf = (dashboard: Dashboard) =>
|
||||
dashboard.columns || DEFAULT_COLUMNS
|
||||
|
||||
export function placement(widget: WidgetDef): Placement {
|
||||
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
||||
return layout.lg ?? layout.md ?? layout.sm ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget's box, as plain CSS grid.
|
||||
*
|
||||
* View mode never loads a grid library: a wall panel that only displays
|
||||
* should not pay for the code that lets someone drag things around.
|
||||
* View mode never loads a grid library: a wall panel that only displays should
|
||||
* not pay for the code that lets someone drag things around.
|
||||
*/
|
||||
export function widgetStyle(widget: WidgetDef): React.CSSProperties {
|
||||
const { w = 3, h = 2 } = placement(widget)
|
||||
export function widgetStyle(
|
||||
widget: WidgetDef,
|
||||
columns = DEFAULT_COLUMNS,
|
||||
): React.CSSProperties {
|
||||
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
|
||||
const width = Math.min(columns, Math.max(1, w))
|
||||
return {
|
||||
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
|
||||
gridRow: `span ${Math.max(1, h)}`,
|
||||
}
|
||||
"--x": Math.min(columns - width, Math.max(0, x)) + 1,
|
||||
"--y": Math.max(0, y) + 1,
|
||||
"--w": width,
|
||||
"--h": Math.max(1, h),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Has anyone actually arranged this dashboard?
|
||||
*
|
||||
* Before drag-and-drop every widget was written at 0,0, so honouring the
|
||||
* stored position would pile the whole page onto one cell.
|
||||
*/
|
||||
export const isPlaced = (widgets: WidgetDef[]) =>
|
||||
widgets.some((widget) => {
|
||||
const { x = 0, y = 0 } = placement(widget)
|
||||
return x > 0 || y > 0
|
||||
})
|
||||
|
||||
export function SectionGrid({
|
||||
section,
|
||||
dashboard,
|
||||
columns = DEFAULT_COLUMNS,
|
||||
renderWidget,
|
||||
className,
|
||||
}: {
|
||||
section: SectionDef_Output
|
||||
/** Which dashboard this is, so an input widget can name itself. */
|
||||
dashboard: string
|
||||
columns?: number
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const widgets = widgetsOf(section)
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
{section.title ? (
|
||||
@@ -66,15 +100,20 @@ export function SectionGrid({
|
||||
</h2>
|
||||
) : null}
|
||||
<div
|
||||
className={cn("grid gap-3", GRID, className)}
|
||||
style={{ gridAutoRows: ROW }}
|
||||
className={cn("widget-grid", className)}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={{ "--widget-cols": columns } as React.CSSProperties}
|
||||
>
|
||||
{widgetsOf(section).map((widget) => (
|
||||
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={widgetStyle(widget, columns)}
|
||||
className="widget-cell"
|
||||
>
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title}>
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
@@ -90,7 +129,7 @@ export function DashboardView({
|
||||
pageId,
|
||||
renderWidget,
|
||||
}: {
|
||||
dashboard: DashboardDef_Output
|
||||
dashboard: Dashboard
|
||||
pageId?: string
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
}) {
|
||||
@@ -126,6 +165,7 @@ export function DashboardView({
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboard={dashboard.name}
|
||||
columns={columnsOf(dashboard)}
|
||||
renderWidget={renderWidget}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user