diff --git a/CHANGELOG.md b/CHANGELOG.md index 879c611fe..4812015c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 26.10.5 + +### Changed + +- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row + ## 26.10.4 ### Fixed diff --git a/package-lock.json b/package-lock.json index da59f4ca7..c5bcb6f90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.4", + "version": "26.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.4", + "version": "26.10.5", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 1cf5909b0..8ca5198a7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.4", + "version": "26.10.5", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 4fa91dc9f..5479f4c5c 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.20 + +Plugin API 1.12.0. + +### Added + +- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6 +- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type +- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns` + ## 1.0.19 Plugin API 1.11.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 9b2ef1834..59ab078fe 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.19 | Plugin API 1.11.0 +SDK 1.0.20 | Plugin API 1.12.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. @@ -643,6 +643,9 @@ 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`. +- 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. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. - Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors). - Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 113a45031..684db9831 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.19", + "version": "1.0.20", "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 845b44200..fcab9e871 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -1711,28 +1711,49 @@ export function usePluginUiPreferences< return bridge.usePluginUiPreferences(bridge.usePluginId()) as never; } +type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6"; +const PANEL_GRID_SIZES: readonly string[] = [ + "auto", + "1", + "2", + "3", + "4", + "5", + "6", +]; + /** - * Grid or list and row density for one of this plugin's views, kept in its - * Appearance area as `viewMode` and `density`. Declare those - * keys in contributes.uiPresets so each preset level can pick a default. + * Grid or list, row density and cards per row for one of this plugin's + * views, kept in its Appearance area as `viewMode`, `density` + * and `columns`. Declare those keys in contributes.uiPresets so each + * preset level can pick a default. Hand columns to ViewToggle and DataView. */ export function usePluginPanelView(prefix = ""): { view: "grid" | "list"; density: "comfortable" | "compact"; compact: boolean; + columns: PanelGridSize; setView: (view: "grid" | "list") => void; setDensity: (density: "comfortable" | "compact") => void; + setColumns: (columns: PanelGridSize) => void; } { const { values, set } = usePluginUiPreferences(); const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid"; const density = values[`${prefix}density`] === "compact" ? "compact" : "comfortable"; + const saved = values[`${prefix}columns`]; + const columns = + typeof saved === "string" && PANEL_GRID_SIZES.includes(saved) + ? (saved as PanelGridSize) + : "auto"; return { view, density, compact: density === "compact", + columns, setView: (next) => set(`${prefix}viewMode`, next), setDensity: (next) => set(`${prefix}density`, next), + setColumns: (next) => set(`${prefix}columns`, next), }; } diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 37cb39b55..d4a364741 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,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.11.0"; +export const PLUGIN_API_VERSION = "1.12.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 a2e578d7c..fdf3d3c9d 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145" DEFAULT_FOLDER_ICON (value): "folder" DashboardCardId (type): DashboardCardId DataColumn (type): DataColumn -DataView (value): ({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element +DataView (value): ({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DefaultOverrides (type): DefaultOverrides DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DesktopUpdateMode (type): DesktopUpdateMode @@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry GridColSpan (type): GridColSpan GridColumns (type): GridColumns GridLayout (type): GridLayout +GridSize (type): GridSize GridSlot (type): GridSlot GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element @@ -321,7 +322,7 @@ UserRole (type): UserRole UserSecondFactorSummary (type): UserSecondFactorSummary VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element VersionInfo (type): VersionInfo -ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element +ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element WebSocketConnectionTarget (type): WebSocketConnectionTarget WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element actionOrder (value): (action: HostActionDef) => number diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index bf020c3ef..aa3c37d13 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -151,6 +151,7 @@ const ALLOWED = [ "GridCardCatalogEntry", "GridColSpan", "GridColumns", + "GridSize", "GridLayout", "GridSlot", "GroupHeading", diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx index e2dac1fa6..d61f65625 100644 --- a/src/ui/components/data-view.tsx +++ b/src/ui/components/data-view.tsx @@ -2,6 +2,8 @@ import type React from "react"; import { cn } from "@/lib/utils"; import { PANEL, + gridSizeStyle, + type GridSize, type PanelDensity, type PanelViewMode, } from "@/components/panel-layout"; @@ -67,6 +69,8 @@ export function DataView({ getKey, renderCard, columns, + size, + minCardWidth, listColumns, onRowClick, isRowActive, @@ -78,7 +82,12 @@ export function DataView({ density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => React.ReactNode; + /** Breakpoint columns, used when no size is given. */ columns: GridColumns; + /** Columns the user picked with ViewToggle. Wins over columns. */ + size?: GridSize; + /** Narrowest card in auto mode, in px. */ + minCardWidth?: number; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; @@ -93,9 +102,19 @@ export function DataView({ if (view === "grid") { return ( -
+
{items.map((item) => ( -
{renderCard(item)}
+
+ {renderCard(item)} +
))}
); diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index fe1b99c50..de28a1905 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -1,11 +1,26 @@ /* eslint-disable react-refresh/only-export-components */ import { Children, Fragment, isValidElement, useRef } from "react"; import type React from "react"; -import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react"; +import { + ArrowLeft, + Columns3, + Grid3X3, + List, + Rows3, + Search, + X, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/separator"; import { DocsLink } from "@/components/docs-link"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; /** One spacing rhythm for every panel and tab. */ export const PANEL = { @@ -19,6 +34,37 @@ export const PANEL = { export type PanelViewMode = "grid" | "list"; export type PanelDensity = "comfortable" | "compact"; +/** Cards per row. "auto" fits as many as the pane has room for. */ +export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6"; + +export const GRID_SIZES: readonly GridSize[] = [ + "auto", + "1", + "2", + "3", + "4", + "5", + "6", +]; + +// Sized off the pane's own width, not the window. Cards keep a min width, +// so narrow panes drop columns on their own. +export function gridSizeStyle( + size: GridSize, + minCardWidth = 320, + gap = "0.5rem", +): React.CSSProperties { + if (size === "auto") { + return { + gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`, + }; + } + const n = Number(size); + return { + gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`, + }; +} + /** The header, toolbar band and body every tab and panel draws. */ export function PanelShell({ chrome = true, @@ -308,20 +354,25 @@ export function Segmented({ ); } -/** Grid or list, plus the compact rows toggle. */ +/** Grid or list, plus the compact rows toggle and the cards per row picker. */ export function ViewToggle({ view, onView, density, onDensity, + columns, + onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; + /** Cards per row in grid view. Pass with onColumns to show the picker. */ + columns?: GridSize; + onColumns?: (next: GridSize) => void; }) { const { t } = useTranslation(); - return ( + const toggle = (
); + + if (!columns || !onColumns || view !== "grid") return toggle; + + return ( +
+ + {toggle} +
+ ); } function ToggleButton({ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 8bbfc961b..f65d8454a 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1325,11 +1325,6 @@ "updated": "Recently updated", "category": "Category" }, - "columns": { - "title": "Columns", - "auto": "Auto", - "count": "{{count}} wide" - }, "table": { "name": "Name", "description": "Description", @@ -1654,7 +1649,12 @@ "gridView": "Grid", "listView": "List", "compactRows": "Compact rows", - "comfortableRows": "Comfortable rows" + "comfortableRows": "Comfortable rows", + "columns": { + "title": "Columns", + "auto": "Auto", + "count": "{{count}} wide" + } }, "fields": { "secretSaved": "Saved. Type to replace it." diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 77857bb29..4f0b8b93d 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -289,6 +289,7 @@ export { PanelShell, Segmented, ViewToggle, + type GridSize, type PanelDensity, type PanelViewMode, } from "@/components/panel-layout"; diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 9e8f83396..a16a9bb0b 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -5,7 +5,6 @@ import { Check, ChevronLeft, ChevronRight, - Columns3, Download, Globe, Library, @@ -37,7 +36,9 @@ import { GroupHeading, PANEL, PanelShell, + GRID_SIZES, ViewToggle, + type GridSize, type PanelViewMode, } from "@/components/panel-layout"; import { DataView, type DataColumn } from "@/components/data-view"; @@ -72,9 +73,6 @@ import { DocsLink } from "@/components/docs-link"; type Section = "installed" | "browse" | "updates" | "registries"; -const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const; -type GridSize = (typeof GRID_SIZES)[number]; - interface Layout { view: PanelViewMode; columns: GridSize; @@ -364,54 +362,16 @@ function LayoutControls({ onView: (next: PanelViewMode) => void; onColumns: (next: GridSize) => void; }) { - const { t } = useTranslation(); return ( - <> - {layout.view === "grid" && ( - - )} - - + ); } -// Sized off the tab's own width, not the window. Cards keep a min width, -// so narrow panes drop columns on their own. -function gridStyle(columns: GridSize): React.CSSProperties { - if (columns === "auto") { - return { - gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))", - }; - } - const n = Number(columns); - const gaps = (n - 1) * 12; - return { - gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`, - }; -} - function PluginCollection({ plugins, layout, @@ -440,13 +400,17 @@ function PluginCollection({ ); } return ( -
- {plugins.map((p) => ( -
- {renderCard(p)} -
- ))} -
+ p.id} + renderCard={renderCard} + columns={{}} + size={layout.columns} + minCardWidth={400} + listColumns={listColumns} + empty={null} + /> ); } diff --git a/src/ui/tests/components/data-view.test.tsx b/src/ui/tests/components/data-view.test.tsx index c572e208f..d4a8c4bc5 100644 --- a/src/ui/tests/components/data-view.test.tsx +++ b/src/ui/tests/components/data-view.test.tsx @@ -34,6 +34,24 @@ describe("DataView", () => { expect(screen.queryByText("Name")).toBeNull(); }); + it("uses the picked size over breakpoint columns", () => { + const { container } = render( + item.id} + renderCard={(item) =>
card {item.name}
} + columns={{ base: 1, md: 2 }} + size="3" + listColumns={[]} + empty={null} + />, + ); + const grid = container.firstElementChild as HTMLElement; + expect(grid.className).not.toContain("md:grid-cols-2"); + expect(grid.style.gridTemplateColumns).toContain("/ 3"); + }); + it("renders a header and clickable rows in list mode", () => { const onRowClick = vi.fn(); renderView("list", onRowClick); diff --git a/src/ui/tests/components/panel-layout.test.tsx b/src/ui/tests/components/panel-layout.test.tsx index 905aba277..039e156b1 100644 --- a/src/ui/tests/components/panel-layout.test.tsx +++ b/src/ui/tests/components/panel-layout.test.tsx @@ -11,6 +11,7 @@ import { PanelShell, Segmented, ViewToggle, + gridSizeStyle, } from "@/components/panel-layout"; afterEach(cleanup); @@ -72,6 +73,34 @@ describe("ViewToggle", () => { fireEvent.click(screen.getByLabelText("panel.compactRows")); expect(onDensity).toHaveBeenCalledWith("compact"); }); + + it("shows the columns picker only in grid view", () => { + const props = { onView: vi.fn(), columns: "auto" as const }; + const { rerender } = render( + , + ); + expect(screen.getByLabelText("panel.columns.title")).toBeTruthy(); + + rerender(); + expect(screen.queryByLabelText("panel.columns.title")).toBeNull(); + + rerender(); + expect(screen.queryByLabelText("panel.columns.title")).toBeNull(); + }); +}); + +describe("gridSizeStyle", () => { + it("fits auto cards to the pane", () => { + expect(gridSizeStyle("auto", 280).gridTemplateColumns).toBe( + "repeat(auto-fill, minmax(min(100%, 280px), 1fr))", + ); + }); + + it("splits the width for a fixed count", () => { + expect(gridSizeStyle("4").gridTemplateColumns).toContain( + "(100% - 3 * 0.5rem) / 4", + ); + }); }); describe("Facts", () => { diff --git a/src/ui/tests/plugins/PluginsTab.test.tsx b/src/ui/tests/plugins/PluginsTab.test.tsx index 1b5ae65e6..64d5fd0a0 100644 --- a/src/ui/tests/plugins/PluginsTab.test.tsx +++ b/src/ui/tests/plugins/PluginsTab.test.tsx @@ -183,17 +183,13 @@ describe("PluginsTab", () => { it("switches to a table and remembers it", async () => { await renderTab(); expect(screen.queryByRole("table")).toBeNull(); - expect( - screen.getByLabelText(en.plugins.manager.columns.title), - ).toBeTruthy(); + expect(screen.getByLabelText(en.panel.columns.title)).toBeTruthy(); fireEvent.click(screen.getByLabelText(en.panel.listView)); expect(screen.getByRole("table")).toBeTruthy(); expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy(); - expect( - screen.queryByLabelText(en.plugins.manager.columns.title), - ).toBeNull(); + expect(screen.queryByLabelText(en.panel.columns.title)).toBeNull(); expect(localStorage.getItem("pluginsTab.view")).toBe("list"); cleanup();