Skip to content

Commit

Permalink
Create StudioTable components (#12731)
Browse files Browse the repository at this point in the history
  • Loading branch information
ErlingHauan authored and Jondyr committed Jun 10, 2024
1 parent 03e1633 commit c005b6c
Show file tree
Hide file tree
Showing 18 changed files with 1,093 additions and 1 deletion.
4 changes: 3 additions & 1 deletion frontend/libs/studio-components/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ module.exports = {
},
},
],

extends: ['plugin:storybook/recommended'],
settings: {
'testing-library/custom-renders': ['rowsToRender'],
},
};
Original file line number Diff line number Diff line change
@@ -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';

<Meta of={StudioTableLocalPaginationStories} />

<Heading level={1} size='small'>
StudioTableLocalPagination
</Heading>
<Paragraph>
The StudioTableLocalPagination component handles pagination internally, eliminating the need for
manual control. It seamlessly manages pagination logic for you.
</Paragraph>

<Canvas of={StudioTableLocalPaginationStories.Preview} />

<Heading level={2} size='xsmall'>
Column format
</Heading>
<Paragraph>Columns must have both an `accessor` and a `value` property.</Paragraph>

```tsx
const columns = [
{
accessor: 'icon',
value: '',
},
{
accessor: 'name',
value: 'Name',
},
{
accessor: 'creator',
value: 'Created by',
},
{
accessor: 'lastChanged',
value: 'Last changed',
},
];
```

<Heading level={2} size='xsmall'>
Row format
</Heading>
<ul>
<li>Rows must have an `id` that is unique.</li>
<li>
The accessors in the columns array is used to display the row properties. Therefore, each
property name has to exactly match the accessor.
</li>
</ul>

```tsx
const rows = [
{
id: 1,
icon: <StarFillIcon />,
name: 'Coordinated register notification',
creator: 'Brønnøysund Register Centre',
lastChanged: '12-04-2023',
},
{
id: 2,
icon: <StarFillIcon />,
name: 'Application for authorisation and license as a healthcare personnel',
creator: 'The Norwegian Directorate of Health',
lastChanged: '05-04-2023',
},
];
```
Original file line number Diff line number Diff line change
@@ -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<typeof StudioTableLocalPagination>;

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) => (
<StudioTableLocalPagination
columns={columns}
rows={rows}
size={args.size}
emptyTableMessage={'No data found'}
isSortable={true}
pagination={{
pageSizeOptions: [5, 10, 20, 50],
pageSizeLabel: 'Rows per page',
nextButtonText: 'Next',
previousButtonText: 'Previous',
itemLabel: (num) => `Page ${num}`,
}}
/>
);

export default meta;
Original file line number Diff line number Diff line change
@@ -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(<StudioTableLocalPagination columns={columns} rows={rows} />);

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(<StudioTableLocalPagination columns={columns} rows={rows} isSortable={false} />);

expect(screen.queryByRole('button', { name: 'Name' })).not.toBeInTheDocument();
});

it('triggers sorting when a sortable column header is clicked', async () => {
render(<StudioTableLocalPagination columns={columns} rows={rows} isSortable />);
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(<StudioTableLocalPagination columns={columns} rows={rows} />);
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(
<StudioTableLocalPagination columns={columns} rows={rows} pagination={paginationProps} />,
);

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(
<StudioTableLocalPagination columns={columns} rows={rows} pagination={paginationProps} />,
);
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(
<StudioTableLocalPagination columns={columns} rows={rows} pagination={paginationProps} />,
);
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(
<StudioTableLocalPagination columns={columns} rows={rows} pagination={paginationProps} />,
);
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(
<StudioTableLocalPagination columns={columns} rows={rows} pagination={paginationProps} />,
);
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(
<StudioTableLocalPagination
columns={columns}
rows={[]}
emptyTableMessage='No rows to display'
/>,
);
expect(screen.getByText('No rows to display')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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<number>(1);
const [pageSize, setPageSize] = useState<number>(pagination?.pageSizeOptions[0] ?? undefined);

const { handleSorting, sortedRows } = useTableSorting(rows, { enable: isSortable });

const initialRowsToRender = getRowsToRender(currentPage, pageSize, rows);
const [rowsToRender, setRowsToRender] = useState<Rows>(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 (
<StudioTableRemotePagination
columns={columns}
rows={rowsToRender}
size={size}
emptyTableMessage={emptyTableMessage}
onSortClick={isSortable && handleSorting}
pagination={studioTableRemotePaginationProps}
ref={ref}
/>
);
},
);

StudioTableLocalPagination.displayName = 'StudioTableLocalPagination';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StudioTableLocalPagination } from './StudioTableLocalPagination';
Loading

0 comments on commit c005b6c

Please sign in to comment.