From 3c4a4ceb99c4e6936bb3c2bb6e4d4bae9481fd4a Mon Sep 17 00:00:00 2001 From: Chaitanya Rangavajhala Date: Mon, 3 Feb 2025 10:19:04 -0500 Subject: [PATCH] vtadmin: enable sorting in all tables (#17468) Signed-off-by: c-r-dev Signed-off-by: Frances Thai Co-authored-by: Frances Thai --- .../dataTable/SortedDataTable.test.tsx | 60 +++++++ .../components/dataTable/SortedDataTable.tsx | 170 ++++++++++++++++++ web/vtadmin/src/components/routes/Schemas.tsx | 72 +++++--- 3 files changed, 273 insertions(+), 29 deletions(-) create mode 100755 web/vtadmin/src/components/dataTable/SortedDataTable.test.tsx create mode 100644 web/vtadmin/src/components/dataTable/SortedDataTable.tsx diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.test.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.test.tsx new file mode 100755 index 00000000000..554a7e2040d --- /dev/null +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.test.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2025 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { JSX } from 'react/jsx-runtime'; +import { SortedDataTable } from './SortedDataTable'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +describe('SortedDataTable', () => { + it('SortedDataTable renders successfully', () => { + const columnProps = [ + { display: 'col1', accessor: 'col1' }, + { display:
col2
, accessor: 'a_col2' }, + ]; + const testData = [ + { col1: 'dcol1', a_col2: '20' }, + { col1: 'dcol11', a_col2: '10' }, + ]; + + render( + + { + return ( + + {item.col1} {item.col2} + + ); + }); + }} + /> + + ); + expect(screen.getAllByRole('table').length).toBe(1); + expect(screen.getAllByRole('row').length).toBe(3); + expect(screen.getAllByRole('columnheader').length).toBe(2); + + // Check onClick on column + const column1 = screen.getByText('col1'); + fireEvent.click(column1); + // dependency on vite-jest to check useState sortColumn if col1 gets set. + }); +}); diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx new file mode 100644 index 00000000000..a8766aa9ee3 --- /dev/null +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -0,0 +1,170 @@ +/** + * Copyright 2025 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useURLPagination } from '../../hooks/useURLPagination'; +import { useURLQuery } from '../../hooks/useURLQuery'; +import { stringify } from '../../util/queryString'; +import { PaginationNav } from './PaginationNav'; +import { useCallback, useMemo, useState } from 'react'; +import { Icon, Icons } from '../Icon'; + +export interface ColumnProps { + // Coulmn display name string | JSX.Element + display: string | JSX.Element; + // Column data accessor + accessor: string; +} + +interface Props { + // When passing a JSX.Element, note that the column element + // will be rendered *inside* a tag. (Note: I don't love this + // abstraction + we'll likely want to revisit this when we add + // table sorting.) + columns: Array; + data: T[]; + pageSize?: number; + renderRows: (rows: T[]) => JSX.Element[]; + title?: string; + // Pass a unique `pageKey` for each DataTable, in case multiple + // DataTables access the same URL. This will be used to + // access page number from the URL. + pageKey?: string; +} + +type SortOrder = 'asc' | 'desc'; + +// Generally, page sizes of ~100 rows are fine in terms of performance, +// but anything over ~50 feels unwieldy in terms of UX. +const DEFAULT_PAGE_SIZE = 50; + +export const SortedDataTable = ({ + columns, + data, + pageSize = DEFAULT_PAGE_SIZE, + renderRows, + title, + pageKey = '', +}: Props) => { + const { pathname } = useLocation(); + const urlQuery = useURLQuery(); + + const pageQueryKey = `${pageKey}page`; + + const totalPages = Math.ceil(data.length / pageSize); + const { page } = useURLPagination({ totalPages, pageQueryKey }); + + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + + const startRow = startIndex + 1; + const lastRow = Math.min(data.length, startIndex + pageSize); + + const formatPageLink = (p: number) => ({ + pathname, + search: stringify({ ...urlQuery.query, [pageQueryKey]: p === 1 ? undefined : p }), + }); + + const [sortColumn, setSortColumn] = useState(null); + const [sortOrder, setSortOrder] = useState('asc'); + + const handleSort = useCallback( + (column: any) => { + if (sortColumn === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortOrder('asc'); + } + }, + [sortColumn, sortOrder] + ); + + const sortedData = useMemo(() => { + if (!sortColumn) return data; + + const compare = (a: { [x: string]: any }, b: { [x: string]: any }) => { + const valueA = a[sortColumn]; + const valueB = b[sortColumn]; + + if (valueA < valueB) { + return sortOrder === 'asc' ? -1 : 1; + } else if (valueA > valueB) { + return sortOrder === 'asc' ? 1 : -1; + } else { + return 0; + } + }; + + return [...data].sort(compare); + }, [data, sortColumn, sortOrder]); + + const dataPage = sortedData.slice(startIndex, endIndex); + + return ( +
+ + {title && } + + + {columns.map((col, cdx) => ( + + ))} + + + {renderRows(dataPage)} +
{title}
+ + + {!!data.length && ( +

+ Showing {startRow} {lastRow > startRow ? `- ${lastRow}` : null} of {data.length} +

+ )} +
+ ); +}; + +type SortTableHeaderProps = { + col: ColumnProps; + cdx: number; + sortOrder: SortOrder; + sortColumn: null | string; + handleSort: (column: any) => void; +}; + +const SortTableHeader: React.FC = ({ col, cdx, sortOrder, sortColumn, handleSort }) => { + const upFillColor = sortOrder === 'asc' && sortColumn === col.accessor ? 'fill-current' : 'fill-gray-300'; + const downFillColor = sortOrder !== 'asc' && sortColumn === col.accessor ? 'fill-current' : 'fill-gray-300'; + return ( + handleSort(col.accessor)}> +
+
{col.display}
+
+ + +
+
+ + ); +}; diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index fff3bb8b2db..5da3870efbc 100644 --- a/web/vtadmin/src/components/routes/Schemas.tsx +++ b/web/vtadmin/src/components/routes/Schemas.tsx @@ -25,7 +25,7 @@ import { formatBytes } from '../../util/formatBytes'; import { getTableDefinitions } from '../../util/tableDefinitions'; import { DataCell } from '../dataTable/DataCell'; import { DataFilter } from '../dataTable/DataFilter'; -import { DataTable } from '../dataTable/DataTable'; +import { ColumnProps, SortedDataTable } from '../dataTable/SortedDataTable'; import { ContentContainer } from '../layout/ContentContainer'; import { WorkspaceHeader } from '../layout/WorkspaceHeader'; import { WorkspaceTitle } from '../layout/WorkspaceTitle'; @@ -33,31 +33,43 @@ import { KeyspaceLink } from '../links/KeyspaceLink'; import { QueryLoadingPlaceholder } from '../placeholders/QueryLoadingPlaceholder'; import { HelpTooltip } from '../tooltip/HelpTooltip'; -const TABLE_COLUMNS = [ - 'Keyspace', - 'Table', -
- Approx. Size{' '} - - Size is an approximate value derived from INFORMATION_SCHEMA. - - } - /> -
, -
- Approx. Rows{' '} - - Row count is an approximate value derived from INFORMATION_SCHEMA - . Actual values may vary by as much as 40% to 50%. - - } - /> -
, +const TABLE_COLUMNS: Array = [ + { display: 'Keyspace', accessor: 'keyspace' }, + { display: 'Table', accessor: 'table' }, + { + display: ( +
+ Approx. Size{' '} + + Size is an approximate value derived from{' '} + INFORMATION_SCHEMA. + + } + /> +
+ ), + accessor: '_tableSize', + }, + { + display: ( +
+ Approx. Rows{' '} + + Row count is an approximate value derived from{' '} + INFORMATION_SCHEMA. Actual values may vary by as much as + 40% to 50%. + + } + /> +
+ ), + accessor: '_tableRowCount', + }, ]; export const Schemas = () => { @@ -74,6 +86,8 @@ export const Schemas = () => { clusterID: d.cluster?.id, keyspace: d.keyspace, table: d.tableDefinition?.name, + _tableSize: d.tableSize?.data_length || 0, + _tableRowCount: d.tableSize?.row_count || 0, _raw: d, })); @@ -96,13 +110,13 @@ export const Schemas = () => { {href ? {row.table} : row.table} - +
{formatBytes(row._raw.tableSize?.data_length)}
{formatBytes(row._raw.tableSize?.data_length, 'B')}
- {(row._raw.tableSize?.row_count || 0).toLocaleString()} + {(row._raw.tableSize?.row_count || 0).toLocaleString()} ); }); @@ -120,7 +134,7 @@ export const Schemas = () => { placeholder="Filter schemas" value={filter || ''} /> - +