diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b32a9b..2959bfd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased](https://github.com/hyparam/hightable/compare/v0.11.0...HEAD) +### Refactored + +- Build the library with Vite instead of Rollup ([#63](https://github.com/hyparam/hightable/pull/63)). +- Harmonize the ESLint and Typescript rules with the other hyparam projects ([#63](https://github.com/hyparam/hightable/pull/63)). + ## [0.11.0](https://github.com/hyparam/hightable/compare/v0.10.0...v0.11.0) - 2025-02-27 ### Added diff --git a/eslint.config.js b/eslint.config.js index 74c977b3..4568e81c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,48 +1,32 @@ import javascript from '@eslint/js' -import typescriptParser from '@typescript-eslint/parser' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' import globals from 'globals' import typescript from 'typescript-eslint' -export default [ +export default typescript.config( + { ignores: ['coverage/', 'dist/'] }, { - ignores: ['coverage/', 'dist/'], - }, - { - files: ['**/*.ts', '**/*.tsx'], - plugins: { - react, - 'react-hooks': reactHooks, - typescript, - }, - + extends: [javascript.configs.recommended, ...typescript.configs.strictTypeChecked, ...typescript.configs.stylisticTypeChecked], + files: ['**/*.{ts,tsx,js}'], languageOptions: { - globals: { - ...globals.browser, - Babel: 'readonly', - React: 'readonly', - ReactDOM: 'readonly', - }, - parser: typescriptParser, + globals: globals.browser, parserOptions: { - ecmaFeatures: { - jsx: true, - }, + project: ['./tsconfig.json', './tsconfig.eslint.json'], + tsconfigRootDir: import.meta.dirname, }, }, - settings: { - react: { - version: 'detect', - }, + plugins: { + react, + 'react-hooks': reactHooks, }, - rules: { - ...javascript.configs.recommended.rules, ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, ...reactHooks.configs.recommended.rules, - ...typescript.configs.eslintRecommended.rules, + ...javascript.configs.recommended.rules, ...typescript.configs.recommended.rules, + // javascript 'arrow-spacing': 'error', camelcase: 'off', 'comma-spacing': 'error', @@ -60,6 +44,7 @@ export default [ 'no-constant-condition': 'off', 'no-extra-parens': 'error', 'no-multi-spaces': 'error', + 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }], 'no-trailing-spaces': 'error', 'no-undef': 'error', 'no-unused-vars': 'off', @@ -77,14 +62,39 @@ export default [ quotes: ['error', 'single'], 'require-await': 'warn', semi: ['error', 'never'], - 'sort-imports': ['error', { ignoreDeclarationSort: true, ignoreMemberSort: false, memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], }], - 'space-infix-ops': 'error', + // typescript + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/require-await': 'warn', + // allow using any - see row.ts - it's not easy to replace with unknown for example + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + // fix an issue with vi.fn in an object (localStorage mock in our tests): see https://github.com/vitest-dev/eslint-plugin-vitest/issues/591 + '@typescript-eslint/unbound-method': 'off', + }, + settings: { react: { version: 'detect' } }, + }, + { + files: ['test/**/*.{ts,tsx}', '*.{js,ts}'], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.node, + ...globals.browser, + }, }, }, -] + { + files: ['**/*.js'], + ...typescript.configs.disableTypeChecked, + } +) diff --git a/package.json b/package.json index 4f6ef6ac..d2525c80 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,22 @@ "type": "git", "url": "git+https://github.com/hyparam/hightable.git" }, - "main": "dist/HighTable.min.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/types/HighTable.d.ts", + "import": "./dist/HighTable.js" + }, + "./src/HighTable.css": "./dist/HighTable.css" + }, "files": [ - "src", "dist" ], - "type": "module", - "types": "dist/HighTable.d.ts", "scripts": { - "build": "rollup -c", + "build:bundle": "vite build", + "build:css": "cp src/HighTable.css dist/HighTable.css", + "build:types": "tsc -b", + "build": "npm run build:bundle && npm run build:types && npm run build:css", "coverage": "vitest run --coverage --coverage.include=src", "lint": "eslint", "prepublishOnly": "npm run build", @@ -36,26 +43,23 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@rollup/plugin-commonjs": "28.0.2", - "@rollup/plugin-node-resolve": "16.0.0", - "@rollup/plugin-replace": "6.0.2", - "@rollup/plugin-terser": "0.4.4", - "@rollup/plugin-typescript": "12.1.2", "@testing-library/react": "16.2.0", "@testing-library/user-event": "14.6.1", "@types/node": "22.13.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.3.4", "@vitest/coverage-v8": "3.0.6", "eslint": "9.21.0", "eslint-plugin-react": "7.37.4", "eslint-plugin-react-hooks": "5.1.0", + "globals": "15.14.0", "jsdom": "26.0.0", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "2.8.1", "typescript": "5.7.3", "typescript-eslint": "8.23.0", + "vite": "6.2.0", "vitest": "3.0.6" }, "peerDependencies": { diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index 0bf03898..00000000 --- a/rollup.config.js +++ /dev/null @@ -1,31 +0,0 @@ -import commonjs from '@rollup/plugin-commonjs' -import resolve from '@rollup/plugin-node-resolve' -import replace from '@rollup/plugin-replace' -import terser from '@rollup/plugin-terser' -import typescript from '@rollup/plugin-typescript' - -export default { - input: 'src/HighTable.tsx', - output: { - file: 'dist/HighTable.min.js', - name: 'HighTable', - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - sourcemap: true, - }, - plugins: [ - replace({ - 'process.env.NODE_ENV': JSON.stringify('production'), // or 'development' based on your build environment - preventAssignment: true, - }), - resolve(), - commonjs(), - typescript({ - exclude: ['test/**'], - }), - terser(), - ], - external: ['react', 'react-dom'], -} diff --git a/src/HighTable.tsx b/src/HighTable.tsx index bd1c5675..626acc08 100644 --- a/src/HighTable.tsx +++ b/src/HighTable.tsx @@ -1,15 +1,18 @@ -import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DataFrame } from './dataframe.js' import { useInputState } from './hooks.js' import { PartialRow } from './row.js' import { Selection, SortIndex, areAllSelected, computeNewSelection, isSelected, toggleAll } from './selection.js' import TableHeader, { OrderBy, cellStyle } from './TableHeader.js' -export { DataFrame, arrayDataFrame, sortableDataFrame } from './dataframe.js' -export { ResolvablePromise, resolvablePromise, wrapPromise } from './promise.js' -export { AsyncRow, Cells, PartialRow, ResolvableRow, Row, asyncRows, awaitRow, awaitRows, resolvableRow } from './row.js' +export { arrayDataFrame, sortableDataFrame } from './dataframe.js' +export type { DataFrame } from './dataframe.js' +export { resolvablePromise, wrapPromise } from './promise.js' +export type { ResolvablePromise } from './promise.js' +export { asyncRows, awaitRow, awaitRows, resolvableRow } from './row.js' +export type { AsyncRow, Cells, PartialRow, ResolvableRow, Row } from './row.js' export { rowCache } from './rowCache.js' -export { Selection } from './selection.js' -export { OrderBy } from './TableHeader.js' +export type { Selection } from './selection.js' +export type { OrderBy } from './TableHeader.js' export { HighTable } /** @@ -30,7 +33,7 @@ const rowHeight = 33 // row height px * @param col column index * @param row row index in the data frame */ -type MouseEventCellHandler = (event: React.MouseEvent, col: number, row: number) => void +type MouseEventCellHandler = (event: MouseEvent, col: number, row: number) => void export interface TableProps { data: DataFrame @@ -45,7 +48,7 @@ export interface TableProps { onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined. selection?: Selection // selection and anchor rows, expressed as data indexes (not as indexes in the table). If undefined, the selection is hidden and the interactions are disabled. onSelectionChange?: (selection: Selection) => void // callback to call when a user interaction changes the selection. The selection is expressed as data indexes (not as indexes in the table). The interactions are disabled if undefined. - stringify?: (value: any) => string | undefined + stringify?: (value: unknown) => string | undefined } /** @@ -91,7 +94,7 @@ export default function HighTable({ const [slice, setSlice] = useState(undefined) const [rowsRange, setRowsRange] = useState({ start: 0, end: 0 }) const [hasCompleteRow, setHasCompleteRow] = useState(false) - const [columnWidths, setColumnWidths] = useState>([]) + const [columnWidths, setColumnWidths] = useState([] as (number | undefined)[]) const [sortIndexes, setSortIndexes] = useState>(() => new Map()) const setColumnWidth = useCallback((columnIndex: number, columnWidth: number | undefined) => { @@ -132,18 +135,19 @@ export default function HighTable({ const showSelectionControls = showSelection && enableSelectionInteractions const showCornerSelection = showSelectionControls || showSelection && areAllSelected({ ranges: selection.ranges, length: data.numRows }) const getOnSelectAllRows = useCallback(() => { - if (!selection || !onSelectionChange) return + if (!selection) return const { ranges } = selection - return () => onSelectionChange({ + return () => { onSelectionChange({ ranges: toggleAll({ ranges, length: data.numRows }), anchor: undefined, - }) + }) } }, [onSelectionChange, data.numRows, selection]) const pendingSelectionRequest = useRef(0) const getOnSelectRowClick = useCallback(({ tableIndex, dataIndex }: {tableIndex: number, dataIndex?: number}) => { // computeNewSelection is responsible to resolve the dataIndex if undefined but needed - if (!selection || !onSelectionChange) return - return async (event: React.MouseEvent) => { + if (!selection) return + async function onSelectRowClick(event: MouseEvent) { + if (!selection) return const useAnchor = event.shiftKey && selection.anchor !== undefined const requestId = ++pendingSelectionRequest.current // provide a cached column index, if available and needed @@ -172,6 +176,9 @@ export default function HighTable({ onSelectionChange(newSelection) } } + return (event: MouseEvent): void => { + void onSelectRowClick(event) + } }, [onSelectionChange, selection, data, orderBy, sortIndexes]) const allRowsSelected = useMemo(() => { if (!selection) return false @@ -193,8 +200,6 @@ export default function HighTable({ const tableRef = useRef(null) const pendingRequest = useRef(0) - if (!data) throw new Error('HighTable: data is required') - // invalidate when data changes so that columns will auto-resize if (slice && data !== slice.data) { // delete the slice @@ -205,7 +210,7 @@ export default function HighTable({ setSortIndexes(new Map()) // if uncontrolled, reset the selection (if controlled, it's the responsibility of the parent to do it) if (!isSelectionControlled) { - onSelectionChange?.({ ranges: [], anchor: undefined }) + onSelectionChange({ ranges: [], anchor: undefined }) } } @@ -216,8 +221,11 @@ export default function HighTable({ */ function handleScroll() { - const clientHeight = scrollRef.current?.clientHeight || 100 // view window height - const scrollTop = scrollRef.current?.scrollTop || 0 // scroll position + // view window height (0 is not allowed - the syntax is verbose, but makes it clear) + const currentClientHeight = scrollRef.current?.clientHeight + const clientHeight = currentClientHeight === undefined || currentClientHeight === 0 ? 100 : currentClientHeight + // scroll position + const scrollTop = scrollRef.current?.scrollTop ?? 0 // determine rows to fetch based on current scroll position (indexes refer to the virtual table domain) const startView = Math.floor(data.numRows * scrollTop / scrollHeight) @@ -225,9 +233,9 @@ export default function HighTable({ const start = Math.max(0, startView - overscan) const end = Math.min(data.numRows, endView + overscan) - if (isNaN(start)) throw new Error('invalid start row ' + start) - if (isNaN(end)) throw new Error('invalid end row ' + end) - if (end - start > 1000) throw new Error('attempted to render too many rows ' + (end - start) + ' table must be contained in a scrollable div') + if (isNaN(start)) throw new Error(`invalid start row ${start}`) + if (isNaN(end)) throw new Error(`invalid end row ${end}`) + if (end - start > 1000) throw new Error(`attempted to render too many rows ${end - start} table must be contained in a scrollable div`) setRowsRange({ start, end }) } @@ -311,11 +319,11 @@ export default function HighTable({ // Subscribe to data updates for (const asyncRow of rowsChunk) { for (const promise of [asyncRow.index, ...Object.values(asyncRow.cells)] ) { - promise.then(() => { + void promise.then(() => { if (pendingRequest.current === requestId) { updateRows() } - }).catch(() => {}) + }) } } @@ -326,19 +334,18 @@ export default function HighTable({ } } // update - fetchRows() + void fetchRows() }, [data, onError, orderBy?.column, slice, rowsRange, hasCompleteRow]) - const memoizedStyles = useMemo(() => columnWidths.map(cellStyle), [columnWidths]) - const onDoubleClick = useCallback((e: React.MouseEvent, col: number, row?: number) => { + const onDoubleClick = useCallback((e: MouseEvent, col: number, row?: number) => { if (row === undefined) { console.warn('Cell onDoubleClick is cancelled because row index is undefined') return } onDoubleClickCell?.(e, col, row) }, [onDoubleClickCell]) - const onMouseDown = useCallback((e: React.MouseEvent, col: number, row?: number) => { + const onMouseDown = useCallback((e: MouseEvent, col: number, row?: number) => { if (row === undefined) { console.warn('Cell onMouseDown is cancelled because row index is undefined') return @@ -353,7 +360,7 @@ export default function HighTable({ * @param col column index * @param row row index. If undefined, onDoubleClickCell and onMouseDownCell will not be called. */ - const Cell = useCallback((value: any, col: number, row?: number): ReactNode => { + const Cell = useCallback((value: unknown, col: number, row?: number): ReactNode => { // render as truncated text let str = stringify(value) let title: string | undefined @@ -365,8 +372,8 @@ export default function HighTable({ role="cell" className={str === undefined ? 'pending' : undefined} key={col} - onDoubleClick={e => onDoubleClick(e, col, row)} - onMouseDown={e => onMouseDown(e, col, row)} + onDoubleClick={e => { onDoubleClick(e, col, row) }} + onMouseDown={e => { onMouseDown(e, col, row) }} style={memoizedStyles[col]} title={title}> {str} @@ -430,7 +437,7 @@ export default function HighTable({ })} {slice?.rows.map((row, rowIndex) => { const tableIndex = slice.offset + rowIndex - const dataIndex = row?.index + const dataIndex = row.index const selected = isRowSelected(dataIndex) ?? false const ariaRowIndex = tableIndex + 2 // 1-based + the header row /** @@ -447,7 +454,7 @@ export default function HighTable({ { showSelection && } {data.header.map((col, colIndex) => - Cell(row?.cells[col], colIndex, dataIndex) + Cell(row.cells[col], colIndex, dataIndex) )} })} @@ -470,11 +477,10 @@ export default function HighTable({ } - /** * Robust stringification of any value, including json and bigints. */ -export function stringify(value: any): string | undefined { +export function stringify(value: unknown): string | undefined { if (typeof value === 'string') return value if (typeof value === 'number') return value.toLocaleString() if (typeof value === 'bigint') return value.toLocaleString() @@ -484,7 +490,8 @@ export function stringify(value: any): string | undefined { if (typeof value === 'object') { return `{${Object.entries(value).map(([k, v]) => `${k}: ${stringify(v)}`).join(', ')}}` } - return value.toString() + // fallback + return JSON.stringify(value) } const stringifyDefault = stringify @@ -520,8 +527,6 @@ export function throttle(fn: () => void, wait: number): () => void { } } - - function rowLabel(rowIndex?: number): string { if (rowIndex === undefined) return '' // rowIndex + 1 because the displayed row numbers are 1-based diff --git a/src/TableHeader.tsx b/src/TableHeader.tsx index d574da9d..9e03290c 100644 --- a/src/TableHeader.tsx +++ b/src/TableHeader.tsx @@ -1,4 +1,4 @@ -import { RefObject, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MouseEvent, RefObject, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' export interface OrderBy { @@ -9,10 +9,10 @@ export interface OrderBy { interface TableProps { header: string[] cacheKey?: string // used to persist column widths - columnWidths: Array + columnWidths: (number | undefined)[] orderBy?: OrderBy // order by column. If undefined, the table is unordered, the sort elements are hidden and the interactions are disabled. setColumnWidth: (columnIndex: number, columnWidth: number | undefined) => void - setColumnWidths: (columnWidths: Array) => void + setColumnWidths: (columnWidths: (number | undefined)[]) => void onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined. dataReady: boolean } @@ -44,17 +44,9 @@ export default function TableHeader({ const [resizing, setResizing] = useState() const headerRefs = useRef(header.map(() => createRef())) - function measureWidth(ref: RefObject): number | undefined { - if (!ref.current) return undefined - // get computed cell padding - const style = window.getComputedStyle(ref.current) - const horizontalPadding = parseInt(style.paddingLeft) + parseInt(style.paddingRight) - return ref.current.offsetWidth - horizontalPadding - } - // Load persisted column widths useEffect(() => { - const userWidths: number[] = new Array(header.length) + const userWidths: (number|undefined)[] = new Array(header.length).fill(undefined) if (cacheKey) { // load user sized column widths loadColumnWidths(cacheKey).forEach(({ columnIndex, columnName, width }) => { @@ -76,31 +68,41 @@ export default function TableHeader({ }, [cacheKey, dataReady, header, setColumnWidths]) // re-measure if header changes // Modify column width - function startResizing(columnIndex: number, e: React.MouseEvent) { + const startResizing = useCallback((columnIndex: number, e: MouseEvent) => { e.stopPropagation() setResizing({ columnIndex, - clientX: e.clientX - (columnWidths[columnIndex] || 0), + clientX: e.clientX - (columnWidths[columnIndex] ?? 0), }) - } + }, [columnWidths]) // Function to handle double click for auto-resizing - function autoResize(columnIndex: number) { + const autoResize = useCallback((columnIndex: number) => { + const columnName = header[columnIndex] + if (columnName === undefined) { + return + } // Remove the width, let it size naturally, and then measure it flushSync(() => { setColumnWidth(columnIndex, undefined) }) - const newWidth = measureWidth(headerRefs.current[columnIndex]) + const headerRef = headerRefs.current[columnIndex] + if (!headerRef) { + // TODO: should we reset the previous width? + // Note that it should not happen, since all the column headers should exist + return + } + const newWidth = measureWidth(headerRef) if (cacheKey && newWidth) { saveColumnWidth(cacheKey, { columnIndex, - columnName: header[columnIndex], + columnName, width: newWidth, }) } setColumnWidth(columnIndex, newWidth) - } + }, [cacheKey, header, setColumnWidth]) // Attach mouse move and mouse up events for column resizing useEffect(() => { @@ -108,19 +110,16 @@ export default function TableHeader({ // save width to local storage if (!resizing) return const { columnIndex } = resizing - if (cacheKey && columnWidths[columnIndex]) { - const width = columnWidths[columnIndex]! - saveColumnWidth(cacheKey, { - columnIndex, - columnName: header[columnIndex], - width, - }) + const columnName = header[columnIndex] + const width = columnWidths[columnIndex] + if (cacheKey && columnName !== undefined && width !== undefined) { + saveColumnWidth(cacheKey, { columnIndex, columnName, width }) } setResizing(undefined) } // Handle mouse move event during resizing - function handleMouseMove({ clientX }: MouseEvent) { + function handleMouseMove({ clientX }: globalThis.MouseEvent) { if (resizing) { setColumnWidth(resizing.columnIndex, Math.max(1, clientX - resizing.clientX)) } @@ -140,7 +139,7 @@ export default function TableHeader({ // Function to handle click for changing orderBy const getOnOrderByClick = useCallback((columnHeader: string) => { if (!onOrderByChange) return undefined - return (e: React.MouseEvent) => { + return (e: MouseEvent) => { // Ignore clicks on resize handle if ((e.target as HTMLElement).tagName === 'SPAN') return if (orderBy?.column === columnHeader) { @@ -169,8 +168,8 @@ export default function TableHeader({ title={columnHeader}> {columnHeader} autoResize(columnIndex)} - onMouseDown={e => startResizing(columnIndex, e)} /> + onDoubleClick={() => { autoResize(columnIndex) }} + onMouseDown={e => { startResizing(columnIndex, e) }} /> )} @@ -187,7 +186,7 @@ export function cellStyle(width: number | undefined) { */ export function loadColumnWidths(key: string): ColumnWidth[] { const json = localStorage.getItem(`column-widths:${key}`) - return json ? JSON.parse(json) : [] + return json ? JSON.parse(json) as ColumnWidth[] : [] } /** @@ -200,3 +199,11 @@ export function saveColumnWidth(key: string, columnWidth: ColumnWidth) { ] localStorage.setItem(`column-widths:${key}`, JSON.stringify(widths)) } + +function measureWidth(ref: RefObject): number | undefined { + if (!ref.current) return undefined + // get computed cell padding + const style = window.getComputedStyle(ref.current) + const horizontalPadding = parseInt(style.paddingLeft) + parseInt(style.paddingRight) + return ref.current.offsetWidth - horizontalPadding +} diff --git a/src/dataframe.ts b/src/dataframe.ts index f8ce19ef..65b92a2d 100644 --- a/src/dataframe.ts +++ b/src/dataframe.ts @@ -100,7 +100,13 @@ export function sortableDataFrame(data: DataFrame): DataFrame { const indexesSlice = columnIndexes.then(indexes => indexes.slice(start, end)) const rowsSlice = indexesSlice.then(indexes => Promise.all( // TODO(SL): optimize to fetch groups of rows instead of individual rows? - indexes.map(i => data.rows({ start: i, end: i + 1 })[0]) + indexes.map(i => { + const asyncRowInArray = data.rows({ start: i, end: i + 1 }) + if (!(0 in asyncRowInArray)) { + throw new Error('data.rows should have return one async row') + } + return asyncRowInArray[0] + }) )) return asyncRows(rowsSlice, end - start, data.header) } else { @@ -112,7 +118,9 @@ export function sortableDataFrame(data: DataFrame): DataFrame { } export function arrayDataFrame(data: Cells[]): DataFrame { - if (!data.length) return { header: [], numRows: 0, rows: () => [], getColumn: () => Promise.resolve([]) } + if (!(0 in data)) { + return { header: [], numRows: 0, rows: () => [], getColumn: () => Promise.resolve([]) } + } const header = Object.keys(data[0]) return { header, diff --git a/src/promise.ts b/src/promise.ts index 6674057c..147becfb 100644 --- a/src/promise.ts +++ b/src/promise.ts @@ -1,6 +1,6 @@ export type WrappedPromise = Promise & { resolved?: T - rejected?: Error + rejected?: any } /** @@ -14,7 +14,7 @@ export function wrapPromise(promise: Promise | T): WrappedPromise { const wrapped: WrappedPromise = promise.then(resolved => { wrapped.resolved = resolved return resolved - }).catch(rejected => { + }).catch((rejected: unknown) => { wrapped.rejected = rejected throw rejected }) @@ -23,7 +23,7 @@ export function wrapPromise(promise: Promise | T): WrappedPromise { export type ResolvablePromise = Promise & { resolve: (value: T) => void - reject: (error: Error) => void + reject: (reason?: any) => void } /** @@ -31,12 +31,14 @@ export type ResolvablePromise = Promise & { */ export function resolvablePromise(): ResolvablePromise { let resolve: (value: T) => void - let reject: (error: Error) => void + let reject: (reason?: any) => void const promise = wrapPromise(new Promise((res, rej) => { resolve = res reject = rej })) as ResolvablePromise + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion promise.resolve = resolve! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion promise.reject = reject! return promise } diff --git a/src/row.ts b/src/row.ts index 063396a4..99a18fd3 100644 --- a/src/row.ts +++ b/src/row.ts @@ -44,37 +44,45 @@ export function resolvableRow(header: string[]): ResolvableRow { */ export function asyncRows(rows: Promise, numRows: number, header: string[]): AsyncRow[] { // Make grid of resolvable promises - const wrapped = new Array(numRows).fill(null).map(_ => resolvableRow(header)) + const wrapped = new Array(numRows).fill(null).map(() => resolvableRow(header)) rows.then(rows => { if (rows.length !== numRows) { console.warn(`Expected ${numRows} rows, got ${rows.length}`) } - for (let i = 0; i < rows.length; i++) { - const row = rows[i] + rows.forEach((row, i) => { + const wrappedRow = wrapped[i] + if (wrappedRow === undefined) { + throw new Error(`Expected row ${i} to exist`) + } for (const key of header) { + const wrappedCell = wrappedRow.cells[key] + if (wrappedCell === undefined) { + throw new Error(`Expected cell ${key} to exist in row ${i}`) + } if (row.cells[key] instanceof Promise) { // each cell can reject or resolve - row.cells[key].then(cell => wrapped[i].cells[key].resolve(cell)).catch(error => wrapped[i].cells[key].reject(error)) + row.cells[key].then(cell => { wrappedCell.resolve(cell) }).catch(error => { wrappedCell.reject(error) }) } else { - wrapped[i].cells[key].resolve(row.cells[key]) + wrappedCell.resolve(row.cells[key]) } } if (row.index instanceof Promise) { // the index can reject or resolve - row.index.then(index => wrapped[i].index.resolve(index)).catch(error => wrapped[i].index.reject(error)) + row.index.then(index => { wrappedRow.index.resolve(index) }).catch(error => { wrappedRow.index.reject(error) }) } else { - wrapped[i].index.resolve(row.index) + wrappedRow.index.resolve(row.index) } } - }).catch(error => { + )}).catch(error => { // Reject all promises on error - for (let i = 0; i < numRows; i++) { - for (const key of header) { - wrapped[i].cells[key].reject(error) + wrapped.forEach((wrappedRow) => { + for (const promise of Object.values(wrappedRow.cells)) { + promise.reject(error) } - wrapped[i].index.reject(error) - } + wrappedRow.index.reject(error) + }) }) + return wrapped } diff --git a/src/rowCache.ts b/src/rowCache.ts index 5e9c9f9a..31eda1fb 100644 --- a/src/rowCache.ts +++ b/src/rowCache.ts @@ -8,7 +8,7 @@ import { AsyncRow } from './row.js' */ export function rowCache(df: DataFrame): DataFrame { // Row cache is stored as a sorted array of RowGroups, per sort order - const caches: {[key: string]: AsyncRow[]} = {} + const caches: Record = {} let hits = 0 let misses = 0 @@ -17,7 +17,11 @@ export function rowCache(df: DataFrame): DataFrame { ...df, rows({ start, end, orderBy }): AsyncRow[] { // Cache per sort order - const cache = caches[orderBy || ''] ||= new Array(df.numRows) + const key = orderBy ?? '' + const cache = caches[key] ?? new Array(df.numRows) + if (!(key in caches)) { + caches[key] = cache + } const n = hits + misses if (n && !(n % 10)) { console.log(`Cache hits: ${hits} / ${hits + misses} (${(100 * hits / (hits + misses)).toFixed(1)}%)`) @@ -44,7 +48,13 @@ export function rowCache(df: DataFrame): DataFrame { if (hasCacheMiss) misses++ else hits++ - return cache.slice(start, end) + return cache.slice(start, end).map(row => { + // Safety check, this should never happen, all the rows should be cached + if (!row) { + throw new Error('Row not cached') + } + return row + }) }, } } diff --git a/src/selection.ts b/src/selection.ts index 5bb9b4b5..27c69594 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -9,7 +9,7 @@ interface Range { start: number // inclusive lower limit, positive integer end: number // exclusive upper limit, positive integer, strictly greater than start (no zero-length ranges). } -export type Ranges = Array +export type Ranges = Range[] // TODO(SL): rename 'ranges' to 'selection' or something else, that does not disclose the implementation. // It would make it easier to switch to a Set for example, if needed @@ -37,7 +37,9 @@ export function areValidRanges(ranges: Ranges): boolean { return false } for (let i = 0; i < ranges.length - 1; i++) { - if (ranges[i].end >= ranges[i + 1].start) { + const range = ranges[i] + const nextRange = ranges[i + 1] + if (!range || !nextRange || range.end >= nextRange.start) { return false } } @@ -61,7 +63,7 @@ export function areAllSelected({ ranges, length }: { ranges: Ranges, length: num if (length && !isValidIndex(length)) { throw new Error('Invalid length') } - return ranges.length === 1 && ranges[0].start === 0 && ranges[0].end === length + return ranges.length === 1 && 0 in ranges && ranges[0].start === 0 && ranges[0].end === length } export function toggleAll({ ranges, length }: { ranges: Ranges, length: number }): Ranges { @@ -89,22 +91,40 @@ export function selectRange({ ranges, range }: { ranges: Ranges, range: Range }) let rangeIndex = 0 // copy the ranges before the new range - while (rangeIndex < ranges.length && ranges[rangeIndex].end < start) { - newRanges.push({ ...ranges[rangeIndex] }) + while (rangeIndex < ranges.length) { + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') + } + if (currentRange.end >= start) { + break + } + newRanges.push({ ...currentRange }) rangeIndex++ } // merge with the new range - while (rangeIndex < ranges.length && ranges[rangeIndex].start <= end) { - range.start = Math.min(range.start, ranges[rangeIndex].start) - range.end = Math.max(range.end, ranges[rangeIndex].end) + while (rangeIndex < ranges.length) { + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') + } + if (currentRange.start > end) { + break + } + range.start = Math.min(range.start, currentRange.start) + range.end = Math.max(range.end, currentRange.end) rangeIndex++ } newRanges.push(range) // copy the remaining ranges while (rangeIndex < ranges.length) { - newRanges.push({ ...ranges[rangeIndex] }) + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') + } + newRanges.push({ ...currentRange }) rangeIndex++ } @@ -127,25 +147,43 @@ export function unselectRange({ ranges, range }: { ranges: Ranges, range: Range let rangeIndex = 0 // copy the ranges before the new range - while (rangeIndex < ranges.length && ranges[rangeIndex].end < start) { - newRanges.push({ ...ranges[rangeIndex] }) + while (rangeIndex < ranges.length) { + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') + } + if (currentRange.end >= start) { + break + } + newRanges.push({ ...currentRange }) rangeIndex++ } // split the ranges intersecting with the new range - while (rangeIndex < ranges.length && ranges[rangeIndex].start < end) { - if (ranges[rangeIndex].start < start) { - newRanges.push({ start: ranges[rangeIndex].start, end: start }) + while (rangeIndex < ranges.length) { + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') } - if (ranges[rangeIndex].end > end) { - newRanges.push({ start: end, end: ranges[rangeIndex].end }) + if (currentRange.start >= end) { + break + } + if (currentRange.start < start) { + newRanges.push({ start: currentRange.start, end: start }) + } + if (currentRange.end > end) { + newRanges.push({ start: end, end: currentRange.end }) } rangeIndex++ } // copy the remaining ranges while (rangeIndex < ranges.length) { - newRanges.push({ ...ranges[rangeIndex] }) + const currentRange = ranges[rangeIndex] + if (!currentRange) { + throw new Error('Invalid range') + } + newRanges.push({ ...currentRange }) rangeIndex++ } @@ -221,7 +259,7 @@ export async function getSortIndex({ data, column }: { data: DataFrame, column: if (dataIndexes.length !== numRows) { throw new Error('Invalid sort index length') } - const tableIndexes = Array(numRows).fill(-1) + const tableIndexes = Array(numRows).fill(-1) for (let i = 0; i < numRows; i++) { const dataIndex = dataIndexes[i] if (dataIndex === undefined) { @@ -273,7 +311,7 @@ export function getDataIndex({ sortIndex, tableIndex }: {sortIndex: SortIndex, t */ export function getTableIndex({ sortIndex, dataIndex }: {sortIndex: SortIndex, dataIndex: number}): number { const tableIndex = sortIndex.tableIndexes[dataIndex] - if (tableIndex === -1) { + if (tableIndex === -1 || tableIndex === undefined) { throw new Error('Data index not found in the data frame') } return tableIndex @@ -312,7 +350,7 @@ export function toTableSelection({ selection, column, data, sortIndex }: { selec if (ranges.length === 0) { // empty selection tableRanges = [] - } else if (ranges.length === 1 && ranges[0].start === 0 && ranges[0].end === numRows) { + } else if (ranges.length === 1 && 0 in ranges && ranges[0].start === 0 && ranges[0].end === numRows) { // all rows selected tableRanges = [{ start: 0, end: numRows }] } else { @@ -362,7 +400,7 @@ export function toDataSelection({ selection, column, data, sortIndex }: { select if (ranges.length === 0) { // empty selection dataRanges = [] - } else if (ranges.length === 1 && ranges[0].start === 0 && ranges[0].end === numRows) { + } else if (ranges.length === 1 && 0 in ranges && ranges[0].start === 0 && ranges[0].end === numRows) { // all data selected dataRanges = [{ start: 0, end: numRows }] } else { diff --git a/test/HighTable.test.tsx b/test/HighTable.test.tsx index 215b7691..565d297c 100644 --- a/test/HighTable.test.tsx +++ b/test/HighTable.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, waitFor, within } from '@testing-library/react' +import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DataFrame, sortableDataFrame } from '../src/dataframe.js' import HighTable from '../src/HighTable.js' @@ -11,7 +12,7 @@ const data: DataFrame = { rows: ({ start, end }) => Array.from({ length: end - start }, (_, index) => ({ index: wrapPromise(index + start), cells: { - ID: wrapPromise('row ' + (index + start)), + ID: wrapPromise(`row ${index + start}`), Count: wrapPromise(1000 - start - index), }, })), @@ -23,7 +24,7 @@ const otherData: DataFrame = { rows: ({ start, end }) => Array.from({ length: end - start }, (_, index) => ({ index: wrapPromise(index + start), cells: { - ID: wrapPromise('other ' + (index + start)), + ID: wrapPromise(`other ${index + start}`), Count: wrapPromise(1000 - start - index), }, })), @@ -33,11 +34,11 @@ describe('HighTable', () => { const mockData = { header: ['ID', 'Name', 'Age'], numRows: 100, - rows: vi.fn(({ start, end }) => Array.from({ length: end - start }, (_, index) => ({ + rows: vi.fn(({ start, end }: { start: number, end: number }) => Array.from({ length: end - start }, (_, index) => ({ index: wrapPromise(index + start), cells: { ID: wrapPromise(index + start), - Name: wrapPromise('Name ' + (index + start)), + Name: wrapPromise(`Name ${index + start}`), Age: wrapPromise(20 + index % 50), }, })) @@ -127,7 +128,6 @@ describe('HighTable', () => { }) }) - describe('When sorted, HighTable', () => { function checkRowContents(row: HTMLElement, rowNumber: string, ID: string, Count: string) { const selectionCell = within(row).getByRole('rowheader') @@ -385,7 +385,6 @@ describe('in controlled selection state, read-only (selection prop), ', () => { }) it('click on a row number cell does nothing', async () => { - const start = 2 const selection = { ranges: [] } const { user, findByRole, queryByRole } = render() // await because we have to wait for the data to be fetched first @@ -394,7 +393,10 @@ describe('in controlled selection state, read-only (selection prop), ', () => { const rowHeader = cell.closest('[role="row"]')?.querySelector('[role="rowheader"]') expect(rowHeader).not.toBeNull() await act(async () => { - rowHeader && await user.click(rowHeader) + if (!rowHeader) { + throw new Error('rowHeader should be defined') + } + await user.click(rowHeader) }) expect(queryByRole('row', { selected: true })).toBeNull() }) @@ -406,7 +408,6 @@ describe('in uncontrolled selection state (onSelection prop), ', () => { }) it('HighTable shows no selection initially and onSelectionChange is not called', async () => { - const start = 2 const onSelectionChange = vi.fn() const { findByRole, queryByRole } = render() // await because we have to wait for the data to be fetched first @@ -461,7 +462,7 @@ describe('in uncontrolled selection state (onSelection prop), ', () => { rerender() // await again, since we have to wait for the new data to be fetched - const other = await findByRole('cell', { name: 'other 2' }) + await findByRole('cell', { name: 'other 2' }) expect(queryByRole('cell', { name: 'row 2' })).toBeNull() expect(queryByRole('row', { selected: true })).toBeNull() expect(onSelectionChange).toHaveBeenCalledWith({ ranges: [] }) diff --git a/test/TableHeader.test.tsx b/test/TableHeader.test.tsx index 947593ee..855c8f30 100644 --- a/test/TableHeader.test.tsx +++ b/test/TableHeader.test.tsx @@ -1,4 +1,5 @@ import { waitFor } from '@testing-library/react' +import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TableHeader, { ColumnWidth, cellStyle, saveColumnWidth } from '../src/TableHeader.js' import { render } from './userEvent.js' @@ -6,7 +7,7 @@ import { render } from './userEvent.js' vi.stubGlobal('localStorage', (() => { let store: Record = {} return { - getItem: vi.fn((key: string) => store[key] || null), + getItem: vi.fn((key: string) => store[key] ?? null), setItem: vi.fn((key: string, value: string) => { store[key] = value }), clear: () => { store = {} }, } @@ -19,7 +20,7 @@ function mockColumnWidths() { setColumnWidth: vi.fn((columnIndex: number, columnWidth: number | undefined) => { obj.columnWidths[columnIndex] = columnWidth }), - setColumnWidths: vi.fn((columnWidths: Array) => { + setColumnWidths: vi.fn((columnWidths: (number | undefined)[]) => { obj.columnWidths = columnWidths }), } diff --git a/test/columnWidths.test.ts b/test/columnWidths.test.ts index 45f4d205..c153c8e2 100644 --- a/test/columnWidths.test.ts +++ b/test/columnWidths.test.ts @@ -4,7 +4,7 @@ import { loadColumnWidths, saveColumnWidth } from '../src/TableHeader.js' vi.stubGlobal('localStorage', (() => { let store: Record = {} return { - getItem: (key: string) => store[key] || null, + getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { store[key] = value }, clear: () => { store = {} }, } diff --git a/test/dataframe.test.ts b/test/dataframe.test.ts index 34d41b67..fd191aac 100644 --- a/test/dataframe.test.ts +++ b/test/dataframe.test.ts @@ -190,8 +190,7 @@ describe('arrayDataFrame', () => { if (!df.getColumn) { throw new Error('getColumn not defined') } - df.getColumn({ column: 'invalid' }) + void df.getColumn({ column: 'invalid' }) }).toThrowError('Invalid column: invalid') }) }) - diff --git a/test/hooks.test.ts b/test/hooks.test.ts index 15825790..72cc5688 100644 --- a/test/hooks.test.ts +++ b/test/hooks.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useInputState } from '../src/hooks.js' describe('in controlled mode (value is defined), ', () => { - const value: string = 'value' + const value = 'value' const onChange = vi.fn() const defaultValue = 'default' const newValue = 'new value' @@ -67,7 +67,7 @@ describe('in controlled mode (value is defined), ', () => { }) describe('in uncontrolled mode (value is undefined), ', () => { - const onChange = vi.fn((_: string) => {}) + const onChange = vi.fn() const value = 'value' const defaultValue = 'default' const newValue = 'new value' @@ -125,4 +125,3 @@ describe('in uncontrolled mode (value is undefined), ', () => { expect(result.current.value).toBe(undefined) }) }) - diff --git a/test/rowCache.test.ts b/test/rowCache.test.ts index c6b0e108..2911e27a 100644 --- a/test/rowCache.test.ts +++ b/test/rowCache.test.ts @@ -8,7 +8,7 @@ function makeDf() { return { header: ['id'], numRows: 10, - rows: vi.fn(({ start, end }): AsyncRow[] => + rows: vi.fn(({ start, end }: {start: number, end: number}): AsyncRow[] => new Array(end - start) .fill(null) .map((_, index) => ({ diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 00000000..b4ead5de --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "test", "**/*.js", "**/*.ts", "**/*.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json index ac3956ab..156fa6a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,28 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": true, - "declaration": true, - "module": "nodenext", - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": false, - "strict": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "target": "esnext", - "outDir": "dist", - "jsx": "react-jsx" + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true, + + "rootDir": "src", + "outDir": "dist/types", + "emitDeclarationOnly": true, + "declaration": true }, - "include": ["src", "test"] + "include": ["src"] } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..4df3288d --- /dev/null +++ b/vite.config.js @@ -0,0 +1,36 @@ +/// +import react from '@vitejs/plugin-react' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +const __dirname = dirname (fileURLToPath (import.meta.url )) + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: resolve (__dirname, 'src/HighTable.js'), + formats: ['es'], + name: 'HighTable', + // the proper extensions will be added + fileName: 'HighTable', + }, + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled + // into your library + external: ['react', 'react/jsx-runtime', 'react-dom'], + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: { + react: 'React', + 'react/jsx-runtime': 'jsx', + 'react-dom': 'ReactDOM', + }, + }, + }, + sourcemap: true, + }, + test: { environment: 'jsdom', globals: true }, +}) diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index 2a2cda2f..00000000 --- a/vitest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - environment: 'jsdom', - globals: true, - }, -})