Skip to content

Commit

Permalink
vtadmin: enable sorting in all tables (#17468)
Browse files Browse the repository at this point in the history
Signed-off-by: c-r-dev <[email protected]>
Signed-off-by: Frances Thai <[email protected]>
Co-authored-by: Frances Thai <[email protected]>
  • Loading branch information
c-r-dev and notfelineit authored Feb 3, 2025
1 parent 78c7470 commit 3c4a4ce
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 29 deletions.
60 changes: 60 additions & 0 deletions web/vtadmin/src/components/dataTable/SortedDataTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <div>col2</div>, accessor: 'a_col2' },
];
const testData = [
{ col1: 'dcol1', a_col2: '20' },
{ col1: 'dcol11', a_col2: '10' },
];

render(
<MemoryRouter initialEntries={[{ pathname: '/schemas', totalPages: 10, pageQueryKey: 'page' }]}>
<SortedDataTable
columns={columnProps}
data={testData}
renderRows={function (rows: any[]): JSX.Element[] {
return rows.map((item, idx) => {
return (
<tr key={idx}>
<td> {item.col1} </td> <td> {item.col2} </td>
</tr>
);
});
}}
/>
</MemoryRouter>
);
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.
});
});
170 changes: 170 additions & 0 deletions web/vtadmin/src/components/dataTable/SortedDataTable.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
// When passing a JSX.Element, note that the column element
// will be rendered *inside* a <th> tag. (Note: I don't love this
// abstraction + we'll likely want to revisit this when we add
// table sorting.)
columns: Array<ColumnProps>;
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 = <T extends object>({
columns,
data,
pageSize = DEFAULT_PAGE_SIZE,
renderRows,
title,
pageKey = '',
}: Props<T>) => {
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 | string>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>('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 (
<div>
<table className="table-fixed">
{title && <caption>{title}</caption>}
<thead>
<tr>
{columns.map((col, cdx) => (
<SortTableHeader
col={col}
cdx={cdx}
sortColumn={sortColumn}
sortOrder={sortOrder}
handleSort={handleSort}
></SortTableHeader>
))}
</tr>
</thead>
<tbody>{renderRows(dataPage)}</tbody>
</table>

<PaginationNav currentPage={page} formatLink={formatPageLink} totalPages={totalPages} />
{!!data.length && (
<p className="text-secondary">
Showing {startRow} {lastRow > startRow ? `- ${lastRow}` : null} of {data.length}
</p>
)}
</div>
);
};

type SortTableHeaderProps = {
col: ColumnProps;
cdx: number;
sortOrder: SortOrder;
sortColumn: null | string;
handleSort: (column: any) => void;
};

const SortTableHeader: React.FC<SortTableHeaderProps> = ({ 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 (
<th key={cdx} onClick={() => handleSort(col.accessor)}>
<div className="flex cursor-pointer items-center">
<div className="mr-1">{col.display}</div>
<div>
<Icon className={`${upFillColor} -mb-1`} icon={Icons.chevronUp} />
<Icon className={`${downFillColor} -mt-1`} icon={Icons.chevronDown} />
</div>
</div>
</th>
);
};
72 changes: 43 additions & 29 deletions web/vtadmin/src/components/routes/Schemas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,39 +25,51 @@ 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';
import { KeyspaceLink } from '../links/KeyspaceLink';
import { QueryLoadingPlaceholder } from '../placeholders/QueryLoadingPlaceholder';
import { HelpTooltip } from '../tooltip/HelpTooltip';

const TABLE_COLUMNS = [
'Keyspace',
'Table',
<div className="text-right">
Approx. Size{' '}
<HelpTooltip
text={
<span>
Size is an approximate value derived from <span className="font-mono">INFORMATION_SCHEMA</span>.
</span>
}
/>
</div>,
<div className="text-right">
Approx. Rows{' '}
<HelpTooltip
text={
// c.f. https://dev.mysql.com/doc/refman/5.7/en/information-schema-tables-table.html
<span>
Row count is an approximate value derived from <span className="font-mono">INFORMATION_SCHEMA</span>
. Actual values may vary by as much as 40% to 50%.
</span>
}
/>
</div>,
const TABLE_COLUMNS: Array<ColumnProps> = [
{ display: 'Keyspace', accessor: 'keyspace' },
{ display: 'Table', accessor: 'table' },
{
display: (
<div className="text-left">
Approx. Size{' '}
<HelpTooltip
text={
<span>
Size is an approximate value derived from{' '}
<span className="font-mono">INFORMATION_SCHEMA</span>.
</span>
}
/>
</div>
),
accessor: '_tableSize',
},
{
display: (
<div className="text-left">
Approx. Rows{' '}
<HelpTooltip
text={
// c.f. https://dev.mysql.com/doc/refman/5.7/en/information-schema-tables-table.html
<span>
Row count is an approximate value derived from{' '}
<span className="font-mono">INFORMATION_SCHEMA</span>. Actual values may vary by as much as
40% to 50%.
</span>
}
/>
</div>
),
accessor: '_tableRowCount',
},
];

export const Schemas = () => {
Expand All @@ -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,
}));

Expand All @@ -96,13 +110,13 @@ export const Schemas = () => {
</KeyspaceLink>
</DataCell>
<DataCell className="font-bold">{href ? <Link to={href}>{row.table}</Link> : row.table}</DataCell>
<DataCell className="text-right">
<DataCell className="text-left">
<div>{formatBytes(row._raw.tableSize?.data_length)}</div>
<div className="text-sm text-secondary">
{formatBytes(row._raw.tableSize?.data_length, 'B')}
</div>
</DataCell>
<DataCell className="text-right">{(row._raw.tableSize?.row_count || 0).toLocaleString()}</DataCell>
<DataCell className="text-left">{(row._raw.tableSize?.row_count || 0).toLocaleString()}</DataCell>
</tr>
);
});
Expand All @@ -120,7 +134,7 @@ export const Schemas = () => {
placeholder="Filter schemas"
value={filter || ''}
/>
<DataTable columns={TABLE_COLUMNS} data={filteredData} renderRows={renderRows} />
<SortedDataTable columns={TABLE_COLUMNS} data={filteredData} renderRows={renderRows} />
<QueryLoadingPlaceholder query={schemasQuery} />
</ContentContainer>
</div>
Expand Down

0 comments on commit 3c4a4ce

Please sign in to comment.