mirror of https://github.com/portainer/portainer
fix(app): datatable global checkbox doesn't reflect the selected state (#470)
parent
438b1f9815
commit
b57855f20d
|
@ -1,7 +1,7 @@
|
||||||
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
// Re-exporting so we don't have to update one meeeeellion files that are already importing these
|
||||||
return [1, -1].includes(Number(val)) ? word : plural;
|
// functions from here.
|
||||||
}
|
export {
|
||||||
|
pluralize,
|
||||||
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
addPlural,
|
||||||
return `${value} ${pluralize(value, word, plural)}`;
|
grammaticallyJoin,
|
||||||
}
|
} from '@/react/common/string-utils';
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
export function capitalize(s: string) {
|
||||||
|
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
||||||
|
return [1, -1].includes(Number(val)) ? word : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
||||||
|
return `${value} ${pluralize(value, word, plural)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins an array of strings into a grammatically correct sentence.
|
||||||
|
*/
|
||||||
|
export function grammaticallyJoin(
|
||||||
|
values: string[],
|
||||||
|
separator = ', ',
|
||||||
|
lastSeparator = ' and '
|
||||||
|
) {
|
||||||
|
if (values.length === 0) return '';
|
||||||
|
if (values.length === 1) return values[0];
|
||||||
|
|
||||||
|
const allButLast = values.slice(0, -1);
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
return `${allButLast.join(separator)}${lastSeparator}${last}`;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
|
@ -170,7 +171,7 @@ describe('Datatable', () => {
|
||||||
fireEvent.click(selectAllCheckbox);
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
// Check if all rows on the page are selected
|
// Check if all rows on the page are selected
|
||||||
expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
|
expect(screen.getByText('2 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
// Deselect
|
// Deselect
|
||||||
fireEvent.click(selectAllCheckbox);
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
@ -192,13 +193,44 @@ describe('Datatable', () => {
|
||||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
|
|
||||||
// Check if all rows on the page are selected
|
// Check if all rows on the page are selected
|
||||||
expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
|
expect(screen.getByText('3 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
// Deselect
|
// Deselect
|
||||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||||
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DatatableWithStore
|
||||||
|
dataset={mockData}
|
||||||
|
columns={mockColumns}
|
||||||
|
data-cy="test-table"
|
||||||
|
title="Test table with search"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select Jane
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
await user.click(checkboxes[2]); // Select the second row
|
||||||
|
|
||||||
|
// Search for John (will hide selected Jane)
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search...');
|
||||||
|
await user.type(searchInput, 'John');
|
||||||
|
|
||||||
|
// Check if the footer text is correct
|
||||||
|
expect(
|
||||||
|
await screen.findByText('1 item selected (1 hidden by filters)')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check if the checkbox is indeterminate
|
||||||
|
const selectAllCheckbox: HTMLInputElement =
|
||||||
|
screen.getByLabelText('Select all rows');
|
||||||
|
expect(selectAllCheckbox.indeterminate).toBe(true);
|
||||||
|
expect(selectAllCheckbox.checked).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test the defaultGlobalFilterFn used in searches
|
// Test the defaultGlobalFilterFn used in searches
|
||||||
|
|
|
@ -171,6 +171,14 @@ export function Datatable<D extends DefaultType>({
|
||||||
|
|
||||||
const selectedRowModel = tableInstance.getSelectedRowModel();
|
const selectedRowModel = tableInstance.getSelectedRowModel();
|
||||||
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
||||||
|
const filteredItems = tableInstance
|
||||||
|
.getFilteredRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
|
||||||
|
const hiddenSelectedItems = useMemo(
|
||||||
|
() => _.difference(selectedItems, filteredItems),
|
||||||
|
[selectedItems, filteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
<Table.Container noWidget={noWidget} aria-label={title}>
|
||||||
|
@ -203,6 +211,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
pageSize={tableState.pagination.pageSize}
|
pageSize={tableState.pagination.pageSize}
|
||||||
pageCount={tableInstance.getPageCount()}
|
pageCount={tableInstance.getPageCount()}
|
||||||
totalSelected={selectedItems.length}
|
totalSelected={selectedItems.length}
|
||||||
|
totalHiddenSelected={hiddenSelectedItems.length}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalSelected: number;
|
totalSelected: number;
|
||||||
|
totalHiddenSelected: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange(page: number): void;
|
onPageChange(page: number): void;
|
||||||
|
@ -14,6 +15,7 @@ interface Props {
|
||||||
|
|
||||||
export function DatatableFooter({
|
export function DatatableFooter({
|
||||||
totalSelected,
|
totalSelected,
|
||||||
|
totalHiddenSelected,
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
@ -22,7 +24,7 @@ export function DatatableFooter({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Table.Footer>
|
<Table.Footer>
|
||||||
<SelectedRowsCount value={totalSelected} />
|
<SelectedRowsCount value={totalSelected} hidden={totalHiddenSelected} />
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
showAll
|
showAll
|
||||||
pageLimit={pageSize}
|
pageLimit={pageSize}
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
|
import { addPlural } from '@/react/common/string-utils';
|
||||||
|
|
||||||
interface SelectedRowsCountProps {
|
interface SelectedRowsCountProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
|
export function SelectedRowsCount({ value, hidden }: SelectedRowsCountProps) {
|
||||||
return value !== 0 ? (
|
return value !== 0 ? (
|
||||||
<div className="infoBar">{value} item(s) selected</div>
|
<div className="infoBar">
|
||||||
|
{addPlural(value, 'item')} selected
|
||||||
|
{hidden !== 0 && ` (${hidden} hidden by filters)`}
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,19 @@
|
||||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
import { ColumnDef, Row, Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Checkbox } from '@@/form-components/Checkbox';
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
|
function allRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function someRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function somePageRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||||
let lastSelectedId = '';
|
let lastSelectedId = '';
|
||||||
|
|
||||||
|
@ -11,15 +23,15 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="select-all"
|
id="select-all"
|
||||||
data-cy={`select-all-checkbox-${dataCy}`}
|
data-cy={`select-all-checkbox-${dataCy}`}
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
checked={allRowsSelected(table)}
|
||||||
indeterminate={table.getIsSomeRowsSelected()}
|
indeterminate={!allRowsSelected(table) && someRowsSelected(table)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// Select all rows if shift key is held down, otherwise only page rows
|
// Select all rows if shift key is held down, otherwise only page rows
|
||||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
||||||
table.getToggleAllRowsSelectedHandler()(e);
|
table.toggleAllRowsSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
table.getToggleAllPageRowsSelectedHandler()(e);
|
table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
|
||||||
}}
|
}}
|
||||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
@ -42,6 +42,8 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
resolvedRef = defaultRef;
|
resolvedRef = defaultRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Need to check this on every render as the browser will always set the element's
|
||||||
|
// indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resolvedRef === null || resolvedRef.current === null) {
|
if (resolvedRef === null || resolvedRef.current === null) {
|
||||||
return;
|
return;
|
||||||
|
@ -50,7 +52,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
if (typeof indeterminate !== 'undefined') {
|
if (typeof indeterminate !== 'undefined') {
|
||||||
resolvedRef.current.indeterminate = indeterminate;
|
resolvedRef.current.indeterminate = indeterminate;
|
||||||
}
|
}
|
||||||
}, [resolvedRef, indeterminate]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md-checkbox flex items-center" title={title || label}>
|
<div className="md-checkbox flex items-center" title={title || label}>
|
||||||
|
|
|
@ -110,6 +110,7 @@ export function AppTemplatesList({
|
||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -86,6 +86,7 @@ export function CustomTemplatesList({
|
||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue