diff --git a/package.json b/package.json index 1b38e0635..6da22b938 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@lit/reactive-element": "2.1.2", "@mdi/js": "7.4.47", "@replit/codemirror-indentation-markers": "^6.5.3", - "@tanstack/lit-table": "^8.21.3", + "@tanstack/lit-table": "^9.1.2", "codemirror": "^6.0.2", "esptool-js": "^0.6.1", "improv-wifi-serial-sdk": "^2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f58f5a0cb..462d6f99b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,8 +62,8 @@ importers: specifier: ^6.5.3 version: 6.5.3(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.8) '@tanstack/lit-table': - specifier: ^8.21.3 - version: 8.21.3(lit@3.3.3) + specifier: ^9.1.2 + version: 9.1.2(@lit/context@1.1.6)(lit@3.3.3) codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -534,15 +534,24 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tanstack/lit-table@8.21.3': - resolution: {integrity: sha512-NXe/No6HRGN+OZMJ1pKmoejiFS1QfjirWYOiahBWMojSsLIB5SqYb45L/jlswpKhEAaMWCdps8c93z8HfMG9Kg==} - engines: {node: '>=12'} + '@tanstack/lit-store@0.14.1': + resolution: {integrity: sha512-YzcmHhuH9siOvFiEH56HIQqkipkPaUJ3weC0jVKe8l3AZ2autYnpMGNvCG5tZFgmdsmvxr6W01zcW+yLlsQ9Tg==} + peerDependencies: + lit: ^3.0.0 + + '@tanstack/lit-table@9.1.2': + resolution: {integrity: sha512-jNNOeajZsC2NXXUFTtsYCIr753PpxMP1qeSQ4KoPDV2hoDQqBTPjpVYktjs8LFX4gXS6ATuZDBpqBNarTbHS6g==} + engines: {node: '>=20'} peerDependencies: + '@lit/context': ^1.1.0 lit: ^3.1.3 - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@tanstack/table-core@9.1.2': + resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==} + engines: {node: '>=20'} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1824,12 +1833,23 @@ snapshots: dependencies: tslib: 2.8.1 - '@tanstack/lit-table@8.21.3(lit@3.3.3)': + '@tanstack/lit-store@0.14.1(lit@3.3.3)': dependencies: - '@tanstack/table-core': 8.21.3 + '@tanstack/store': 0.11.1 lit: 3.3.3 - '@tanstack/table-core@8.21.3': {} + '@tanstack/lit-table@9.1.2(@lit/context@1.1.6)(lit@3.3.3)': + dependencies: + '@lit/context': 1.1.6 + '@tanstack/lit-store': 0.14.1(lit@3.3.3) + '@tanstack/table-core': 9.1.2 + lit: 3.3.3 + + '@tanstack/store@0.11.1': {} + + '@tanstack/table-core@9.1.2': + dependencies: + '@tanstack/store': 0.11.1 '@tybys/wasm-util@0.10.3': dependencies: diff --git a/src/components/dashboard/device-row.ts b/src/components/dashboard/device-row.ts new file mode 100644 index 000000000..5ed350a4f --- /dev/null +++ b/src/components/dashboard/device-row.ts @@ -0,0 +1,40 @@ +import type { ConfiguredDevice, DeviceState, Label } from "../../api/types/devices.js"; +import type { FirmwareJob } from "../../api/types/firmware-jobs.js"; + +/** One row of the device table, derived from a ``ConfiguredDevice`` + * by ``device-table``'s ``willUpdate``. Lives in its own leaf module + * so ``table-columns`` and ``table-features`` can both depend on it + * without importing each other. */ +export interface DeviceRow { + status: DeviceState; + name: string; + friendly_name: string; + address: string; + ip: string; + ip_addresses: string[]; + mac_address: string; + platform: string; + version: string; + comment: string; + area: string; + /** Resolved label objects (catalog joined against + * ``device.labels``) so the cell renderer doesn't need access to + * the catalog itself. ``device-table`` performs the resolve when + * building rows. */ + labels: Label[]; + config: string; + build_size_bytes: number; + // Raw has_pending_changes (device truth) — drives the encryption lock only. + hasPendingChanges: boolean; + // mDNS-gated display flags (see util/device-sync.ts): modified dot + install + // button, update column + update button. + showModified: boolean; + showUpdate: boolean; + hasQueuedUpdate: boolean; + api_enabled: boolean; + api_encrypted: boolean; + api_encryption_active: string | null; + busy: boolean; + recentJob: FirmwareJob | null; + _device: ConfiguredDevice; +} diff --git a/src/components/dashboard/device-table-grid.ts b/src/components/dashboard/device-table-grid.ts index 648a6e86f..0c5d4502e 100644 --- a/src/components/dashboard/device-table-grid.ts +++ b/src/components/dashboard/device-table-grid.ts @@ -1,5 +1,4 @@ import { flexRender } from "@tanstack/lit-table"; -import type { Cell, Header, HeaderGroup, Row, Table } from "@tanstack/lit-table"; import { html, nothing, type TemplateResult } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { repeat } from "lit/directives/repeat.js"; @@ -7,18 +6,18 @@ import type { ConfiguredDevice } from "../../api/types/devices.js"; import type { LocalizeFunc } from "../../common/localize.js"; import { tourAnchor } from "../guided-tour/tour-anchor.js"; import { getActiveTourConfiguration } from "../guided-tour/tour-session.js"; -import type { DeviceRow } from "./table-columns.js"; +import type { DeviceTable, DeviceTableRow } from "./table-features.js"; export interface DeviceTableHeadProps { - table: Table; + table: DeviceTable; selectMode: boolean; allSelected: boolean; onToggleAll: () => void; } export interface DeviceTableBodyProps { - table: Table; - rows: Row[]; + table: DeviceTable; + rows: DeviceTableRow[]; selectMode: boolean; selectedDevices: Set; highlightConfiguration: string | null; @@ -40,7 +39,7 @@ export function renderDeviceTableHead(p: DeviceTableHeadProps): TemplateResult { return html` ${p.table.getHeaderGroups().map( - (hg: HeaderGroup) => html` + (hg) => html` ${ p.selectMode @@ -54,19 +53,25 @@ export function renderDeviceTableHead(p: DeviceTableHeadProps): TemplateResult { ` : nothing } - ${hg.headers.map((header: Header) => { + ${hg.headers.map((header) => { const sorted = header.column.getIsSorted(); const canSort = header.column.getCanSort(); + const ariaSort = + sorted === "asc" + ? "ascending" + : sorted === "desc" + ? "descending" + : "none"; + const sortIcon = + sorted === "asc" + ? "chevron-up" + : sorted === "desc" + ? "chevron-down" + : "unfold-more-horizontal"; return html` ` : nothing } @@ -161,7 +160,7 @@ export function renderDeviceTableBody(p: DeviceTableBodyProps): TemplateResult { ` : nothing } - ${row.getVisibleCells().map((cell: Cell) => { + ${row.getVisibleCells().map((cell) => { // The stacked mobile layout (table-styles.ts) shows each // cell's column header as a field label. It's a real // span (not a CSS ::before) so screen readers announce diff --git a/src/components/dashboard/device-table.ts b/src/components/dashboard/device-table.ts index b0e9531ad..07f9d42cd 100644 --- a/src/components/dashboard/device-table.ts +++ b/src/components/dashboard/device-table.ts @@ -20,17 +20,13 @@ import { mdiUpload, } from "@mdi/js"; import { - type ColumnDef, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, + type ColumnVisibilityState, + functionalUpdate, type PaginationState, type SortingState, TableController, - type VisibilityState, + type Updater, } from "@tanstack/lit-table"; -import type { Row, Table } from "@tanstack/lit-table"; import type { PropertyValues } from "lit"; import { html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; @@ -59,6 +55,13 @@ import { import { tableCellStyles } from "./table-cell-styles.js"; import type { ToggleableColumn } from "./table-column-toggle.js"; import { createDeviceColumns, type DeviceRow } from "./table-columns.js"; +import { + type DeviceColumnDef, + type DeviceTable, + deviceTableFeatures, + type DeviceTableFeatures, + type DeviceTableRow, +} from "./table-features.js"; import { tableLayoutStyles } from "./table-styles.js"; import "@home-assistant/webawesome/dist/components/icon/icon.js"; @@ -89,12 +92,7 @@ registerMdiIcons({ upload: mdiUpload, }); -const coreRowModel = getCoreRowModel(); -const sortedRowModel = getSortedRowModel(); -const filteredRowModel = getFilteredRowModel(); -const paginatedRowModel = getPaginationRowModel(); - -const DEFAULT_HIDDEN_COLUMNS: VisibilityState = { +const DEFAULT_HIDDEN_COLUMNS: ColumnVisibilityState = { comment: false, area: false, labels: false, @@ -147,7 +145,7 @@ export class ESPHomeDeviceTable extends LitElement { /** Column visibility from preferences — the host mirrors saves back, so a remount reseeds it. */ @property({ attribute: false }) - initialColumnVisibility: VisibilityState | null = null; + initialColumnVisibility: ColumnVisibilityState | null = null; /** Page size from preferences — the host mirrors saves back, so a remount reseeds it. */ @property({ type: Number, attribute: "initial-page-size" }) @@ -157,7 +155,7 @@ export class ESPHomeDeviceTable extends LitElement { private _sorting: SortingState = []; @state() - private _columnVisibility: VisibilityState = { ...DEFAULT_HIDDEN_COLUMNS }; + private _columnVisibility: ColumnVisibilityState = { ...DEFAULT_HIDDEN_COLUMNS }; @state() private _pageSize = 25; @@ -181,34 +179,27 @@ export class ESPHomeDeviceTable extends LitElement { @query(".table-scroll") private _scrollContainer!: HTMLDivElement; - private _tableController = new TableController(this); + private _tableController = new TableController(this); private _rows: DeviceRow[] = []; private _visibleConfigs: string[] = []; - private _columns: ColumnDef[] = []; + private _columns: DeviceColumnDef[] = []; private _prevLocalize: LocalizeFunc | null = null; // ─── Stable callbacks ─── - private _handleSortingChange = ( - updater: SortingState | ((old: SortingState) => SortingState) - ) => { - this._sorting = typeof updater === "function" ? updater(this._sorting) : updater; + private _handleSortingChange = (updater: Updater) => { + this._sorting = functionalUpdate(updater, this._sorting); fireEvent(this, "table-sort-change", this._sorting); }; - private _handleVisibilityChange = ( - updater: VisibilityState | ((old: VisibilityState) => VisibilityState) - ) => { - this._columnVisibility = - typeof updater === "function" ? updater(this._columnVisibility) : updater; + private _handleVisibilityChange = (updater: Updater) => { + this._columnVisibility = functionalUpdate(updater, this._columnVisibility); fireEvent(this, "table-visibility-change", this._columnVisibility); }; - private _handlePaginationChange = ( - updater: PaginationState | ((old: PaginationState) => PaginationState) - ) => { + private _handlePaginationChange = (updater: Updater) => { const current = { pageSize: this._pageSize, pageIndex: this._pageIndex }; - const next = typeof updater === "function" ? updater(current) : updater; + const next = functionalUpdate(updater, current); const pageSizeChanged = next.pageSize !== this._pageSize; this._pageSize = next.pageSize; this._pageIndex = next.pageIndex; @@ -218,7 +209,7 @@ export class ESPHomeDeviceTable extends LitElement { }; private _globalFilterFn = ( - row: Row, + row: DeviceTableRow, _columnId: string, filterValue: unknown ): boolean => { @@ -227,7 +218,7 @@ export class ESPHomeDeviceTable extends LitElement { // dashboard's select-all scoping helper matches the same rows // this filter makes visible (single source of truth). const q = (filterValue as string).trim().toLowerCase(); - return matchesDeviceRow(row.original as DeviceRow, q); + return matchesDeviceRow(row.original, q); }; // ─── Lifecycle ─── @@ -284,7 +275,7 @@ export class ESPHomeDeviceTable extends LitElement { comment: d.comment || "", area: d.area || "", // Resolve labels here once per render rather than from the - // cell renderer — TanStack's sortingFn / filterFn read the + // cell renderer — TanStack's sortFn / filterFn read the // accessor value, so they need the resolved objects rather // than opaque ids. labels: resolveLabelIds(d.labels, this._labelCatalog), @@ -327,10 +318,7 @@ export class ESPHomeDeviceTable extends LitElement { onSortingChange: this._handleSortingChange, onColumnVisibilityChange: this._handleVisibilityChange, onPaginationChange: this._handlePaginationChange, - getCoreRowModel: coreRowModel, - getSortedRowModel: sortedRowModel, - getFilteredRowModel: filteredRowModel, - getPaginationRowModel: paginatedRowModel, + features: deviceTableFeatures, globalFilterFn: this._globalFilterFn, }); @@ -342,7 +330,6 @@ export class ESPHomeDeviceTable extends LitElement { ); const rows = table.getRowModel().rows; this._visibleConfigs = table.getFilteredRowModel().rows.map((r) => r.original.config); - const pgState = table.getState().pagination; const toggleCols: ToggleableColumn[] = table .getAllColumns() .filter((c) => c.getCanHide()) @@ -380,7 +367,7 @@ export class ESPHomeDeviceTable extends LitElement { , toggleCols: ToggleableColumn[]) { + private _renderControls(table: DeviceTable, toggleCols: ToggleableColumn[]) { return html`
diff --git a/src/components/dashboard/prefs.ts b/src/components/dashboard/prefs.ts index 2d9e81888..a967882da 100644 --- a/src/components/dashboard/prefs.ts +++ b/src/components/dashboard/prefs.ts @@ -1,4 +1,4 @@ -import type { SortingState, VisibilityState } from "@tanstack/lit-table"; +import type { ColumnVisibilityState, SortingState } from "@tanstack/lit-table"; import type { UserPreferences } from "../../api/types/system.js"; import { SortDirection } from "../../api/types/system.js"; import type { ESPHomePageDashboard } from "../../pages/dashboard.js"; @@ -41,7 +41,7 @@ export function saveTablePreference(host: ESPHomePageDashboard, e: CustomEvent): : null, }; } else if (type === "table-visibility-change") { - const visibility = (e as CustomEvent).detail; + const visibility = (e as CustomEvent).detail; host._tableColumnVisibility = visibility; patch = { table_column_visibility: visibility }; } else if (type === "table-page-size-change") { diff --git a/src/components/dashboard/render-content.ts b/src/components/dashboard/render-content.ts index 723c4e1bf..20caf5378 100644 --- a/src/components/dashboard/render-content.ts +++ b/src/components/dashboard/render-content.ts @@ -1,4 +1,4 @@ -import type { SortingState, VisibilityState } from "@tanstack/lit-table"; +import type { ColumnVisibilityState, SortingState } from "@tanstack/lit-table"; import { html, type TemplateResult } from "lit"; import type { AdoptableDevice, ConfiguredDevice } from "../../api/types/devices.js"; import type { ESPHomePageDashboard } from "../../pages/dashboard.js"; @@ -193,7 +193,7 @@ export function renderTable(host: ESPHomePageDashboard): TemplateResult { .selectedDevices=${host._selectedDevices} .highlightConfiguration=${host._recentlyAdopted} @table-sort-change=${(e: CustomEvent) => host._saveTablePreference(e)} - @table-visibility-change=${(e: CustomEvent) => + @table-visibility-change=${(e: CustomEvent) => host._saveTablePreference(e)} @table-page-size-change=${(e: CustomEvent) => host._saveTablePreference(e)} @row-click=${(e: CustomEvent) => diff --git a/src/components/dashboard/table-columns.ts b/src/components/dashboard/table-columns.ts index 1eea539b3..b0b4479b3 100644 --- a/src/components/dashboard/table-columns.ts +++ b/src/components/dashboard/table-columns.ts @@ -1,8 +1,6 @@ -import type { ColumnDef } from "@tanstack/lit-table"; import { html, nothing } from "lit"; import type { ConfiguredDevice, Label } from "../../api/types/devices.js"; import { DeviceState } from "../../api/types/devices.js"; -import type { FirmwareJob } from "../../api/types/firmware-jobs.js"; import { JobStatus } from "../../api/types/firmware-jobs.js"; import type { LocalizeFunc } from "../../common/localize.js"; import { DEVICE_SORT_COLLATOR, deviceSortKey } from "../../util/device-sort.js"; @@ -14,40 +12,9 @@ import { renderLabelChips } from "../../util/label-chip-template.js"; import { busyActionLabel, updateActionTitle } from "../../util/update-tooltip.js"; import { renderVisitWebUiLink } from "../../util/visit-web-ui-link.js"; import { buildWebUiUrl } from "../../util/web-ui-url.js"; +import type { DeviceColumnDef } from "./table-features.js"; -export interface DeviceRow { - status: DeviceState; - name: string; - friendly_name: string; - address: string; - ip: string; - ip_addresses: string[]; - mac_address: string; - platform: string; - version: string; - comment: string; - area: string; - /** Resolved label objects (catalog joined against - * ``device.labels``) so the cell renderer doesn't need access to - * the catalog itself. ``device-table`` performs the resolve when - * building rows. */ - labels: Label[]; - config: string; - build_size_bytes: number; - // Raw has_pending_changes (device truth) — drives the encryption lock only. - hasPendingChanges: boolean; - // mDNS-gated display flags (see util/device-sync.ts): modified dot + install - // button, update column + update button. - showModified: boolean; - showUpdate: boolean; - hasQueuedUpdate: boolean; - api_enabled: boolean; - api_encrypted: boolean; - api_encryption_active: string | null; - busy: boolean; - recentJob: FirmwareJob | null; - _device: ConfiguredDevice; -} +export type { DeviceRow } from "./device-row.js"; const RECENT_ICON: Record = { [JobStatus.QUEUED]: null, @@ -93,7 +60,7 @@ const valueCell = (cls: string, val: string) => export function createDeviceColumns( localize: LocalizeFunc, selectMode = false -): ColumnDef[] { +): DeviceColumnDef[] { const indicatorDot = (show: boolean, variant: string, labelKey: string) => show ? html``; }, - sortingFn: (rowA, rowB) => + sortFn: (rowA, rowB) => DEVICE_SORT_COLLATOR.compare( deviceSortKey(rowA.original), deviceSortKey(rowB.original) @@ -357,7 +324,7 @@ export function createDeviceColumns( if (!labels || labels.length === 0) return EMPTY_CELL; return renderLabelChips(labels, { max: 3 }); }, - sortingFn: (rowA, rowB) => { + sortFn: (rowA, rowB) => { const a = rowA.original.labels.map((l) => l.name).join(","); const b = rowB.original.labels.map((l) => l.name).join(","); return a.localeCompare(b); @@ -389,7 +356,7 @@ export function createDeviceColumns( // but "16777216" vs "2097152" puts the smaller value // above the larger one). Explicit ``a - b`` is the // canonical numeric sort and removes the ambiguity. - sortingFn: (rowA, rowB) => + sortFn: (rowA, rowB) => rowA.original.build_size_bytes - rowB.original.build_size_bytes, size: 120, enableHiding: true, diff --git a/src/components/dashboard/table-features.ts b/src/components/dashboard/table-features.ts new file mode 100644 index 000000000..9c69a208a --- /dev/null +++ b/src/components/dashboard/table-features.ts @@ -0,0 +1,52 @@ +import type { CellContext, ColumnDef, Row, Table } from "@tanstack/lit-table"; +import { + columnFilteringFeature, + columnSizingFeature, + columnVisibilityFeature, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + globalFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_text, + tableFeatures, +} from "@tanstack/lit-table"; +import type { DeviceRow } from "./device-row.js"; + +/** + * The TanStack Table v9 feature set for the device table. v9 tree-shakes + * per-feature APIs, so every feature the table's types touch must be + * registered here — the search box needs global filtering (whose row + * model lives in the column-filtering feature), the column defs set + * ``size`` (column sizing), and the header/pagination controls need + * sorting, visibility, and pagination. The ``sortFns`` registry lists + * only the comparators ``auto`` resolves for this table's string + * columns (keeping v8's alphanumeric behavior without shipping the + * full built-in set); ``datetime`` and ``basic`` are deliberately + * unregistered — a registry miss falls back to ``basic`` silently in + * production builds, so a future Date-valued column must add + * ``sortFn_datetime`` here. + */ +export const deviceTableFeatures = tableFeatures({ + columnFilteringFeature, + columnSizingFeature, + columnVisibilityFeature, + globalFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text }, +}); + +export type DeviceTableFeatures = typeof deviceTableFeatures; + +// Composed aliases so consumers don't restate the +// ```` pairing at every site. +export type DeviceTable = Table; +export type DeviceTableRow = Row; +export type DeviceColumnDef = ColumnDef; +export type DeviceCellContext = CellContext; diff --git a/src/pages/dashboard.ts b/src/pages/dashboard.ts index 6b39e52f8..e1b4c2e46 100644 --- a/src/pages/dashboard.ts +++ b/src/pages/dashboard.ts @@ -11,7 +11,7 @@ import { mdiViewGrid, mdiWeb, } from "@mdi/js"; -import type { SortingState, VisibilityState } from "@tanstack/lit-table"; +import type { ColumnVisibilityState, SortingState } from "@tanstack/lit-table"; import { html, LitElement, type PropertyValues, type TemplateResult } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import memoizeOne from "memoize-one"; @@ -305,7 +305,7 @@ export class ESPHomePageDashboard extends LitElement { @state() _view: DashboardView = DashboardView.CARDS; @state() _tablePageSize = 25; @state() _tableSorting: SortingState | null = null; - @state() _tableColumnVisibility: VisibilityState | null = null; + @state() _tableColumnVisibility: ColumnVisibilityState | null = null; private _adoptHighlightTimer: ReturnType | null = null; _pendingAdoptScroll: string | null = null; diff --git a/test/components/dashboard/device-table.test.ts b/test/components/dashboard/device-table.test.ts index a781d43c9..fc1547461 100644 --- a/test/components/dashboard/device-table.test.ts +++ b/test/components/dashboard/device-table.test.ts @@ -139,6 +139,63 @@ describe("device-table All rendering", () => { }); }); +describe("device-table auto sort registry", () => { + // Pins the order the slim v9 ``sortFns`` registry produces for a + // string column resolved through ``auto`` (alphanumeric). A registry + // miss falls back to ``basic`` silently in production builds, so this + // is the only signal if the registry is slimmed further. + it("sorts the platform column alphanumerically via auto resolution", async () => { + // host10/host2 and the uppercase entry make the assertion fail under a + // plain lexicographic (``basic``) compare, so the fallback can't pass. + const platforms = ["esp32", "rp2040", "host10", "BK72XX", "esp32-c3", "host2"]; + const el = new ESPHomeDeviceTable(); + el.devices = platforms.map((platform, i) => + makeConfiguredDevice({ + name: `dev-${i}`, + friendly_name: `Dev ${i}`, + configuration: `dev-${i}.yaml`, + target_platform: platform, + }) + ); + el.initialSorting = [{ id: "platform", desc: false }]; + document.body.appendChild(el); + await el.updateComplete; + await el.updateComplete; + + // Skip the mobile stack label span; the value span carries the platform. + const order = Array.from( + el.shadowRoot!.querySelectorAll("tbody td.col-platform span:not(.cell-stack-label)") + ).map((span) => span.textContent!.trim()); + expect(order).toEqual(["BK72XX", "esp32", "esp32-c3", "host2", "host10", "rp2040"]); + }); + + // Digit-free strings resolve through the registry's ``text`` entry. + // ``basic`` is case-sensitive ("Bedroom" before "attic"), so this order + // only holds while ``text`` stays registered. + it("sorts a digit-free column case-insensitively via the text entry", async () => { + const comments = ["kitchen", "Bedroom", "attic"]; + const el = new ESPHomeDeviceTable(); + el.devices = comments.map((comment, i) => + makeConfiguredDevice({ + name: `dev-${i}`, + friendly_name: `Dev ${i}`, + configuration: `dev-${i}.yaml`, + comment, + }) + ); + el.initialColumnVisibility = { comment: true }; + el.initialSorting = [{ id: "comment", desc: false }]; + document.body.appendChild(el); + await el.updateComplete; + await el.updateComplete; + + const order = Array.from( + el.shadowRoot!.querySelectorAll("tbody td.col-comment span:not(.cell-stack-label)") + ).map((span) => span.textContent!.trim()); + expect(order).toEqual(["attic", "Bedroom", "kitchen"]); + }); +}); + describe("device-table Version column identity gating", () => { async function mountWithVersionColumn( device: ConfiguredDevice diff --git a/test/components/dashboard/prefs.test.ts b/test/components/dashboard/prefs.test.ts index 43fa919cb..daa9f6e83 100644 --- a/test/components/dashboard/prefs.test.ts +++ b/test/components/dashboard/prefs.test.ts @@ -4,7 +4,7 @@ * saveTablePreference mirrors each change onto the host fields that * seed a remounted table. */ -import type { VisibilityState } from "@tanstack/lit-table"; +import type { ColumnVisibilityState } from "@tanstack/lit-table"; import { describe, expect, it, vi } from "vitest"; import { SortDirection } from "../../../src/api/types/system.js"; import { saveTablePreference } from "../../../src/components/dashboard/prefs.js"; @@ -33,7 +33,7 @@ describe("saveTablePreference", () => { it("mirrors a column-visibility change onto the host and persists it", () => { const { host, updatePreferences } = makeHost(); - const visibility: VisibilityState = { comment: true, ip: false }; + const visibility: ColumnVisibilityState = { comment: true, ip: false }; saveTablePreference(host, event("table-visibility-change", visibility)); expect(host._tableColumnVisibility).toBe(visibility); expect(updatePreferences).toHaveBeenCalledWith({ diff --git a/test/components/dashboard/table-columns.test.ts b/test/components/dashboard/table-columns.test.ts index 425010b4e..a1176d59a 100644 --- a/test/components/dashboard/table-columns.test.ts +++ b/test/components/dashboard/table-columns.test.ts @@ -6,7 +6,6 @@ * column's value font where monospace made it render as a narrow, * hyphen-looking glyph. Populated cells keep their own font. */ -import type { CellContext } from "@tanstack/lit-table"; import { type TemplateResult } from "lit"; import { describe, expect, it } from "vitest"; import { clickCollect, identityLocalize, renderInto } from "../../_dom.js"; @@ -15,6 +14,7 @@ import { createDeviceColumns, type DeviceRow, } from "../../../src/components/dashboard/table-columns.js"; +import type { DeviceCellContext } from "../../../src/components/dashboard/table-features.js"; const columns = createDeviceColumns(identityLocalize); @@ -29,7 +29,7 @@ function columnByKey(key: string) { function renderCell(key: string, value: unknown): TemplateResult { const cell = columnByKey(key); // The data columns only read info.getValue(); a minimal stub suffices. - const info = { getValue: () => value } as unknown as CellContext; + const info = { getValue: () => value } as unknown as DeviceCellContext; return cell(info) as TemplateResult; } @@ -59,7 +59,7 @@ function renderActionsCell(rowOverrides: Partial = {}): TemplateResul }, ...rowOverrides, } as unknown as DeviceRow; - const info = { row: { original: row } } as unknown as CellContext; + const info = { row: { original: row } } as unknown as DeviceCellContext; return col.cell(info) as TemplateResult; } @@ -101,7 +101,7 @@ describe("device table status dot with name_add_mac_suffix", () => { _device: { name_add_mac_suffix: nameAddMacSuffix }, }, }, - } as unknown as CellContext; + } as unknown as DeviceCellContext; return rendered(columnByKey("status")(info) as TemplateResult); }; @@ -224,7 +224,7 @@ function renderNameCell( _device: { web_port: null }, ...rowOverrides, } as unknown as DeviceRow; - const info = { row: { original: row } } as unknown as CellContext; + const info = { row: { original: row } } as unknown as DeviceCellContext; return col.cell(info) as TemplateResult; }