From 9613b787a78ee67b8f985d68df4b784a5cc53990 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:29:46 -0600 Subject: [PATCH] feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26) --- CHANGELOG.md | 7 + packages/plugin-sdk/CHANGELOG.md | 10 + packages/plugin-sdk/PLUGIN-GUIDE.md | 8 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/frontend.ts | 27 + packages/plugin-sdk/src/manifest.ts | 2 +- packages/plugin-sdk/ui.api.txt | 12 + scripts/check-sdk-ui-exports.cjs | 12 + src/ui/components/charts/LineChart.tsx | 8 + src/ui/components/metric-card.tsx | 34 +- src/ui/components/tile-grid/TileFrame.tsx | 72 ++ src/ui/components/tile-grid/TileGrid.tsx | 827 ++++++++++++++++++ src/ui/components/tile-grid/TilePicker.tsx | 221 +++++ src/ui/components/tile-grid/context.ts | 8 + src/ui/components/tile-grid/index.ts | 4 + src/ui/components/tile-grid/layout.ts | 234 +++++ src/ui/dashboard/dashboard-cards-registry.ts | 22 + src/ui/locales/en.json | 22 + src/ui/plugin-host/app.ts | 2 + src/ui/plugin-host/sdk-ui.ts | 14 + .../components/tile-grid/TileGrid.test.tsx | 299 +++++++ .../tests/components/tile-grid/layout.test.ts | 157 ++++ 22 files changed, 1997 insertions(+), 7 deletions(-) create mode 100644 src/ui/components/tile-grid/TileFrame.tsx create mode 100644 src/ui/components/tile-grid/TileGrid.tsx create mode 100644 src/ui/components/tile-grid/TilePicker.tsx create mode 100644 src/ui/components/tile-grid/context.ts create mode 100644 src/ui/components/tile-grid/index.ts create mode 100644 src/ui/components/tile-grid/layout.ts create mode 100644 src/ui/tests/components/tile-grid/TileGrid.test.tsx create mode 100644 src/ui/tests/components/tile-grid/layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e8e324f..79e041ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ - Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals - API keys can be limited to reading or running actions, when an admin makes them - A click on a host can open its host page, when a plugin offers one +- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings +- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline ### Changed +- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps +- Line charts that cover a few minutes show seconds in their times +- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on + - Host status, import and export, and credential tools now start on for new installs - Sync now starts on in a new desktop app, so it can link to a server - The first run plugin picker leaves plugins you did not choose off instead of removing them @@ -21,6 +27,7 @@ - Password fields with the show button are the same height as the other fields next to them - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot +- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind ## 26.11.0 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 6b64200e2..edb69328f 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.26 + +Plugin API 1.15.0. + +### Added + +- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type +- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid +- Dashboard cards get `config`, `setConfig` and `editMode` + ## 1.0.25 Plugin API 1.14.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 67877a860..621bd94f8 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.25 | Plugin API 1.14.0 +SDK 1.0.26 | Plugin API 1.15.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -768,6 +768,12 @@ exports with props is `ui.api.txt` in the Termix repo `PanePrompt` for small prompts. - Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`. +- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are + `{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps + preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. Needs Termix 26.11.1. +- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the + card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for + status and action tiles. - Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 330cbfea6..d72578dec 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.25", + "version": "1.0.26", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index b604057fe..1fe6f52db 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -799,6 +799,26 @@ export interface KeybindingDefaultContribution { export interface DashboardCardProps { isVisible: boolean; shell: ShellApi; + /** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */ + config?: Record; + /** Saves this tile's settings. Keep it small, it is stored in one line. */ + setConfig?: (config: Record) => void; + /** True while the user is arranging the dashboard. */ + editMode?: boolean; +} + +/** One tile a card with `entries` offers in the dashboard's add list. */ +export interface DashboardCardEntry { + /** Stable within the card, used to mark entries already placed. */ + key: string; + title: string; + description?: string; + /** Group heading in the add list. Defaults to the card title. */ + group?: string; + kind?: "card" | "chart" | "signal" | "action" | "resource" | "session"; + /** Passed back as `config`. */ + config: Record; + defaultSize?: { w: number; h?: number | null }; } export interface DashboardCardContribution { @@ -813,6 +833,13 @@ export interface DashboardCardContribution { * edge, for a strip of numbers. Defaults to "framed". */ frame?: "framed" | "bare"; + /** Size on the 12 column dashboard grid when added. Core 26.11.1+. */ + defaultSize?: { w: number; h?: number | null }; + /** + * Lets the card be placed many times, once per entry, each with its own + * `config`. Titles come back already translated. Core 26.11.1+. + */ + entries?: (language: string) => Promise; component: ComponentType; } diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 023a61adb..281fdb6c5 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -42,7 +42,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.14.0"; +export const PLUGIN_API_VERSION = "1.15.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 954533256..60021fcf0 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -291,6 +291,7 @@ SurfaceKind (type): SurfaceKind SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element Switch (value): ({ className, ...props }: SwitchProps & RefAttributes) => Element SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element +TILE_COLUMNS (value): 12 TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element Tab (type): Tab TabShellCallbacks (type): TabShellCallbacks @@ -305,6 +306,13 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d Textarea (value): ForwardRefExoticComponent> TextareaProps (type): TextareaProps ThemeId (type): string +TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element +TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element +TileGridProps (type): TileGridProps +TileKind (type): TileKind +TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element +TilePickerEntry (type): TilePickerEntry +TileSlot (type): TileSlot TlsStatus (type): TlsStatus ToolsTab (type): ToolsTab Tooltip (value): ({ ...props }: TooltipProps) => Element @@ -341,6 +349,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }> adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record) => Promise> adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise +appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[] applyAccentColor (value): (colorValue: string) => void applyFontSize (value): (id: FontSizeId) => void applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise @@ -384,6 +393,7 @@ endSplitDrag (value): (commit: boolean) => void filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[] findCoreHostDefault (value): (key: string) => CoreHostDefault findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding +flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[] getActiveSessions (value): () => Promise getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }> @@ -514,6 +524,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string rem (value): (px: number) => string removeAdminStatus (value): (userId: string) => Promise> removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> +removeTile (value): (slots: TileSlot[], id: string) => TileSlot[] renameCredentialFolder (value): (oldName: string, newName: string) => Promise> renameFolder (value): (oldName: string, newName: string) => Promise> reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }> @@ -543,6 +554,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences +sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[] saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }> saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 170c112ce..0b78339cd 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,18 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "TileFrame", + "TileGrid", + "TilePicker", + "appendTile", + "flowTiles", + "removeTile", + "sanitizeTiles", + "TILE_COLUMNS", + "TileGridProps", + "TileKind", + "TilePickerEntry", + "TileSlot", "useHostSections", "operationsApi", "listHostSections", diff --git a/src/ui/components/charts/LineChart.tsx b/src/ui/components/charts/LineChart.tsx index 9870e9b55..1079c5a53 100644 --- a/src/ui/components/charts/LineChart.tsx +++ b/src/ui/components/charts/LineChart.tsx @@ -26,6 +26,14 @@ const TICK_COUNT = 5; function formatTimestamp(ts: string, rangeMs: number): string { const d = parseServerTime(ts); + // A live chart covers a minute or two, so minutes alone would repeat. + if (rangeMs <= 10 * 60 * 1000) { + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } if (rangeMs <= 25 * 60 * 60 * 1000) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } diff --git a/src/ui/components/metric-card.tsx b/src/ui/components/metric-card.tsx index 26ba3bd87..72431c7f1 100644 --- a/src/ui/components/metric-card.tsx +++ b/src/ui/components/metric-card.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useInSeamlessTile } from "@/components/tile-grid/context"; import { cn } from "@/lib/utils"; /** Default cap (px) for a scrollable card body before it scrolls. */ @@ -29,11 +30,36 @@ export function MetricCard({ /** Max body height in px for scrollable cards. */ scrollMax?: number; }) { + // In an edge to edge tile grid the grid draws the lines, so the card takes + // the dashboard tile look: no border or fill, the small title row. + const tiled = useInSeamlessTile(); return ( -
-
- {icon} - +
+
+ + {icon} + + {title} {action &&
{action}
} diff --git a/src/ui/components/tile-grid/TileFrame.tsx b/src/ui/components/tile-grid/TileFrame.tsx new file mode 100644 index 000000000..24d5efc51 --- /dev/null +++ b/src/ui/components/tile-grid/TileFrame.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * The look every tile shares with the dashboard: a small uppercase title row + * over a body that scrolls when the tile has a fixed height. The grid draws + * the lines between tiles. `bare` drops the title row. + */ +export function TileFrame({ + title, + icon, + actions, + bare = false, + onTitleClick, + className, + bodyClassName, + children, +}: { + title?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + bare?: boolean; + onTitleClick?: () => void; + className?: string; + bodyClassName?: string; + children: ReactNode; +}) { + return ( +
+ {!bare && (title || icon || actions) && ( +
+ {icon && ( + + {icon} + + )} + {onTitleClick ? ( + + ) : ( + + {title} + + )} + {actions && ( + + {actions} + + )} +
+ )} +
+ {children} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/TileGrid.tsx b/src/ui/components/tile-grid/TileGrid.tsx new file mode 100644 index 000000000..c459ed5d7 --- /dev/null +++ b/src/ui/components/tile-grid/TileGrid.tsx @@ -0,0 +1,827 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { GripVertical, Settings2, X } from "lucide-react"; +import { remScale } from "@/lib/rem"; +import { cn } from "@/lib/utils"; +import { + MAX_TILE_HEIGHT, + MIN_TILE_COLUMNS, + MIN_TILE_HEIGHT, + TILE_COLUMNS, + clampTileColumns, + settleTiles, + tileBottom, + tileBoxes, + tilesFromBoxes, + type TileBox, + type TileSlot, +} from "./layout"; +import { SeamlessTileContext } from "./context"; +import { gridLines } from "./layout"; + +type Edge = "left" | "right" | "bottom" | "corner"; +interface Proposal { + x?: number; + y?: number; + w?: number; + h?: number | null; +} +interface Interaction { + id: string; + kind: "move" | "resize"; + base: TileSlot[]; + proposal: Proposal; + frame: { left: number; top: number; width: number; height?: number }; +} + +const DRAG_THRESHOLD = 4; +const SNAP = 8; +const SCROLL_EDGE = 48; + +const styles = ` +.tg-grid { position: relative; } +.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; } +.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; } +.tg-tile[data-active] { z-index: 40; } +.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; } +.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; } +.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; } +[data-tg-drop-preview] { + position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box; + background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent); + border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent); +} +.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; } +.tg-handle > span { background: var(--border); } +.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); } +.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; } +.tg-edge-left { left: 0; } +.tg-edge-right { right: 0; } +.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; } +.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; } +.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); } +.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); } +.tg-card[data-fixed-height="true"] { height: 100%; } +.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; } +.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; } +`; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +function propose(base: TileSlot[], id: string, proposal: Proposal) { + return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot)); +} + +function scrollParent(element: HTMLElement | null) { + for (let node = element?.parentElement; node; node = node.parentElement) { + const { overflowY } = getComputedStyle(node); + if (overflowY === "auto" || overflowY === "scroll") return node; + } + return null; +} + +const NO_DRAG = + "button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle"; + +export interface TileGridProps { + slots: TileSlot[]; + editMode: boolean; + renderTile: (id: string) => ReactNode; + /** Name of a tile, for the edit controls. */ + tileLabel: (id: string) => string; + onChange: (slots: TileSlot[]) => void; + /** Shows a settings button on each tile while editing. */ + onSettings?: (id: string) => void; + /** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */ + gap?: number; + /** Below this width tiles stack in one column and editing is off. */ + stackBelow?: number; + minColumns?: number; + minHeight?: number; + /** Shown while editing an empty grid. */ + empty?: ReactNode; + className?: string; +} + +/** + * A 12 column grid of tiles the user can drag and resize. Tiles keep their + * column and settle upward, so a layout never has holes it did not ask for. + */ +export function TileGrid({ + slots, + editMode, + renderTile, + tileLabel, + onChange, + onSettings, + gap = 0, + stackBelow = 640, + minColumns = MIN_TILE_COLUMNS, + minHeight = MIN_TILE_HEIGHT, + empty, + className, +}: TileGridProps) { + const { t } = useTranslation(); + const root = useRef(null); + const gridEl = useRef(null); + const [width, setWidth] = useState(0); + // Whether the grid reaches the bottom of the view it scrolls in. Then its + // last row meets the bottom of the screen and needs no line under it. + const [overflows, setOverflows] = useState(false); + const overflowsRef = useRef(overflows); + overflowsRef.current = overflows; + const [heights, setHeights] = useState>({}); + const [active, setActive] = useState(null); + const cancel = useRef<(() => void) | null>(null); + const heightsRef = useRef(heights); + heightsRef.current = heights; + + const scale = remScale(); + const seamless = gap <= 0; + // Edge to edge tiles draw no lines of their own. The grid works out every + // line between them and draws each once, so a line is never doubled at any + // display scaling and a taller neighbour still gets its edge. + const inset = seamless ? 0 : gap * scale; + const settleGap = seamless ? 0 : gap; + const pitch = (width + inset) / TILE_COLUMNS; + const stacked = width > 0 && width < stackBelow; + const editing = editMode && !stacked; + + const settled = useMemo( + () => settleTiles(tileBoxes(slots, heights), settleGap), + [slots, heights, settleGap], + ); + const preview = useMemo( + () => + active + ? settleTiles( + tileBoxes( + propose(active.base, active.id, active.proposal), + heights, + ), + settleGap, + ) + : settled, + [active, settled, heights, settleGap], + ); + const boxById = useMemo( + () => new Map(preview.map((box) => [box.id, box])), + [preview], + ); + // Same elements across pointer moves, so tiles only re-render on new data. + const content = useMemo( + () => + new Map( + slots.map((slot) => [slot.id, renderTile(slot.id)]), + ), + [slots, renderTile], + ); + + useLayoutEffect(() => { + const element = root.current; + if (!element) return; + const update = () => + setWidth(element.getBoundingClientRect().width || element.clientWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useLayoutEffect(() => { + const element = root.current; + const scroller = seamless ? scrollParent(element) : null; + if (!element || !scroller) { + setOverflows(false); + return; + } + // Where the grid's last row ends inside the scrolled content, without the + // kept bottom line's px, so adding or dropping that line never flips it. + const update = () => { + const end = + element.getBoundingClientRect().bottom - + scroller.getBoundingClientRect().top + + scroller.scrollTop - + (overflowsRef.current ? 0 : 1); + setOverflows(end >= scroller.clientHeight - 0.5); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(scroller); + observer.observe(element); + return () => observer.disconnect(); + }, [seamless, settled, width]); + + // An outside change (undo, reset, host switch) ends any drag in progress. + useEffect(() => () => cancel.current?.(), [slots, editing]); + + const reportHeight = useCallback( + (id: string, height: number) => + setHeights((previous) => + Math.abs((previous[id] ?? -1) - height) < 0.5 + ? previous + : { ...previous, [id]: height }, + ), + [], + ); + + const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => { + const next = id ? propose(base, id, proposal) : base; + const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap); + onChange(tilesFromBoxes(next, boxes)); + }; + const settledBase = (): TileSlot[] => + slots.map((slot) => ({ + ...slot, + y: settled.find((box) => box.id === slot.id)?.y ?? slot.y, + })); + + const begin = ( + event: ReactPointerEvent, + id: string, + edge?: Edge, + ) => { + if (event.button !== 0 || !editing || cancel.current) return; + const element = gridEl.current; + const box = settled.find((b) => b.id === id); + const slot = slots.find((s) => s.id === id); + if (!element || !box || !slot) return; + if (!edge) { + const target = event.target as HTMLElement; + if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return; + const native = event.nativeEvent; + const scrolls = + target.scrollHeight > target.clientHeight || + target.scrollWidth > target.clientWidth; + if ( + scrolls && + (native.offsetX > target.clientWidth || + native.offsetY > target.clientHeight) + ) + return; + } else { + event.preventDefault(); + event.stopPropagation(); + } + const kind = edge ? "resize" : "move"; + const base = settledBase(); + const origin = element.getBoundingClientRect(); + const startLeft = box.x * pitch; + const startTop = box.y * scale; + const startWidth = box.w * pitch - inset; + const startHeight = box.h * scale; + const fixed = slot.h != null; + const startX = event.clientX; + const startY = event.clientY; + const grabX = startX - origin.left - startLeft; + const grabY = startY - origin.top - startTop; + const scroller = scrollParent(element); + const others = settled.filter((b) => b.id !== id); + let pointer = { x: startX, y: startY }; + let started = kind === "resize"; + let current: Interaction | null = null; + let timer: ReturnType | undefined; + + const compute = (): Interaction => { + const rect = element.getBoundingClientRect(); + if (kind === "move") { + const left = pointer.x - rect.left - grabX; + const top = pointer.y - rect.top - grabY; + return { + id, + kind, + base, + proposal: { + x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w), + y: Math.max(0, top / scale), + }, + frame: { + left, + top, + width: startWidth, + height: fixed ? startHeight : undefined, + }, + }; + } + const dx = pointer.x - startX; + const dy = pointer.y - rect.top - (startY - origin.top); + const proposal: Proposal = {}; + const frame: Interaction["frame"] = { + left: startLeft, + top: startTop, + width: startWidth, + height: fixed ? startHeight : undefined, + }; + const minWidth = minColumns * pitch - inset; + if (edge === "right" || edge === "corner") { + const raw = clamp( + startWidth + dx, + minWidth, + (TILE_COLUMNS - box.x) * pitch - inset, + ); + proposal.w = Math.min( + clampTileColumns((raw + inset) / pitch, minColumns), + TILE_COLUMNS - box.x, + ); + frame.width = raw; + } + if (edge === "left") { + const right = box.x + box.w; + const raw = clamp(startWidth - dx, minWidth, right * pitch - inset); + proposal.w = Math.min( + clampTileColumns((raw + inset) / pitch, minColumns), + right, + ); + proposal.x = right - proposal.w; + frame.width = raw; + frame.left = startLeft + startWidth - raw; + } + if (edge === "bottom" || edge === "corner") { + let height = + clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) / + scale; + // Line up with the bottom of a nearby tile. + for (const other of others) { + const bottom = other.y + other.h - box.y; + if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) { + height = bottom; + break; + } + } + proposal.h = Math.round(height); + frame.height = proposal.h * scale; + } + return { id, kind, base, proposal, frame }; + }; + const update = () => { + current = compute(); + setActive(current); + }; + const autoScroll = () => { + if (!scroller) return; + const rect = scroller.getBoundingClientRect(); + const step = + pointer.y < rect.top + SCROLL_EDGE + ? -12 + : pointer.y > rect.bottom - SCROLL_EDGE + ? 12 + : 0; + if (!step) return; + const before = scroller.scrollTop; + scroller.scrollTop += step; + if (scroller.scrollTop !== before) update(); + }; + const move = (e: PointerEvent) => { + if (e.pointerId !== event.pointerId) return; + pointer = { x: e.clientX, y: e.clientY }; + if ( + !started && + Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD + ) + return; + if (!started) { + started = true; + window.getSelection?.()?.removeAllRanges(); + if (scroller) timer = setInterval(autoScroll, 16); + } + update(); + }; + const finish = (save: boolean) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", cancelled); + window.removeEventListener("keydown", key); + window.removeEventListener("blur", blur); + clearInterval(timer); + cancel.current = null; + setActive(null); + if (save && started && current) commit(base, id, current.proposal); + }; + const up = (e: PointerEvent) => { + if (e.pointerId === event.pointerId) finish(true); + }; + const cancelled = (e: PointerEvent) => { + if (e.pointerId === event.pointerId) finish(false); + }; + const key = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + finish(false); + }; + const blur = () => finish(false); + cancel.current = () => finish(false); + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", cancelled); + window.addEventListener("keydown", key); + window.addEventListener("blur", blur); + if (started) update(); + }; + + const moveByKey = (event: ReactKeyboardEvent, id: string) => { + const box = settled.find((b) => b.id === id); + if (!box || !event.key.startsWith("Arrow")) return; + const near = settled.filter( + (b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w, + ); + let proposal: Proposal | null = null; + if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 }; + if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS) + proposal = { x: box.x + 1 }; + if (event.key === "ArrowUp") { + const above = near + .filter((b) => b.y < box.y) + .sort((a, b) => b.y - a.y)[0]; + if (above) proposal = { y: Math.max(0, above.y - 1) }; + } + if (event.key === "ArrowDown") { + const below = near + .filter((b) => b.y > box.y) + .sort((a, b) => a.y - b.y)[0]; + if (below) proposal = { y: below.y + 1 }; + } + event.preventDefault(); + if (proposal) commit(settledBase(), id, proposal); + }; + + const resizeByKey = ( + event: ReactKeyboardEvent, + id: string, + axis: "width" | "height", + ) => { + const box = settled.find((b) => b.id === id); + if (!box) return; + const keys = + axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"]; + if (![...keys, "Home", "End"].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const sign = event.key === keys[1] ? 1 : -1; + if (axis === "width") { + const max = TILE_COLUMNS - box.x; + const w = + event.key === "Home" + ? minColumns + : event.key === "End" + ? max + : clamp(box.w + sign, minColumns, max); + if (w !== box.w) commit(settledBase(), id, { w }); + } else { + const current = Math.round(box.h); + const h = + event.key === "Home" + ? minHeight + : event.key === "End" + ? MAX_TILE_HEIGHT + : clamp( + current + sign * (event.shiftKey ? 50 : 10), + minHeight, + MAX_TILE_HEIGHT, + ); + if (h !== current) commit(settledBase(), id, { h }); + } + }; + + const remove = (id: string) => + commit( + settledBase().filter((slot) => slot.id !== id), + null, + {}, + ); + + if (stacked) { + return ( + +
+ {[...settled] + .sort((a, b) => a.y - b.y || a.x - b.x) + .map((box) => ( +
+ {content.get(box.id)} +
+ ))} +
+
+ ); + } + + const activeBox = active ? boxById.get(active.id) : undefined; + // Edge to edge tiles sit on whole px so the lines between them are crisp. + const snap = (value: number) => (seamless ? Math.round(value) : value); + const leftOf = (box: TileBox) => snap(box.x * pitch); + const widthOf = (box: TileBox) => + seamless + ? snap((box.x + box.w) * pitch) - snap(box.x * pitch) + : box.w * pitch - inset; + const bottom = tileBottom(preview, settleGap) * scale; + // A kept bottom line sits one px under the last row. + const height = Math.max( + 0, + seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset, + ); + const lines = seamless + ? gridLines( + preview.map((box) => ({ + l: leftOf(box), + t: snap(box.y * scale), + r: leftOf(box) + widthOf(box), + b: snap((box.y + box.h) * scale), + })), + snap(width), + overflows, + ) + : []; + + return ( + +
+
+ + {lines.map((line) => ( + + + ); +} + +function Tile({ + id, + box, + label, + editMode, + active, + left, + top, + width, + height, + measure, + scale, + seamless, + minColumns, + minHeight, + onHeight, + onPointerDown, + onMoveKey, + onResizeKey, + onSettings, + onRemove, + children, +}: { + id: string; + box: TileBox; + label: string; + editMode: boolean; + active?: Interaction["kind"]; + left: number; + top: number; + width: number; + height?: number; + /** The tile has no height of its own and reports its content's. */ + measure: boolean; + scale: number; + seamless: boolean; + minColumns: number; + minHeight: number; + onHeight: (id: string, height: number) => void; + onPointerDown: (event: ReactPointerEvent, edge?: Edge) => void; + onMoveKey: (event: ReactKeyboardEvent) => void; + onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void; + onSettings?: () => void; + onRemove: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const card = useRef(null); + const body = useRef(null); + const fixed = !measure; + + // Tiles with no height of their own report their content's so the grid can + // place them. Edge to edge, that is rounded up to a whole px. + useLayoutEffect(() => { + const element = seamless ? body.current : card.current; + if (!element || fixed) return; + const report = () => { + const own = element.getBoundingClientRect().height; + onHeight(id, (seamless ? Math.ceil(own) : own) / scale); + }; + report(); + const observer = new ResizeObserver(report); + observer.observe(element); + return () => observer.disconnect(); + }, [id, fixed, scale, seamless, onHeight]); + + const button = + "flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground"; + + return ( +
onPointerDown(event) : undefined} + > +
+
+ {children} +
+ {editMode && ( + <> +
+
+ + {onSettings && ( + + )} + +
+ + ); +} diff --git a/src/ui/components/tile-grid/TilePicker.tsx b/src/ui/components/tile-grid/TilePicker.tsx new file mode 100644 index 000000000..1a27b82da --- /dev/null +++ b/src/ui/components/tile-grid/TilePicker.tsx @@ -0,0 +1,221 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + ChevronDown, + ChevronRight, + Plus, + Search, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type TileKind = + "card" | "signal" | "action" | "session" | "resource" | "chart"; + +export interface TilePickerEntry { + /** Tile id the grid gets when this entry is added. */ + id: string; + title: string; + description?: string; + /** Group heading, usually the plugin name. */ + group: string; + kind: TileKind; + icon?: ReactNode; + /** Already on the grid; shown checked and not added twice. */ + placed?: boolean; +} + +const KINDS: TileKind[] = [ + "card", + "chart", + "signal", + "action", + "resource", + "session", +]; +const KIND_LABELS: Record = { + all: "tileGrid.kind.all", + card: "tileGrid.kind.card", + chart: "tileGrid.kind.chart", + signal: "tileGrid.kind.signal", + action: "tileGrid.kind.action", + resource: "tileGrid.kind.resource", + session: "tileGrid.kind.session", +}; + +/** + * Everything that can go on a grid, in a narrow column beside it while + * editing. Search, filter by kind and fold groups, so long lists from many + * plugins stay usable. + */ +export function TilePicker({ + entries, + onAdd, + onClose, + className, +}: { + entries: TilePickerEntry[]; + onAdd: (entry: TilePickerEntry) => void; + onClose?: () => void; + className?: string; +}) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [kind, setKind] = useState(null); + const [folded, setFolded] = useState>(() => new Set()); + + const kinds = useMemo( + () => KINDS.filter((k) => entries.some((entry) => entry.kind === k)), + [entries], + ); + const groups = useMemo(() => { + const needle = query.trim().toLowerCase(); + const shown = entries.filter( + (entry) => + (!kind || entry.kind === kind) && + (!needle || + entry.title.toLowerCase().includes(needle) || + entry.group.toLowerCase().includes(needle) || + entry.description?.toLowerCase().includes(needle)), + ); + const map = new Map(); + for (const entry of shown) { + const list = map.get(entry.group) ?? []; + list.push(entry); + map.set(entry.group, list); + } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [entries, query, kind]); + const searching = query.trim().length > 0; + + const toggle = (group: string) => + setFolded((current) => { + const next = new Set(current); + if (next.has(group)) next.delete(group); + else next.add(group); + return next; + }); + + return ( +
+
+ + + {t("tileGrid.addTiles")} + + {onClose && ( + + )} +
+
+ + {kinds.length > 1 && ( +
+ {[null, ...kinds].map((k) => ( + + ))} +
+ )} +
+
+ {groups.length === 0 && ( +

+ {t("tileGrid.noMatches")} +

+ )} + {groups.map(([group, list]) => { + const open = searching || !folded.has(group); + return ( +
+ + {open && ( +
    + {list.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/context.ts b/src/ui/components/tile-grid/context.ts new file mode 100644 index 000000000..e1cfb5769 --- /dev/null +++ b/src/ui/components/tile-grid/context.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react"; + +/** True inside an edge to edge TileGrid, where the grid draws the lines. */ +export const SeamlessTileContext = createContext(false); + +export function useInSeamlessTile(): boolean { + return useContext(SeamlessTileContext); +} diff --git a/src/ui/components/tile-grid/index.ts b/src/ui/components/tile-grid/index.ts new file mode 100644 index 000000000..4f1db2c21 --- /dev/null +++ b/src/ui/components/tile-grid/index.ts @@ -0,0 +1,4 @@ +export { TileGrid, type TileGridProps } from "./TileGrid"; +export { TileFrame } from "./TileFrame"; +export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker"; +export * from "./layout"; diff --git a/src/ui/components/tile-grid/layout.ts b/src/ui/components/tile-grid/layout.ts new file mode 100644 index 000000000..126a86b2a --- /dev/null +++ b/src/ui/components/tile-grid/layout.ts @@ -0,0 +1,234 @@ +/** + * A tile on a 12 column grid. x and w are columns, y is unscaled px from the + * top (the grid settles it), h is a fixed height in unscaled px or null for + * the tile's natural height. + */ +export interface TileSlot { + id: string; + x: number; + y: number; + w: number; + h?: number | null; +} + +/** A tile with a resolved height, as the grid draws it. */ +export interface TileBox { + id: string; + x: number; + y: number; + w: number; + h: number; +} + +export const TILE_COLUMNS = 12; +export const MIN_TILE_COLUMNS = 2; +export const MIN_TILE_HEIGHT = 48; +export const MAX_TILE_HEIGHT = 10000; +/** y given to a new tile so it settles under everything else. */ +export const BOTTOM = 1_000_000; + +export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) => + Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min)); + +const overlapsX = (a: TileBox, b: TileBox) => + a.x < b.x + b.w && b.x < a.x + a.w; + +/** + * Settles tiles top to bottom in (y, x) order. Each tile sits right under the + * lowest tile placed before it in the columns it covers. Tiles never move + * sideways and never jump above a tile that came before them. + */ +export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] { + const order = [...boxes].sort( + (a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1), + ); + const placed: TileBox[] = []; + for (const box of order) { + const y = placed.reduce( + (top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top), + 0, + ); + placed.push({ ...box, y }); + } + return placed; +} + +export function tileBoxes( + slots: TileSlot[], + heights: Record, + fallback = 160, +): TileBox[] { + return slots.map((slot) => ({ + id: slot.id, + x: slot.x, + y: slot.y, + w: slot.w, + h: slot.h ?? heights[slot.id] ?? fallback, + })); +} + +export function tileBottom(boxes: TileBox[], gap = 0) { + return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0); +} + +/** Drops broken entries and duplicates and keeps every tile inside the grid. */ +export function sanitizeTiles( + value: unknown, + min = MIN_TILE_COLUMNS, +): TileSlot[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: TileSlot[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const slot = raw as Record; + const id = typeof slot.id === "string" ? slot.id : ""; + if (!id || id.length > 300 || seen.has(id)) continue; + seen.add(id); + const w = clampTileColumns(Number(slot.w), min); + const x = Math.max( + 0, + Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0), + ); + const y = Math.max(0, Number(slot.y) || 0); + const h = + slot.h == null || !Number.isFinite(Number(slot.h)) + ? null + : Math.max( + MIN_TILE_HEIGHT, + Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))), + ); + out.push({ id, x, y, w, h }); + } + return out.slice(0, 200); +} + +/** Adds a tile at the bottom, at the left. */ +export function appendTile( + slots: TileSlot[], + tile: { id: string; w?: number; h?: number | null }, +): TileSlot[] { + if (slots.some((slot) => slot.id === tile.id)) return slots; + return [ + ...slots, + { + id: tile.id, + x: 0, + y: BOTTOM, + w: clampTileColumns(tile.w ?? 4), + h: tile.h ?? null, + }, + ]; +} + +export function removeTile(slots: TileSlot[], id: string): TileSlot[] { + return slots.filter((slot) => slot.id !== id); +} + +/** Writes settled positions back, in reading order. */ +export function tilesFromBoxes( + slots: TileSlot[], + boxes: TileBox[], +): TileSlot[] { + const byId = new Map(boxes.map((box) => [box.id, box])); + return slots + .map((slot) => { + const box = byId.get(slot.id); + return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot; + }) + .sort((a, b) => a.y - b.y || a.x - b.x); +} + +/** + * Lays tiles out left to right in rows, for layouts that only know an order + * and a width (old dashboards, generated defaults). + */ +export function flowTiles( + tiles: { id: string; w: number; h?: number | null }[], + rowHeight = 160, +): TileSlot[] { + const out: TileSlot[] = []; + let x = 0; + let row = 0; + for (const tile of tiles) { + const w = clampTileColumns(tile.w); + if (x + w > TILE_COLUMNS) { + x = 0; + row++; + } + out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null }); + x += w; + } + return out; +} + +/** A tile's box in whole screen px. */ +export interface PixelRect { + l: number; + t: number; + r: number; + b: number; +} + +/** A 1 px line the grid draws, as a box. */ +export interface GridLine { + x: number; + y: number; + w: number; + h: number; +} + +function mergeSpans(spans: [number, number][]): [number, number][] { + const sorted = [...spans].sort((a, b) => a[0] - b[0]); + const out: [number, number][] = []; + for (const [start, end] of sorted) { + const last = out[out.length - 1]; + if (last && start <= last[1]) last[1] = Math.max(last[1], end); + else out.push([start, end]); + } + return out; +} + +/** + * The lines between edge to edge tiles, each drawn once. A line sits on the + * first px column or row past a tile, so it covers the edge of whatever is + * next to it. The outer left, top and right edges are left out. With + * `openBottom` (the grid runs to the bottom of the screen) so is the line + * along the very bottom; a column that ends higher keeps its bottom line. + * Lines reach one px further so corners join. + */ +export function gridLines( + rects: PixelRect[], + width: number, + openBottom = false, +): GridLine[] { + const lowest = Math.max(0, ...rects.map((rect) => rect.b)); + const end = openBottom ? lowest : Infinity; + const vertical = new Map(); + const horizontal = new Map(); + const add = ( + map: Map, + at: number, + span: [number, number], + ) => { + if (span[1] <= span[0]) return; + map.set(at, [...(map.get(at) ?? []), span]); + }; + for (const rect of rects) { + if (rect.r <= rect.l || rect.b <= rect.t) continue; + const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)]; + if (rect.l > 0) add(vertical, rect.l, down); + if (rect.r < width) add(vertical, rect.r, down); + const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)]; + if (rect.t > 0) add(horizontal, rect.t, across); + if (rect.b < end) add(horizontal, rect.b, across); + } + const lines: GridLine[] = []; + for (const [x, spans] of vertical) + for (const [start, end] of mergeSpans(spans)) + lines.push({ x, y: start, w: 1, h: end - start }); + for (const [y, spans] of horizontal) + for (const [start, end] of mergeSpans(spans)) + lines.push({ x: start, y, w: end - start, h: 1 }); + return lines; +} diff --git a/src/ui/dashboard/dashboard-cards-registry.ts b/src/ui/dashboard/dashboard-cards-registry.ts index 09c560e80..effcea93a 100644 --- a/src/ui/dashboard/dashboard-cards-registry.ts +++ b/src/ui/dashboard/dashboard-cards-registry.ts @@ -6,6 +6,24 @@ export interface DashboardCardRenderProps { isVisible: boolean; /** The shell, e.g. to open the full-page version of the card. */ shell: TabShellCallbacks; + /** This copy's settings, for cards with entries. Empty otherwise. */ + config?: Record; + setConfig?: (config: Record) => void; + /** True while the user is arranging the dashboard. */ + editMode?: boolean; +} + +/** One thing the add list offers from a card that can be placed many times. */ +export interface DashboardCardEntry { + /** Stable within the card, used to mark entries already placed. */ + key: string; + title: string; + description?: string; + /** Group heading in the add list. Defaults to the card title. */ + group?: string; + kind?: "card" | "chart" | "signal" | "action" | "resource" | "session"; + config: Record; + defaultSize?: { w: number; h?: number | null }; } /** @@ -23,6 +41,10 @@ export interface RegisteredDashboardCard { defaultPanel?: "main" | "side"; /** "framed" gets a titled header from the dashboard; "bare" draws its own. */ frame?: "framed" | "bare"; + /** Size on the 12 column dashboard grid when added. */ + defaultSize?: { w: number; h?: number | null }; + /** Lists what this card can show; each entry becomes its own tile. */ + entries?: (language: string) => Promise; component: ComponentType; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 10c2eca63..1d22c8e27 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1694,5 +1694,27 @@ "admin": "Admin", "user": "User" } + }, + "tileGrid": { + "empty": "No tiles yet. Add some from the list.", + "move": "Drag to move", + "moveTile": "Move {{tile}}", + "settings": "{{tile}} settings", + "remove": "Remove {{tile}}", + "width": "Width", + "height": "Height", + "addTiles": "Add tiles", + "closePicker": "Close", + "search": "Search tiles", + "noMatches": "Nothing matches.", + "kind": { + "all": "All", + "card": "Cards", + "chart": "Charts", + "signal": "Status", + "action": "Actions", + "resource": "Lists", + "session": "Sessions" + } } } diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index f84c8fe70..8147a1301 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -534,6 +534,8 @@ export function createPluginApp( defaultHeight: card.defaultHeight, defaultPanel: card.defaultPanel, frame: card.frame, + defaultSize: card.defaultSize, + entries: card.entries, component: scoped( card.component as unknown as ComponentType, ), diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 5f8d6f7ed..843707321 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -127,6 +127,20 @@ export { type GridLayout, type GridSlot, } from "@/components/card-grid/types"; +export { + TileFrame, + TileGrid, + TilePicker, + appendTile, + flowTiles, + removeTile, + sanitizeTiles, + TILE_COLUMNS, + type TileGridProps, + type TileKind, + type TilePickerEntry, + type TileSlot, +} from "@/components/tile-grid"; export { ConnectionScreen } from "@/components/connection/ConnectionScreen"; export { type ConnectionStatus } from "@/components/connection/connection-status"; export { diff --git a/src/ui/tests/components/tile-grid/TileGrid.test.tsx b/src/ui/tests/components/tile-grid/TileGrid.test.tsx new file mode 100644 index 000000000..833591097 --- /dev/null +++ b/src/ui/tests/components/tile-grid/TileGrid.test.tsx @@ -0,0 +1,299 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import "@testing-library/jest-dom/vitest"; +import { TileGrid } from "../../../components/tile-grid/TileGrid"; +import { MetricCard } from "../../../components/metric-card"; +import type { TileSlot } from "../../../components/tile-grid/layout"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +// 1212px wide grid: one column is (1212 + 12) / 12 = 102px. +let gridWidth = 1212; +const PITCH = 102; +const original = HTMLElement.prototype.getBoundingClientRect; + +beforeAll(() => { + document.documentElement.style.fontSize = "14px"; + vi.stubGlobal( + "PointerEvent", + class extends MouseEvent { + pointerId: number; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + } + }, + ); + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + if (this.dataset.tgCard) return { height: 200, width: 0 } as DOMRect; + return { + left: 0, + top: 0, + width: gridWidth, + height: 0, + right: gridWidth, + bottom: 0, + } as DOMRect; + }; +}); +afterAll(() => { + HTMLElement.prototype.getBoundingClientRect = original; + document.documentElement.style.removeProperty("font-size"); + vi.unstubAllGlobals(); +}); +beforeEach(() => { + gridWidth = 1212; +}); +afterEach(cleanup); + +const three: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: null }, + { id: "disk", x: 8, y: 0, w: 4, h: null }, +]; + +function mount(layout = three, editMode = true) { + const changed = vi.fn(); + function Harness() { + const [value, setValue] = useState(layout); + return ( + id} + onChange={(next) => { + changed(next); + setValue(next); + }} + renderTile={(id) =>
{id}
} + /> + ); + } + return { ...render(), changed }; +} + +const tile = (id: string) => + document.querySelector(`[data-tg-tile="${id}"]`)!; +const position = (element: HTMLElement) => { + const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec( + element.style.transform, + )!; + return { x: Number(match[1]), y: Number(match[2]) }; +}; +const pointer = ( + target: Element | Window, + type: "pointerDown" | "pointerMove" | "pointerUp", + x: number, + y: number, +) => + fireEvent[type](target, { clientX: x, clientY: y, button: 0, pointerId: 1 }); +const slot = (slots: TileSlot[], id: string) => slots.find((s) => s.id === id)!; + +describe("TileGrid", () => { + it("places tiles on the 12 column grid", () => { + mount(); + expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 }); + expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 }); + expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 }); + expect(tile("cpu").style.width).toBe(`${4 * PITCH - 12}px`); + }); + + it("drops a dragged card exactly where the preview showed it", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10 + 4 * PITCH, 300); + const ghost = document.querySelector( + "[data-tg-drop-preview]", + )!; + const previewed = position(ghost); + expect(previewed).toEqual({ x: 4 * PITCH, y: 212 }); + expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 }); + expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 }); + pointer(window, "pointerUp", 10 + 4 * PITCH, 300); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 4, y: 212 }); + expect(position(tile("cpu"))).toEqual(previewed); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 }); + expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0 }); + }); + + it("moves a card into another column without touching the rest", () => { + const layout: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: null }, + ]; + const { changed } = mount(layout); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10 + 8 * PITCH, 10); + pointer(window, "pointerUp", 10 + 8 * PITCH, 10); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 8, y: 0 }); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 }); + }); + + it("does nothing on a click without movement", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 12, 11); + pointer(window, "pointerUp", 12, 11); + expect(changed).not.toHaveBeenCalled(); + }); + + it("only pushes the overlapping card when a card gets wider", () => { + const { changed } = mount(); + const edge = tile("cpu").querySelector(".tg-edge-right")!; + pointer(edge, "pointerDown", 400, 100); + pointer(window, "pointerMove", 400 + 2 * PITCH, 100); + expect(tile("cpu").dataset.active).toBe("resize"); + pointer(window, "pointerUp", 400 + 2 * PITCH, 100); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 0, y: 0, w: 6 }); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 212 }); + expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0, w: 4 }); + }); + + it("resizes from the left edge keeping the right side in place", () => { + const { changed } = mount(); + const edge = tile("disk").querySelector(".tg-edge-left")!; + pointer(edge, "pointerDown", 8 * PITCH, 100); + pointer(window, "pointerMove", 8 * PITCH + 2 * PITCH, 100); + pointer(window, "pointerUp", 8 * PITCH + 2 * PITCH, 100); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "disk")).toMatchObject({ x: 10, w: 2 }); + }); + + it("pins the height and snaps to a neighbour's bottom", () => { + const layout: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: 300 }, + ]; + const { changed } = mount(layout); + const edge = tile("memory").querySelector(".tg-edge-bottom")!; + pointer(edge, "pointerDown", 500, 300); + pointer(window, "pointerMove", 500, 196); + pointer(window, "pointerUp", 500, 196); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "memory").h).toBe(200); + }); + + it("cancels on Escape without saving", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10, 300); + fireEvent.keyDown(window, { key: "Escape" }); + pointer(window, "pointerUp", 10, 300); + expect(changed).not.toHaveBeenCalled(); + expect(document.querySelector("[data-tg-drop-preview]")).toBeNull(); + expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 }); + }); + + it("resizes and moves with the keyboard", () => { + const { changed } = mount(); + const width = tile("disk").querySelector('[aria-orientation="vertical"]')!; + fireEvent.keyDown(width, { key: "ArrowLeft" }); + expect(slot(changed.mock.calls[0][0], "disk").w).toBe(3); + const grip = tile("cpu").querySelector("[data-tg-grip]")!; + fireEvent.keyDown(grip, { key: "ArrowRight" }); + expect(slot(changed.mock.calls[1][0], "cpu").x).toBe(1); + expect(slot(changed.mock.calls[1][0], "memory")).toMatchObject({ y: 212 }); + }); + + it("removes tiles", () => { + const { changed } = mount(); + fireEvent.click(screen.getAllByLabelText("tileGrid.remove")[0]); + expect(changed.mock.calls[0][0]).toHaveLength(2); + }); + + it("draws each line between edge to edge tiles once", () => { + render( + id} + onChange={() => {}} + renderTile={(id) =>
{id}
} + />, + ); + // Whole px tiles that touch, with no lines of their own. + expect(position(tile("memory")).x).toBe(404); + expect(parseFloat(tile("cpu").style.width)).toBe(404); + expect(parseFloat(tile("cpu").style.height)).toBe(100); + expect(tile("cpu").querySelector(".tg-card")!.className).not.toMatch( + /(^| )border/, + ); + const vertical = [ + ...document.querySelectorAll("[data-tg-line].border-l"), + ].map((el) => [ + parseFloat(el.style.left), + parseFloat(el.style.top), + parseFloat(el.style.height), + ]); + // One line down the middle, as tall as the taller side, and one on the + // open right edge of the middle tile. Drawn as borders so they are as + // thin as other lines at any display scaling. + expect(vertical).toEqual([ + [404, 0, 301], + [808, 0, 301], + ]); + }); + + it("gives metric cards the tile look only in an edge to edge grid", () => { + const card = (gap?: number) => + render( + id} + onChange={() => {}} + renderTile={() => ( + + x + + )} + />, + ).container.querySelector(".tg-content > div")!.className; + expect(card()).not.toContain("bg-card"); + cleanup(); + expect(card(12)).toContain("border border-border bg-card"); + }); + + it("stacks cards with no edit controls on narrow screens", () => { + gridWidth = 500; + mount(); + expect(document.querySelector("[data-tg-tile]")).toBeNull(); + expect(screen.getByTestId("body-disk")).toBeInTheDocument(); + expect(screen.queryAllByRole("separator")).toHaveLength(0); + }); + + it("shows no edit controls outside edit mode", () => { + mount(three, false); + expect(screen.queryAllByRole("separator")).toHaveLength(0); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + expect(document.querySelector("[data-active]")).toBeNull(); + }); +}); diff --git a/src/ui/tests/components/tile-grid/layout.test.ts b/src/ui/tests/components/tile-grid/layout.test.ts new file mode 100644 index 000000000..cc958b763 --- /dev/null +++ b/src/ui/tests/components/tile-grid/layout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + BOTTOM, + appendTile, + flowTiles, + gridLines, + removeTile, + sanitizeTiles, + settleTiles, + tileBottom, + tileBoxes, + tilesFromBoxes, +} from "../../../components/tile-grid/layout"; + +describe("tile grid layout", () => { + it("settles tiles under the ones above them in the same columns", () => { + const boxes = settleTiles([ + { id: "a", x: 0, y: 0, w: 6, h: 100 }, + { id: "b", x: 6, y: 0, w: 6, h: 50 }, + { id: "c", x: 6, y: 500, w: 6, h: 50 }, + { id: "d", x: 0, y: 900, w: 12, h: 20 }, + ]); + const y = Object.fromEntries(boxes.map((box) => [box.id, box.y])); + expect(y).toEqual({ a: 0, b: 0, c: 50, d: 100 }); + expect(tileBottom(boxes)).toBe(120); + }); + + it("overlaps by the gap when it is negative, for shared borders", () => { + const boxes = settleTiles( + [ + { id: "a", x: 0, y: 0, w: 12, h: 100 }, + { id: "b", x: 0, y: 1, w: 12, h: 100 }, + ], + -1, + ); + expect(boxes[1].y).toBe(99); + }); + + it("uses measured heights for tiles without a fixed one", () => { + const boxes = tileBoxes( + [ + { id: "a", x: 0, y: 0, w: 4, h: null }, + { id: "b", x: 0, y: 0, w: 4, h: 30 }, + { id: "c", x: 0, y: 0, w: 4 }, + ], + { a: 77 }, + 10, + ); + expect(boxes.map((box) => box.h)).toEqual([77, 30, 10]); + }); + + it("appends at the bottom once and removes by id", () => { + const start = [{ id: "a", x: 0, y: 0, w: 4, h: null }]; + const added = appendTile(start, { id: "b", w: 30 }); + expect(added[1]).toEqual({ id: "b", x: 0, y: BOTTOM, w: 12, h: null }); + expect(appendTile(added, { id: "b" })).toBe(added); + expect(removeTile(added, "a").map((slot) => slot.id)).toEqual(["b"]); + }); + + it("writes settled positions back in reading order", () => { + const slots = [ + { id: "b", x: 6, y: 300, w: 6, h: 10 }, + { id: "a", x: 0, y: 0, w: 6, h: 10 }, + ]; + const out = tilesFromBoxes(slots, settleTiles(tileBoxes(slots, {}))); + expect(out.map((slot) => [slot.id, slot.y])).toEqual([ + ["a", 0], + ["b", 0], + ]); + }); + + it("cleans saved layouts", () => { + expect(sanitizeTiles("nope")).toEqual([]); + expect( + sanitizeTiles([ + { id: "a", x: 11, y: -5, w: 6, h: 3 }, + { id: "a", x: 0, y: 0, w: 4 }, + { x: 0 }, + null, + { id: "b", x: "x", y: 10, w: 1, h: "big" }, + ]), + ).toEqual([ + { id: "a", x: 6, y: 0, w: 6, h: 48 }, + { id: "b", x: 0, y: 10, w: 2, h: null }, + ]); + }); + + it("flows tiles into rows by width", () => { + const out = flowTiles([ + { id: "a", w: 8 }, + { id: "b", w: 4 }, + { id: "c", w: 6 }, + ]); + expect(out.map((slot) => [slot.id, slot.x, slot.y])).toEqual([ + ["a", 0, 0], + ["b", 8, 0], + ["c", 0, 160], + ]); + }); +}); + +describe("gridLines", () => { + it("draws a shared edge once, as long as the longer side", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 120 }, + ], + 200, + ); + expect(lines.filter((line) => line.w === 1)).toEqual([ + { x: 100, y: 0, w: 1, h: 121 }, + ]); + expect(lines.filter((line) => line.h === 1)).toEqual([ + { x: 0, y: 50, w: 101, h: 1 }, + { x: 100, y: 120, w: 100, h: 1 }, + ]); + }); + + it("leaves out the outer left, top and right edges and keeps bottoms", () => { + expect(gridLines([{ l: 0, t: 0, r: 200, b: 80 }], 200)).toEqual([ + { x: 0, y: 80, w: 200, h: 1 }, + ]); + }); + + it("drops the very bottom line when the grid runs to the bottom of the screen", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 120 }, + ], + 200, + true, + ); + // The shorter column still gets its bottom line. + expect(lines.filter((line) => line.h === 1)).toEqual([ + { x: 0, y: 50, w: 101, h: 1 }, + ]); + expect(lines.filter((line) => line.w === 1)).toEqual([ + { x: 100, y: 0, w: 1, h: 120 }, + ]); + }); + + it("joins rows into one line across tiles", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 50 }, + { l: 0, t: 50, r: 200, b: 90 }, + ], + 200, + ); + expect(lines.filter((line) => line.h === 1 && line.y === 50)).toEqual([ + { x: 0, y: 50, w: 200, h: 1 }, + ]); + }); +});