From 42c7f10e795afba06f645f3667eaaae30280913e Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 21 Apr 2026 10:38:38 +0300 Subject: [PATCH] feat(ui): introduce SortableList [BE-12806] (#2367) Co-authored-by: Claude Sonnet 4.6 --- .storybook/public/mockServiceWorker.js | 200 +++++--- .../DropdownMenu/DropdownMenu.stories.tsx | 57 +++ .../DropdownMenu/DropdownMenu.test.tsx | 167 +++++++ .../components/DropdownMenu/DropdownMenu.tsx | 126 +++++ .../GroupSortTableHeader.stories.tsx | 97 ++++ .../GroupSortTableHeader.test.tsx | 168 +++++++ .../GroupSortTable/GroupSortTableHeader.tsx | 75 +++ .../components/GroupSortTable/SortByGroup.tsx | 142 ++++++ .../SortableList/SortableList.stories.tsx | 434 ++++++++++++++++++ .../SortableList/SortableList.test.tsx | 272 +++++++++++ .../components/SortableList/SortableList.tsx | 106 +++++ .../SortableList/SortableListBody.tsx | 103 +++++ .../SortableList/SortableListCard.tsx | 9 + .../SortableList/SortableListGroup.tsx | 75 +++ .../SortableList/SortableListItem.tsx | 13 + .../SortableList/SortableListPager.tsx | 38 ++ .../SortableList/SortableListPagerInfo.tsx | 55 +++ .../SortableList/SortableListPagerNav.tsx | 134 ++++++ .../SortableList/SortableListSkeleton.tsx | 30 ++ .../SortableList/sortable-list.store.ts | 59 +++ .../SortableList/sortable-list.story-utils.ts | 184 ++++++++ app/react/components/datatables/types.ts | 17 +- 22 files changed, 2485 insertions(+), 76 deletions(-) create mode 100644 app/react/components/DropdownMenu/DropdownMenu.stories.tsx create mode 100644 app/react/components/DropdownMenu/DropdownMenu.test.tsx create mode 100644 app/react/components/DropdownMenu/DropdownMenu.tsx create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx create mode 100644 app/react/components/GroupSortTable/GroupSortTableHeader.tsx create mode 100644 app/react/components/GroupSortTable/SortByGroup.tsx create mode 100644 app/react/components/SortableList/SortableList.stories.tsx create mode 100644 app/react/components/SortableList/SortableList.test.tsx create mode 100644 app/react/components/SortableList/SortableList.tsx create mode 100644 app/react/components/SortableList/SortableListBody.tsx create mode 100644 app/react/components/SortableList/SortableListCard.tsx create mode 100644 app/react/components/SortableList/SortableListGroup.tsx create mode 100644 app/react/components/SortableList/SortableListItem.tsx create mode 100644 app/react/components/SortableList/SortableListPager.tsx create mode 100644 app/react/components/SortableList/SortableListPagerInfo.tsx create mode 100644 app/react/components/SortableList/SortableListPagerNav.tsx create mode 100644 app/react/components/SortableList/SortableListSkeleton.tsx create mode 100644 app/react/components/SortableList/sortable-list.store.ts create mode 100644 app/react/components/SortableList/sortable-list.story-utils.ts diff --git a/.storybook/public/mockServiceWorker.js b/.storybook/public/mockServiceWorker.js index aaff631f3..71d697ebc 100644 --- a/.storybook/public/mockServiceWorker.js +++ b/.storybook/public/mockServiceWorker.js @@ -2,26 +2,26 @@ /* tslint:disable */ /** - * Mock Service Worker (2.0.11). + * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. - * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39'; +const PACKAGE_VERSION = '2.12.10'; +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); const activeClientIds = new Set(); -self.addEventListener('install', function () { +addEventListener('install', function () { self.skipWaiting(); }); -self.addEventListener('activate', function (event) { +addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()); }); -self.addEventListener('message', async function (event) { - const clientId = event.source.id; +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id'); if (!clientId || !self.clients) { return; @@ -48,7 +48,10 @@ self.addEventListener('message', async function (event) { case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { type: 'INTEGRITY_CHECK_RESPONSE', - payload: INTEGRITY_CHECKSUM, + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, }); break; } @@ -58,16 +61,16 @@ self.addEventListener('message', async function (event) { sendToClient(client, { type: 'MOCKING_ENABLED', - payload: true, + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, }); break; } - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId); - break; - } - case 'CLIENT_CLOSED': { activeClientIds.delete(clientId); @@ -85,72 +88,91 @@ self.addEventListener('message', async function (event) { } }); -self.addEventListener('fetch', function (event) { - const { request } = event; +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now(); // Bypass navigation requests. - if (request.mode === 'navigate') { + if (event.request.mode === 'navigate') { return; } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { return; } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). + // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { return; } - // Generate unique request ID. const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId)); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); }); -async function handleRequest(event, requestId) { +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event); - const response = await getResponse(event, client, requestId); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - (async function () { - const responseClone = response.clone(); + const serializedRequest = await serializeRequest(requestCloneForEvents); - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { type: responseClone.type, status: responseClone.status, statusText: responseClone.statusText, - body: responseClone.body, headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, }, }, - [responseClone.body] - ); - })(); + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [] + ); } return response; } -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ async function resolveMainClient(event) { const client = await self.clients.get(event.clientId); + if (activeClientIds.has(event.clientId)) { + return client; + } + if (client?.frameType === 'top-level') { return client; } @@ -171,20 +193,37 @@ async function resolveMainClient(event) { }); } -async function getResponse(event, client, requestId) { - const { request } = event; - +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = request.clone(); + const requestClone = event.request.clone(); function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()); + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention']; + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== 'msw/passthrough'); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } return fetch(requestClone, { headers }); } @@ -202,37 +241,19 @@ async function getResponse(event, client, requestId) { return passthrough(); } - // Bypass requests with the explicit bypass header. - // Such requests can be issued by "ctx.fetch()". - const mswIntention = request.headers.get('x-msw-intention'); - if (['bypass', 'passthrough'].includes(mswIntention)) { - return passthrough(); - } - // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer(); + const serializedRequest = await serializeRequest(event.request); const clientMessage = await sendToClient( client, { type: 'REQUEST', payload: { id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, + interceptedAt: requestInterceptedAt, + ...serializedRequest, }, }, - [requestBuffer] + [serializedRequest.body] ); switch (clientMessage.type) { @@ -240,7 +261,7 @@ async function getResponse(event, client, requestId) { return respondWithMock(clientMessage.data); } - case 'MOCK_NOT_FOUND': { + case 'PASSTHROUGH': { return passthrough(); } } @@ -248,6 +269,12 @@ async function getResponse(event, client, requestId) { return passthrough(); } +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel(); @@ -260,11 +287,15 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); }); } -async function respondWithMock(response) { +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create @@ -282,3 +313,24 @@ async function respondWithMock(response) { return mockedResponse; } + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/app/react/components/DropdownMenu/DropdownMenu.stories.tsx b/app/react/components/DropdownMenu/DropdownMenu.stories.tsx new file mode 100644 index 000000000..ec57701b5 --- /dev/null +++ b/app/react/components/DropdownMenu/DropdownMenu.stories.tsx @@ -0,0 +1,57 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; +import { Server, Cloud } from 'lucide-react'; + +import { Icon } from '@@/Icon'; + +import { DropdownMenu } from './DropdownMenu'; + +export default { + component: DropdownMenu, + title: 'Components/DropdownMenu', +} as Meta; + +const options = [ + { key: 'Production', count: 12, icon: }, + { key: 'Staging', count: 5, icon: }, + { key: 'Development', count: 8 }, +]; + +export function Interactive() { + const [selected, setSelected] = useState(null); + + return ( + + ); +} + +export function WithBadge() { + return ( + {}} + badge="Production" + data-cy="dropdown-menu-badge" + /> + ); +} + +export function WithoutCounts() { + return ( + {}} + data-cy="dropdown-menu-no-counts" + /> + ); +} diff --git a/app/react/components/DropdownMenu/DropdownMenu.test.tsx b/app/react/components/DropdownMenu/DropdownMenu.test.tsx new file mode 100644 index 000000000..97e9bbfa4 --- /dev/null +++ b/app/react/components/DropdownMenu/DropdownMenu.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DropdownMenu } from './DropdownMenu'; + +const defaultOptions = [ + { key: 'Docker', count: 3 }, + { key: 'Kubernetes', count: 2 }, +]; + +function renderDropdown( + overrides: Partial> = {} +) { + const props = { + label: 'Group', + options: defaultOptions, + selected: null as string | null, + onSelect: vi.fn(), + ...overrides, + }; + + return { + ...render(), + props, + }; +} + +describe('DropdownMenu', () => { + test('renders the trigger button with the label', () => { + renderDropdown({ label: 'Platform' }); + + expect( + screen.getByRole('button', { name: /Platform/i }) + ).toBeInTheDocument(); + }); + + test('opens the menu on click showing All and all options', async () => { + const user = userEvent.setup(); + renderDropdown(); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /Docker/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible(); + }); + + test('displays counts for each option', async () => { + const user = userEvent.setup(); + renderDropdown(); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + const menu = screen.getByRole('menu', { name: /Group/i }); + expect(menu).toHaveTextContent('3'); + expect(menu).toHaveTextContent('2'); + }); + + test('renders option icons when provided', async () => { + const user = userEvent.setup(); + renderDropdown({ + options: [ + { key: 'Docker', count: 3, icon: }, + ], + }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect( + document.querySelector('[data-testid="docker-icon"]') + ).toBeInTheDocument(); + }); + + test('renders option label when provided instead of key', async () => { + const user = userEvent.setup(); + renderDropdown({ + options: [{ key: 'docker', label: 'Docker Engine', count: 5 }], + }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(screen.getByText('Docker Engine')).toBeInTheDocument(); + }); + + test('shows badge when badge is provided', () => { + renderDropdown({ badge: 'Docker' }); + + const button = screen.getByRole('button', { name: /Group/i }); + expect(button).toHaveTextContent('Docker'); + }); + + test('does not show badge when badge is not provided', () => { + renderDropdown({ badge: undefined }); + + const button = screen.getByRole('button', { name: /Group/i }); + expect(button).not.toHaveTextContent('Docker'); + }); + + test('calls onClick when trigger button is clicked', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + renderDropdown({ onClick }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(onClick).toHaveBeenCalled(); + }); + + test('closes the menu when clicking outside', async () => { + const user = userEvent.setup(); + renderDropdown(); + + await user.click(screen.getByRole('button', { name: /Group/i })); + expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible(); + + await user.click(document.body); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + test('highlights the selected option', async () => { + const user = userEvent.setup(); + renderDropdown({ selected: 'Docker' }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + const dockerItem = screen.getByRole('menuitem', { name: /Docker/ }); + expect(dockerItem.className).toContain('bg-blue-2'); + }); + + test('highlights All when nothing is selected', async () => { + const user = userEvent.setup(); + renderDropdown({ selected: null }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + const allItem = screen.getByRole('menuitem', { name: /All/ }); + expect(allItem.className).toContain('bg-blue-2'); + }); + + test('applies custom className to trigger button', () => { + renderDropdown({ className: 'custom-class' }); + + expect(screen.getByRole('button', { name: /Group/i })).toHaveClass( + 'custom-class' + ); + }); + + test('shows loading spinner and hides menu items when options is undefined', async () => { + const user = userEvent.setup(); + renderDropdown({ options: undefined }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(screen.getByText('Loading...')).toBeVisible(); + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); + + test('applies data-cy to trigger button', () => { + renderDropdown({ 'data-cy': 'sort-by-group-button' }); + + expect(screen.getByRole('button', { name: /Group/i })).toHaveAttribute( + 'data-cy', + 'sort-by-group-button' + ); + }); +}); diff --git a/app/react/components/DropdownMenu/DropdownMenu.tsx b/app/react/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 000000000..7e3138bfc --- /dev/null +++ b/app/react/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { ChevronDown, Loader2 } from 'lucide-react'; +import { + Menu, + MenuButton as ReachMenuButton, + MenuList, + MenuItem, +} from '@reach/menu-button'; +import clsx from 'clsx'; + +export interface DropdownOption { + key: string; + label?: string; + count?: number; + icon?: React.ReactNode; +} + +interface Props { + label: string; + options: DropdownOption[] | undefined; + selected: string | null; + onSelect: (key: string | null) => void; + badge?: string | null; + onClick?: () => void; + className?: string; + 'data-cy'?: string; +} + +const menuListStyles = + 'overflow-hidden rounded-lg ' + + 'shadow-[0_6px_12px_rgba(0,0,0,0.18)] ' + + 'border border-solid border-gray-4 th-dark:border-gray-8 ' + + 'bg-white th-dark:bg-gray-iron-11 th-highcontrast:bg-black'; + +const menuItemBase = + 'flex items-center gap-2 w-full border-none px-3 py-1.5 text-left text-sm whitespace-nowrap bg-transparent cursor-pointer'; + +const menuItemSelected = + '!bg-blue-2 text-blue-8 [&[data-selected]]:!bg-blue-2 [&[data-selected]]:!text-blue-8 ' + + 'th-dark:!bg-blue-8 th-dark:text-blue-4 th-dark:[&[data-selected]]:!bg-blue-8 th-dark:[&[data-selected]]:!text-blue-4 ' + + 'th-highcontrast:!bg-blue-8 th-highcontrast:!text-white th-highcontrast:[&[data-selected]]:!bg-blue-8 th-highcontrast:[&[data-selected]]:!text-white'; + +const menuItemUnselected = + 'hover:bg-gray-3 [&[data-selected]]:!bg-gray-3 [&[data-selected]]:!text-gray-9 ' + + 'th-dark:hover:bg-gray-8 th-dark:[&[data-selected]]:!bg-gray-8 th-dark:[&[data-selected]]:!text-white ' + + 'th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black th-highcontrast:[&[data-selected]]:!bg-white th-highcontrast:[&[data-selected]]:!text-black'; + +const countBadge = + 'ml-2 rounded-full px-1.5 py-0.5 text-xs font-normal ' + + 'bg-gray-2 text-gray-9 ' + + 'th-dark:bg-gray-7 th-dark:text-gray-3 ' + + 'th-highcontrast:bg-blue-8 th-highcontrast:text-white'; + +export function DropdownMenu({ + label, + options, + selected, + onSelect, + badge, + onClick, + className, + 'data-cy': dataCy, +}: Props) { + return ( + + onClick?.()} + data-cy={dataCy} + > + {label} + {badge && ( + + {badge} + + )} + + + {typeof options === 'undefined' ? ( +
+ + Loading... +
+ ) : ( + <> + onSelect(null)} + className={clsx( + menuItemBase, + !selected ? menuItemSelected : menuItemUnselected + )} + > + All + + {options.map((option) => ( + onSelect(option.key)} + className={clsx( + menuItemBase, + selected === option.key + ? menuItemSelected + : menuItemUnselected + )} + > + {option.icon && ( + + {option.icon} + + )} + {option.label ?? option.key} + {option.count !== undefined && ( + {option.count} + )} + + ))} + + )} +
+
+ ); +} diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx new file mode 100644 index 000000000..a66d3127a --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.stories.tsx @@ -0,0 +1,97 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; +import { Server, Cloud } from 'lucide-react'; + +import { Icon } from '@@/Icon'; + +import { GroupSortTableHeader } from './GroupSortTableHeader'; + +export default { + component: GroupSortTableHeader, + title: 'Components/Tables/GroupSortTableHeader', +} as Meta; + +const groupOptions = { + group: [ + { key: 'Production', count: 12, icon: }, + { key: 'Staging', count: 5, icon: }, + { key: 'Development', count: 8 }, + ], +}; + +const sortOptions = [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'group', label: 'Group', grouped: true }, +] as const; + +type SortKey = (typeof sortOptions)[number]['key']; + +export function Interactive() { + const [sortBy, setSortBy] = useState('name'); + const [searchTerm, setSearchTerm] = useState(''); + const [groupFilter, setGroupFilter] = useState(null); + + return ( + + ); +} + +export function WithGroupFilter() { + const [sortBy, setSortBy] = useState('group'); + const [searchTerm, setSearchTerm] = useState(''); + + return ( + {}} + searchPlaceholder="Search environments..." + data-cy="group-sort" + /> + ); +} + +export function WithActionButton() { + const [sortBy, setSortBy] = useState('name'); + const [searchTerm, setSearchTerm] = useState(''); + const [groupFilter, setGroupFilter] = useState(null); + + return ( + + Add environment + + } + data-cy="group-sort" + /> + ); +} diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx new file mode 100644 index 000000000..02d4fe66f --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.test.tsx @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { GroupSortTableHeader } from './GroupSortTableHeader'; + +const defaultSortOptions = [ + { key: 'Group' as const, label: 'Group', grouped: true }, + { key: 'Platform' as const, label: 'Platform', grouped: true }, + { key: 'Health' as const, label: 'Health' }, +]; + +const defaultGroups = [ + { key: 'Docker', count: 3 }, + { key: 'Kubernetes', count: 2 }, +]; + +function renderHeader( + overrides: Partial< + React.ComponentProps> + > = {} +) { + const props = { + sortBy: 'Group' as string, + onSortChange: vi.fn(), + searchTerm: '', + onSearchChange: vi.fn(), + sortOptions: defaultSortOptions, + groupFilter: null, + groupOptions: { Group: defaultGroups, Platform: defaultGroups }, + onGroupFilterChange: vi.fn(), + 'data-cy': 'cy', + ...overrides, + }; + + return { + ...render(), + props, + }; +} + +describe('GroupSortTableHeader', () => { + test('clicking the active sort button opens the dropdown with group options', async () => { + const user = userEvent.setup(); + renderHeader({ sortBy: 'Group' }); + + const groupBtn = screen.getByRole('button', { name: /Group/i }); + await user.click(groupBtn); + + expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /Docker/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible(); + }); + + test('clicking an inactive sort button calls onSortChange and opens the dropdown', async () => { + const user = userEvent.setup(); + const onSortChange = vi.fn(); + renderHeader({ sortBy: 'Group', onSortChange }); + + await user.click(screen.getByRole('button', { name: /Platform/i })); + + expect(onSortChange).toHaveBeenCalledWith('Platform'); + expect(screen.getByRole('menu', { name: /Platform/i })).toBeVisible(); + }); + + test('dropdown shows group options when opened', async () => { + const user = userEvent.setup(); + renderHeader({ + sortBy: 'Group', + sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }], + }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(screen.getByRole('menuitem', { name: /Docker/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible(); + }); + + test('active group filter is shown as a badge inside the active sort button', () => { + renderHeader({ sortBy: 'Group', groupFilter: 'Docker' }); + + const groupBtn = screen.getByRole('button', { name: /Group/i }); + expect(groupBtn).toHaveTextContent('Docker'); + }); + + test('clicking outside the dropdown closes it', async () => { + const user = userEvent.setup(); + renderHeader({ sortBy: 'Group' }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible(); + + await user.click(document.body); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + test('search input renders with the correct placeholder', () => { + renderHeader(); + + expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument(); + }); + + test('search input accepts user input', async () => { + const user = userEvent.setup(); + renderHeader(); + + const searchInput = screen.getByPlaceholderText('Filter...'); + await user.type(searchInput, 'test'); + + expect(searchInput).toHaveValue('test'); + }); + + test('renders custom search placeholder', () => { + renderHeader({ searchPlaceholder: 'Search environments...' }); + + expect( + screen.getByPlaceholderText('Search environments...') + ).toBeInTheDocument(); + }); + + test('renders action button when provided', () => { + renderHeader({ + actionButton: , + }); + + expect( + screen.getByRole('button', { name: /Add item/i }) + ).toBeInTheDocument(); + }); + + test('All option is present in dropdown menu', async () => { + const user = userEvent.setup(); + renderHeader({ + sortBy: 'Group', + groupFilter: 'Docker', + sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }], + }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible(); + }); + + test('displays group counts in dropdown', async () => { + const user = userEvent.setup(); + renderHeader({ + sortBy: 'Group', + sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }], + }); + + await user.click(screen.getByRole('button', { name: /Group/i })); + + const menu = screen.getByRole('menu', { name: /Group/i }); + expect(menu).toHaveTextContent('3'); + expect(menu).toHaveTextContent('2'); + }); + + test('renders all sort option buttons', () => { + renderHeader(); + + expect(screen.getByRole('button', { name: /Group/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Platform/i }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Health/i })).toBeInTheDocument(); + }); +}); diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx new file mode 100644 index 000000000..9928a203f --- /dev/null +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { AutomationTestingProps } from '@/types'; + +import { SearchBar } from '@@/datatables/SearchBar'; + +import { DropdownOption } from '../DropdownMenu/DropdownMenu'; + +import { SortByGroup, SortOption } from './SortByGroup'; + +export type { SortOption }; + +interface Props { + sortBy: TSortKey; + onSortChange: (key: TSortKey) => void; + searchTerm: string; + onSearchChange: (term: string) => void; + sortOptions: SortOption[]; + searchPlaceholder?: string; + actionButton?: React.ReactNode; + groupFilter: string | null; + groupOptions?: Record; + onGroupFilterChange: (value: string | null) => void; +} + +export function GroupSortTableHeader({ + sortBy, + onSortChange, + searchTerm, + onSearchChange, + sortOptions, + searchPlaceholder = 'Filter...', + actionButton, + groupFilter, + groupOptions, + onGroupFilterChange, + 'data-cy': dataCy, +}: Props & AutomationTestingProps) { + return ( +
+ +
+ + {actionButton} +
+
+ ); +} diff --git a/app/react/components/GroupSortTable/SortByGroup.tsx b/app/react/components/GroupSortTable/SortByGroup.tsx new file mode 100644 index 000000000..b5b3760fc --- /dev/null +++ b/app/react/components/GroupSortTable/SortByGroup.tsx @@ -0,0 +1,142 @@ +import clsx from 'clsx'; + +import { DropdownMenu, DropdownOption } from '../DropdownMenu/DropdownMenu'; + +export interface SortOption { + key: TSortKey; + label: string; + grouped?: boolean; +} + +export interface SortByGroupProps { + sortBy: TSortKey; + onSortChange: (key: TSortKey) => void; + sortOptions: SortOption[]; + groupFilter: string | null; + groupOptions?: Record; + onGroupFilterChange: (value: string | null) => void; + dataCy?: string; +} + +export function SortByGroup({ + sortBy, + onSortChange, + sortOptions, + groupFilter, + groupOptions, + onGroupFilterChange, + dataCy, +}: SortByGroupProps) { + return ( + <> + + SORT BY: + +
+ {sortOptions.map((option, index) => ( + + ))} +
+ + ); +} + +const baseBtn = + 'px-4 py-1.5 text-xs align-middle font-medium transition-colors'; +const activeBtn = + 'z-10 border-none rounded-md font-medium ' + + 'bg-white text-gray-8 ' + + 'th-dark:bg-gray-iron-10 th-dark:text-white ' + + 'th-highcontrast:bg-white th-highcontrast:text-black'; +const inactiveBtn = + 'text-muted border-none rounded-md ' + + 'bg-gray-4 hover:bg-gray-2 ' + + 'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9 ' + + 'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black'; + +interface SortOptionItemProps { + option: SortOption; + isActive: boolean; + isFirst: boolean; + isLast: boolean; + onSortChange: (key: TSortKey) => void; + groupFilter: string | null; + groupOptions?: Record; + onGroupFilterChange: (value: string | null) => void; + dataCy?: string; +} + +function SortOptionItem({ + option, + isActive, + isFirst, + isLast, + onSortChange, + groupFilter, + groupOptions, + onGroupFilterChange, + dataCy, +}: SortOptionItemProps) { + const className = clsx( + baseBtn, + isActive ? activeBtn : inactiveBtn, + isFirst && 'rounded-l-md', + isLast && 'rounded-r-md' + ); + + if (option.grouped) { + return ( + { + if (!isActive) { + onSortChange(option.key); + } + }} + /> + ); + } + + return ( + + ); +} diff --git a/app/react/components/SortableList/SortableList.stories.tsx b/app/react/components/SortableList/SortableList.stories.tsx new file mode 100644 index 000000000..c635a835a --- /dev/null +++ b/app/react/components/SortableList/SortableList.stories.tsx @@ -0,0 +1,434 @@ +import { useQuery } from '@tanstack/react-query'; +import { Meta } from '@storybook/react'; + +import { DropdownOption } from '../DropdownMenu/DropdownMenu'; + +import { SortableList, SortableGroup, SortableListState } from './SortableList'; +import { + useGroupOptions, + useGroups, + computePagedGroups, +} from './sortable-list.story-utils'; +import { SortableListItem } from './SortableListItem'; +import { useSortableListState } from './sortable-list.store'; + +export default { + component: SortableList, + title: 'Design System/SortableList', +} as Meta; + +export function ClientSidePagination() { + const data = getData(); + const storageKey = 'fruits-veggies'; + const tableState = useSortableListState(storageKey, 'color'); + + const colorOptions = useGroupOptions( + 'color', + data, + getProduceGroupKeys, + getProduceGroupKey, + getProduceGroupLabel + ); + const categoryOptions = useGroupOptions( + 'category', + data, + getProduceGroupKeys, + getProduceGroupKey, + getProduceGroupLabel + ); + + const { groups, totalCount } = useGroups( + tableState, + data, + 'color', + getProduceGroupKeys, + getProduceGroupKey, + getProduceGroupLabel, + (i) => `${i.name} ${i.color} ${i.category} ${i.description}`, + getProduceGroupIcon + ); + + return ( + + ); +} + +export function ServerSidePagination() { + const allData = getData(); + const storageKey = 'fruits-veggies-latency-demo'; + const tableState = useSortableListState(storageKey, 'color'); + const sortBy = tableState.sortBy?.id ?? 'color'; + + const colorOptions = useGroupOptions( + 'color', + allData, + getProduceGroupKeys, + getProduceGroupKey, + getProduceGroupLabel + ); + const categoryOptions = useGroupOptions( + 'category', + allData, + getProduceGroupKeys, + getProduceGroupKey, + getProduceGroupLabel + ); + + const { data, isPreviousData } = useQuery( + [ + storageKey, + sortBy, + tableState.page, + tableState.pageSize, + tableState.groupFilter, + tableState.search, + ], + async () => { + await new Promise((resolve) => { + setTimeout(resolve, 800); + }); + return computePagedGroups({ + items: allData, + sortBy, + page: tableState.page, + pageSize: tableState.pageSize, + groupFilter: tableState.groupFilter, + search: tableState.search, + getGroupKeys: getProduceGroupKeys, + getGroupKey: getProduceGroupKey, + getGroupLabel: getProduceGroupLabel, + getSearchText: (i) => + `${i.name} ${i.color} ${i.category} ${i.description}`, + getGroupIcon: getProduceGroupIcon, + }); + }, + { keepPreviousData: true, staleTime: 0 } + ); + + return ( + + ); +} + +interface Produce { + id: string; + name: string; + color: string; + category: Category; + description: string; +} + +function getData(): Produce[] { + return [ + { + id: '1', + name: 'Apple', + color: 'Red', + category: 'fruit', + description: 'Crisp, sweet or tart; great for snacking and pies.', + }, + { + id: '2', + name: 'Banana', + color: 'Yellow', + category: 'fruit', + description: 'Soft, creamy, and high in potassium.', + }, + { + id: '3', + name: 'Beet', + color: 'Purple', + category: 'veggie', + description: 'Earthy root vegetable; excellent roasted or in salads.', + }, + { + id: '4', + name: 'Blueberry', + color: 'Blue', + category: 'fruit', + description: 'Small, antioxidant-rich berries for baking and smoothies.', + }, + { + id: '5', + name: 'Broccoli', + color: 'Green', + category: 'veggie', + description: 'Nutritious green florets; steam or roast.', + }, + { + id: '6', + name: 'Carrot', + color: 'Orange', + category: 'veggie', + description: 'Crunchy, sweet root; raw or cooked.', + }, + { + id: '7', + name: 'Cherry', + color: 'Red', + category: 'fruit', + description: 'Sweet or sour stone fruit; summer favorite.', + }, + { + id: '8', + name: 'Cucumber', + color: 'Green', + category: 'veggie', + description: 'Cool, crisp, and hydrating; perfect in salads.', + }, + { + id: '9', + name: 'Grape', + color: 'Purple', + category: 'fruit', + description: 'Juicy clusters; eaten fresh or turned into wine.', + }, + { + id: '10', + name: 'Kale', + color: 'Green', + category: 'veggie', + description: 'Leafy green superfood; hearty in salads and soups.', + }, + { + id: '11', + name: 'Lemon', + color: 'Yellow', + category: 'fruit', + description: 'Tangy citrus; juice, zest, or slice for drinks.', + }, + { + id: '12', + name: 'Lettuce', + color: 'Green', + category: 'veggie', + description: 'Mild leafy base for salads and wraps.', + }, + { + id: '13', + name: 'Onion', + color: 'Yellow', + category: 'veggie', + description: 'Pungent allium; foundational in savory cooking.', + }, + { + id: '14', + name: 'Orange', + color: 'Orange', + category: 'fruit', + description: 'Sweet citrus; juice or eat segments.', + }, + { + id: '15', + name: 'Pear', + color: 'Green', + category: 'fruit', + description: 'Sweet, grainy texture; good fresh or poached.', + }, + { + id: '16', + name: 'Pepper', + color: 'Red', + category: 'veggie', + description: 'Sweet or hot; bell or chili varieties.', + }, + { + id: '17', + name: 'Plum', + color: 'Purple', + category: 'fruit', + description: 'Stone fruit; sweet when ripe, great for jam.', + }, + { + id: '18', + name: 'Radish', + color: 'Red', + category: 'veggie', + description: 'Peppery crunch; slice into salads or eat whole.', + }, + { + id: '19', + name: 'Raspberry', + color: 'Red', + category: 'fruit', + description: 'Delicate, tart berries; fresh or in desserts.', + }, + { + id: '20', + name: 'Spinach', + color: 'Green', + category: 'veggie', + description: 'Tender leaves; salads, smoothies, or cooked.', + }, + { + id: '21', + name: 'Strawberry', + color: 'Red', + category: 'fruit', + description: 'Classic red berry; summer staple.', + }, + { + id: '22', + name: 'Sweet potato', + color: 'Orange', + category: 'veggie', + description: 'Naturally sweet tuber; bake, mash, or fry.', + }, + { + id: '23', + name: 'Tomato', + color: 'Red', + category: 'veggie', + description: 'Juicy and versatile; salads, sauce, or cooked.', + }, + { + id: '24', + name: 'Watermelon', + color: 'Green', + category: 'fruit', + description: 'Refreshing, watery summer melon.', + }, + { + id: '25', + name: 'Zucchini', + color: 'Green', + category: 'veggie', + description: 'Mild summer squash; grill, spiralize, or bake.', + }, + ]; +} + +const COLOR_ORDER = [ + 'Red', + 'Orange', + 'Yellow', + 'Green', + 'Blue', + 'Purple', +] as const; +const CATEGORY_ORDER: Category[] = ['fruit', 'veggie'] as const; + +const COLOR_DOT_CLASSES: Record = { + Red: 'bg-error-7', + Orange: 'bg-warning-7', + Yellow: 'bg-yellow-7', + Green: 'bg-success-7', + Blue: 'bg-blue-7', + Purple: 'bg-purple-7', +}; + +type Category = 'fruit' | 'veggie'; + +const PRODUCE_SORT_OPTIONS = [ + { key: 'name', label: 'Name' }, + { key: 'color', label: 'Color', grouped: true }, + { key: 'category', label: 'Category', grouped: true }, +] as const; + +const GRID_COLS = + '44px minmax(80px, 1fr) minmax(60px, 0.6fr) minmax(120px, 2fr)'; + +function ProduceList({ + tableState, + groupOptions, + groups, + totalCount, + isLoading, +}: { + tableState: SortableListState; + groupOptions?: Record; + groups: Array>; + totalCount: number; + isLoading?: boolean; +}) { + return ( + item.id} + showGroupHeaders + emptyMessage="No items found" + searchPlaceholder="Filter fruits & veggies..." + renderColumnHeaders={() => ( +
+ Color + Name + Category + Description +
+ )} + data-cy="produce" + renderItem={(item) => ( + +
+
+ +
+ + {item.name} + + + {item.category} + + + {item.description} + +
+
+ )} + /> + ); +} + +function getProduceGroupLabel(sortBy: string, key: string): string { + if (sortBy === 'category') { + return key === 'fruit' ? 'Fruit' : 'Veggies'; + } + return key; +} + +function getProduceGroupKey(item: Produce, sortBy: string): string { + return sortBy === 'color' ? item.color : item.category; +} + +function getProduceGroupKeys(sortBy: string): string[] { + if (sortBy === 'color') return [...COLOR_ORDER]; + if (sortBy === 'category') return [...CATEGORY_ORDER]; + return []; +} + +function getProduceGroupIcon(key: string): JSX.Element { + return ( + + ); +} diff --git a/app/react/components/SortableList/SortableList.test.tsx b/app/react/components/SortableList/SortableList.test.tsx new file mode 100644 index 000000000..0d7effc48 --- /dev/null +++ b/app/react/components/SortableList/SortableList.test.tsx @@ -0,0 +1,272 @@ +import { type ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; + +import { SortableList, SortableListState, SortableGroup } from './SortableList'; + +interface Item { + id: number; + name: string; + status: string; +} + +const ITEMS: Item[] = [ + { id: 1, name: 'Alpha', status: 'healthy' }, + { id: 2, name: 'Beta', status: 'error' }, + { id: 3, name: 'Gamma', status: 'healthy' }, + { id: 4, name: 'Delta', status: 'syncing' }, + { id: 5, name: 'Epsilon', status: 'healthy' }, + { id: 6, name: 'Zeta', status: 'error' }, + { id: 7, name: 'Eta', status: 'healthy' }, + { id: 8, name: 'Theta', status: 'syncing' }, +]; + +const SORT_OPTIONS = [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, +]; + +describe('SortableList', () => { + it('renders items in groups', () => { + renderList(); + + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Theta')).toBeInTheDocument(); + }); + + it('calls setSortBy when a sort option is clicked', async () => { + const user = userEvent.setup(); + const setSortBy = vi.fn(); + renderList({ state: { setSortBy } }); + + await user.click(screen.getByText('Status')); + + expect(setSortBy).toHaveBeenCalledWith('status', false); + }); + + it('shows group headers when showGroupHeaders=true and multiple groups', () => { + renderList({ + groups: makeStatusGroups(ITEMS), + state: { sortBy: { id: 'status', desc: false } }, + groupOptions: { + status: [{ key: 'healthy' }, { key: 'error' }, { key: 'syncing' }], + }, + showGroupHeaders: true, + }); + + expect(screen.getByText('Healthy')).toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('hides group header when there is only one group', () => { + renderList({ + groups: makeGroups(ITEMS, 'SingleGroup'), + showGroupHeaders: true, + }); + + expect(screen.queryByText('SingleGroup')).not.toBeInTheDocument(); + }); + + it('shows empty message when groups is empty', () => { + renderList({ groups: [], totalCount: 0 }); + + expect(screen.getByText('No items found')).toBeInTheDocument(); + }); + + it('shows custom empty message', () => { + renderList({ + groups: [], + totalCount: 0, + emptyMessage: 'No workflows match your search', + }); + + expect( + screen.getByText('No workflows match your search') + ).toBeInTheDocument(); + }); + + it('shows pagination info when totalCount exceeds pageSize', () => { + renderList({ + groups: makeGroups(ITEMS.slice(0, 5)), + totalCount: 20, + state: { pageSize: 5, page: 0 }, + }); + + expect(screen.getByText(/Showing/)).toBeInTheDocument(); + expect(screen.getByText(/of/)).toBeInTheDocument(); + }); + + it('calls setPage when next-page button is clicked', async () => { + const user = userEvent.setup(); + const setPage = vi.fn(); + renderList({ + state: { pageSize: 5, page: 0, setPage }, + groups: makeGroups(ITEMS.slice(0, 5)), + totalCount: 20, + }); + + await user.click(screen.getByTitle('Next page')); + + expect(setPage).toHaveBeenCalledWith(1); + }); + + it('calls setPage(0) when first-page button is clicked from page 2', async () => { + const user = userEvent.setup(); + const setPage = vi.fn(); + renderList({ + state: { pageSize: 5, page: 1, setPage }, + groups: makeGroups(ITEMS.slice(5, 10)), + totalCount: 20, + }); + + await user.click(screen.getByTitle('First page')); + + expect(setPage).toHaveBeenCalledWith(0); + }); + + it('renders column headers for each group when renderColumnHeaders is provided', () => { + const groups = makeStatusGroups(ITEMS); + renderList({ + groups, + showGroupHeaders: true, + renderColumnHeaders: () =>
col-header
, + }); + + expect(screen.getAllByText('col-header')).toHaveLength(groups.length); + }); + + it('calls renderColumnHeaders with the group key and items', () => { + const groups = makeStatusGroups(ITEMS); + const renderColumnHeaders = vi.fn(() => null); + renderList({ groups, showGroupHeaders: true, renderColumnHeaders }); + + groups.forEach((g) => + expect(renderColumnHeaders).toHaveBeenCalledWith(g.key, g.items) + ); + }); + + it('renders the group icon in the group header', () => { + const groups: SortableGroup[] = [ + { + key: 'a', + label: 'Group A', + icon: , + items: ITEMS.slice(0, 2), + }, + { key: 'b', label: 'Group B', items: ITEMS.slice(2, 4) }, + ]; + renderList({ + groups, + groupOptions: { name: [{ key: 'a' }, { key: 'b' }] }, + totalCount: 4, + showGroupHeaders: true, + }); + + expect(screen.getByText('★')).toBeInTheDocument(); + }); + + it('renders the group description in the group header', () => { + const groups: SortableGroup[] = [ + { + key: 'a', + label: 'Group A', + description: 'hint text', + items: ITEMS.slice(0, 2), + }, + { key: 'b', label: 'Group B', items: ITEMS.slice(2, 4) }, + ]; + renderList({ + groups, + groupOptions: { name: [{ key: 'a' }, { key: 'b' }] }, + totalCount: 4, + showGroupHeaders: true, + }); + + expect(screen.getByText('hint text')).toBeInTheDocument(); + }); +}); + +function renderList({ + state = createMockState(), + groups = makeGroups(ITEMS), + totalCount = ITEMS.length, + renderItem = (item: Item) =>
{item.name}
, + groupOptions, + showGroupHeaders, + emptyMessage, + renderColumnHeaders, + getItemKey, + searchPlaceholder, + actionButton, + isLoading, +}: { + state?: Partial; + groups?: SortableGroup[]; + totalCount?: number; + renderItem?: (item: Item, index: number) => ReactNode; + groupOptions?: Record; + showGroupHeaders?: boolean; + emptyMessage?: string; + renderColumnHeaders?: (groupKey: string, items: Item[]) => ReactNode; + getItemKey?: (item: Item, index: number) => string | number; + searchPlaceholder?: string; + actionButton?: ReactNode; + isLoading?: boolean; +} = {}) { + const tableState = createMockState(state); + + render( + + ); +} + +function makeGroups(items: Item[], label = 'Items'): SortableGroup[] { + if (items.length === 0) return []; + return [{ key: 'items', label, items }]; +} + +function makeStatusGroups(items: Item[]): SortableGroup[] { + const byStatus = items.reduce>((acc, item) => { + const group = acc[item.status] ?? []; + return { ...acc, [item.status]: [...group, item] }; + }, {}); + return Object.entries(byStatus).map(([status, statusItems]) => ({ + key: status, + label: status.charAt(0).toUpperCase() + status.slice(1), + items: statusItems, + })); +} + +function createMockState( + overrides?: Partial +): SortableListState { + return { + sortBy: { id: 'name', desc: false }, + setSortBy: vi.fn(), + pageSize: 10, + setPageSize: vi.fn(), + page: 0, + setPage: vi.fn(), + groupFilter: null, + setGroupFilter: vi.fn(), + search: '', + setSearch: vi.fn(), + ...overrides, + }; +} diff --git a/app/react/components/SortableList/SortableList.tsx b/app/react/components/SortableList/SortableList.tsx new file mode 100644 index 000000000..8501207a8 --- /dev/null +++ b/app/react/components/SortableList/SortableList.tsx @@ -0,0 +1,106 @@ +import '@@/datatables/datatable.css'; + +import { ReactNode } from 'react'; + +import { AutomationTestingProps } from '@/types'; + +import { + SortOption, + GroupSortTableHeader, +} from '../GroupSortTable/GroupSortTableHeader'; +import { DropdownOption } from '../DropdownMenu/DropdownMenu'; + +import { SortableGroup } from './SortableListGroup'; +import { SortableListBody } from './SortableListBody'; +import { SortableListCard } from './SortableListCard'; +import { SortableListPager } from './SortableListPager'; +import { SortableListState } from './sortable-list.store'; + +export type { SortableGroup }; +export type { SortableListState }; +export type { SortOption }; + +interface Props extends AutomationTestingProps { + tableState: SortableListState; + sortOptions: SortOption[]; + groupOptions?: Record; + groups: SortableGroup[]; + totalCount: number; + renderItem: (item: T, index: number) => ReactNode; + renderColumnHeaders?: (groupKey: string, items: T[]) => ReactNode; + getItemKey?: (item: T, index: number) => string | number; + showGroupHeaders?: boolean; + emptyMessage?: string; + searchPlaceholder?: string; + actionButton?: ReactNode; + isLoading?: boolean; +} + +export function SortableList({ + tableState, + sortOptions, + groupOptions, + groups, + totalCount, + renderItem, + renderColumnHeaders, + getItemKey, + showGroupHeaders = true, + emptyMessage = 'No items found', + searchPlaceholder, + actionButton, + isLoading = false, + 'data-cy': dataCy, +}: Props) { + const activeSortKey = tableState.sortBy?.id ?? sortOptions[0]?.key ?? ''; + + return ( + + { + tableState.setSortBy(key, false); + tableState.setGroupFilter(null); + tableState.setPage(0); + }} + searchTerm={tableState.search} + onSearchChange={(value) => { + tableState.setSearch(value); + tableState.setPage(0); + }} + groupFilter={tableState.groupFilter} + onGroupFilterChange={(value) => { + tableState.setGroupFilter(value); + tableState.setPage(0); + }} + groupOptions={groupOptions} + sortOptions={sortOptions} + searchPlaceholder={searchPlaceholder} + actionButton={actionButton} + data-cy={`${dataCy}-header`} + /> + +
+ 0 + } + renderItem={renderItem} + renderColumnHeaders={renderColumnHeaders} + getItemKey={getItemKey} + emptyMessage={emptyMessage} + /> +
+ + +
+ ); +} diff --git a/app/react/components/SortableList/SortableListBody.tsx b/app/react/components/SortableList/SortableListBody.tsx new file mode 100644 index 000000000..c12a4e52d --- /dev/null +++ b/app/react/components/SortableList/SortableListBody.tsx @@ -0,0 +1,103 @@ +import { Loader2, Search } from 'lucide-react'; +import { ReactNode } from 'react'; + +import { Icon } from '@@/Icon'; + +import { SortableGroup, SortableListGroup } from './SortableListGroup'; +import { SortableListSkeleton } from './SortableListSkeleton'; + +interface Props { + isLoading: boolean; + groups: SortableGroup[]; + showGroupHeaders: boolean; + renderItem: (item: T, index: number) => ReactNode; + renderColumnHeaders?: (groupKey: string, items: T[]) => ReactNode; + getItemKey?: (item: T, index: number) => string | number; + emptyMessage: string; +} + +export function SortableListBody({ + isLoading, + groups, + showGroupHeaders, + renderItem, + renderColumnHeaders, + getItemKey, + emptyMessage, +}: Props) { + if (isLoading) { + if (groups.length === 0) { + return ; + } + + return ( +
+
+ +
+
+ +
+
+ ); + } + + if (groups.length > 0) { + return ( + + ); + } + + return ( +
+ +

{emptyMessage}

+
+ ); +} + +interface GroupsProps { + groups: SortableGroup[]; + showGroupHeaders: boolean; + renderItem: (item: T, index: number) => ReactNode; + renderColumnHeaders?: (groupKey: string, items: T[]) => ReactNode; + getItemKey?: (item: T, index: number) => string | number; +} + +function Groups({ + groups, + showGroupHeaders, + renderItem, + renderColumnHeaders, + getItemKey, +}: GroupsProps) { + return ( + <> + {groups.map((group) => ( + + ))} + + ); +} diff --git a/app/react/components/SortableList/SortableListCard.tsx b/app/react/components/SortableList/SortableListCard.tsx new file mode 100644 index 000000000..81600a347 --- /dev/null +++ b/app/react/components/SortableList/SortableListCard.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react'; + +export function SortableListCard({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/app/react/components/SortableList/SortableListGroup.tsx b/app/react/components/SortableList/SortableListGroup.tsx new file mode 100644 index 000000000..1835f031d --- /dev/null +++ b/app/react/components/SortableList/SortableListGroup.tsx @@ -0,0 +1,75 @@ +import { Fragment, ReactNode } from 'react'; +import clsx from 'clsx'; + +export interface SortableGroup { + key: string; + label: string; + description?: string; + icon?: ReactNode; + items: T[]; +} + +interface Props { + group: SortableGroup; + showHeader: boolean; + renderItem: (item: T, index: number) => ReactNode; + renderColumnHeaders?: (groupKey: string, items: T[]) => ReactNode; + getItemKey?: (item: T, index: number) => string | number; +} + +export function SortableListGroup({ + group, + showHeader, + renderItem, + renderColumnHeaders, + getItemKey, +}: Props) { + return ( +
+ {showHeader && ( +
+ {group.icon && ( + + {group.icon} + + )} + + {group.label} + + + {group.items.length} + + {group.description && ( + + {group.description} + + )} +
+ )} + + {renderColumnHeaders && ( +
{renderColumnHeaders(group.key, group.items)}
+ )} + +
+ {group.items.map((item, index) => ( + + {renderItem(item, index)} + + ))} +
+
+ ); +} diff --git a/app/react/components/SortableList/SortableListItem.tsx b/app/react/components/SortableList/SortableListItem.tsx new file mode 100644 index 000000000..86b69bb9e --- /dev/null +++ b/app/react/components/SortableList/SortableListItem.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +export function SortableListItem({ children }: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/app/react/components/SortableList/SortableListPager.tsx b/app/react/components/SortableList/SortableListPager.tsx new file mode 100644 index 000000000..33def182e --- /dev/null +++ b/app/react/components/SortableList/SortableListPager.tsx @@ -0,0 +1,38 @@ +import { SortableListPagerInfo } from './SortableListPagerInfo'; +import { SortableListPagerNav } from './SortableListPagerNav'; + +interface Props { + page: number; + pageSize: number; + totalCount: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function SortableListPager({ + page, + pageSize, + totalCount, + onPageChange, + onPageSizeChange, +}: Props) { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const safePage = Math.min(page, totalPages - 1); + + return ( +
+ + +
+ ); +} diff --git a/app/react/components/SortableList/SortableListPagerInfo.tsx b/app/react/components/SortableList/SortableListPagerInfo.tsx new file mode 100644 index 000000000..08391373e --- /dev/null +++ b/app/react/components/SortableList/SortableListPagerInfo.tsx @@ -0,0 +1,55 @@ +import clsx from 'clsx'; + +const PAGE_SIZE_OPTIONS = [1, 10, 25, 50, 100] as const; + +interface Props { + page: number; + pageSize: number; + totalCount: number; + onPageSizeChange: (pageSize: number) => void; + onPageChange: (page: number) => void; +} + +export function SortableListPagerInfo({ + page, + pageSize, + totalCount, + onPageSizeChange, + onPageChange, +}: Props) { + if (totalCount === 0) return null; + + const start = page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, totalCount); + + return ( +
+ + Showing{' '} + + {start}–{end} + {' '} + of {totalCount} + + + +
+ ); +} diff --git a/app/react/components/SortableList/SortableListPagerNav.tsx b/app/react/components/SortableList/SortableListPagerNav.tsx new file mode 100644 index 000000000..8cfc3c494 --- /dev/null +++ b/app/react/components/SortableList/SortableListPagerNav.tsx @@ -0,0 +1,134 @@ +import { ReactNode } from 'react'; +import { + ChevronsLeft, + ChevronLeft, + ChevronRight, + ChevronsRight, +} from 'lucide-react'; +import clsx from 'clsx'; + +interface Props { + safePage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function SortableListPagerNav({ + safePage, + totalPages, + onPageChange, +}: Props) { + if (totalPages <= 1) return null; + + const pageNumbers = buildPageNumbers(totalPages, safePage); + + return ( +
+ onPageChange(0)} + title="First page" + > + + + onPageChange(safePage - 1)} + title="Previous page" + > + + + {pageNumbers.map((p, i) => + p === '...' ? ( + + … + + ) : ( + onPageChange(p as number)} + > + {(p as number) + 1} + + ) + )} + = totalPages - 1} + onClick={() => onPageChange(safePage + 1)} + title="Next page" + > + + + = totalPages - 1} + onClick={() => onPageChange(totalPages - 1)} + title="Last page" + > + + +
+ ); +} + +function buildPageNumbers( + totalPages: number, + current: number +): Array { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i); + } + const pages: Array = [0]; + if (current > 2) pages.push('...'); + for ( + let i = Math.max(1, current - 1); + i <= Math.min(totalPages - 2, current + 1); + i++ + ) { + pages.push(i); + } + if (current < totalPages - 3) pages.push('...'); + pages.push(totalPages - 1); + return pages; +} + +const btnBase = clsx( + 'flex h-7 w-7 items-center justify-center rounded', + 'border border-solid border-gray-4 th-dark:border-gray-7', + 'text-sm text-gray-7 th-dark:text-gray-4', + 'transition-colors hover:bg-gray-3 th-dark:hover:bg-gray-iron-9', + 'disabled:cursor-not-allowed disabled:opacity-40' +); + +const btnActivePage = clsx( + 'border-blue-7 bg-blue-7 text-white hover:bg-blue-6', + 'th-dark:border-blue-7 th-dark:bg-blue-7 th-dark:text-white' +); + +interface PagerButtonProps { + onClick: () => void; + disabled?: boolean; + title?: string; + active?: boolean; + children: ReactNode; +} + +function PagerButton({ + onClick, + disabled, + title, + active, + children, +}: PagerButtonProps) { + return ( + + ); +} diff --git a/app/react/components/SortableList/SortableListSkeleton.tsx b/app/react/components/SortableList/SortableListSkeleton.tsx new file mode 100644 index 000000000..9cf030946 --- /dev/null +++ b/app/react/components/SortableList/SortableListSkeleton.tsx @@ -0,0 +1,30 @@ +const ROWS = [ + { primary: 'w-2/5', secondary: 'w-16' }, + { primary: 'w-3/5', secondary: 'w-20' }, + { primary: 'w-1/3', secondary: 'w-14' }, + { primary: 'w-1/2', secondary: 'w-16' }, + { primary: 'w-2/5', secondary: 'w-12' }, +]; + +export function SortableListSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => { + const { primary, secondary } = ROWS[i % ROWS.length]; + return ( +
+
+
+
+ ); + })} +
+ ); +} diff --git a/app/react/components/SortableList/sortable-list.store.ts b/app/react/components/SortableList/sortable-list.store.ts new file mode 100644 index 000000000..c3d80bfa5 --- /dev/null +++ b/app/react/components/SortableList/sortable-list.store.ts @@ -0,0 +1,59 @@ +import { useState } from 'react'; + +import { + BasicTableSettings, + BackendPaginationTableSettings, + backendPaginationSettings, + createPersistedStore, + createTableStore, + ZustandSetFunc, +} from '@@/datatables/types'; +import { useTableState, TableState } from '@@/datatables/useTableState'; + +interface SortableListSettings + extends BasicTableSettings, + BackendPaginationTableSettings { + groupFilter: string | null; + setGroupFilter: (filter: string | null) => void; +} + +export type SortableListState = TableState; + +function sortableListExtras( + set: ZustandSetFunc +): Partial { + return { + ...backendPaginationSettings(set), + groupFilter: null, + setGroupFilter: (filter: string | null) => + set((s) => ({ ...s, groupFilter: filter, page: 0 })), + }; +} + +export function createSortableListStore( + storageKey: string, + defaultSort?: string +) { + return createPersistedStore( + storageKey, + defaultSort, + sortableListExtras + ); +} + +export function createSortableListStoreUnpersisted(defaultSort?: string) { + return createTableStore( + defaultSort, + sortableListExtras + ); +} + +export function useSortableListState( + storageKey: string, + defaultSort?: string +): SortableListState { + const [store] = useState(() => + createSortableListStore(storageKey, defaultSort) + ); + return useTableState(store, storageKey); +} diff --git a/app/react/components/SortableList/sortable-list.story-utils.ts b/app/react/components/SortableList/sortable-list.story-utils.ts new file mode 100644 index 000000000..0fe99b6b6 --- /dev/null +++ b/app/react/components/SortableList/sortable-list.story-utils.ts @@ -0,0 +1,184 @@ +import { useMemo, ReactNode } from 'react'; + +import { DropdownOption } from '../DropdownMenu/DropdownMenu'; + +import { SortableGroup, SortableListState } from './SortableList'; + +export function useGroupOptions( + sortBy: string, + items: T[], + getGroupKeys: (sortBy: string) => string[], + getGroupKey: (item: T, sortBy: string) => string, + getGroupLabel: (sortBy: string, key: string) => string +): DropdownOption[] { + return useMemo(() => { + const groupKeys = getGroupKeys(sortBy); + function key(item: T) { + return getGroupKey(item, sortBy); + } + return buildGroupFilterOptions(items, groupKeys, key, (k) => + getGroupLabel(sortBy, k) + ); + }, [getGroupKey, getGroupKeys, getGroupLabel, items, sortBy]); +} + +export interface ComputePagedGroupsParams { + items: T[]; + sortBy: string; + page: number; + pageSize: number; + groupFilter: string | null; + search: string; + getGroupKeys: (sortBy: string) => string[]; + getGroupKey: (item: T, sortBy: string) => string; + getGroupLabel: (sortBy: string, key: string) => string; + getSearchText: (item: T) => string; + getGroupIcon?: (key: string) => ReactNode; +} + +export function computePagedGroups({ + items, + sortBy, + page, + pageSize, + groupFilter, + search, + getGroupKeys, + getGroupKey, + getGroupLabel, + getSearchText, + getGroupIcon, +}: ComputePagedGroupsParams): { + groups: SortableGroup[]; + totalCount: number; +} { + const groupKeys = getGroupKeys(sortBy); + + const filtered = filterByGroupAndSearch( + items, + groupFilter, + key, + search, + getSearchText + ); + const sorted = sortItemsByGroup(filtered, groupKeys, key); + const paginated = sorted.slice(page * pageSize, (page + 1) * pageSize); + return { + groups: buildSortableGroups( + paginated, + groupKeys, + key, + (k) => getGroupLabel(sortBy, k), + getGroupIcon + ), + totalCount: filtered.length, + }; + + function key(item: T) { + return getGroupKey(item, sortBy); + } +} + +export function useGroups( + tableState: SortableListState, + items: T[], + defaultSortKey: string, + getGroupKeys: (sortBy: string) => string[], + getGroupKey: (item: T, sortBy: string) => string, + getGroupLabel: (sortBy: string, key: string) => string, + getSearchText: (item: T) => string, + getGroupIcon?: (key: string) => ReactNode +): { groups: SortableGroup[]; totalCount: number } { + const sortBy = tableState.sortBy?.id ?? defaultSortKey; + return useMemo( + () => + computePagedGroups({ + items, + sortBy, + page: tableState.page, + pageSize: tableState.pageSize, + groupFilter: tableState.groupFilter, + search: tableState.search, + getGroupKeys, + getGroupKey, + getGroupLabel, + getSearchText, + getGroupIcon, + }), + [ + getGroupKeys, + sortBy, + items, + tableState.groupFilter, + tableState.search, + tableState.page, + tableState.pageSize, + getSearchText, + getGroupIcon, + getGroupKey, + getGroupLabel, + ] + ); +} + +function filterByGroupAndSearch( + items: T[], + groupFilter: string | null, + getGroupKey: (item: T) => string, + search: string, + getSearchText: (item: T) => string +): T[] { + const q = search.toLowerCase().trim(); + return items.filter((item) => { + if (groupFilter && getGroupKey(item) !== groupFilter) return false; + if (q) return getSearchText(item).toLowerCase().includes(q); + return true; + }); +} + +function buildGroupFilterOptions( + allItems: T[], + groupKeys: string[], + getGroupKey: (item: T) => string, + getLabel: (key: string) => string +): DropdownOption[] { + return groupKeys.map((k) => ({ + key: k, + label: getLabel(k), + count: allItems.filter((item) => getGroupKey(item) === k).length, + })); +} + +function sortItemsByGroup( + items: T[], + groupKeys: string[], + getGroupKey: (item: T) => string +): T[] { + if (groupKeys.length === 0) return items; + return [...items].sort( + (a, b) => + groupKeys.indexOf(getGroupKey(a)) - groupKeys.indexOf(getGroupKey(b)) + ); +} + +function buildSortableGroups( + paginated: T[], + groupKeys: string[], + getGroupKey: (item: T) => string, + getLabel: (key: string) => string, + getIcon?: (key: string) => ReactNode +): SortableGroup[] { + if (groupKeys.length === 0) { + return paginated.length > 0 + ? [{ key: 'all', label: 'All', items: paginated }] + : []; + } + return groupKeys + .map((k) => ({ + key: k, + label: getLabel(k), + icon: getIcon?.(k), + items: paginated.filter((item) => getGroupKey(item) === k), + })) + .filter((g) => g.items.length > 0); +} diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts index b9339b515..e15ed06fa 100644 --- a/app/react/components/datatables/types.ts +++ b/app/react/components/datatables/types.ts @@ -129,8 +129,7 @@ export interface TableSettingsWithRefreshable export function createPersistedStore( storageKey: string, initialSortBy?: string | { id: string; desc: boolean }, - create: (set: ZustandSetFunc) => Omit = () => - ({}) as T + create: (set: ZustandSetFunc) => Partial = () => ({}) as T ) { return createStore()( persist( @@ -147,3 +146,17 @@ export function createPersistedStore( ) ); } + +export function createTableStore( + initialSortBy?: string | { id: string; desc: boolean }, + create: (set: ZustandSetFunc) => Partial = () => ({}) as T +) { + return createStore()( + (set) => + ({ + ...sortableSettings(set, initialSortBy), + ...paginationSettings(set), + ...create(set), + }) as T + ); +}