import { toCsvString } from '../actions/download' import type { TableColumn, TableRow, TableSortDirection, TableWidgetData, } from './types' /** * Compares two values with a consistent ordering across primitive types. * Strings use locale comparison; numbers subtract; booleans rank false < true; * objects/arrays compare by JSON serialization. Null/undefined sort to the end * regardless of direction. */ export function compareValues( a: unknown, b: unknown, direction: TableSortDirection, ): number { if (a == null && b == null) return 0 if (a == null) return 1 if (b == null) return -1 let cmp = 0 if (typeof a === 'string' && typeof b === 'string') { cmp = a.localeCompare(b) } else if (typeof a === 'number' && typeof b === 'number') { cmp = a - b } else if (typeof a === 'boolean' && typeof b === 'boolean') { cmp = a === b ? 0 : a ? 1 : -1 } else if (typeof a === 'object' || typeof b === 'object') { cmp = JSON.stringify(a).localeCompare(JSON.stringify(b)) } return direction === 'asc' ? cmp : -cmp } /** Returns a new array sorted by the named column. Does not mutate the input. */ export function sortRows( rows: readonly T[], columnId: string, direction: TableSortDirection, ): T[] { return [...rows].sort((a, b) => compareValues(a[columnId], b[columnId], direction), ) } /** Returns the slice for the requested page. Out-of-range pages return []. */ export function paginateRows( rows: readonly T[], page: number, pageSize: number, ): T[] { if (pageSize <= 0) return rows.slice() const start = page * pageSize return rows.slice(start, start + pageSize) } /** * Resolves the effective column list. When the widget store has a * `columnOrder` set (e.g. ChangeColumn was used), the column array is * reordered to match. Unknown ids in the order are skipped; columns not * present in the order keep their original relative position at the end. */ export function resolveColumns( columns: readonly TableColumn[], columnOrder: readonly string[] | undefined, ): readonly TableColumn[] { if (!columnOrder || columnOrder.length === 0) return columns const byId = new Map(columns.map((c) => [c.id, c])) const ordered: TableColumn[] = [] const seen = new Set() for (const id of columnOrder) { const c = byId.get(id) if (c && !seen.has(id)) { ordered.push(c) seen.add(id) } } for (const c of columns) { if (!seen.has(c.id)) ordered.push(c) } return ordered } /** Applies sort and pagination to the input data. Pure. */ export function deriveVisibleRows( rows: readonly T[], options: { sort?: { columnId: string | null; direction: TableSortDirection } page: number pageSize: number }, ): { sorted: readonly T[]; visible: T[] } { const sorted = options.sort?.columnId != null ? sortRows(rows, options.sort.columnId, options.sort.direction) : rows const visible = paginateRows(sorted, options.page, options.pageSize) return { sorted, visible } } export function tableDataToCsv( data: TableWidgetData, columns: readonly TableColumn[], ): string { // Pre-stringify with the table's own header/cell rules, then delegate the // CSV assembly (quoting + spreadsheet formula-injection guard) to the shared // `toCsvString` so escaping stays identical to every other widget's export. const rows: string[][] = [columns.map((c) => stringifyHeader(c.label))] for (const row of data) { rows.push(columns.map((c) => stringifyCell(row[c.id]))) } return toCsvString(rows) } function stringifyHeader(label: unknown): string { if (typeof label === 'string') return label if (typeof label === 'number' || typeof label === 'boolean') return String(label) return '' } function stringifyCell(value: unknown): string { if (value == null) return '' if (typeof value === 'string') return value if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (Array.isArray(value) || typeof value === 'object') return JSON.stringify(value) return '' }