diff --git a/README.md b/README.md index 815471a..3b391fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ -# React Final Table ![CI](https://github.com/Buuntu/react-final-table/workflows/tests/badge.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![codecov](https://codecov.io/gh/Buuntu/react-final-table/branch/master/graph/badge.svg)](https://codecov.io/gh/Buuntu/react-final-table) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) +# React Final Table -A headless UI library for React tables, inspired by +![CI](https://github.com/Buuntu/react-final-table/workflows/tests/badge.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![codecov](https://codecov.io/gh/Buuntu/react-final-table/branch/master/graph/badge.svg)](https://codecov.io/gh/Buuntu/react-final-table) ![minzipped-size](https://badgen.net/bundlephobia/minzip/react-final-table) ![release](https://badgen.net/github/release/Buuntu/react-final-table) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) + +A [headless UI component +libray](https://www.merrickchristensen.com/articles/headless-user-interface-components/) +for managing complex table state in React. + +Inspired by [react-table](https://github.com/tannerlinsley/react-table) but with Typescript support built in and a simpler API. @@ -16,10 +22,18 @@ support built in and a simpler API. ## Motivation -While there are a plethora of table libraries available for every framework, -most are opinionated about the UI. This is a minimal, type-safe headless UI -library that you can plugin to whatever frontend framework you're using, as long -as you're using React 16 and functional components. +While there is an abundance of table libraries out there to help with sorting, filtering, pagination, and more, most are opinionated +about the user interface. Opinionated UIs can seem nice at first, but they +quickly become limiting. To embrace the Unix philosphy of separation of +concerns, the interface should be separate from the engine (from [The Art of +Unix +Programming](https://www.goodreads.com/book/show/104745.The_Art_of_UNIX_Programming)). + +This is a minimal, type-safe, headless UI component library that you can plugin +to whatever frontend you're using, as long as you're using React 16 and +[Hooks](https://reactjs.org/docs/hooks-intro.html). You are then free to style +your table any way you want while using **React Final Table** to manage complex +state changes. ## Install @@ -50,6 +64,29 @@ const { }); ``` +### `useTable` Arguments + +#### `columns` + +The first argument is an array of columns of type ColumnType. Each column has the following signature: + +```typescript +type ColumnType = { + name: string; + label?: string; + hidden?: boolean; + sort?: ((a: RowType, b: RowType) => number) | undefined; + render?: ({ value, row }: { value: any; row: T }) => React.ReactNode; + headerRender?: HeaderRenderType; +}; +``` + +Only name is required, the rest are optional arguments. + +#### `rows` + +Rows is the second argument to useTable and can be an array of any _object_ type. + ### Basic example ```tsx diff --git a/examples/material-ui/src/App.tsx b/examples/material-ui/src/App.tsx index 19e24d9..ca6c376 100644 --- a/examples/material-ui/src/App.tsx +++ b/examples/material-ui/src/App.tsx @@ -45,7 +45,9 @@ const columns = [ }, ]; -const data = [ +type DataType = { first_name: string, last_name: string, date_born: string }; + +const data: DataType[] = [ { first_name: 'Frodo', last_name: 'Baggins', @@ -69,10 +71,10 @@ function App() { originalRows, toggleSort, toggleAll, - } = useTable(columns, data, { + } = useTable(columns, data, { selectable: true, filter: useCallback( - (rows: RowType[]) => { + (rows: RowType[]) => { return rows.filter(row => { return ( row.cells.filter(cell => { @@ -107,7 +109,7 @@ function App() { {headers.map(column => ( toggleSort(column.name)}> - {column.label}{' '} + {column.render()}{' '} {column.sorted.on ? ( <> {column.sorted.asc ? ( diff --git a/package.json b/package.json index 94c6532..0e38833 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.5.1", + "version": "1.1.0", "license": "MIT", "author": "Gabriel Abud", "main": "dist/index.js", @@ -49,5 +49,13 @@ "tsdx": "^0.13.2", "tslib": "^2.0.1", "typescript": "^3.9.7" - } + }, + "repository": { + "type": "git", + "url": "https://github.com/Buuntu/react-final-table" + }, + "bugs": { + "url": "https://github.com/Buuntu/react-final-table/issues" + }, + "homepage": "https://github.com/Buuntu/react-final-table#readme" } diff --git a/src/hooks.tsx b/src/hooks.tsx index 053eaf8..5937be0 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -1,8 +1,7 @@ -import { useMemo, useReducer, useEffect } from 'react'; +import { useMemo, useReducer, useEffect, ReactNode } from 'react'; import { - ColumnByIdType, - ColumnByIdsType, + ColumnByNamesType, ColumnType, TableState, TableAction, @@ -10,6 +9,9 @@ import { UseTableReturnType, UseTableOptionsType, RowType, + HeaderType, + HeaderRenderType, + ColumnStateType, } from './types'; import { byTextAscending, byTextDescending } from './utils'; @@ -19,7 +21,7 @@ const createReducer = () => ( ): TableState => { switch (action.type) { case 'TOGGLE_SORT': - if (!(action.columnName in state.columnsById)) { + if (!(action.columnName in state.columnsByName)) { throw new Error(`Invalid column, ${action.columnName} not found`); } @@ -27,19 +29,25 @@ const createReducer = () => ( let sortedRows: RowType[] = []; + // loop through all columns and set the sort parameter to off unless + // it's the specified column (only one column at a time for ) const columnCopy = state.columns.map(column => { + // if the row was found if (action.columnName === column.name) { isAscending = column.sorted.asc; if (column.sort) { sortedRows = isAscending ? state.rows.sort(column.sort) : state.rows.sort(column.sort).reverse(); + // default to sort by string } else { - sortedRows = state.rows.sort( - isAscending - ? byTextAscending(object => object.original[action.columnName]) - : byTextDescending(object => object.original[action.columnName]) - ); + sortedRows = isAscending + ? state.rows.sort( + byTextAscending(object => object.original[action.columnName]) + ) + : state.rows.sort( + byTextDescending(object => object.original[action.columnName]) + ); } return { ...column, @@ -62,7 +70,7 @@ const createReducer = () => ( ...state, columns: columnCopy, rows: sortedRows, - columnsById: getColumnsById(columnCopy), + columnsByName: getColumnsByName(columnCopy), }; case 'GLOBAL_FILTER': const filteredRows = action.filter(state.originalRows); @@ -120,11 +128,10 @@ const createReducer = () => ( row => row.selected === true ); - if (stateCopy.selectedRows.length === stateCopy.rows.length) { - stateCopy.toggleAllState = true; - } else { - stateCopy.toggleAllState = false; - } + stateCopy.toggleAllState = + stateCopy.selectedRows.length === stateCopy.rows.length + ? (stateCopy.toggleAllState = true) + : (stateCopy.toggleAllState = false); return stateCopy; case 'TOGGLE_ALL': @@ -168,11 +175,14 @@ export const useTable = ( data: T[], options?: UseTableOptionsType ): UseTableReturnType => { - const columnsWithSorting = useMemo( + const columnsWithSorting: ColumnStateType[] = useMemo( () => columns.map(column => { return { ...column, + label: column.label ? column.label : column.name, + hidden: column.hidden ? column.hidden : false, + sort: column.sort, sorted: { on: false, asc: true, @@ -181,12 +191,11 @@ export const useTable = ( }), [columns] ); - const columnsById: ColumnByIdsType = useMemo( - () => getColumnsById(columnsWithSorting), - [columns] - ); + const columnsByName = useMemo(() => getColumnsByName(columnsWithSorting), [ + columnsWithSorting, + ]); - const tableData = useMemo(() => { + const tableData: RowType[] = useMemo(() => { const sortedData = sortDataInOrder(data, columnsWithSorting); const newData = sortedData.map((row, idx) => { @@ -198,22 +207,22 @@ export const useTable = ( cells: Object.entries(row) .map(([column, value]) => { return { - hidden: columnsById[column].hidden, + hidden: columnsByName[column].hidden, field: column, value: value, - render: makeRender(value, columnsById[column], row), + render: makeRender(value, columnsByName[column].render, row), }; }) .filter(cell => !cell.hidden), }; }); return newData; - }, [data, columnsWithSorting, columnsById]); + }, [data, columnsWithSorting, columnsByName]); const reducer = createReducer(); const [state, dispatch] = useReducer(reducer, { columns: columnsWithSorting, - columnsById: columnsById, + columnsByName: columnsByName, originalRows: tableData, rows: tableData, selectedRows: [], @@ -221,6 +230,18 @@ export const useTable = ( filterOn: false, }); + const headers: HeaderType[] = useMemo(() => { + return [ + ...state.columns.map(column => { + const label = column.label ? column.label : column.name; + return { + ...column, + render: makeHeaderRender(label, column.headerRender), + }; + }), + ]; + }, [state.columns]); + useEffect(() => { if (options && options.filter) { dispatch({ type: 'GLOBAL_FILTER', filter: options.filter }); @@ -230,7 +251,7 @@ export const useTable = ( }, [options?.filter]); return { - headers: state.columns.filter(column => !column.hidden), + headers: headers.filter(column => !column.hidden), rows: state.rows, originalRows: state.originalRows, selectedRows: state.selectedRows, @@ -244,13 +265,17 @@ export const useTable = ( const makeRender = ( value: any, - column: ColumnByIdType, + render: (({ value, row }: { value: any; row: T }) => ReactNode) | undefined, row: T ) => { - if (column.render) { - return () => column.render({ row, value }); - } - return () => value; + return render ? () => render({ row, value }) : () => value; +}; + +const makeHeaderRender = ( + label: string, + render: HeaderRenderType | undefined +) => { + return render ? () => render({ label }) : () => label; }; const sortDataInOrder = ( @@ -269,20 +294,21 @@ const sortDataInOrder = ( }); }; -const getColumnsById = ( +const getColumnsByName = ( columns: ColumnType[] -): ColumnByIdsType => { - const columnsById: ColumnByIdsType = {}; +): ColumnByNamesType => { + const columnsByName: ColumnByNamesType = {}; columns.forEach(column => { const col: any = { label: column.label, }; + if (column.render) { col['render'] = column.render; } col['hidden'] = column.hidden; - columnsById[column.name] = col; + columnsByName[column.name] = col; }); - return columnsById; + return columnsByName; }; diff --git a/src/test/selectionGlobalFiltering.spec.tsx b/src/test/selectionGlobalFiltering.spec.tsx index 452dcae..ab5cdf1 100644 --- a/src/test/selectionGlobalFiltering.spec.tsx +++ b/src/test/selectionGlobalFiltering.spec.tsx @@ -50,7 +50,7 @@ const TableWithSelection = ({ {headers.map((header, idx) => ( - {header.label} + {header.render()} ))} @@ -135,7 +135,7 @@ const TableWithFilter = ({ {headers.map((header, idx) => ( - {header.label} + {header.render()} ))} @@ -217,7 +217,7 @@ const TableWithSelectionAndFiltering = ({ {headers.map((header, idx) => ( - {header.label} + {header.render()} ))} diff --git a/src/test/table.spec.tsx b/src/test/table.spec.tsx index 151231b..80fb803 100644 --- a/src/test/table.spec.tsx +++ b/src/test/table.spec.tsx @@ -43,7 +43,7 @@ const Table = ({ {headers.map((header, idx) => ( - {header.label} + {header.render()} ))} @@ -99,12 +99,30 @@ const columnsWithRender: ColumnType[] = [ }, ]; -test('Should see custom render HTML', () => { +test('Should see custom row render HTML', () => { const rtl = render(); expect(rtl.getAllByTestId('first-name')).toHaveLength(2); }); +const columnsWithColRender: ColumnType[] = [ + { + name: 'firstName', + label: 'First Name', + headerRender: ({ label }) =>

{label}

, + }, + { + name: 'lastName', + label: 'Last Name', + }, +]; + +test('Should see custom column render HTML', () => { + const rtl = render(
); + + expect(rtl.getAllByTestId('first-name')).toHaveLength(1); +}); + // to supress console error from test output beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/types.ts b/src/types.ts index 6ca13cc..7a2114d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,9 +3,24 @@ export type ColumnType = { label?: string; hidden?: boolean; sort?: ((a: RowType, b: RowType) => number) | undefined; - render?: (value: any) => React.ReactNode; + render?: ({ value, row }: { value: any; row: T }) => React.ReactNode; + headerRender?: HeaderRenderType; }; +export type ColumnStateType = { + name: string; + label: string; + hidden: boolean; + sort?: ((a: RowType, b: RowType) => number) | undefined; + sorted: { + on: boolean; + asc: boolean; + }; + headerRender?: HeaderRenderType; +}; + +export type HeaderRenderType = ({ label }: { label: any }) => React.ReactNode; + // this is the type saved as state and returned export type HeaderType = { name: string; @@ -16,30 +31,29 @@ export type HeaderType = { asc: boolean; }; sort?: ((a: RowType, b: RowType) => number) | undefined; - render?: (value: any) => React.ReactNode; + render: () => React.ReactNode; }; export type DataType = { [key: string]: any }; -export type ColumnByIdsType = { - [key: string]: ColumnByIdType; +export type ColumnByNamesType = { + [key: string]: ColumnType; }; -export type RenderFunctionType = ({ +export type RenderFunctionType = ({ value, row, -}: RenderFunctionArgsType) => React.ReactNode | undefined; +}: RenderFunctionArgsType) => React.ReactNode | undefined; -type RenderFunctionArgsType = { +type RenderFunctionArgsType = { value: any; - row: Object; + row: T; }; -export type ColumnByIdType = { - label: string; - render: RenderFunctionType; - hidden?: boolean; -}; +export type ColumnByNameType = Omit< + Required>, + 'name' | 'sort' +>; export interface RowType { id: number; @@ -93,8 +107,8 @@ export interface UseTableReturnType { } export type TableState = { - columnsById: ColumnByIdsType; - columns: HeaderType[]; + columnsByName: ColumnByNamesType; + columns: ColumnStateType[]; rows: RowType[]; originalRows: RowType[]; selectedRows: RowType[];