diff --git a/app/portainer/helpers/strings.ts b/app/portainer/helpers/strings.ts index dc130b0e5..fb8d69bc6 100644 --- a/app/portainer/helpers/strings.ts +++ b/app/portainer/helpers/strings.ts @@ -1,7 +1,7 @@ -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)}`; -} +// Re-exporting so we don't have to update one meeeeellion files that are already importing these +// functions from here. +export { + pluralize, + addPlural, + grammaticallyJoin, +} from '@/react/common/string-utils'; diff --git a/app/react/common/string-utils.ts b/app/react/common/string-utils.ts new file mode 100644 index 000000000..591d6de90 --- /dev/null +++ b/app/react/common/string-utils.ts @@ -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}`; +} diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx index 3b5a1e63f..5664a3ed6 100644 --- a/app/react/components/datatables/Datatable.test.tsx +++ b/app/react/components/datatables/Datatable.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, it, expect } from 'vitest'; import { createColumnHelper, @@ -170,7 +171,7 @@ describe('Datatable', () => { fireEvent.click(selectAllCheckbox); // 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 fireEvent.click(selectAllCheckbox); @@ -192,13 +193,44 @@ describe('Datatable', () => { fireEvent.click(selectAllCheckbox, { shiftKey: true }); // 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 fireEvent.click(selectAllCheckbox, { shiftKey: true }); const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox'); 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( + + ); + + // 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 diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index f845e221d..4ca608aa3 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -171,6 +171,14 @@ export function Datatable({ const selectedRowModel = tableInstance.getSelectedRowModel(); 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 ( @@ -203,6 +211,7 @@ export function Datatable({ pageSize={tableState.pagination.pageSize} pageCount={tableInstance.getPageCount()} totalSelected={selectedItems.length} + totalHiddenSelected={hiddenSelectedItems.length} /> ); diff --git a/app/react/components/datatables/DatatableFooter.tsx b/app/react/components/datatables/DatatableFooter.tsx index 61e7b4dbf..0907ae260 100644 --- a/app/react/components/datatables/DatatableFooter.tsx +++ b/app/react/components/datatables/DatatableFooter.tsx @@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount'; interface Props { totalSelected: number; + totalHiddenSelected: number; pageSize: number; page: number; onPageChange(page: number): void; @@ -14,6 +15,7 @@ interface Props { export function DatatableFooter({ totalSelected, + totalHiddenSelected, pageSize, page, onPageChange, @@ -22,7 +24,7 @@ export function DatatableFooter({ }: Props) { return ( - +