From a4dae1e0b71ff3d79a3dae61805263244cf367fd Mon Sep 17 00:00:00 2001 From: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> Date: Wed, 15 May 2024 20:22:43 +0200 Subject: [PATCH] Create StudioTable components (#12731) --- frontend/libs/studio-components/.eslintrc.js | 4 +- .../StudioTableLocalPagination.mdx | 72 +++++++++ .../StudioTableLocalPagination.stories.tsx | 54 +++++++ .../StudioTableLocalPagination.test.tsx | 145 +++++++++++++++++ .../StudioTableLocalPagination.tsx | 75 +++++++++ .../StudioTableLocalPagination/index.ts | 1 + .../StudioTableRemotePagination.mdx | 74 +++++++++ .../StudioTableRemotePagination.module.css | 23 +++ .../StudioTableRemotePagination.stories.tsx | 77 +++++++++ .../StudioTableRemotePagination.test.tsx | 105 +++++++++++++ .../StudioTableRemotePagination.tsx | 132 ++++++++++++++++ .../StudioTableRemotePagination/index.ts | 2 + .../StudioTableRemotePagination/mockData.tsx | 148 ++++++++++++++++++ .../utils.test.tsx | 53 +++++++ .../StudioTableRemotePagination/utils.tsx | 9 ++ .../studio-components/src/components/index.ts | 2 + .../src/hooks/useTableSorting.test.tsx | 70 +++++++++ .../src/hooks/useTableSorting.tsx | 48 ++++++ 18 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.mdx create mode 100644 frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.stories.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.test.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableLocalPagination/index.ts create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.mdx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.module.css create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.stories.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.test.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/index.ts create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/mockData.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.test.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.tsx create mode 100644 frontend/libs/studio-components/src/hooks/useTableSorting.test.tsx create mode 100644 frontend/libs/studio-components/src/hooks/useTableSorting.tsx diff --git a/frontend/libs/studio-components/.eslintrc.js b/frontend/libs/studio-components/.eslintrc.js index ba3eb447227..95317e6a918 100644 --- a/frontend/libs/studio-components/.eslintrc.js +++ b/frontend/libs/studio-components/.eslintrc.js @@ -34,6 +34,8 @@ module.exports = { }, }, ], - extends: ['plugin:storybook/recommended'], + settings: { + 'testing-library/custom-renders': ['rowsToRender'], + }, }; diff --git a/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.mdx b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.mdx new file mode 100644 index 00000000000..33faed76db3 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.mdx @@ -0,0 +1,72 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Heading, Paragraph } from '@digdir/design-system-react'; +import * as StudioTableLocalPaginationStories from './StudioTableLocalPagination.stories'; +import { StudioTableLocalPagination } from './StudioTableLocalPagination'; + + + + + StudioTableLocalPagination + + + The StudioTableLocalPagination component handles pagination internally, eliminating the need for + manual control. It seamlessly manages pagination logic for you. + + + + + + Column format + +Columns must have both an `accessor` and a `value` property. + +```tsx +const columns = [ + { + accessor: 'icon', + value: '', + }, + { + accessor: 'name', + value: 'Name', + }, + { + accessor: 'creator', + value: 'Created by', + }, + { + accessor: 'lastChanged', + value: 'Last changed', + }, +]; +``` + + + Row format + + + +```tsx +const rows = [ + { + id: 1, + icon: , + name: 'Coordinated register notification', + creator: 'Brønnøysund Register Centre', + lastChanged: '12-04-2023', + }, + { + id: 2, + icon: , + name: 'Application for authorisation and license as a healthcare personnel', + creator: 'The Norwegian Directorate of Health', + lastChanged: '05-04-2023', + }, +]; +``` diff --git a/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.stories.tsx b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.stories.tsx new file mode 100644 index 00000000000..6a3687d8aa5 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { StudioTableLocalPagination } from './StudioTableLocalPagination'; +import { columns, rows } from '../StudioTableRemotePagination/mockData'; + +type Story = StoryFn; + +const meta: Meta = { + title: 'Studio/StudioTableLocalPagination', + component: StudioTableLocalPagination, + argTypes: { + columns: { + description: 'An array of objects representing the table columns.', + }, + rows: { + description: 'An array of objects representing the table rows.', + }, + size: { + control: 'radio', + options: ['small', 'medium', 'large'], + description: 'The size of the table.', + }, + emptyTableMessage: { + description: 'The message to display when the table is empty.', + }, + isSortable: { + description: + 'Boolean that sets sorting to true or false. If set to false, the sorting buttons are hidden.', + }, + pagination: { + description: + 'An object containing pagination-related props. If not provided, pagination is hidden.', + }, + }, +}; + +export const Preview: Story = (args) => ( + `Page ${num}`, + }} + /> +); + +export default meta; diff --git a/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.test.tsx b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.test.tsx new file mode 100644 index 00000000000..fb4e811e188 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StudioTableLocalPagination } from './StudioTableLocalPagination'; +import type { StudioTableLocalPaginationProps } from './StudioTableLocalPagination'; +import { columns, rows } from '../StudioTableRemotePagination/mockData'; + +describe('StudioTableLocalPagination', () => { + const paginationProps: StudioTableLocalPaginationProps['pagination'] = { + pageSizeOptions: [5, 10, 50], + pageSizeLabel: 'Rows per page', + nextButtonText: 'Next', + previousButtonText: 'Previous', + itemLabel: (num) => `Page ${num}`, + }; + + it('renders the table with columns and rows', () => { + render(); + + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Created by' })).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'Coordinated register notification' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'The Norwegian Directorate of Health' }), + ).toBeInTheDocument(); + }); + + it('does not render sorting buttons when isSortable is set to false', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Name' })).not.toBeInTheDocument(); + }); + + it('triggers sorting when a sortable column header is clicked', async () => { + render(); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Name' })); + const [, firstBodyRow, secondBodyRow] = screen.getAllByRole('row'); + + expect( + within(firstBodyRow).getByRole('cell', { name: 'A-melding – all forms' }), + ).toBeInTheDocument(); + + expect( + within(secondBodyRow).getByRole('cell', { name: 'Application for VAT registration' }), + ).toBeInTheDocument(); + }); + + it('renders the complete table when pagination prop is not provided', () => { + render(); + expect( + screen.getByRole('cell', { name: 'Coordinated register notification' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'Application for a certificate of good conduct' }), + ).toBeInTheDocument(); + }); + + it('renders pagination controls when pagination prop is provided', () => { + render( + , + ); + + expect(screen.getByRole('combobox', { name: 'Rows per page' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); + }); + + it('changes page when the "Next" button is clicked', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Next' })); + + expect( + screen.queryByRole('cell', { name: 'Coordinated register notification' }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'A-melding – all forms' })).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'Application for VAT registration' }), + ).toBeInTheDocument(); + }); + + it('changes page when the "Page 2" button is clicked', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Page 2' })); + + expect( + screen.queryByRole('cell', { name: 'Coordinated register notification' }), + ).not.toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'A-melding – all forms' })).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'Application for VAT registration' }), + ).toBeInTheDocument(); + }); + + it('changes page size when a different page size option is selected', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Rows per page' }), '10'); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const tableBodyRows = within(tableBody).getAllByRole('row'); + expect(tableBodyRows).toHaveLength(10); + }); + + it('sets currentPage to 1 when no rows are displayed', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Page 4' })); + const lastPageBody = screen.getAllByRole('rowgroup')[1]; + const lastPageRow = within(lastPageBody).getAllByRole('row'); + expect(lastPageRow.length).toBe(1); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Rows per page' }), '50'); + const tableBody = screen.getAllByRole('rowgroup')[1]; + const tableBodyRows = within(tableBody).getAllByRole('row'); + expect(tableBodyRows.length).toBeGreaterThan(10); + }); + + it('displays the empty table message when there are no rows to display', () => { + render( + , + ); + expect(screen.getByText('No rows to display')).toBeInTheDocument(); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.tsx b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.tsx new file mode 100644 index 00000000000..f515200afc7 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/StudioTableLocalPagination.tsx @@ -0,0 +1,75 @@ +import React, { forwardRef, useEffect, useState } from 'react'; +import { StudioTableRemotePagination } from '../StudioTableRemotePagination'; +import type { Rows } from '../StudioTableRemotePagination'; +import { useTableSorting } from '../../hooks/useTableSorting'; +import { getRowsToRender } from '../StudioTableRemotePagination/utils'; + +export type StudioTableLocalPaginationProps = { + columns: Record<'accessor' | 'value', string>[]; + rows: Rows; + size?: 'small' | 'medium' | 'large'; + emptyTableMessage?: string; + isSortable?: boolean; + pagination?: { + pageSizeOptions: number[]; + pageSizeLabel: string; + nextButtonText: string; + previousButtonText: string; + itemLabel: (num: number) => string; + }; +}; + +export const StudioTableLocalPagination = forwardRef< + HTMLTableElement, + StudioTableLocalPaginationProps +>( + ( + { columns, rows, isSortable = true, size = 'medium', emptyTableMessage, pagination }, + ref, + ): React.ReactElement => { + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(pagination?.pageSizeOptions[0] ?? undefined); + + const { handleSorting, sortedRows } = useTableSorting(rows, { enable: isSortable }); + + const initialRowsToRender = getRowsToRender(currentPage, pageSize, rows); + const [rowsToRender, setRowsToRender] = useState(initialRowsToRender); + + useEffect(() => { + const newRowsToRender = getRowsToRender(currentPage, pageSize, sortedRows || rows); + + const isOutOfRange = !newRowsToRender.length && currentPage > 1; + if (isOutOfRange) { + setCurrentPage(1); + setRowsToRender(getRowsToRender(1, pageSize, sortedRows || rows)); + return; + } + + setRowsToRender(newRowsToRender); + }, [sortedRows, rows, currentPage, pageSize]); + + const totalPages = Math.ceil(rows.length / pageSize); + + const studioTableRemotePaginationProps = pagination && { + ...pagination, + currentPage, + totalPages, + onPageChange: setCurrentPage, + onPageSizeChange: setPageSize, + }; + + return ( + + ); + }, +); + +StudioTableLocalPagination.displayName = 'StudioTableLocalPagination'; diff --git a/frontend/libs/studio-components/src/components/StudioTableLocalPagination/index.ts b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/index.ts new file mode 100644 index 00000000000..378813de81c --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableLocalPagination/index.ts @@ -0,0 +1 @@ +export { StudioTableLocalPagination } from './StudioTableLocalPagination'; diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.mdx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.mdx new file mode 100644 index 00000000000..93b406caa6b --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.mdx @@ -0,0 +1,74 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import { Heading, Paragraph } from '@digdir/design-system-react'; +import * as StudioTableRemotePaginationStories from './StudioTableRemotePagination.stories'; +import { StudioTableRemotePagination } from './StudioTableRemotePagination'; +import { propInfoColumns, propInfoRowsRemotePagination } from './mockData'; + + + + + StudioTableRemotePagination + + + StudioTableRemotePagination brings together Digdir Designsystemet's `Table` and `Pagination` + components. This component is useful when data is retrieved in chunks, and the pagination logic is + managed externally. + + + + + + Column format + +Columns must have both an `accessor` and a `value` property. + +```tsx +const columns = [ + { + accessor: 'icon', + value: '', + }, + { + accessor: 'name', + value: 'Name', + }, + { + accessor: 'creator', + value: 'Created by', + }, + { + accessor: 'lastChanged', + value: 'Last changed', + }, +]; +``` + + + Row format + +
    +
  • Rows must have an `id` that is unique.
  • +
  • + The accessors in the columns array is used to display the row properties. Therefore, each + property name has to exactly match the accessor. +
  • +
+ +```tsx +const rows = [ + { + id: 1, + icon: , + name: 'Coordinated register notification', + creator: 'Brønnøysund Register Centre', + lastChanged: '12-04-2023', + }, + { + id: 2, + icon: , + name: 'Application for authorisation and license as a healthcare personnel', + creator: 'The Norwegian Directorate of Health', + lastChanged: '05-04-2023', + }, +]; +``` diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.module.css b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.module.css new file mode 100644 index 00000000000..7443079f715 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.module.css @@ -0,0 +1,23 @@ +.table { + width: 100%; +} + +.emptyTableMessage { + padding: var(--fds-spacing-3); + text-align: center; +} + +.paginationContainer { + display: flex; + justify-content: space-between; + margin-top: var(--fds-spacing-5); +} + +.selectorContainer { + display: flex; + gap: var(--fds-spacing-3); +} + +.label { + margin: auto 0; +} diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.stories.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.stories.tsx new file mode 100644 index 00000000000..a7bb2fa1ee2 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.stories.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { StudioTableRemotePagination } from './StudioTableRemotePagination'; +import { columns, rows } from './mockData'; +import { useTableSorting } from '../../hooks/useTableSorting'; +import { getRowsToRender } from './utils'; + +type Story = StoryFn; + +const meta: Meta = { + title: 'Studio/StudioTableRemotePagination', + component: StudioTableRemotePagination, + argTypes: { + columns: { + description: 'An array of objects representing the table columns.', + }, + rows: { + description: 'An array of objects representing the table rows.', + }, + size: { + control: 'radio', + options: ['small', 'medium', 'large'], + description: 'The size of the table.', + }, + emptyTableMessage: { + description: 'The message to display when the table is empty.', + }, + onSortClick: { + description: + 'Function to be invoked when a sortable column header is clicked. If not provided, sorting buttons are hidden.', + }, + pagination: { + description: + 'An object containing pagination-related props. If not provided, pagination is hidden.', + }, + }, +}; + +export const Preview: Story = (args) => { + // Example of external logic + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(5); + + const { handleSorting, sortedRows } = useTableSorting(rows, { enable: true }); + + const rowsToRender = getRowsToRender(currentPage, pageSize, sortedRows || rows); + const totalPages = Math.ceil(rows.length / pageSize); + + if (!rowsToRender.length && (sortedRows.length || rows.length)) { + setCurrentPage(1); + } + + const paginationProps = { + currentPage, + totalPages, + pageSizeOptions: [5, 10, 20, 50], + pageSizeLabel: 'Rows per page', + onPageChange: setCurrentPage, + onPageSizeChange: setPageSize, + itemLabel: (num: number) => `Page ${num}`, + nextButtonText: 'Next', + previousButtonText: 'Previous', + }; + + return ( + + ); +}; + +export default meta; diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.test.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.test.tsx new file mode 100644 index 00000000000..2b1ed44ce3f --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StudioTableRemotePagination } from './StudioTableRemotePagination'; +import type { PaginationProps } from './StudioTableRemotePagination'; +import { columns, rows } from './mockData'; + +describe('StudioTableRemotePagination', () => { + const paginationProps: PaginationProps = { + currentPage: 1, + totalPages: 2, + pageSizeOptions: [5, 10, 20], + pageSizeLabel: 'Rows per page', + onPageChange: jest.fn(), + onPageSizeChange: jest.fn(), + nextButtonText: 'Next', + previousButtonText: 'Previous', + itemLabel: (num) => `Page ${num}`, + }; + + it('renders the table with columns and rows', () => { + render(); + + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Created by' })).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'Coordinated register notification' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { name: 'The Norwegian Directorate of Health' }), + ).toBeInTheDocument(); + }); + + it('triggers the handleSorting function when a sortable column header is clicked', async () => { + const handleSorting = jest.fn(); + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Name' })); + + expect(handleSorting).toHaveBeenCalledWith('name'); + }); + + it('renders the pagination controls when pagination prop is provided', () => { + render( + , + ); + + expect(screen.getByRole('combobox', { name: 'Rows per page' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); + }); + + it('does not render the pagination controls when pagination prop is not provided', () => { + render(); + + expect(screen.queryByRole('combobox', { name: 'Rows per page' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + }); + + it('triggers the onPageChange function when "Next" is clicked', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Next' })); + + expect(paginationProps.onPageChange).toHaveBeenCalledWith(2); + }); + + it('triggers the onPageChange function when "Page 2" is clicked', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Page 2' })); + + expect(paginationProps.onPageChange).toHaveBeenCalledWith(2); + }); + + it('triggers the onPageSizeChange function when the page size is changed', async () => { + render( + , + ); + const user = userEvent.setup(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Rows per page' }), '10'); + + expect(paginationProps.onPageSizeChange).toHaveBeenCalledWith(10); + }); + + it('displays the empty table message when there are no rows to display', () => { + render( + , + ); + expect(screen.getByText('No rows to display')).toBeInTheDocument(); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.tsx new file mode 100644 index 00000000000..4b9641a8086 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/StudioTableRemotePagination.tsx @@ -0,0 +1,132 @@ +import { Label, NativeSelect, Pagination, Paragraph, Table } from '@digdir/design-system-react'; +import React, { forwardRef, useId } from 'react'; +import classes from './StudioTableRemotePagination.module.css'; + +type tableSize = 'small' | 'medium' | 'large'; +type labelSize = 'xsmall' | 'small' | 'medium'; +export const resizeLabelMap: Record = { + small: 'xsmall', + medium: 'small', + large: 'medium', +}; + +export type Columns = Record<'accessor' | 'value', string>[]; +export type Rows = (Record & Record<'id', string | number>)[]; + +export type PaginationProps = { + currentPage: number; + totalPages: number; + pageSizeOptions: number[]; + pageSizeLabel: string; + onPageChange: (currentPage: number) => void; + onPageSizeChange: (currentSize: number) => void; + nextButtonText: string; + previousButtonText: string; + itemLabel: (num: number) => string; +}; + +export type StudioTableRemotePaginationProps = { + columns: Columns; + rows: Rows; + size?: 'small' | 'medium' | 'large'; + emptyTableMessage?: string; + onSortClick?: (columnKey: string) => void; + pagination?: PaginationProps; +}; + +export const StudioTableRemotePagination = forwardRef< + HTMLTableElement, + StudioTableRemotePaginationProps +>( + ( + { columns, rows, size = 'medium', emptyTableMessage, onSortClick, pagination }, + ref, + ): React.ReactElement => { + const isSortable = onSortClick && rows.length > 0; + const isPaginationActive = pagination && rows.length > 0; + + const { + currentPage, + totalPages, + pageSizeOptions, + pageSizeLabel, + onPageChange: handlePageChange, + onPageSizeChange: handlePageSizeChange, + nextButtonText, + previousButtonText, + itemLabel, + } = pagination || {}; + + const labelId = useId(); + const labelSize = resizeLabelMap[size]; + + return ( + <> + + + + {columns.map(({ accessor, value }) => ( + onSortClick(accessor)} + > + {value} + + ))} + + + + {rows.map((row) => ( + + {columns.map(({ accessor }) => ( + {row[accessor]} + ))} + + ))} + +
+ {!rows.length && ( + + {emptyTableMessage} + + )} + {isPaginationActive && ( +
+
+ handlePageSizeChange(Number(e.target.value))} + size={size} + > + {pageSizeOptions.map((pageSizeOption) => ( + + ))} + + +
+ {totalPages > 1 && ( + + )} +
+ )} + + ); + }, +); + +StudioTableRemotePagination.displayName = 'StudioTableRemotePagination'; diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/index.ts b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/index.ts new file mode 100644 index 00000000000..da6888f6aee --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/index.ts @@ -0,0 +1,2 @@ +export { StudioTableRemotePagination } from './StudioTableRemotePagination'; +export type { Rows } from './StudioTableRemotePagination'; diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/mockData.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/mockData.tsx new file mode 100644 index 00000000000..58a8ec69646 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/mockData.tsx @@ -0,0 +1,148 @@ +import { Button } from '@digdir/design-system-react'; +import { StarFillIcon, StarIcon } from '@navikt/aksel-icons'; +import React from 'react'; +import type { Rows } from './StudioTableRemotePagination'; + +type IconButtonProps = { + icon: React.ReactNode; +}; + +const IconButton = ({ icon }: IconButtonProps): React.ReactElement => ( + +); + +export const columns = [ + { + accessor: 'icon', + value: '', + }, + { + accessor: 'name', + value: 'Name', + }, + { + accessor: 'creator', + value: 'Created by', + }, + { + accessor: 'lastChanged', + value: 'Last changed', + }, +]; + +export const rows: Rows = [ + { + id: 1, + icon: } />, + name: 'Coordinated register notification', + creator: 'Brønnøysund Register Centre', + lastChanged: '12-04-2023', + }, + { + id: 2, + icon: } />, + name: 'Application for authorisation and license as a healthcare personnel', + creator: 'The Norwegian Directorate of Health', + lastChanged: '05-04-2023', + }, + { + id: 3, + icon: } />, + name: 'Produkter og tjenester fra Brønnøysundregistrene', + creator: 'Brønnøysund Register Centre', + lastChanged: '16-04-2023', + }, + { + id: 4, + icon: } />, + name: 'Contact form - Norwegian Tax Administration (private individual)', + creator: 'Tax Administration', + lastChanged: '08-04-2023', + }, + { + id: 5, + icon: } />, + name: 'Contact form - Norwegian Tax Administration (commercial)', + creator: 'Tax Administration', + lastChanged: '01-04-2023', + }, + { + id: 6, + icon: } />, + name: 'A-melding – all forms', + creator: 'Brønnøysund Register Centre', + lastChanged: '14-04-2023', + }, + { + id: 7, + icon: } />, + name: 'Application for VAT registration', + creator: 'Tax Administration', + lastChanged: '03-04-2023', + }, + { + id: 8, + icon: } />, + name: 'Reporting of occupational injuries and diseases', + creator: 'Norwegian Labour Inspection Authority', + lastChanged: '11-04-2023', + }, + { + id: 9, + icon: } />, + name: 'Application for a residence permit', + creator: 'Norwegian Directorate of Immigration', + lastChanged: '06-04-2023', + }, + { + id: 10, + icon: } />, + name: 'Application for a work permit', + creator: 'Norwegian Directorate of Immigration', + lastChanged: '15-04-2023', + }, + { + id: 11, + icon: } />, + name: 'Notification of change of address', + creator: 'Norwegian Tax Administration', + lastChanged: '09-04-2023', + }, + { + id: 12, + icon: } />, + name: 'Application for a Norwegian national ID number', + creator: 'Norwegian Tax Administration', + lastChanged: '02-04-2023', + }, + { + id: 13, + icon: } />, + name: 'Reporting of temporary layoffs', + creator: 'Norwegian Labour and Welfare Administration', + lastChanged: '07-04-2023', + }, + { + id: 14, + icon: } />, + name: 'Application for parental benefit', + creator: 'Norwegian Labour and Welfare Administration', + lastChanged: '13-04-2023', + }, + { + id: 15, + icon: } />, + name: 'Reporting of VAT', + creator: 'Tax Administration', + lastChanged: '04-04-2023', + }, + { + id: 16, + icon: } />, + name: 'Application for a certificate of good conduct', + creator: 'Norwegian Police', + lastChanged: '10-04-2023', + }, +]; diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.test.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.test.tsx new file mode 100644 index 00000000000..3bd2712999e --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.test.tsx @@ -0,0 +1,53 @@ +import { getRowsToRender } from './utils'; + +describe('getRowsToRender', () => { + const rows = [ + { id: 1, name: 'Row 1' }, + { id: 2, name: 'Row 2' }, + { id: 3, name: 'Row 3' }, + { id: 4, name: 'Row 4' }, + { id: 5, name: 'Row 5' }, + ]; + + it('should return all rows when pageSize is 0', () => { + const currentPage = 1; + const pageSize = 0; + const rowsToRender = getRowsToRender(currentPage, pageSize, rows); + expect(rowsToRender).toEqual(rows); + }); + + it('should return the correct rows for the first page', () => { + const currentPage = 1; + const pageSize = 2; + const rowsToRender = getRowsToRender(currentPage, pageSize, rows); + + expect(rowsToRender).toEqual([ + { id: 1, name: 'Row 1' }, + { id: 2, name: 'Row 2' }, + ]); + }); + + it('should return the correct rows for the last page', () => { + const currentPage = 3; + const pageSize = 2; + const rowsToRender = getRowsToRender(currentPage, pageSize, rows); + + expect(rowsToRender).toEqual([{ id: 5, name: 'Row 5' }]); + }); + + it('should return an empty array when currentPage is out of range', () => { + const currentPage = 4; + const pageSize = 2; + const rowsToRender = getRowsToRender(currentPage, pageSize, rows); + + expect(rowsToRender).toEqual([]); + }); + + it('should return an empty array when rows is an empty array', () => { + const currentPage = 1; + const pageSize = 2; + const rowsToRender = getRowsToRender(currentPage, pageSize, []); + + expect(rowsToRender).toEqual([]); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.tsx b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.tsx new file mode 100644 index 00000000000..a292352dab7 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTableRemotePagination/utils.tsx @@ -0,0 +1,9 @@ +import type { Rows } from './StudioTableRemotePagination'; + +export const getRowsToRender = (currentPage: number, pageSize: number, rows: Rows): Rows => { + if (!pageSize) return rows; + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return rows.slice(startIndex, endIndex); +}; diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index 04625e7e136..a34601bddcf 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -19,6 +19,8 @@ export * from './StudioPageSpinner'; export * from './StudioProperty'; export * from './StudioSectionHeader'; export * from './StudioSpinner'; +export * from './StudioTableRemotePagination'; +export * from './StudioTableLocalPagination'; export * from './StudioTextarea'; export * from './StudioTextfield'; export * from './StudioToggleableTextfield'; diff --git a/frontend/libs/studio-components/src/hooks/useTableSorting.test.tsx b/frontend/libs/studio-components/src/hooks/useTableSorting.test.tsx new file mode 100644 index 00000000000..d43c5534cfe --- /dev/null +++ b/frontend/libs/studio-components/src/hooks/useTableSorting.test.tsx @@ -0,0 +1,70 @@ +import { useTableSorting } from './useTableSorting'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { Rows } from '../components'; + +describe('useTableSorting', () => { + const rows: Rows = [ + { + id: 1, + name: 'A form', + creator: 'Digdir', + }, + { + id: 2, + name: 'B form', + creator: 'Brreg', + }, + { + id: 3, + name: 'C form', + creator: 'Skatt', + }, + ]; + + it('should render the initial state', () => { + const { result } = renderHook(() => useTableSorting(rows, { enable: true })); + expect(result.current.sortedRows).toEqual(rows); + }); + + it('should sort rows in ascending order when a column is clicked', async () => { + const { result } = renderHook(() => useTableSorting(rows, { enable: true })); + await waitFor(() => result.current.handleSorting('creator')); + + const creatorsAscending: string[] = []; + result.current.sortedRows.forEach((row) => { + creatorsAscending.push(String(row.creator)); + }); + + expect(creatorsAscending[0]).toEqual('Brreg'); + expect(creatorsAscending[1]).toEqual('Digdir'); + expect(creatorsAscending[2]).toEqual('Skatt'); + }); + + it('should sort rows in descending order when the same column is clicked again', async () => { + const { result } = renderHook(() => useTableSorting(rows, { enable: true })); + await waitFor(() => result.current.handleSorting('creator')); + await waitFor(() => result.current.handleSorting('creator')); + + const creatorsDescending: string[] = []; + result.current.sortedRows.forEach((row) => { + creatorsDescending.push(String(row.creator)); + }); + + expect(creatorsDescending[0]).toEqual('Skatt'); + expect(creatorsDescending[1]).toEqual('Digdir'); + expect(creatorsDescending[2]).toEqual('Brreg'); + }); + + it('should reset the sort direction to ascending when a different column is clicked', async () => { + const { result } = renderHook(() => useTableSorting(rows, { enable: true })); + await waitFor(() => result.current.handleSorting('creator')); + await waitFor(() => result.current.handleSorting('id')); + expect(result.current.sortedRows).toEqual(rows); + }); + + it("should make 'sortedRows' and 'handleSorting' undefined when enable is false", () => { + const { result } = renderHook(() => useTableSorting(rows, { enable: false })); + expect(result.current.sortedRows).toBeUndefined(); + expect(result.current.handleSorting).toBeUndefined(); + }); +}); diff --git a/frontend/libs/studio-components/src/hooks/useTableSorting.tsx b/frontend/libs/studio-components/src/hooks/useTableSorting.tsx new file mode 100644 index 00000000000..9f39bb88bf8 --- /dev/null +++ b/frontend/libs/studio-components/src/hooks/useTableSorting.tsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import type { Rows } from '../components'; + +export const useTableSorting = (rows: Rows, options: Record<'enable', boolean>) => { + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState('asc'); + const [sortedRows, setSortedRows] = useState(rows); + + const toggleSortDirection = () => { + setSortDirection((prevDirection) => (prevDirection === 'asc' ? 'desc' : 'asc')); + }; + + const handleSorting = (columnKey: string) => { + if (sortColumn === columnKey) { + toggleSortDirection(); + } else { + setSortColumn(columnKey); + setSortDirection('asc'); + } + }; + + useEffect(() => { + if (sortColumn !== null) { + const newSortedRows = [...rows].sort((a, b) => { + const rowA = a[sortColumn]; + const rowB = b[sortColumn]; + if (rowA > rowB) return sortDirection === 'asc' ? 1 : -1; + if (rowA < rowB) return sortDirection === 'asc' ? -1 : 1; + return 0; + }); + setSortedRows(newSortedRows); + } else { + setSortedRows(rows); + } + }, [sortColumn, sortDirection, rows]); + + if (!options.enable) { + return { + sortedRows: undefined, + handleSorting: undefined, + }; + } + + return { + sortedRows, + handleSorting, + }; +};