diff --git a/CHANGELOG.md b/CHANGELOG.md index a11a9777..f05a7176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,17 @@ ### Changed -- **Breaking** the `OrderBy` type is now an array of column sorts: `{ column: string; direction: 'ascending' | 'descending' }[]`. If empty, the data is not sorted. If it contains one element, the data is sorted along the column, in the specified direction. If it contains multiple elements, the first column is used to sort, then the second one is used for the rows with the same value, and so on ([#67](https://github.com/hyparam/hightable/pull/67), [#68](https://github.com/hyparam/hightable/pull/68)). +- **Breaking** the `OrderBy` type is now an array of column sorts: `{ column: string; direction: 'ascending' | 'descending' }[]`. If empty, the data is not sorted. If it contains one element, the data is sorted along the column, in the specified direction. If it contains multiple elements, the first column is used to sort, then the second one is used to handle the ties, and so on ([#67](https://github.com/hyparam/hightable/pull/67), [#68](https://github.com/hyparam/hightable/pull/68), [#69](https://github.com/hyparam/hightable/pull/69)). - **Breaking** the `orderBy` property in `rows` method uses the new `OrderBy` type. If `data.sortable` is `true`, the data frame is able to sort along the columns as described above. - **Breaking** the `orderBy` property in `HighTable` and `TableHeader` uses the new `OrderBy` type. - **Breaking** the `onOrderByChange` property in `HighTable` and `TableHeader` that takes the new `OrderBy` argument. -- **Breaking** successive clicks on a column header follow a new behavior: instead of toggling between ascending sort and no sort, it now cycles through ascending, descending, and no sort ([#68](https://github.com/hyparam/hightable/pull/68)). +- **Breaking** click on a column header has a new behavior: it sorts along that column first, and uses the other columns of `orderBy` as secondary sorts. If the column was already the first column, it follows the cycle ascending -> descending -> no sort ([#69](https://github.com/hyparam/hightable/pull/69)). - **Breaking** the top left cell of the table now handles the checkbox to select all the rows (and the absolutely positioned div is removed). It can affect overriden CSS ([#70](https://github.com/hyparam/hightable/pull/70)). - **Breaking** all CSS classes have been removed. Use the `className` prop to apply custom styles ([#75](https://github.com/hyparam/hightable/pull/75)). - changed the format of the keys in local storage when storing the column widths. Each column now has its own key ([#71](https://github.com/hyparam/hightable/pull/71)). - split the CSS styles into mandatory functional styles and optional theme styles ([#75](https://github.com/hyparam/hightable/pull/75)). - the selection checkboxes are now disabled while the data is being loaded ([#77](https://github.com/hyparam/hightable/pull/77)). +- sortableDataFrame now supports sorting along multiple columns ([#69](https://github.com/hyparam/hightable/pull/69)). ### Refactored diff --git a/src/components/HighTable/HighTable.tsx b/src/components/HighTable/HighTable.tsx index cf1cbd44..61dad798 100644 --- a/src/components/HighTable/HighTable.tsx +++ b/src/components/HighTable/HighTable.tsx @@ -1,7 +1,7 @@ import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DataFrame } from '../../helpers/dataframe.js' import { PartialRow } from '../../helpers/row.js' -import { Selection, SortIndex, areAllSelected, isSelected, toggleAll, toggleIndexInSelection, toggleRangeInSelection, toggleRangeInTable } from '../../helpers/selection.js' +import { Selection, areAllSelected, isSelected, toggleAll, toggleIndexInSelection, toggleRangeInSelection, toggleRangeInTable } from '../../helpers/selection.js' import { OrderBy, areEqualOrderBy } from '../../helpers/sort.js' import { cellStyle } from '../../helpers/width.js' import { useInputState } from '../../hooks/useInputState.js' @@ -93,7 +93,7 @@ export default function HighTable({ // TODO(SL): remove this state and only rely on the data frame for these operations? // ie. cache the previous sort indexes in the data frame itself - const [sortIndexes, setSortIndexes] = useState>(() => new Map()) + const [ranksMap, setRanksMap] = useState>>(() => new Map()) // Sorting is disabled if the data is not sortable const { @@ -159,8 +159,8 @@ export default function HighTable({ tableIndex, orderBy, data, - sortIndexes, - setSortIndexes, + ranksMap, + setRanksMap, }) if (requestId === pendingSelectionRequest.current) { // only update the selection if the request is still the last one @@ -170,7 +170,7 @@ export default function HighTable({ return (event: MouseEvent): void => { void onSelectRowClick(event) } - }, [onSelectionChange, selection, data, orderBy, sortIndexes]) + }, [data, onSelectionChange, orderBy, ranksMap, selection]) const allRowsSelected = useMemo(() => { if (!selection) return false const { ranges } = selection @@ -198,7 +198,7 @@ export default function HighTable({ // reset the flag, the column widths will be recalculated setHasCompleteRow(false) // delete the cached sort indexes - setSortIndexes(new Map()) + setRanksMap(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 }) diff --git a/src/helpers/dataframe.ts b/src/helpers/dataframe.ts index d6e10beb..7b0af898 100644 --- a/src/helpers/dataframe.ts +++ b/src/helpers/dataframe.ts @@ -55,17 +55,61 @@ export function getGetColumn(data: DataFrame): GetColumn { } } -export async function getColumnIndex({ data, column }: {data: DataFrame, column: string}): Promise { +// return the column ranks in ascending order +// we can get the descending order replacing the rank with numRows - rank - 1. It's not exactly the rank of +// the descending order, because the rank is the first, not the last, of the ties. But it's enough for the +// purpose of sorting. +export async function getRanks({ data, column }: {data: DataFrame, column: string}): Promise { if (!data.header.includes(column)) { throw new Error(`Invalid column: ${column}`) } const getColumn = getGetColumn(data) - const values = await getColumn({ column }) - return Array.from(values.keys()).sort((a, b) => { - if (values[a] < values[b]) return -1 - if (values[a] > values[b]) return 1 + const valuesWithIndex = (await getColumn({ column })).map((value, index) => ({ value, index })) + const sortedValuesWithIndex = Array.from(valuesWithIndex).sort(({ value: a }, { value: b }) => { + if (a < b) return -1 + if (a > b) return 1 return 0 }) + const numRows = sortedValuesWithIndex.length + const ascendingRanks = sortedValuesWithIndex.reduce(({ lastValue, lastRank, ranks }, { value, index }, rank) => { + if (value === lastValue) { + ranks[index] = lastRank + return { ranks, lastValue, lastRank } + } else { + ranks[index] = rank + return { ranks, lastValue: value, lastRank: rank } + } + }, { ranks: Array(numRows).fill(-1), lastValue: undefined, lastRank: 0 }).ranks + return ascendingRanks +} + +export function computeDataIndexes(orderBy: { direction: 'ascending' | 'descending', ranks: number[] }[]): number[] { + if (!(0 in orderBy)) { + throw new Error('orderBy should have at least one element') + } + const numRows = orderBy[0].ranks.length + const indexes = Array.from({ length: numRows }, (_, i) => i) + const dataIndexes = indexes.sort((a, b) => { + for (const { direction, ranks } of orderBy) { + const rankA = ranks[a] + const rankB = ranks[b] + if (rankA === undefined || rankB === undefined) { + throw new Error('Invalid ranks') + } + const value = direction === 'ascending' ? 1 : -1 + if (rankA < rankB) return -value + if (rankA > rankB) return value + } + return 0 + }) + // dataIndexes[0] gives the index of the first row in the sorted table + return dataIndexes +} + +export function getUnsortedRanks({ data }: { data: DataFrame }): Promise { + const { numRows } = data + const ranks = Array.from({ length: numRows }, (_, i) => i) + return Promise.resolve(ranks) } /** @@ -86,33 +130,32 @@ export async function getColumnIndex({ data, column }: {data: DataFrame, column: export function sortableDataFrame(data: DataFrame): DataFrame { if (data.sortable) return data // already sortable - const indexesByColumn = new Map>() + // TODO(SL): call addGetColumn() to cache the rows if needed + // TODO(SL): create another type (DataFrameWithRanks?) that provides the cached ranks (and/or the cached data indexes for a given orderBy) + + const ranksByColumn = new Map>() return { ...data, rows({ start, end, orderBy }): AsyncRow[] { if (orderBy && orderBy.length > 0) { - if (!(0 in orderBy)) { - throw new Error('orderBy should have at least one element') - } - // TODO(SL): support multiple columns - const { column, direction } = orderBy[0] - if (!data.header.includes(column)) { - throw new Error(`Invalid orderBy field: ${column}`) - } - const columnIndexes = indexesByColumn.get(column) ?? getColumnIndex({ data, column }) - if (!indexesByColumn.has(column)) { - indexesByColumn.set(column, columnIndexes) + if (orderBy.some(({ column }) => !data.header.includes(column)) ){ + throw new Error(`Invalid orderBy field: ${orderBy.map(({ column }) => column).join(', ')}`) } - const indexesSlice = columnIndexes.then(indexes => { - if (direction === 'ascending') { - return indexes.slice(start, end) - } else { - // descending order - const newStart = data.numRows - end - const newEnd = data.numRows - start - return indexes.slice(newStart, newEnd).reverse() + // TODO(SL): only fetch ranks if needed? + // To get a consistent order in case of ties, we append a fake column orderby, to sort by the ascending indexes of the rows in the last case + const orderByWithDefaultSort = [...orderBy, { column: '', direction: 'ascending' as const }] + const orderByWithRanks = orderByWithDefaultSort.map(async ({ column, direction }) => { + const ranksPromise = ranksByColumn.get(column) ?? (column === '' ? getUnsortedRanks({ data }) : getRanks({ data, column })) + if (!ranksByColumn.has(column)) { + ranksByColumn.set(column, ranksPromise) } + const ranks = await ranksPromise + return { column, direction, ranks } }) + // We cannot slice directly, because columns can have ties in the borders of the slice + // TODO(SL): avoid sorting along the whole columns, maybe sort only the slice, and expand if needed + const indexes = Promise.all(orderByWithRanks).then(computeDataIndexes) + const indexesSlice = indexes.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? // if so: maybe the 'reverse' above should be done after fetching the rows diff --git a/src/helpers/selection.ts b/src/helpers/selection.ts index aa958063..c6155d66 100644 --- a/src/helpers/selection.ts +++ b/src/helpers/selection.ts @@ -1,5 +1,5 @@ -import { DataFrame, getColumnIndex } from './dataframe.js' -import { Direction, OrderBy } from './sort.js' +import { DataFrame, computeDataIndexes, getRanks, getUnsortedRanks } from './dataframe.js' +import { OrderBy } from './sort.js' /** * A selection is modelled as an array of ordered and non-overlapping ranges. @@ -242,109 +242,59 @@ export function toggleRangeInSelection({ selection, index }: { selection: Select return { ranges: extendFromAnchor({ ranges: selection.ranges, anchor: selection.anchor, index }), anchor: selection.anchor } } -export interface SortIndex { - column: string - dataIndexes: number[] // TODO(SL) use a typed array? - tableIndexes: number[] // TODO(SL) use a typed array? -} - /** - * Get the sort index of the data frame, for a given order. + * Compute the table indexes from the data indexes. * - * @param {Object} params - * @param {DataFrame} params.data - The data frame. - * @param {string} params.column - The column name. + * @param {number[]} permutationIndexes - The data frame index of each row of the sorted table (dataIndexes[tableIndex] = dataIndex). * - * @returns {Promise} A Promise to the sort index. + * @returns {number[]} The index of each row in the sorted table (tableIndexes[dataIndex] = tableIndex). */ -export async function getSortIndex({ data, column }: { data: DataFrame, column: string }): Promise { - // TODO(SL): rename as fetch/compute instead of get, to make it clear it's async - const { header, numRows } = data - if (!header.includes(column)) { - throw new Error('orderBy column is not in the data frame') - } - const dataIndexes = await getColumnIndex({ data, column }) - if (dataIndexes.length !== numRows) { - throw new Error('Invalid sort index length') - } - const tableIndexes = Array(numRows).fill(-1) - for (let i = 0; i < numRows; i++) { - const dataIndex = dataIndexes[i] - if (dataIndex === undefined) { - throw new Error('Data index not found in the data frame') +export function invertPermutationIndexes(permutationIndexes: number[]): number[] { + const numIndexes = permutationIndexes.length + const invertedIndexes = Array(numIndexes).fill(-1) + permutationIndexes.forEach((index, invertedIndex) => { + if (index < 0 || index >= numIndexes) { + throw new Error('Invalid index: out of bounds') } - if (typeof dataIndex !== 'number') { - throw new Error('Invalid data index: not a number') + if (!Number.isInteger(index)) { + throw new Error('Invalid index: not an integer') } - if (dataIndex < 0 || dataIndex >= numRows) { - throw new Error('Invalid data index: out of bounds') + if (invertedIndexes[index] !== -1) { + throw new Error('Duplicate index') } - if (tableIndexes[dataIndex] !== -1) { - throw new Error('Duplicate data index') - } - tableIndexes[dataIndex] = i - } - // check if there are missing indexes - if (tableIndexes.some(index => index === -1)) { - throw new Error('Missing indexes in the sort index') - } - return { column, dataIndexes, tableIndexes } + invertedIndexes[index] = invertedIndex + }) + return invertedIndexes } /** - * Convert a table index to a data index, using the sort index. + * Get an element from an array, or raise if it's outside of the range. * * @param {Object} params - * @param {SortIndex} params.sortIndex - The sort index. - * @param {number} params.tableIndex - The index of the row in the sorted table. - * @param {Direction} params.direction - The direction of the sort. + * @param {number} params.index - The index of the element. + * @param {T[]} params.array - The array of elements (array[index] = element). * - * @returns {number} The index of the row in the data frame. + * @returns {T} The element. */ -export function getDataIndex({ sortIndex, tableIndex, direction }: {sortIndex: SortIndex, tableIndex: number, direction: Direction}): number { - const index = direction === 'ascending' ? tableIndex : sortIndex.tableIndexes.length - tableIndex - 1 - const dataIndex = sortIndex.dataIndexes[index] - if (dataIndex === undefined) { - throw new Error('Table index not found in the data frame') - } - return dataIndex -} - -/** - * Convert a data index to a table index, using the sort index. - * - * @param {Object} params - * @param {SortIndex} params.sortIndex - The sort index. - * @param {number} params.dataIndex - The index of the row in the data frame. - * @param {Direction} params.direction - The direction of the sort. - * - * @returns {number} The index of the row in the sorted table. - */ -export function getTableIndex({ sortIndex, dataIndex, direction }: {sortIndex: SortIndex, dataIndex: number, direction: Direction}): number { - const tableIndex = sortIndex.tableIndexes[dataIndex] - if (tableIndex === -1 || tableIndex === undefined) { +export function getElement({ index, array }: {index: number, array: T[]}): T { + const element = array[index] + if (element === undefined) { throw new Error('Data index not found in the data frame') } - return direction === 'ascending' ? tableIndex : sortIndex.tableIndexes.length - tableIndex - 1 + return element } /** - * Convert from a selection of data indexes to a selection of table indexes. - * - * Data indexes: the indexes of the selected rows in the data frame. - * Table indexes: the indexes of the selected rows in the sorted table. + * Convert a selection between two domains, using a permutation array. * * @param {Object} params - * @param {Selection} params.selection - The selection of data indexes. - * @param {string} params.column - The column to sort the rows along. - * @param {DataFrame} params.data - The data frame. - * @param {SortIndex} params.sortIndex - The sort index of the data frame for the column. - * @param {Direction} params.direction - The direction of the sort. + * @param {Selection} params.selection - A selection of indexes. + * @param {number[]} params.permutationIndexes - An array that maps every index to another index (permutationIndexes[index] = permutedIndex). * - * @returns {Promise} A Promise to the selection of table indexes. + * @returns {Selection} A selection of permuted indexes. */ -export function toTableSelection({ selection, column, data, sortIndex, direction }: { selection: Selection, column: string, data: DataFrame, sortIndex: SortIndex, direction: Direction }): Selection { - const { header, numRows, sortable } = data +export function convertSelection({ selection, permutationIndexes }: { selection: Selection, permutationIndexes: number[] }): Selection { + const numElements = permutationIndexes.length const { ranges, anchor } = selection if (!areValidRanges(selection.ranges)) { throw new Error('Invalid ranges') @@ -352,80 +302,24 @@ export function toTableSelection({ selection, column, data, sortIndex, direction if (anchor !== undefined && !isValidIndex(anchor)) { throw new Error('Invalid anchor') } - if (!header.includes(column)) { - throw new Error('orderBy column is not in the data frame') - } - if (column && !sortable) { - throw new Error('Data frame is not sortable') - } - let tableRanges: Ranges = [] + let nextRanges: Ranges = [] if (ranges.length === 0) { // empty selection - tableRanges = [] - } else if (ranges.length === 1 && 0 in ranges && ranges[0].start === 0 && ranges[0].end === numRows) { + nextRanges = [] + } else if (ranges.length === 1 && 0 in ranges && ranges[0].start === 0 && ranges[0].end === numElements) { // all rows selected - tableRanges = [{ start: 0, end: numRows }] + nextRanges = [{ start: 0, end: numElements }] } else { // naive implementation, could be optimized for (const range of ranges) { const { start, end } = range - for (let dataIndex = start; dataIndex < end; dataIndex++) { - tableRanges = selectIndex({ ranges: tableRanges, index: getTableIndex({ sortIndex, dataIndex, direction }) }) + for (let index = start; index < end; index++) { + nextRanges = selectIndex({ ranges: nextRanges, index: getElement({ index, array: permutationIndexes }) }) } } } - const anchorTableIndex = anchor !== undefined ? getTableIndex({ sortIndex, dataIndex: anchor, direction }) : undefined - return { ranges: tableRanges, anchor: anchorTableIndex } -} - -/** - * Convert from a selection of table indexes to a selection of data indexes. - * - * Table indexes: the indexes of the selected rows in the sorted table. - * Data indexes: the indexes of the selected rows in the data frame. - * - * @param {Object} params - * @param {Selection} params.selection - The selection of table indexes. - * @param {string} params.column - The column to sort the rows along. - * @param {DataFrame} params.data - The data frame. - * @param {SortIndex} params.sortIndex - The sort index of the data frame for the column. - * @param {Direction} params.direction - The direction of the sort. - * - * @returns {Promise} A Promise to the selection of data indexes. - */ -export function toDataSelection({ selection, column, data, sortIndex, direction }: { selection: Selection, column: string, data: DataFrame, sortIndex: SortIndex, direction: Direction }): Selection { - const { header, numRows, sortable } = data - const { ranges, anchor } = selection - if (!areValidRanges(selection.ranges)) { - throw new Error('Invalid ranges') - } - if (anchor !== undefined && !isValidIndex(anchor)) { - throw new Error('Invalid anchor') - } - if (column && !header.includes(column)) { - throw new Error('orderBy column is not in the data frame') - } - if (column && !sortable) { - throw new Error('Data frame is not sortable') - } - - let dataRanges: Ranges = [] - if (ranges.length === 0) { - // empty selection - dataRanges = [] - } 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 { - // naive implementation, could be optimized - for (const range of ranges) { - for (let tableIndex = range.start; tableIndex < range.end; tableIndex++) { - dataRanges = selectIndex({ ranges: dataRanges, index: getDataIndex({ sortIndex, tableIndex, direction }) }) - } - } - } - const anchorIndex = anchor !== undefined ? getDataIndex({ sortIndex, tableIndex: anchor, direction }) : undefined - return { ranges: dataRanges, anchor: anchorIndex } + const nextAnchor = anchor !== undefined ? getElement({ index: anchor, array: permutationIndexes }) : undefined + return { ranges: nextRanges, anchor: nextAnchor } } /** @@ -438,41 +332,49 @@ export function toDataSelection({ selection, column, data, sortIndex, direction * which requires the sort index of the data frame. If not available, it must be computed, which is * an async operation that can be expensive. * - * TODO(SL): add typescript overloads for the function to make it clear which parameters work together? - * * @param {Object} params * @param {number} params.tableIndex - The index of the row in the table (table domain, sorted row indexes). * @param {Selection} params.selection - The current selection state (data domain, row indexes). * @param {OrderBy} params.orderBy - The order if the rows are sorted. * @param {DataFrame} params.data - The data frame. - * @param {Map | undefined} params.sortIndexes - The map of sort indexes for each column of the data frame for the column (they can be missing, if so thay will be populated in this function). - * @param {function | undefined} params.setSortIndexes - A function to update the map of sort indexes. + * @param {Map} params.ranks - The map of ranks for each column of the data frame (they can be missing, if so thay will be populated in this function). + * @param {function} params.setColumnRanks - A function to update the map of column ranks. */ export async function toggleRangeInTable({ tableIndex, selection, orderBy, data, - sortIndexes: cachedSortIndexes, - setSortIndexes, -}: { tableIndex: number, selection: Selection, orderBy: OrderBy, data: DataFrame, sortIndexes?: Map, setSortIndexes?: (sortIndexes: Map) => void }): Promise { + ranksMap, + setRanksMap, +}: { tableIndex: number, selection: Selection, orderBy: OrderBy, data: DataFrame, ranksMap: Map>, setRanksMap: (setter: (ranksMap: Map>) => Map>) => void }): Promise { // Extend the selection from the anchor to the index with sorted data // Convert the indexes to work in the data domain before converting back. if (!data.sortable) { throw new Error('Data frame is not sortable') } - if (!(0 in orderBy)) { - throw new Error('orderBy should have at least one element') - } - // TODO(SL): support multiple columns - const { column, direction } = orderBy[0] - const sortIndex = cachedSortIndexes?.get(column) ?? await getSortIndex({ data, column }) - if (setSortIndexes && !cachedSortIndexes?.has(column)) { - setSortIndexes(new Map(cachedSortIndexes).set(column, sortIndex)) - } - const tableSelection = toTableSelection({ selection, column, data, sortIndex, direction }) + const orderByWithDefaultSort = [...orderBy, { column: '', direction: 'ascending' as const }] + const orderByWithRanksPromises = orderByWithDefaultSort.map(({ column, direction }) => { + return { + column, + direction, + ranks: ranksMap.get(column) ?? (column === '' ? getUnsortedRanks({ data }) : getRanks({ data, column })), + } + }) + if (orderByWithRanksPromises.some(({ column }) => !ranksMap.has(column))) { + setRanksMap(ranksMap => { + const nextRanksMap = new Map(ranksMap) + orderByWithRanksPromises.forEach(({ column, ranks }) => nextRanksMap.set(column, ranks)) + return nextRanksMap + }) + } + const orderByWithRanks = await Promise.all(orderByWithRanksPromises.map(async ({ column, direction, ranks }) => ({ column, direction, ranks: await ranks }))) + + const dataIndexes = computeDataIndexes(orderByWithRanks) + const tableIndexes = invertPermutationIndexes(dataIndexes) + const tableSelection = convertSelection({ selection, permutationIndexes: tableIndexes }) const { ranges, anchor } = tableSelection const newTableSelection = { ranges: extendFromAnchor({ ranges, anchor, index: tableIndex }), anchor } - const newDataSelection = toDataSelection({ selection: newTableSelection, column, data, sortIndex, direction }) + const newDataSelection = convertSelection({ selection: newTableSelection, permutationIndexes: dataIndexes }) return newDataSelection } diff --git a/src/helpers/sort.ts b/src/helpers/sort.ts index 75b6c712..82d5be8d 100644 --- a/src/helpers/sort.ts +++ b/src/helpers/sort.ts @@ -33,24 +33,17 @@ export function partitionOrderBy(orderBy: OrderBy, column: string): {prefix: Ord } export function toggleColumn(column: string, orderBy: OrderBy): OrderBy { - const { item } = partitionOrderBy(orderBy, column) - if (!item) { - // TODO(SL): when multiple columns are not supported yet, append the new column with ascending to the current orderBy - // return [...orderBy, { column, direction: 'ascending' }] - // for now: remove the existing columns and only sort by the new column - // none -> ascending - return [{ column, direction: 'ascending' }] - } else if (item.direction === 'ascending') { - // TODO(SL): when multiple columns are not supported yet, replace the column with descending - // return [...prefix, { column, direction: 'descending' }, ...suffix] - // for now: remove the existing columns and only sort by the new column - // ascending -> descending - return [{ column, direction: 'descending' }] - } else { - // TODO(SL): when multiple columns are not supported yet, remove the column - // return [...prefix, ...suffix] - // for now: return an empty array - // descending -> none - return [] + const { prefix, item, suffix } = partitionOrderBy(orderBy, column) + if (item && prefix.length === 0) { + // the column is the principal column. Cycle through the directions: ascending -> descending -> none + if (item.direction === 'ascending') { + // ascending -> descending + return [{ column, direction: 'descending' }, ...suffix] + } else { + // descending -> none + return [ ...suffix] + } } + // the column is not the principal column. Set it as the principal column with ascending direction + return [{ column, direction: 'ascending' }, ...prefix, ...suffix] } diff --git a/test/components/TableHeader/TableHeader.test.tsx b/test/components/TableHeader/TableHeader.test.tsx index 8a02708a..86cc2bec 100644 --- a/test/components/TableHeader/TableHeader.test.tsx +++ b/test/components/TableHeader/TableHeader.test.tsx @@ -71,7 +71,7 @@ describe('TableHeader', () => { expect(onOrderByChange).toHaveBeenCalledWith([]) }) - it('changes orderBy to a new column when a different header is clicked', async () => { + it('prepends a new column with ascending order to orderBy when a different header is clicked', async () => { const onOrderByChange = vi.fn() const { user, getByText } = render( { const addressHeader = getByText('Address') await user.click(addressHeader) - expect(onOrderByChange).toHaveBeenCalledWith([{ column: 'Address', direction: 'ascending' }]) + expect(onOrderByChange).toHaveBeenCalledWith([{ column: 'Address', direction: 'ascending' }, { column: 'Age', direction: 'ascending' }]) }) }) diff --git a/test/helpers/dataframe.test.ts b/test/helpers/dataframe.test.ts index 4f037673..fbcd37ef 100644 --- a/test/helpers/dataframe.test.ts +++ b/test/helpers/dataframe.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { DataFrame, arrayDataFrame, getGetColumn, sortableDataFrame } from '../../src/helpers/dataframe.js' +import { DataFrame, arrayDataFrame, getGetColumn, getRanks, sortableDataFrame } from '../../src/helpers/dataframe.js' import { AsyncRow, Row, awaitRows } from '../../src/helpers/row.js' import { wrapPromise } from '../../src/utils/promise.js' @@ -61,6 +61,35 @@ describe('getGetColumn', () => { }) +describe('getRanks', () => { + const data = [ + { id: 3, name: 'Charlie', age: 25 }, + { id: 1, name: 'Alice', age: 30 }, + { id: 2, name: 'Bob', age: 20 }, + { id: 4, name: 'Dani', age: 20 }, + ].map((cells, index) => ({ cells, index })) + + const dataFrame: DataFrame = { + header: ['id', 'name', 'age'], + numRows: data.length, + rows({ start, end }): AsyncRow[] { + // Return the slice of data between start and end indices + return data.slice(start, end).map(wrapObject) + }, + sortable: false, + } + + it('should return different indexes when all the values are different', async () => { + const ranks = await getRanks({ data: dataFrame, column: 'id' }) + expect(ranks).toEqual([2, 0, 1, 3]) + }) + + it('should return equal indexes when the values are the same', async () => { + const ranks = await getRanks({ data: dataFrame, column: 'age' }) + expect(ranks).toEqual([2, 3, 0, 0]) + }) +}) + describe('sortableDataFrame', () => { const data = [ { id: 3, name: 'Charlie', age: 25 }, @@ -118,13 +147,23 @@ describe('sortableDataFrame', () => { ]) }) - it('should return data sorted by column "age" in descending order', async () => { + it('should return data sorted by column "age" in descending order, using the data index in case of ties', async () => { const rows = await awaitRows(sortableDf.rows({ start: 0, end: 4, orderBy: [{ column: 'age', direction: 'descending' as const }] })) expect(rows).toEqual([ { index: 1, cells:{ id: 1, name: 'Alice', age: 30 } }, { index: 0, cells:{ id: 3, name: 'Charlie', age: 25 } }, + { index: 2, cells:{ id: 2, name: 'Bob', age: 20 } }, + { index: 3, cells:{ id: 4, name: 'Dani', age: 20 } }, + ]) + }) + + it('should return data sorted by columns "age" in ascending order and "name" in descending order', async () => { + const rows = await awaitRows(sortableDf.rows({ start: 0, end: 4, orderBy: [{ column: 'age', direction: 'ascending' as const }, { column: 'name', direction: 'descending' as const }] })) + expect(rows).toEqual([ { index: 3, cells:{ id: 4, name: 'Dani', age: 20 } }, { index: 2, cells:{ id: 2, name: 'Bob', age: 20 } }, + { index: 0, cells:{ id: 3, name: 'Charlie', age: 25 } }, + { index: 1, cells:{ id: 1, name: 'Alice', age: 30 } }, ]) }) diff --git a/test/helpers/rowCache.test.ts b/test/helpers/rowCache.test.ts index a0ee2d90..e0d09319 100644 --- a/test/helpers/rowCache.test.ts +++ b/test/helpers/rowCache.test.ts @@ -47,6 +47,45 @@ describe('rowCache', () => { expect(df.rows).toHaveBeenCalledTimes(1) }) + it('should cache rows for each orderBy combination', async () => { + const df = makeDf() + const dfCached = rowCache(df) + + const orderBy1 = [{ column: 'id', direction: 'ascending' as const }] + const orderBy2 = [{ column: 'id', direction: 'descending' as const }] + const orderBy3 = [{ column: 'id', direction: 'ascending' as const }, { column: 'id', direction: 'descending' as const }] + + // Initial fetch to cache rows + const rowsPre1 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy1 })) + expect(rowsPre1).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(1) + + // Subsequent fetch should use cache + const rowsPost1 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy1 })) + expect(rowsPost1).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(1) + + // Subsequent fetch with another orderBy should not use cache + const rowsPre2 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy2 })) + expect(rowsPre2).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(2) + + // Subsequent fetch with a third orderBy should not use cache + const rowsPre3 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy3 })) + expect(rowsPre3).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(3) + + // Subsequent fetch with the second orderBy should use cache + const rowsPost2 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy2 })) + expect(rowsPost2).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(3) + + // Subsequent fetch with the third orderBy should not cache + const rowsPost3 = await awaitRows(dfCached.rows({ start: 3, end: 6, orderBy: orderBy3 })) + expect(rowsPost3).toEqual([{ id: 3 }, { id: 4 }, { id: 5 }].map((cells, index) => ({ cells, index: index + 3 }))) + expect(df.rows).toHaveBeenCalledTimes(3) + }) + it('should handle adjacent cached blocks', async () => { const df = makeDf() const dfCached = rowCache(df) diff --git a/test/helpers/selection.test.ts b/test/helpers/selection.test.ts index a9347229..b50edc39 100644 --- a/test/helpers/selection.test.ts +++ b/test/helpers/selection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, test, vi } from 'vitest' import { DataFrame, sortableDataFrame } from '../../src/helpers/dataframe.js' import { AsyncRow, Row } from '../../src/helpers/row.js' -import { SortIndex, areAllSelected, areValidRanges, extendFromAnchor, isSelected, isValidIndex, isValidRange, selectRange, toDataSelection, toTableSelection, toggleAll, toggleIndex, toggleIndexInSelection, toggleRangeInSelection, toggleRangeInTable, unselectRange } from '../../src/helpers/selection.js' +import { areAllSelected, areValidRanges, convertSelection, extendFromAnchor, invertPermutationIndexes, isSelected, isValidIndex, isValidRange, selectRange, toggleAll, toggleIndex, toggleIndexInSelection, toggleRangeInSelection, toggleRangeInTable, unselectRange } from '../../src/helpers/selection.js' import { wrapPromise } from '../../src/utils/promise.js' describe('an index', () => { @@ -219,13 +219,6 @@ const data = [ { id: 4, name: 'Dani', age: 20 }, ].map((cells, index) => ({ cells, index })) -const nameSortIndex: SortIndex = { column: 'name', tableIndexes: [2, 0, 1, 3], dataIndexes: [1, 2, 0, 3] } -const ageSortIndex: SortIndex = { column: 'age', tableIndexes: [2, 3, 0, 1], dataIndexes: [2, 3, 0, 1] } - -const sortIndexes = new Map([ - ['name', nameSortIndex], -]) - export function wrapObject({ index, cells }: Row): AsyncRow { return { index: wrapPromise(index), @@ -247,117 +240,65 @@ const dataFrame: DataFrame = { const sortableDf = sortableDataFrame(dataFrame) -describe('toTableSelection', () => { - it('should throw an error if the ranges are invalid', () => { - expect( - () => toTableSelection({ selection: { ranges: [{ start: 1, end: 0 }], anchor: 0 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('Invalid ranges') - }) - it('should throw an error if the anchor is invalid', () => { - expect( - () => toTableSelection({ selection: { ranges: [{ start: 0, end: 1 }], anchor: -3 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('Invalid anchor') - }) - it('should throw an error if the column is not in the data headers', () => { - expect( - () => toTableSelection({ selection: { ranges: [{ start: 0, end: 1 }] }, column: 'doesnotexist', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('column is not in the data frame') - }) - it('should throw an error if the column is set but the data is not sortable', () => { +describe('invertPermutationIndexes', () => { + it('should throw an error if an index is negative', () => { expect( - () => toTableSelection({ selection: { ranges: [{ start: 0, end: 1 }] }, column: 'name', data: { ...sortableDf, sortable: false }, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('Data frame is not sortable') + () => invertPermutationIndexes([-1]) + ).toThrow('Invalid index: out of bounds') }) - it('should return the same ranges, but not the same anchor, if no row is selected', () => { - // the anchor data index is 2, ie: the third row (name=Bob) - its table index when sorted by name is 1 + it('should throw an error if an index is greater or equal to the number of elements', () => { expect( - toTableSelection({ selection: { ranges: [], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [], anchor: 1 }) + () => invertPermutationIndexes([1]) + ).toThrow('Invalid index: out of bounds') }) - it('should return the same ranges, but not the same anchor, if no row is selected, in descending order', () => { - // the anchor data index is 2, ie: the third row (name=Bob) - its table index when sorted by descending name is 2 + it('should throw an error if an index is not an integer', () => { expect( - toTableSelection({ selection: { ranges: [], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [], anchor: 2 }) + () => invertPermutationIndexes([0.5, 1]) + ).toThrow('Invalid index: not an integer') }) - it('should return the same ranges, but not the same anchor, if all the rows are selected', () => { + it('should throw an error if an index is duplicated', () => { expect( - toTableSelection({ selection: { ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 1 }) + () => invertPermutationIndexes([0, 0]) + ).toThrow('Duplicate index') }) - it('should return the same ranges, but not the same anchor, if all the rows are selected, in descending order', () => { - expect( - toTableSelection({ selection: { ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 2 }) - }) - it('should translate the ranges and the anchor, if some rows are selected', () => { - // Bob and Dani are selected. Their data indexes are 2 and 3, and their table indexes when sorted by name are 1 and 3. The anchor is Bob: data: 2 -> table: 1. - expect( - toTableSelection({ selection: { ranges: [{ start: 2, end: 4 }], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [{ start: 1, end: 2 }, { start: 3, end: 4 }], anchor: 1 }) - }) - it('should translate the ranges and the anchor, respecting the descending order, if some rows are selected', () => { - // Bob and Dani are selected. Their data indexes are 2 and 3, and their table indexes when sorted by descending order of name are 2 and 0. The anchor is Bob: data: 2 -> table: 2. - expect( - toTableSelection({ selection: { ranges: [{ start: 2, end: 4 }], anchor: 2 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [{ start: 0, end: 1 }, { start: 2, end: 3 }], anchor: 2 }) + it.for([ + [[], []], + [[0], [0]], + [[5, 0, 3, 1, 2, 4], [1, 3, 4, 2, 5, 0]], + ])('should invert the permutation indexes', ([dataIndexes, expectedTableIndexes]) => { + expect(invertPermutationIndexes(dataIndexes)).toEqual(expectedTableIndexes) }) }) -describe('toDataSelection', () => { +describe('convertSelection', () => { + const permutationIndexes = [1, 3, 4, 2, 5, 0] it('should throw an error if the ranges are invalid', () => { expect( - () => toDataSelection({ selection: { ranges: [{ start: 1, end: 0 }], anchor: 0 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) + () => convertSelection({ selection: { ranges: [{ start: 1, end: 0 }], anchor: 0 }, permutationIndexes }) ).toThrow('Invalid ranges') }) it('should throw an error if the anchor is invalid', () => { expect( - () => toDataSelection({ selection: { ranges: [{ start: 0, end: 1 }], anchor: -3 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) + () => convertSelection({ selection: { ranges: [{ start: 0, end: 1 }], anchor: -3 }, permutationIndexes }) ).toThrow('Invalid anchor') }) - it('should throw an error if the column is not in the data headers', () => { - expect( - () => toDataSelection({ selection: { ranges: [{ start: 0, end: 1 }] }, column: 'doesnotexist', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('column is not in the data frame') - }) - it('should throw an error if the orderBy column is set but the data is not sortable', () => { - expect( - () => toDataSelection({ selection: { ranges: [{ start: 0, end: 1 }] }, column: 'name', data: { ...sortableDf, sortable: false }, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toThrow('Data frame is not sortable') - }) it('should return the same ranges, but not the same anchor, if no row is selected', () => { - // the anchor table index is 1, ie: the second row in the table sorted by name (name=Bob) - its data index is 2 - expect( - toDataSelection({ selection: { ranges: [], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [], anchor: 2 }) - }) - it('should return the same ranges, but not the same anchor, if no row is selected in descending order', () => { - // the anchor table index is 1, ie: the second row in the table sorted by descending name (name=Charlie) - its data index is 0 + // the anchor index is 2, its permuted index is 4 expect( - toDataSelection({ selection: { ranges: [], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [], anchor: 0 }) + convertSelection({ selection: { ranges: [], anchor: 2 }, permutationIndexes }) + ).toEqual({ ranges: [], anchor: 4 }) }) it('should return the same ranges, but not the same anchor, if all the rows are selected', () => { + const numRows = permutationIndexes.length expect( - toDataSelection({ selection: { ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 2 }) - }) - it('should return the same ranges, but not the same anchor, if all the rows are selected, in descending order', () => { - expect( - toDataSelection({ selection: { ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [{ start: 0, end: sortableDf.numRows }], anchor: 0 }) + convertSelection({ selection: { ranges: [{ start: 0, end: numRows }], anchor: 2 }, permutationIndexes }) + ).toEqual({ ranges: [{ start: 0, end: numRows }], anchor: 4 }) }) it('should translate the ranges and the anchor, if some rows are selected', () => { - // Rows 2 and 3 of the table sorted by name are Charlie and Dani, and the anchor is Bob. Their data indexes are 0, 3 and 2. - expect( - toDataSelection({ selection: { ranges: [{ start: 2, end: 4 }], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'ascending' }) - ).toEqual({ ranges: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 2 }) - }) - it('should translate the ranges and the anchor, respecting the descending order, if some rows are selected', () => { - // Rows 2 and 3 of the table sorted by descending name are Bob and Alice, and the anchor is Charlie. Their data indexes are 2, 1, and 0. + // Rows 0 and 1 are selected, and their permuted indexes are 1 and 3. The anchor is 2 -> permuted: 4. expect( - toDataSelection({ selection: { ranges: [{ start: 2, end: 4 }], anchor: 1 }, column: 'name', data: sortableDf, sortIndex: nameSortIndex, direction: 'descending' }) - ).toEqual({ ranges: [{ start: 1, end: 3 }], anchor: 0 }) + convertSelection({ selection: { ranges: [{ start: 0, end: 2 }], anchor: 2 }, permutationIndexes }) + ).toEqual({ ranges: [{ start: 1, end: 2 }, { start: 3, end: 4 }], anchor: 4 }) }) }) @@ -404,10 +345,19 @@ describe('toggleRangeInSelection', () => { describe('toggleRangeInTable', () => { // default values - const selection = { ranges: [{ start: 0, end: 1 }], anchor: 0 } - const orderBy = [{ column: 'id', direction: 'ascending' as const }] + const selection = { ranges: [{ start: 1, end: 2 }], anchor: 1 } + const orderBy = [{ column: 'name', direction: 'ascending' as const }] const data = sortableDf - const props = { tableIndex: 0, selection, orderBy, data } + const ranksMap = new Map() + const setRanksMap = vi.fn() + const props = { tableIndex: 2, selection, orderBy, data, ranksMap, setRanksMap } + // { id: 3, name: 'Charlie', age: 25 }, + // { id: 1, name: 'Alice', age: 30 }, + // { id: 2, name: 'Bob', age: 20 }, + // { id: 4, name: 'Dani', age: 20 }, + const ageRanks = [2, 3, 0, 0] + const nameRanks = [2, 0, 1, 3] + const indexRanks = [0, 1, 2, 3] it('should throw an error if the table index is invalid', async () => { await expect( toggleRangeInTable({ ...props, tableIndex: -3 }) @@ -426,7 +376,7 @@ describe('toggleRangeInTable', () => { it('should throw an error if the orderBy column is not in the data headers', async () => { await expect( toggleRangeInTable({ ...props, orderBy: [{ column: 'doesnotexist', direction: 'ascending' }] }) - ).rejects.toThrow('orderBy column is not in the data frame') + ).rejects.toThrow('Invalid column: doesnotexist') }) it('should throw an error if the data is not sortable', async () => { await expect( @@ -448,7 +398,7 @@ describe('toggleRangeInTable', () => { * new selection: indexes=1,2,0 (Alice, Bob, Charlie) */ await expect( - toggleRangeInTable({ tableIndex: 2, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'ascending' }], data }) + toggleRangeInTable(props) ).resolves.toEqual({ ranges: [{ start: 0, end: 3 }], anchor: 1 }) }) it('should extend the selection (descending order)', async () => { @@ -466,12 +416,21 @@ describe('toggleRangeInTable', () => { * new selection: indexes=1,2 (Alice, Bob) */ await expect( - toggleRangeInTable({ tableIndex: 2, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'descending' }], data }) + toggleRangeInTable({ ...props, orderBy: [{ column: 'name', direction: 'descending' }] }) ).resolves.toEqual({ ranges: [{ start: 1, end: 3 }], anchor: 1 }) }) - it('should extend the selection using nameSortIndex if provided', async () => { + it('should call setRanksMap if new ranks are computed', async () => { + let cachedRanksMap = new Map>() + const setRanksMap = vi.fn(function (setter: (ranksMap: Map>) => Map>) { + cachedRanksMap = setter(cachedRanksMap) + }) + await toggleRangeInTable({ ...props, setRanksMap }) + expect(setRanksMap).toHaveBeenCalledOnce() + expect(cachedRanksMap).toEqual(new Map([['name', Promise.resolve(nameRanks)], ['', Promise.resolve(indexRanks)]])) + }) + it('should extend the selection using ranksMap if provided', async () => { /** - * sorted rows (by age, since it's what the wrong sort index provides): + * sorted rows (by age, not by name, since it's what the wrong ranks map provides): * { name: 'Bob' }, index: 2 * { name: 'Dani' }, index: 3 * { name: 'Charlie' }, index: 0 @@ -483,38 +442,14 @@ describe('toggleRangeInTable', () => { * * new selection: indexes=0,1 (Charlie, Alice) */ - const wrongButTrustedSortIndexes = new Map([['name', ageSortIndex]]) + const wrongButTrustedRanksMap = new Map([['name', Promise.resolve(ageRanks)]]) await expect( - toggleRangeInTable({ tableIndex: 2, sortIndexes: wrongButTrustedSortIndexes, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'ascending' }], data }) + toggleRangeInTable({ ...props, ranksMap: wrongButTrustedRanksMap }) ).resolves.toEqual({ ranges: [{ start: 0, end: 2 }], anchor: 1 }) }) - it('should extend the selection (descending order) using nameSortIndex if provided', async () => { - /** - * sorted rows (by descending age, since it's what the wrong sort index provides): - * { name: 'Alice' }, index: 1 - * { name: 'Charlie' }, index: 0 - * { name: 'Dani' }, index: 3 - * { name: 'Bob' }, index: 2 - * - * current selection: index=1 (Alice) - * - * extend to Dani (index 3) using tableIndex=2 - * - * new selection: indexes=1,0,3 (Alice, Charlie, Dani) - */ - const wrongButTrustedSortIndexes = new Map([['name', ageSortIndex]]) - await expect( - toggleRangeInTable({ tableIndex: 2, sortIndexes: wrongButTrustedSortIndexes, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'descending' }], data }) - ).resolves.toEqual({ ranges: [{ start: 0, end: 2 }, { start: 3, end: 4 }], anchor: 1 }) - }) - it('should call setSortIndex if provided', async () => { - const setSortIndexes = vi.fn() - await toggleRangeInTable({ tableIndex: 2, setSortIndexes, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'ascending' }], data }) - expect(setSortIndexes).toHaveBeenCalledWith(sortIndexes) - }) - it('should call setSortIndex if provided (descending order)', async () => { - const setSortIndexes = vi.fn() - await toggleRangeInTable({ tableIndex: 2, setSortIndexes, selection: { ranges: [{ start: 1, end: 2 }], anchor: 1 }, orderBy: [{ column: 'name', direction: 'descending' }], data }) - expect(setSortIndexes).toHaveBeenCalledWith(sortIndexes) + it('should not call setRanksMap if all ranks are provided', async () => { + const setRanksMap = vi.fn() + await toggleRangeInTable({ ...props, ranksMap: new Map([['name', Promise.resolve(nameRanks)], ['', Promise.resolve(indexRanks)]]), setRanksMap }) + expect(setRanksMap).not.toHaveBeenCalled() }) }) diff --git a/test/helpers/sort.test.ts b/test/helpers/sort.test.ts index 924bcda3..f51103b1 100644 --- a/test/helpers/sort.test.ts +++ b/test/helpers/sort.test.ts @@ -33,17 +33,24 @@ describe('partitionOrderBy', () => { }) describe('toggleColumn', () => { - it('should return an array with the column if the column is not in the orderBy', () => { + it('should return an array with the column as first element if the column is not in the orderBy', () => { expect(toggleColumn('name', [])).toEqual([nameAsc]) - expect(toggleColumn('name', [ageAsc])).toEqual([nameAsc]) - expect(toggleColumn('name', [ageAsc, idAsc])).toEqual([nameAsc]) + expect(toggleColumn('name', [ageAsc])).toEqual([nameAsc, ageAsc]) + expect(toggleColumn('name', [ageAsc, idAsc])).toEqual([nameAsc, ageAsc, idAsc]) }) - it('should return an array with the column in descending direction, if the column is in the orderBy with ascending direction', () => { + it('should return an array with the column as first element if the column is not the first element in the orderBy', () => { + expect(toggleColumn('name', [ageAsc, nameAsc])).toEqual([nameAsc, ageAsc]) + expect(toggleColumn('name', [ageAsc, nameDesc])).toEqual([nameAsc, ageAsc]) + expect(toggleColumn('name', [ageAsc, nameDesc, nameDesc])).toEqual([nameAsc, ageAsc, nameDesc]) + }) + it('should return an array with the column in descending direction, if the column is the first element in the orderBy with ascending direction', () => { expect(toggleColumn('name', [nameAsc])).toEqual([nameDesc]) - expect(toggleColumn('name', [ageAsc, nameAsc])).toEqual([nameDesc]) + expect(toggleColumn('name', [nameAsc, ageAsc])).toEqual([nameDesc, ageAsc]) + expect(toggleColumn('name', [nameAsc, nameDesc])).toEqual([nameDesc, nameDesc]) }) - it('should return an empty array if the column is in the orderBy with descending direction', () => { + it('should remove the first element if the column is the first element in the orderBy with descending direction', () => { expect(toggleColumn('name', [nameDesc])).toEqual([]) - expect(toggleColumn('name', [ageAsc, nameDesc])).toEqual([]) + expect(toggleColumn('name', [nameDesc, ageAsc])).toEqual([ageAsc]) + expect(toggleColumn('name', [nameDesc, nameAsc])).toEqual([nameAsc]) }) })