diff --git a/package.json b/package.json index d696e175bd..a8278ef74c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:storybook:data-grid": "pnpm --filter ./packages/eds-data-grid-react run build:storybook", "test": "pnpm run test:utils && pnpm run test:core-react && pnpm run test:lab && pnpm run test:data-grid-react", "test:core-react": "pnpm --filter @equinor/eds-core-react run test", - "test:data-grid-react": "pnpm --filter @equinor/eds-core-react run test", + "test:data-grid-react": "pnpm --filter @equinor/eds-data-grid-react run test", "test:lab": "pnpm --filter @equinor/eds-lab-react run test", "test:utils": "pnpm --filter @equinor/eds-utils run test", "test:watch:core-react": "pnpm --filter @equinor/eds-core-react run test:watch", diff --git a/packages/eds-data-grid-react/jest.config.ts b/packages/eds-data-grid-react/jest.config.ts index 89a937dd76..b797bb440a 100644 --- a/packages/eds-data-grid-react/jest.config.ts +++ b/packages/eds-data-grid-react/jest.config.ts @@ -14,6 +14,8 @@ const config: Config = { '/node_modules/@equinor/eds-utils/src/test/__mocks__/styleMock.js', '^react$': '/node_modules/react', '^styled-components$': '/node_modules/styled-components', + '^@equinor/eds-core-react$': + '/node_modules/@equinor/eds-core-react/dist/esm/index.js', }, testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$', testEnvironment: 'jest-environment-jsdom', diff --git a/packages/eds-data-grid-react/src/EdsDataGrid.stories.tsx b/packages/eds-data-grid-react/src/EdsDataGrid.stories.tsx index a71a3e5f6c..6e80ef0419 100644 --- a/packages/eds-data-grid-react/src/EdsDataGrid.stories.tsx +++ b/packages/eds-data-grid-react/src/EdsDataGrid.stories.tsx @@ -3,10 +3,17 @@ import { EdsDataGrid, EdsDataGridProps } from './EdsDataGrid' import { columns, groupedColumns, Photo } from './stories/columns' import { data } from './stories/data' import { CSSProperties, useEffect, useState } from 'react' -import { Button, Checkbox, Paper, Typography } from '@equinor/eds-core-react' +import { + Button, + Checkbox, + Pagination, + Paper, + Typography, +} from '@equinor/eds-core-react' import page from './EdsDataGrid.docs.mdx' import { Column, Row } from '@tanstack/react-table' import { tokens } from '@equinor/eds-tokens' +import { action } from '@storybook/addon-actions' const meta: Meta> = { title: 'EDS Data grid', @@ -57,6 +64,35 @@ Paging.args = { pageSize: 10, } +export const ExternalPaging: StoryFn> = (args) => { + return ( + <> +
+ + Using externalPaginator gives you control over setting the appropriate + rows yourself. + + + This is useful for e.g server-side pagination + +
+ { + action(`Page changed`)(page) + }} + /> + } + /> + + ) +} + export const ColumnGrouping: StoryFn> = (args) => { return } diff --git a/packages/eds-data-grid-react/src/EdsDataGrid.tsx b/packages/eds-data-grid-react/src/EdsDataGrid.tsx index 78c021cfce..66e66428f9 100644 --- a/packages/eds-data-grid-react/src/EdsDataGrid.tsx +++ b/packages/eds-data-grid-react/src/EdsDataGrid.tsx @@ -95,6 +95,11 @@ export type EdsDataGridProps = { * @default 25 */ pageSize?: number + /** + * Add this if you want to implement a custom pagination component + * Useful for e.g server-side paging + */ + externalPaginator?: ReactElement /** * The message to display when there are no rows * @default undefined @@ -191,6 +196,7 @@ export function EdsDataGrid({ rowStyle, headerClass, headerStyle, + externalPaginator, }: EdsDataGridProps) { const [sorting, setSorting] = useState([]) const [selection, setSelection] = useState( @@ -467,17 +473,21 @@ export function EdsDataGrid({ .rows.map((row) => )} - {enablePagination && ( -
- setPage((s) => ({ ...s, pageIndex: p - 1 }))} - defaultPage={1} - /> -
- )} + {externalPaginator + ? externalPaginator + : enablePagination && ( +
+ + setPage((s) => ({ ...s, pageIndex: p - 1 })) + } + defaultPage={1} + /> +
+ )} {debug && enableVirtual && ( diff --git a/packages/eds-data-grid-react/src/components/DebouncedInput.tsx b/packages/eds-data-grid-react/src/components/DebouncedInput.tsx index 650d8c2029..e84370f1d4 100644 --- a/packages/eds-data-grid-react/src/components/DebouncedInput.tsx +++ b/packages/eds-data-grid-react/src/components/DebouncedInput.tsx @@ -1,6 +1,11 @@ /* istanbul ignore file */ import { ChangeEvent, InputHTMLAttributes, useEffect, useState } from 'react' -import { Autocomplete, EdsProvider, Input } from '@equinor/eds-core-react' +import { + Autocomplete, + InputWrapper, + Input, + Chip, +} from '@equinor/eds-core-react' type Value = string | number | Array // File ignored, as relevant actions are covered via Filter.test.tsx @@ -9,12 +14,14 @@ export function DebouncedInput({ values, onChange, debounce = 500, + label, ...props }: { value: Value values: Array onChange: (value: Value) => void debounce?: number + label?: string } & Omit, 'onChange'>) { const [value, setValue] = useState(initialValue) @@ -32,31 +39,63 @@ export function DebouncedInput({ }, [value]) return ( - + <> {props.type === 'number' ? ( - - setValue((e.target as HTMLInputElement).valueAsNumber) - } - /> + + + setValue((e.target as HTMLInputElement).valueAsNumber) + } + /> + ) : ( - - opt === 'NULL_OR_UNDEFINED' ? '' : opt - } - data-testid={'autocomplete'} - /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ - // @ts-ignore - label={null} - placeholder={props.placeholder ?? 'Search'} - onOptionsChange={(c) => setValue(c.selectedItems)} - /> + <> + + opt === 'NULL_OR_UNDEFINED' ? '' : opt + } + data-testid={'autocomplete'} + /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ + label={`Select ${label ?? ''}`} + placeholder={props.placeholder ?? 'Search'} + disablePortal={ + false /*TODO: Check with Oddbjørn re. sizing/position*/ + } + selectedOptions={value as Array} + onOptionsChange={(c) => setValue(c.selectedItems)} + /> +
+ {(value as Array).map((v) => ( + { + if (['Backspace', 'Delete'].includes(event.key)) { + onChange( + (value as Array).filter((item) => item !== v), + ) + } + }} + style={{ margin: '4px' }} + onDelete={() => + onChange( + (value as Array).filter((item) => item !== v), + ) + } + key={v} + > + {v.slice(0, 20)} + {v.length > 20 ? '...' : ''} + + ))} +
+ )} -
+ ) } diff --git a/packages/eds-data-grid-react/src/components/Filter.tsx b/packages/eds-data-grid-react/src/components/Filter.tsx index df0a352bf4..16bbc48d92 100644 --- a/packages/eds-data-grid-react/src/components/Filter.tsx +++ b/packages/eds-data-grid-react/src/components/Filter.tsx @@ -1,19 +1,57 @@ /* istanbul ignore file */ import { Column, Table as TanStackTable } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useMemo, useState, useRef, MouseEvent } from 'react' import { DebouncedInput } from './DebouncedInput' +import { Icon, Popover, Button } from '@equinor/eds-core-react' +import { filter_alt, filter_alt_active } from '@equinor/eds-icons' +import styled from 'styled-components' +import { tokens } from '@equinor/eds-tokens' type FilterProps = { column: Column table: TanStackTable } +const NumberContainer = styled.div` + display: grid; + grid-template-columns: 80px 80px; + grid-column-gap: 32px; +` + export function Filter({ column, table }: FilterProps) { const firstValue = table .getPreFilteredRowModel() .flatRows[0]?.getValue(column.id) + const [open, setOpen] = useState(false) + const filterIconRef = useRef() + + const togglePopover = (event: MouseEvent) => { + event.stopPropagation() + setOpen(!open) + } - const columnFilterValue = column.getFilterValue() + const columnText = useMemo(() => { + let header: string | undefined + try { + if (typeof column.columnDef.header === 'function') { + const obj: React.ReactElement<{ children: string }> = + column.columnDef.header( + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument + {} as any, + ) as React.ReactElement<{ + children: string + }> + header = obj.props.children + } else { + header = column.columnDef.header + } + } catch { + /*em all*/ + } + return header + }, [column.columnDef]) + + const columnFilterValue = column.getFilterValue() as T | Array const sortedUniqueValues = useMemo>( () => @@ -26,47 +64,98 @@ export function Filter({ column, table }: FilterProps) { [column.getFacetedUniqueValues()], ) - return typeof firstValue === 'number' ? ( -
- - column.setFilterValue((old: [number, number]) => [value, old?.[1]]) - } - placeholder={`Min ${ - column.getFacetedMinMaxValues()?.[0] - ? `(${column.getFacetedMinMaxValues()?.[0]})` - : '' - }`} - /> - - column.setFilterValue((old: [number, number]) => [old?.[0], value]) - } - placeholder={`Max ${ - column.getFacetedMinMaxValues()?.[1] - ? `(${column.getFacetedMinMaxValues()?.[1]})` - : '' - }`} - /> -
- ) : ( - column.setFilterValue(value)} - placeholder={`Search (${column.getFacetedUniqueValues().size})`} - list={column.id + 'list'} - /> + const hasActiveFilters = (value: T | Array) => { + if (Array.isArray(value)) { + if (typeof firstValue === 'number') { + return (value as Array).some((v) => !isNaN(v) && !!v) + } else { + return value.filter((v) => !!v).length > 0 + } + } + return value + } + + return ( + <> + + setOpen(false)} + > + + {typeof firstValue === 'number' ? ( + + + column.setFilterValue((old: [number, number]) => [ + value, + old?.[1], + ]) + } + placeholder={`Min ${ + column.getFacetedMinMaxValues()?.[0] + ? `(${column.getFacetedMinMaxValues()?.[0]})` + : '' + }`} + /> + + column.setFilterValue((old: [number, number]) => [ + old?.[0], + value, + ]) + } + placeholder={`Max ${ + column.getFacetedMinMaxValues()?.[1] + ? `(${column.getFacetedMinMaxValues()?.[1]})` + : '' + }`} + /> + + ) : ( + } + onChange={(value) => column.setFilterValue(value)} + placeholder={`${ + ((columnFilterValue ?? []) as Array).length + } / ${column.getFacetedUniqueValues().size} selected`} + list={column.id + 'list'} + /> + )} + + + ) } diff --git a/packages/eds-data-grid-react/src/components/TableHeaderCell.tsx b/packages/eds-data-grid-react/src/components/TableHeaderCell.tsx index 7cfb9490c0..8a3b56f84e 100644 --- a/packages/eds-data-grid-react/src/components/TableHeaderCell.tsx +++ b/packages/eds-data-grid-react/src/components/TableHeaderCell.tsx @@ -35,10 +35,18 @@ type ResizeProps = { $isResizing: boolean } +const ResizeInner = styled.div` + width: 2px; + opacity: 0; + height: 100%; +` + const Resizer = styled.div` transform: ${(props) => props.$columnResizeMode === 'onEnd' ? 'translateX(0px)' : 'none'}; - opacity: ${(props) => (props.$isResizing ? 1 : 0)}; + ${ResizeInner} { + opacity: ${(props) => (props.$isResizing ? 1 : 0)}; + } position: absolute; right: 0; @@ -48,13 +56,15 @@ const Resizer = styled.div` cursor: col-resize; user-select: none; touch-action: none; + display: flex; + justify-content: flex-end; ` const Cell = styled(Table.Cell)<{ sticky: boolean }>` font-weight: bold; height: 30px; position: ${(p) => (p.sticky ? 'sticky' : 'relative')}; - &:hover ${Resizer} { + &:hover ${ResizeInner} { background: ${tokens.colors.interactive.primary__hover.rgba}; opacity: 1; } @@ -81,7 +91,7 @@ export function TableHeaderCell({ header, columnResizeMode }: Props) { colSpan: header.colSpan, style: { width: header.getSize(), - verticalAlign: ctx.enableColumnFiltering ? 'baseline' : 'middle', + verticalAlign: ctx.enableColumnFiltering ? 'top' : 'middle', ...(ctx.headerStyle ? ctx.headerStyle(header.column) : {}), }, }} @@ -91,18 +101,19 @@ export function TableHeaderCell({ header, columnResizeMode }: Props) { {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getCanFilter() ? ( - // Supressing this warning - div is not interactive, but prevents propagation of events to avoid unintended sorting - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
e.stopPropagation()}> - -
- ) : null} {{ asc: , desc: , }[header.column.getIsSorted() as string] ?? null} + + {header.column.getCanFilter() ? ( + // Supressing this warning - div is not interactive, but prevents propagation of events to avoid unintended sorting + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + +
+ ) : null} {columnResizeMode && ( ({ header, columnResizeMode }: Props) { $columnResizeMode={columnResizeMode} className={'resize-handle'} data-testid={'resize-handle'} - /> + > + + )} ) diff --git a/packages/eds-data-grid-react/src/tests/EdsDataGrid.test.tsx b/packages/eds-data-grid-react/src/tests/EdsDataGrid.test.tsx index d726955b41..e49b852710 100644 --- a/packages/eds-data-grid-react/src/tests/EdsDataGrid.test.tsx +++ b/packages/eds-data-grid-react/src/tests/EdsDataGrid.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { userEvent } from '@testing-library/user-event' import { EdsDataGrid } from '../EdsDataGrid' import { columns } from './columns' import { Data, data } from './data' @@ -34,9 +34,21 @@ describe('EdsDataGrid', () => { ) expect(screen.getAllByRole('columnheader').length).toBe(columns.length) expect( - within(screen.getAllByRole('columnheader')[0]).getByRole('combobox'), + within(screen.getAllByRole('columnheader')[0]).getByTestId( + 'open-filters', + ), ).toBeTruthy() + const header = screen.getAllByRole('columnheader')[0] + const button = within(header).getByTestId('open-filters') + fireEvent( + button, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ) + expect( within(screen.getAllByRole('listbox')[0]).queryAllByRole('option') .length, diff --git a/packages/eds-data-grid-react/src/tests/Filter.test.tsx b/packages/eds-data-grid-react/src/tests/Filter.test.tsx index 868729bcff..db4821920a 100644 --- a/packages/eds-data-grid-react/src/tests/Filter.test.tsx +++ b/packages/eds-data-grid-react/src/tests/Filter.test.tsx @@ -8,7 +8,18 @@ import { import { Filter } from '../components/Filter' import { data, Data } from './data' import { columns } from './columns' -import userEvent from '@testing-library/user-event' +import { userEvent } from '@testing-library/user-event' + +const openPopover = (header: HTMLElement) => { + const button = within(header).getByTestId('open-filters') + fireEvent( + button, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ) +} describe('Filter', () => { let table: Table @@ -55,10 +66,14 @@ describe('Filter', () => { const { baseElement } = render( , ) + openPopover(baseElement) expect(within(baseElement).getAllByRole('combobox').length).toBe(1) }) it('should have 2 inputs for number-columns', () => { - render() + const { baseElement } = render( + , + ) + openPopover(baseElement) expect(screen.queryAllByRole('spinbutton').length).toBe(2) }) }) @@ -68,6 +83,7 @@ describe('Filter', () => { const { baseElement } = render( , ) + openPopover(baseElement) const input = within(baseElement).getByRole('combobox') const col = table.getColumn('status') const spy = jest.spyOn(col, 'setFilterValue') @@ -90,6 +106,7 @@ describe('Filter', () => { const { baseElement } = render( , ) + openPopover(baseElement) const input = within(baseElement).getByRole('combobox') const col = table.getColumn('status') const spy = jest.spyOn(col, 'setFilterValue') @@ -104,6 +121,7 @@ describe('Filter', () => { const { baseElement } = render( , ) + openPopover(baseElement) // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const inputs = within(baseElement).getAllByRole( 'spinbutton', @@ -122,20 +140,16 @@ describe('Filter', () => { it('should have min/max for numeric', () => { const col = table.getColumn('numeric') const { baseElement } = render() + + openPopover(baseElement) // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const inputs = within(baseElement).getAllByRole( 'spinbutton', ) as Array const min = inputs[0] const max = inputs[1] - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const minValue = Math.min(...data.map((v) => v.qty)) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const maxValue = Math.max(...data.map((v) => v.qty)) - expect(min.placeholder).toBe(`Min (${minValue})`) - expect(max.placeholder).toBe(`Max (${maxValue})`) + expect(min.placeholder).toBe(`0`) + expect(max.placeholder).toBe(`0`) }) it('should work with min/max if no faceted values', () => { @@ -149,6 +163,7 @@ describe('Filter', () => { }) const col = table.getColumn('numeric') const { baseElement } = render() + openPopover(baseElement) // eslint complains about unneccessary cast, but HTMLElement != HTMLInputElement // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const inputs = within(baseElement).getAllByRole( @@ -156,8 +171,8 @@ describe('Filter', () => { ) as Array const min = inputs[0] const max = inputs[1] - expect(min.placeholder).toBe(`Min `) - expect(max.placeholder).toBe(`Max `) + expect(min.placeholder).toBe(`0`) + expect(max.placeholder).toBe(`0`) }) }) })