From d95149c5de10a3cd4ed1976819cff3ae026376bf Mon Sep 17 00:00:00 2001 From: c-r-dev Date: Mon, 6 Jan 2025 18:28:35 -0500 Subject: [PATCH 1/7] vtadmin: enable sorting in all tables Signed-off-by: c-r-dev --- .../components/dataTable/SortedDataTable.tsx | 142 ++++++++++++++++++ web/vtadmin/src/components/routes/Schemas.tsx | 20 +-- 2 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 web/vtadmin/src/components/dataTable/SortedDataTable.tsx diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx new file mode 100644 index 00000000000..f74e81c9c3c --- /dev/null +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -0,0 +1,142 @@ +/** + * 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'; + +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; +} + +// 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}
handleSort(col.accessor)}> +
+ {col.display} + {sortColumn === col.accessor && ( + {sortOrder === 'asc' ? '▲' : '▼'} + )} +
+
+ + + {!!data.length && ( +

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

+ )} +
+ ); +}; diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index fff3bb8b2db..d7ac9f4a708 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,10 +33,10 @@ import { KeyspaceLink } from '../links/KeyspaceLink'; import { QueryLoadingPlaceholder } from '../placeholders/QueryLoadingPlaceholder'; import { HelpTooltip } from '../tooltip/HelpTooltip'; -const TABLE_COLUMNS = [ - 'Keyspace', - 'Table', -
+const TABLE_COLUMNS : Array = [ + {display: 'Keyspace', accessor : 'keyspace'}, + {display: 'Table' , accessor : 'table'}, + {display :
Approx. Size{' '} } /> -
, -
+
, accessor : '_tableSize'}, + {display:
Approx. Rows{' '} } /> -
, +
, accessor : '_tableRowCount'}, ]; export const Schemas = () => { @@ -74,6 +74,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, })); @@ -120,7 +122,7 @@ export const Schemas = () => { placeholder="Filter schemas" value={filter || ''} /> - + From d93c4c95c5ac23ec4520800c9bdbad84f965dd9a Mon Sep 17 00:00:00 2001 From: c-r-dev Date: Tue, 7 Jan 2025 13:16:53 -0500 Subject: [PATCH 2/7] fixed linting Signed-off-by: c-r-dev --- .../components/dataTable/SortedDataTable.tsx | 72 +++++++++---------- web/vtadmin/src/components/routes/Schemas.tsx | 62 +++++++++------- 2 files changed, 73 insertions(+), 61 deletions(-) diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx index f74e81c9c3c..b856ee7f7c6 100644 --- a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -24,9 +24,9 @@ import { useCallback, useMemo, useState } from 'react'; export interface ColumnProps { // Coulmn display name string | JSX.Element - display: string| JSX.Element, - // Column data accessor - accessor: string + display: string | JSX.Element; + // Column data accessor + accessor: string; } interface Props { @@ -79,53 +79,53 @@ export const SortedDataTable = ({ 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(() => { + 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; - } + + 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]); + }, [data, sortColumn, sortOrder]); const dataPage = sortedData.slice(startIndex, endIndex); - return (
{title && } - {columns.map((col, cdx) => ( - - ))} + + ))} {renderRows(dataPage)} diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index d7ac9f4a708..e1a9f2031da 100644 --- a/web/vtadmin/src/components/routes/Schemas.tsx +++ b/web/vtadmin/src/components/routes/Schemas.tsx @@ -33,31 +33,43 @@ import { KeyspaceLink } from '../links/KeyspaceLink'; import { QueryLoadingPlaceholder } from '../placeholders/QueryLoadingPlaceholder'; import { HelpTooltip } from '../tooltip/HelpTooltip'; -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'}, +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 = () => { From 86bf8d87935d72d96e12a2cd2fc888e0e5701bea Mon Sep 17 00:00:00 2001 From: c-r-dev Date: Wed, 15 Jan 2025 07:42:03 +0530 Subject: [PATCH 3/7] text left align style changes Signed-off-by: c-r-dev --- web/vtadmin/src/components/routes/Schemas.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index e1a9f2031da..5da3870efbc 100644 --- a/web/vtadmin/src/components/routes/Schemas.tsx +++ b/web/vtadmin/src/components/routes/Schemas.tsx @@ -38,7 +38,7 @@ const TABLE_COLUMNS: Array = [ { display: 'Table', accessor: 'table' }, { display: ( -
+
Approx. Size{' '} = [ }, { display: ( -
+
Approx. Rows{' '} { {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()} ); }); From 431ad9fc46ebd62265c34469881af6aa21744b1d Mon Sep 17 00:00:00 2001 From: c-r-dev Date: Thu, 16 Jan 2025 21:27:51 +0530 Subject: [PATCH 4/7] added test case for new SortedDataTable Signed-off-by: c-r-dev --- .../dataTable/SortedDataTable.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100755 web/vtadmin/src/components/dataTable/SortedDataTable.test.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 ( +
+ + + ); + }); + }} + /> + + ); + 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. + }); +}); From 30007fee713b61d72dcbd67666f1c84300540b38 Mon Sep 17 00:00:00 2001 From: c-r-dev Date: Fri, 24 Jan 2025 10:54:09 -0500 Subject: [PATCH 5/7] made cursor a pointer on hovering table column header Signed-off-by: c-r-dev --- web/vtadmin/src/components/dataTable/SortedDataTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx index b856ee7f7c6..1154b00ba6f 100644 --- a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -114,15 +114,15 @@ export const SortedDataTable = ({ return (
-
{title}
handleSort(col.accessor)}> + {columns.map((col, cdx) => ( + handleSort(col.accessor)}>
- {col.display} - {sortColumn === col.accessor && ( - {sortOrder === 'asc' ? '▲' : '▼'} - )} + {col.display} + {sortColumn === col.accessor && {sortOrder === 'asc' ? '▲' : '▼'}}
-
{item.col1} {item.col2}
+
{title && } {columns.map((col, cdx) => ( ))} From 0636402b28e3edea501e9a36b10d712cab037ee3 Mon Sep 17 00:00:00 2001 From: Frances Thai Date: Sun, 2 Feb 2025 15:31:09 -0800 Subject: [PATCH 6/7] Update UI for sort icon Signed-off-by: Frances Thai --- .../components/dataTable/SortedDataTable.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx index 1154b00ba6f..a9c9b41a3c6 100644 --- a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -21,6 +21,7 @@ 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 @@ -45,6 +46,8 @@ interface Props { 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; @@ -76,8 +79,8 @@ export const SortedDataTable = ({ search: stringify({ ...urlQuery.query, [pageQueryKey]: p === 1 ? undefined : p }), }); - const [sortColumn, setSortColumn] = useState(null); - const [sortOrder, setSortOrder] = useState('asc'); + const [sortColumn, setSortColumn] = useState(null); + const [sortOrder, setSortOrder] = useState('asc'); const handleSort = useCallback( (column: any) => { @@ -118,14 +121,7 @@ export const SortedDataTable = ({ {title && } - {columns.map((col, cdx) => ( - - ))} + {columns.map((col, cdx) => )} {renderRows(dataPage)} @@ -140,3 +136,27 @@ export const SortedDataTable = ({ ); }; + +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 ( + + ) +} From a607e9bea1a61694e4657097d10d3dbf271a67ff Mon Sep 17 00:00:00 2001 From: Frances Thai Date: Sun, 2 Feb 2025 15:34:55 -0800 Subject: [PATCH 7/7] Run lint fix Signed-off-by: Frances Thai --- .../components/dataTable/SortedDataTable.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx index a9c9b41a3c6..a8766aa9ee3 100644 --- a/web/vtadmin/src/components/dataTable/SortedDataTable.tsx +++ b/web/vtadmin/src/components/dataTable/SortedDataTable.tsx @@ -46,7 +46,7 @@ interface Props { pageKey?: string; } -type SortOrder = 'asc' | 'desc' +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. @@ -121,7 +121,15 @@ export const SortedDataTable = ({ {title && } - {columns.map((col, cdx) => )} + {columns.map((col, cdx) => ( + + ))} {renderRows(dataPage)} @@ -138,16 +146,16 @@ export const SortedDataTable = ({ }; type SortTableHeaderProps = { - col: ColumnProps - cdx: number - sortOrder: SortOrder - sortColumn: null | string - handleSort: (column: any) => void -} + 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" +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 ( - ) -} + ); +};
{title}
handleSort(col.accessor)}> -
+
{col.display} - {sortColumn === col.accessor && {sortOrder === 'asc' ? '▲' : '▼'}} + {sortColumn === col.accessor && (sortOrder === 'asc' ? '▲' : '▼')}
{title}
handleSort(col.accessor)}> -
- {col.display} - {sortColumn === col.accessor && (sortOrder === 'asc' ? '▲' : '▼')} -
-
handleSort(col.accessor)}> +
+
{col.display}
+
+ + +
+
+
{title}
handleSort(col.accessor)}>
@@ -158,5 +166,5 @@ const SortTableHeader: React.FC = ({col, cdx, sortOrder, s