diff --git a/cypress/component/DataViewTableSorting.cy.tsx b/cypress/component/DataViewTableSorting.cy.tsx new file mode 100644 index 0000000..79cae0f --- /dev/null +++ b/cypress/component/DataViewTableSorting.cy.tsx @@ -0,0 +1,109 @@ +/* eslint-disable no-nested-ternary */ +import React from 'react'; +import { useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks'; +import { DataViewTable, DataViewTr, DataViewTh } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; +import { BrowserRouter, useSearchParams } from 'react-router-dom'; +import { ThProps } from '@patternfly/react-table'; + +interface Repository { + name: string; + branches: string; + prs: string; + workspaces: string; + lastCommit: string; +} + +const COLUMNS = [ + { label: 'Repository', key: 'name', index: 0 }, + { label: 'Branch', key: 'branches', index: 1 }, + { label: 'Pull request', key: 'prs', index: 2 }, + { label: 'Workspace', key: 'workspaces', index: 3 }, + { label: 'Last commit', key: 'lastCommit', index: 4 }, +]; + +const repositories: Repository[] = [ + { name: 'Repository one', branches: 'Branch one', prs: 'Pull request one', workspaces: 'Workspace one', lastCommit: '2023-11-01' }, + { name: 'Repository six', branches: 'Branch six', prs: 'Pull request six', workspaces: 'Workspace six', lastCommit: '2023-11-06' }, + { name: 'Repository two', branches: 'Branch two', prs: 'Pull request two', workspaces: 'Workspace two', lastCommit: '2023-11-02' }, + { name: 'Repository five', branches: 'Branch five', prs: 'Pull request five', workspaces: 'Workspace five', lastCommit: '2023-11-05' }, + { name: 'Repository three', branches: 'Branch three', prs: 'Pull request three', workspaces: 'Workspace three', lastCommit: '2023-11-03' }, + { name: 'Repository four', branches: 'Branch four', prs: 'Pull request four', workspaces: 'Workspace four', lastCommit: '2023-11-04' }, +]; + +const sortData = (data: Repository[], sortBy: keyof Repository | undefined, direction: 'asc' | 'desc' | undefined) => + sortBy && direction + ? [ ...data ].sort((a, b) => + direction === 'asc' + ? a[sortBy] < b[sortBy] ? -1 : a[sortBy] > b[sortBy] ? 1 : 0 + : a[sortBy] > b[sortBy] ? -1 : a[sortBy] < b[sortBy] ? 1 : 0 + ) + : data; + +const TestTable: React.FunctionComponent = () => { + const [ searchParams, setSearchParams ] = useSearchParams(); + const { sortBy, direction, onSort } = useDataViewSort({ searchParams, setSearchParams }); + const sortByIndex = React.useMemo(() => COLUMNS.findIndex(item => item.key === sortBy), [ sortBy ]); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: sortByIndex, + direction, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => onSort(_event, COLUMNS[index].key, direction), + columnIndex, + }); + + const columns: DataViewTh[] = COLUMNS.map((column, index) => ({ + cell: column.label, + props: { sort: getSortParams(index) }, + })); + + const rows: DataViewTr[] = React.useMemo( + () => + sortData(repositories, sortBy ? sortBy as keyof Repository : undefined, direction).map(({ name, branches, prs, workspaces, lastCommit }) => [ + name, + branches, + prs, + workspaces, + lastCommit, + ]), + [ sortBy, direction ] + ); + + return ; +}; + +describe('DataViewTable Sorting with Hook', () => { + it('sorts by repository name in ascending and descending order', () => { + cy.mount( + + + + ); + + cy.get('[data-ouia-component-id="test-table-th-0"]').click(); + cy.get('[data-ouia-component-id="test-table-td-0-0"]').should('contain', 'Repository five'); + cy.get('[data-ouia-component-id="test-table-td-5-0"]').should('contain', 'Repository two'); + + cy.get('[data-ouia-component-id="test-table-th-0"]').click(); + cy.get('[data-ouia-component-id="test-table-td-0-0"]').should('contain', 'Repository two'); + cy.get('[data-ouia-component-id="test-table-td-5-0"]').should('contain', 'Repository five'); + }); + + it('sorts by last commit date in ascending and descending order', () => { + cy.mount( + + + + ); + + cy.get('[data-ouia-component-id="test-table-th-4"]').click(); + cy.get('[data-ouia-component-id="test-table-td-0-4"]').should('contain', '2023-11-01'); + cy.get('[data-ouia-component-id="test-table-td-5-4"]').should('contain', '2023-11-06'); + + cy.get('[data-ouia-component-id="test-table-th-4"]').click(); + cy.get('[data-ouia-component-id="test-table-td-0-4"]').should('contain', '2023-11-06'); + cy.get('[data-ouia-component-id="test-table-td-5-4"]').should('contain', '2023-11-01'); + }); +}); diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md index 1e31d79..7469001 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md @@ -16,7 +16,7 @@ sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/mod --- import { useMemo } from 'react'; import { BrowserRouter, useSearchParams } from 'react-router-dom'; -import { useDataViewPagination, useDataViewSelection, useDataViewFilters } from '@patternfly/react-data-view/dist/dynamic/Hooks'; +import { useDataViewPagination, useDataViewSelection, useDataViewFilters, useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks'; import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; import { BulkSelect, BulkSelectValue } from '@patternfly/react-component-groups/dist/dynamic/BulkSelect'; import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; @@ -119,3 +119,34 @@ This example demonstrates the setup and usage of filters within the data view. I ```js file="./FiltersExample.tsx" ``` + +### Sort state + +The `useDataViewSort` hook manages the sorting state of a data view. It provides an easy way to handle sorting logic, including synchronization with URL parameters and defining default sorting behavior. + +**Initial values:** +- `initialSort` object to set default `sortBy` and `direction` values: + - `sortBy`: key of the initial column to sort. + - `direction`: default sorting direction (`asc` or `desc`). +- Optional `searchParams` object to manage URL-based synchronization of sort state. +- Optional `setSearchParams` function to update the URL parameters when sorting changes. +- `defaultDirection` to set the default direction when no direction is specified. +- Customizable parameter names for the URL: + - `sortByParam`: name of the URL parameter for the column key. + - `directionParam`: name of the URL parameter for the sorting direction. + +The `useDataViewSort` hook integrates seamlessly with React Router to manage sort state via URL parameters. Alternatively, you can use `URLSearchParams` and `window.history.pushState` APIs, or other routing libraries. If URL synchronization is not configured, the sort state is managed internally within the component. + +**Return values:** +- `sortBy`: key of the column currently being sorted. +- `direction`: current sorting direction (`asc` or `desc`). +- `onSort`: function to handle sorting changes programmatically or via user interaction. + +### Sorting example + +This example demonstrates how to set up and use sorting functionality within a data view. The implementation includes dynamic sorting by column with persistence of sort state in the URL using React Router. + + +```js file="./SortingExample.tsx" + +``` diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/SortingExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/SortingExample.tsx new file mode 100644 index 0000000..879f1cd --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Functionality/SortingExample.tsx @@ -0,0 +1,87 @@ +/* eslint-disable no-nested-ternary */ +import React, { useMemo } from 'react'; +import { useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks'; +import { DataViewTable, DataViewTr, DataViewTh } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; +import { ThProps } from '@patternfly/react-table'; +import { BrowserRouter, useSearchParams } from 'react-router-dom'; + +interface Repository { + name: string; + branches: string; + prs: string; + workspaces: string; + lastCommit: string; +}; + +const COLUMNS = [ + { label: 'Repository', key: 'name', index: 0 }, + { label: 'Branch', key: 'branches', index: 1 }, + { label: 'Pull request', key: 'prs', index: 2 }, + { label: 'Workspace', key: 'workspaces', index: 3 }, + { label: 'Last commit', key: 'lastCommit', index: 4 } +]; + +const repositories: Repository[] = [ + { name: 'Repository one', branches: 'Branch one', prs: 'Pull request one', workspaces: 'Workspace one', lastCommit: 'Timestamp one' }, + { name: 'Repository two', branches: 'Branch two', prs: 'Pull request two', workspaces: 'Workspace two', lastCommit: 'Timestamp two' }, + { name: 'Repository three', branches: 'Branch three', prs: 'Pull request three', workspaces: 'Workspace three', lastCommit: 'Timestamp three' }, + { name: 'Repository four', branches: 'Branch four', prs: 'Pull request four', workspaces: 'Workspace four', lastCommit: 'Timestamp four' }, + { name: 'Repository five', branches: 'Branch five', prs: 'Pull request five', workspaces: 'Workspace five', lastCommit: 'Timestamp five' }, + { name: 'Repository six', branches: 'Branch six', prs: 'Pull request six', workspaces: 'Workspace six', lastCommit: 'Timestamp six' } +]; + +const sortData = (data: Repository[], sortBy: string | undefined, direction: 'asc' | 'desc' | undefined) => + sortBy && direction + ? [ ...data ].sort((a, b) => + direction === 'asc' + ? a[sortBy] < b[sortBy] ? -1 : a[sortBy] > b[sortBy] ? 1 : 0 + : a[sortBy] > b[sortBy] ? -1 : a[sortBy] < b[sortBy] ? 1 : 0 + ) + : data; + +const ouiaId = 'TableExample'; + +export const MyTable: React.FunctionComponent = () => { + const [ searchParams, setSearchParams ] = useSearchParams(); + const { sortBy, direction, onSort } = useDataViewSort({ searchParams, setSearchParams }); + const sortByIndex = useMemo(() => COLUMNS.findIndex(item => item.key === sortBy), [ sortBy ]); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: sortByIndex, + direction, + defaultDirection: 'asc' + }, + onSort: (_event, index, direction) => onSort(_event, COLUMNS[index].key, direction), + columnIndex + }); + + const columns: DataViewTh[] = COLUMNS.map((column, index) => ({ + cell: column.label, + props: { sort: getSortParams(index) } + })); + + const rows: DataViewTr[] = useMemo(() => sortData(repositories, sortBy, direction).map(({ name, branches, prs, workspaces, lastCommit }) => [ + name, + branches, + prs, + workspaces, + lastCommit, + ]), [ sortBy, direction ]); + + return ( + + ); +}; + +export const BasicExample: React.FunctionComponent = () => ( + + + +) + diff --git a/packages/module/src/Hooks/index.ts b/packages/module/src/Hooks/index.ts index 546a0da..3ce609c 100644 --- a/packages/module/src/Hooks/index.ts +++ b/packages/module/src/Hooks/index.ts @@ -1,3 +1,4 @@ export * from './pagination'; export * from './selection'; export * from './filters'; +export * from './sort'; diff --git a/packages/module/src/Hooks/sort.test.tsx b/packages/module/src/Hooks/sort.test.tsx new file mode 100644 index 0000000..473924d --- /dev/null +++ b/packages/module/src/Hooks/sort.test.tsx @@ -0,0 +1,84 @@ +import '@testing-library/jest-dom'; +import { renderHook, act } from '@testing-library/react'; +import { useDataViewSort, UseDataViewSortProps, DataViewSortConfig, DataViewSortParams } from './sort'; + +describe('useDataViewSort', () => { + const initialSort: DataViewSortConfig = { sortBy: 'name', direction: 'asc' }; + + it('should initialize with provided initial sort config', () => { + const { result } = renderHook(() => useDataViewSort({ initialSort })); + expect(result.current).toEqual(expect.objectContaining(initialSort)); + }); + + it('should initialize with empty sort config if no initialSort is provided', () => { + const { result } = renderHook(() => useDataViewSort()); + expect(result.current).toEqual(expect.objectContaining({ sortBy: undefined, direction: 'asc' })); + }); + + it('should update sort state when onSort is called', () => { + const { result } = renderHook(() => useDataViewSort({ initialSort })); + act(() => { + result.current.onSort(undefined, 'age', 'desc'); + }); + expect(result.current).toEqual(expect.objectContaining({ sortBy: 'age', direction: 'desc' })); + }); + + it('should sync with URL search params if isUrlSyncEnabled', () => { + const searchParams = new URLSearchParams(); + const setSearchParams = jest.fn(); + const props: UseDataViewSortProps = { + initialSort, + searchParams, + setSearchParams, + }; + + const { result } = renderHook(() => useDataViewSort(props)); + + expect(setSearchParams).toHaveBeenCalledTimes(1); + expect(result.current).toEqual(expect.objectContaining(initialSort)); + }); + + it('should validate direction and fallback to default direction if invalid direction is provided', () => { + const searchParams = new URLSearchParams(); + searchParams.set(DataViewSortParams.SORT_BY, 'name'); + searchParams.set(DataViewSortParams.DIRECTION, 'invalid-direction'); + const { result } = renderHook(() => useDataViewSort({ searchParams, defaultDirection: 'desc' })); + + expect(result.current).toEqual(expect.objectContaining({ sortBy: 'name', direction: 'desc' })); + }); + + it('should update search params when URL sync is enabled and sort changes', () => { + const searchParams = new URLSearchParams(); + const setSearchParams = jest.fn(); + const props: UseDataViewSortProps = { + initialSort, + searchParams, + setSearchParams, + }; + + const { result } = renderHook(() => useDataViewSort(props)); + act(() => { + expect(setSearchParams).toHaveBeenCalledTimes(1); + result.current.onSort(undefined, 'priority', 'desc'); + }); + + expect(setSearchParams).toHaveBeenCalledTimes(2); + expect(result.current).toEqual(expect.objectContaining({ sortBy: 'priority', direction: 'desc' })); + }); + + it('should prioritize searchParams values', () => { + const searchParams = new URLSearchParams(); + searchParams.set(DataViewSortParams.SORT_BY, 'category'); + searchParams.set(DataViewSortParams.DIRECTION, 'desc'); + + const { result } = renderHook( + (props: UseDataViewSortProps) => useDataViewSort(props), + { initialProps: { initialSort, searchParams } } + ); + + expect(result.current).toEqual(expect.objectContaining({ + sortBy: 'category', + direction: 'desc', + })); + }); +}); diff --git a/packages/module/src/Hooks/sort.ts b/packages/module/src/Hooks/sort.ts new file mode 100644 index 0000000..ed06f22 --- /dev/null +++ b/packages/module/src/Hooks/sort.ts @@ -0,0 +1,87 @@ +import { ISortBy } from "@patternfly/react-table"; +import { useState, useEffect, useMemo } from "react"; + +export enum DataViewSortParams { + SORT_BY = 'sortBy', + DIRECTION = 'direction' +}; + +const validateDirection = (direction: string | null | undefined, defaultDirection: ISortBy['direction']): ISortBy['direction'] => ( + direction === 'asc' || direction === 'desc' ? direction : defaultDirection +); + +export interface DataViewSortConfig { + /** Attribute to sort the entries by */ + sortBy: string | undefined; + /** Sort direction */ + direction: ISortBy['direction']; +}; + +export interface UseDataViewSortProps { + /** Initial sort config */ + initialSort?: DataViewSortConfig; + /** Current search parameters as a string */ + searchParams?: URLSearchParams; + /** Function to set search parameters */ + setSearchParams?: (params: URLSearchParams) => void; + /** Default direction */ + defaultDirection?: ISortBy['direction']; + /** Sort by URL param name */ + sortByParam?: string; + /** Direction URL param name */ + directionParam?: string; +}; + +export const useDataViewSort = (props?: UseDataViewSortProps) => { + const { + initialSort, + searchParams, + setSearchParams, + defaultDirection = 'asc', + sortByParam = DataViewSortParams.SORT_BY, + directionParam = DataViewSortParams.DIRECTION + } = props ?? {}; + + const isUrlSyncEnabled = useMemo(() => searchParams && !!setSearchParams, [ searchParams, setSearchParams ]); + + const [ state, setState ] = useState({ + sortBy: searchParams?.get(sortByParam) ?? initialSort?.sortBy, + direction: validateDirection(searchParams?.get(directionParam) as ISortBy['direction'], initialSort?.direction), + }); + + const updateSearchParams = (sortBy: string, direction: ISortBy['direction']) => { + if (isUrlSyncEnabled && sortBy) { + const params = new URLSearchParams(searchParams); + params.set(sortByParam, `${sortBy}`); + params.set(directionParam, `${direction}`); + setSearchParams?.(params); + } + }; + + useEffect(() => { + state.sortBy && state.direction && updateSearchParams(state.sortBy, state.direction); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const currentSortBy = searchParams?.get(sortByParam) || state.sortBy; + const currentDirection = searchParams?.get(directionParam) as ISortBy['direction'] || state.direction; + const validDirection = validateDirection(currentDirection, defaultDirection); + currentSortBy !== state.sortBy || validDirection !== state.direction && setState({ sortBy: currentSortBy, direction: validDirection }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ searchParams?.toString() ]); + + const onSort = ( + _event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined, + newSortBy: string, + newSortDirection: ISortBy['direction'] + ) => { + setState({ sortBy: newSortBy, direction: newSortDirection }); + updateSearchParams(newSortBy, newSortDirection); + }; + + return { + ...state, + onSort + }; +};