From c005b6c1e6a2247c549c706d420af2a8d94d8feb 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
+
+
+ - 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/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,
+ };
+};