fix(usegroupby): fix useGroupBy, add grouping example and fix some tests

This commit is contained in:
tannerlinsley 2019-07-30 09:56:44 -06:00
parent d7a4db0979
commit af739d91d0
34 changed files with 12313 additions and 958 deletions

View File

@ -1,7 +1,7 @@
{
"presets": [
[
"@babel/env",
"@babel/preset-env",
{
"modules": false
}
@ -10,9 +10,7 @@
],
"env": {
"test": {
"presets": [
"@babel/preset-env"
]
"presets": ["@babel/preset-env", "@babel/react"]
}
}
}

View File

@ -4,16 +4,13 @@ module.exports = {
moduleDirectories: [
'node_modules',
/*
* make 'test/utils' available in tests, e.g.
* __dirname makes 'test/utils' available in tests, e.g.
*
* const {myModule} = require('utils/my-test-helper')
*/
__dirname,
],
rootDir: path.resolve(__dirname, '../../'),
roots: ['<rootDir>/src', __dirname],
transformIgnorePatterns: ['node_modules'],
}

View File

@ -2,14 +2,11 @@ const commonConfig = require('./jest.common')
module.exports = {
...commonConfig,
displayName: 'unit',
coverageDirectory: '../../coverage',
testMatch: ['<rootDir>/src/**/tests/**/*.js'],
testMatch: ['<rootDir>/src/**/*.test.js'],
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
},
setupFilesAfterEnv: ['./configs/tests/setup.common.js'],
}

View File

@ -0,0 +1 @@
import 'snapshot-diff/extend-expect'

View File

@ -0,0 +1,4 @@
{
"presets": ["react-app"],
"plugins": ["styled-components"]
}

View File

@ -0,0 +1 @@
SKIP_PREFLIGHT_CHECK=true

View File

@ -0,0 +1,7 @@
{
"extends": ["react-app", "prettier"],
"rules": {
// "eqeqeq": 0,
// "jsx-a11y/anchor-is-valid": 0
}
}

View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,29 @@
const path = require('path')
const resolveFrom = require('resolve-from')
const fixLinkedDependencies = config => {
config.resolve = {
...config.resolve,
alias: {
...config.resolve.alias,
react$: resolveFrom(path.resolve('node_modules'), 'react'),
'react-dom$': resolveFrom(path.resolve('node_modules'), 'react-dom'),
},
}
return config
}
const includeSrcDirectory = config => {
config.resolve = {
...config.resolve,
modules: [path.resolve('src'), ...config.resolve.modules],
}
return config
}
module.exports = [
['use-babel-config', '.babelrc'],
['use-eslint-config', '.eslintrc'],
fixLinkedDependencies,
// includeSrcDirectory,
]

View File

@ -0,0 +1,6 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app) and Rescripts.
You can:
- [Open this example in a new CodeSandbox](https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/basic)
- `yarn` and `yarn start` to run and edit the example

View File

@ -0,0 +1,35 @@
{
"private": true,
"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",
"eject": "rescripts eject"
},
"dependencies": {
"namor": "^1.1.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-scripts": "3.0.1",
"react-table": "next",
"styled-components": "^4.3.2"
},
"devDependencies": {
"@rescripts/cli": "^0.0.11",
"@rescripts/rescript-use-babel-config": "^0.0.8",
"@rescripts/rescript-use-eslint-config": "^0.0.9",
"babel-eslint": "10.0.1"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,260 @@
import React from 'react'
import styled from 'styled-components'
import { useTable, useGroupBy, useExpanded } from 'react-table'
import makeData from './makeData'
const Styles = styled.div`
padding: 1rem;
table {
border-spacing: 0;
border: 1px solid black;
tr {
:last-child {
td {
border-bottom: 0;
}
}
}
th,
td {
margin: 0;
padding: 0.5rem;
border-bottom: 1px solid black;
border-right: 1px solid black;
:last-child {
border-right: 0;
}
}
}
`
function Table({ columns, data }) {
const {
getTableProps,
headerGroups,
rows,
prepareRow,
state: [{ groupBy, expanded }],
} = useTable(
{
columns,
data,
},
useGroupBy,
useExpanded
)
// We don't want to render all 2000 rows for this example, so cap
// it at 20 for this use case
const firstPageRows = rows.slice()
return (
<>
<pre>
<code>{JSON.stringify({ groupBy, expanded }, null, 2)}</code>
</pre>
<Legend />
<table {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th {...column.getHeaderProps()}>
{column.canGroupBy ? (
<span {...column.getGroupByToggleProps()}>
{column.grouped ? '🛑' : '👊'}
</span>
) : null}
{column.render('Header')}
{/* Add a sort direction indicator */}
<span>
{column.sorted ? (column.sortedDesc ? ' 🔽' : ' 🔼') : ''}
</span>
</th>
))}
</tr>
))}
</thead>
<tbody>
{firstPageRows.map(
(row, i) =>
prepareRow(row) || (
<tr {...row.getRowProps()}>
{row.cells.map(cell => {
return (
<td
{...cell.getCellProps()}
style={{
background: cell.grouped
? '#0aff0082'
: cell.aggregated
? '#ffa50078'
: cell.repeatedValue
? '#ff000042'
: 'white',
}}
>
{cell.grouped ? (
// Add an expander and row count to the grouped cell
<>
<span
style={{
cursor: 'pointer',
}}
onClick={() => row.toggleExpanded()}
>
{row.isExpanded ? '👇' : '👉'}
</span>
{cell.render('Cell')} ({row.subRows.length})
</>
) : cell.aggregated ? (
// Use the Aggregated renderer for cells that have
// aggregated values
cell.render('Aggregated')
) : cell.repeatedValue ? null : ( // For cells with repeated values, render null
// Otherwise, just render the regular cell
cell.render('Cell')
)}
</td>
)
})}
</tr>
)
)}
</tbody>
</table>
<br />
<div>Showing the first 20 results of {rows.length} rows</div>
</>
)
}
function Legend() {
return (
<div
css={`
padding: 0.5rem 0;
`}
>
<span
css={`
display: inline-block;
background: #0aff0082;
padding: 0.5rem;
`}
>
Grouped
</span>{' '}
<span
css={`
display: inline-block;
background: #ffa50078;
padding: 0.5rem;
`}
>
Aggregated
</span>{' '}
<span
css={`
display: inline-block;
background: #ff000042;
padding: 0.5rem;
`}
>
Repeated Value
</span>
</div>
)
}
function roundedMedian(values) {
let min = values[0] || ''
let max = values[0] || ''
values.forEach(value => {
min = Math.min(min, value)
max = Math.max(max, value)
})
return Math.round((min + max) / 2)
}
function App() {
const columns = React.useMemo(
() => [
{
Header: 'Name',
columns: [
{
Header: 'First Name',
accessor: 'firstName',
// Use a two-stage aggregator here to first
// count the total rows being aggregated,
// then sum any of those counts if they are
// aggregated further
aggregate: ['sum', 'count'],
Aggregated: ({ value }) => `${value} Names`,
},
{
Header: 'Last Name',
accessor: 'lastName',
// Use another two-stage aggregator here to
// first count the UNIQUE values from the rows
// being aggregated, then sum those counts if
// they are aggregated further
aggregate: ['sum', 'uniqueCount'],
Aggregated: ({ value }) => `${value} Unique Names`,
},
],
},
{
Header: 'Info',
columns: [
{
Header: 'Age',
accessor: 'age',
// Aggregate the average age of visitors
aggregate: 'average',
Aggregated: ({ value }) => `${value} (avg)`,
},
{
Header: 'Visits',
accessor: 'visits',
// Aggregate the sum of all visits
aggregate: 'sum',
Aggregated: ({ value }) => `${value} (total)`,
},
{
Header: 'Status',
accessor: 'status',
},
{
Header: 'Profile Progress',
accessor: 'progress',
// Use our custom roundedMedian aggregator
aggregate: roundedMedian,
Aggregated: ({ value }) => `${value} (med)`,
},
],
},
],
[]
)
const data = React.useMemo(() => makeData(100), [])
return (
<Styles>
<Table columns={columns} data={data} />
</Styles>
)
}
export default App

View File

@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})

View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorker from './serviceWorker'
ReactDOM.render(<App />, document.getElementById('root'))
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

View File

@ -0,0 +1,40 @@
import namor from 'namor'
const range = len => {
const arr = []
for (let i = 0; i < len; i++) {
arr.push(i)
}
return arr
}
const newPerson = () => {
const statusChance = Math.random()
return {
firstName: namor.generate({ words: 1, numbers: 0 }),
lastName: namor.generate({ words: 1, numbers: 0 }),
age: Math.floor(Math.random() * 30),
visits: Math.floor(Math.random() * 100),
progress: Math.floor(Math.random() * 100),
status:
statusChance > 0.66
? 'relationship'
: statusChance > 0.33
? 'complicated'
: 'single',
}
}
export default function makeData(...lens) {
const makeDataLevel = (depth = 0) => {
const len = lens[depth]
return range(len).map(d => {
return {
...newPerson(),
subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
}
})
}
return makeDataLevel()
}

View File

@ -0,0 +1,135 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config)
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
)
})
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config)
}
})
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
if (installingWorker == null) {
return
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
)
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration)
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration)
}
}
}
}
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type')
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config)
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister()
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -143,7 +143,7 @@ function App() {
[]
)
const data = React.useMemo(() => makeData(1000), [])
const data = React.useMemo(() => makeData(2000), [])
return (
<Styles>

View File

@ -18,7 +18,7 @@
"module": "dist/index.es.js",
"jsnext:main": "dist/index.es.js",
"scripts": {
"commit": "git commit",
"commit": "git-cz",
"test": "is-ci 'test:ci' 'test:dev'",
"test:dev": "jest --watch",
"test:ci": "jest",
@ -57,6 +57,7 @@
"@testing-library/react": "^8.0.7",
"babel-eslint": "9.x",
"commitizen": "^4.0.3",
"core-js": "3",
"cross-env": "^5.2.0",
"eslint": "5.x",
"eslint-config-prettier": "^4.3.0",
@ -80,16 +81,21 @@
"jest-watch-select-projects": "^0.1.2",
"jest-watch-typeahead": "^0.3.1",
"lint-staged": "^9.2.1",
"prop-types": "^15.5.0",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"rollup": "^0.68.0",
"rollup-plugin-babel": "^4.3.3",
"rollup-plugin-commonjs": "^9.1.3",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-peer-deps-external": "^2.2.0",
"rollup-plugin-uglify": "^6.0.2"
"rollup-plugin-uglify": "^6.0.2",
"snapshot-diff": "^0.5.2"
},
"config": {
"commitizen": {
"path": "node_modules/cz-conventional-changelog"
}
}
},
"browserslist": "> 0.25%, not dead"
}

View File

@ -5,3 +5,23 @@ export function sum(values, rows) {
export function average(values, rows) {
return Math.round((sum(values, rows) / values.length) * 100) / 100
}
export function median(values) {
let min = values[0] || ''
let max = values[0] || ''
values.forEach(value => {
min = Math.min(min, value)
max = Math.max(max, value)
})
return (min + max) / 2
}
export function uniqueCount(values) {
return new Set(values).size
}
export function count(values) {
return values.length
}

View File

@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders a basic table 1`] = `
<DocumentFragment>
<table
class=""
>
<thead>
<tr
class=""
>
<th
class=""
colspan="2"
>
Name
</th>
<th
class=""
colspan="4"
>
Info
</th>
</tr>
<tr
class=""
>
<th
class=""
colspan="1"
>
First Name
</th>
<th
class=""
colspan="1"
>
Last Name
</th>
<th
class=""
colspan="1"
>
Age
</th>
<th
class=""
colspan="1"
>
Visits
</th>
<th
class=""
colspan="1"
>
Status
</th>
<th
class=""
colspan="1"
>
Profile Progress
</th>
</tr>
</thead>
<tbody>
<tr
class=""
>
<td
class=""
>
tanner
</td>
<td
class=""
>
linsley
</td>
<td
class=""
>
29
</td>
<td
class=""
>
100
</td>
<td
class=""
>
In Relationship
</td>
<td
class=""
>
50
</td>
</tr>
<tr
class=""
>
<td
class=""
>
derek
</td>
<td
class=""
>
perkins
</td>
<td
class=""
>
40
</td>
<td
class=""
>
40
</td>
<td
class=""
>
Single
</td>
<td
class=""
>
80
</td>
</tr>
<tr
class=""
>
<td
class=""
>
joe
</td>
<td
class=""
>
bergevin
</td>
<td
class=""
>
45
</td>
<td
class=""
>
20
</td>
<td
class=""
>
Complicated
</td>
<td
class=""
>
10
</td>
</tr>
</tbody>
</table>
</DocumentFragment>
`;

View File

@ -1,111 +0,0 @@
import '@testing-library/react/cleanup-after-each'
import '@testing-library/jest-dom/extend-expect'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
import React from 'react'
import { render } from '@testing-library/react'
import { useTable } from '../useTable'
function Table({ columns, data }) {
// Use the state and functions returned from useTable to build your UI
const { getTableProps, headerGroups, rows, prepareRow } = useTable({
columns,
data,
})
// Render the UI for your table
return (
<table {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps()}>{column.render('Header')}</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map(
(row, i) =>
prepareRow(row) || (
<tr {...row.getRowProps()}>
{row.cells.map(cell => {
return <td {...cell.getCellProps()}>{cell.render('Cell')}</td>
})}
</tr>
)
)}
</tbody>
</table>
)
}
function App() {
const columns = React.useMemo(
() => [
{
Header: 'Name',
columns: [
{
Header: 'First Name',
accessor: 'firstName',
},
{
Header: 'Last Name',
accessor: 'lastName',
},
],
},
{
Header: 'Info',
columns: [
{
Header: 'Age',
accessor: 'age',
},
{
Header: 'Visits',
accessor: 'visits',
},
{
Header: 'Status',
accessor: 'status',
},
{
Header: 'Profile Progress',
accessor: 'progress',
},
],
},
],
[]
)
const data = React.useMemo(
() => [
{
firstName: 'tanner',
lastName: 'linsley',
age: 29,
visits: 100,
status: 'In Relationship',
progress: 50,
},
],
[]
)
return <Table columns={columns} data={data} />
}
test('renders a basic table', () => {
const { getByText } = render(<App />)
expect(getByText('tanner')).toBeInTheDocument()
expect(getByText('linsley')).toBeInTheDocument()
expect(getByText('29')).toBeInTheDocument()
expect(getByText('100')).toBeInTheDocument()
expect(getByText('In Relationship')).toBeInTheDocument()
expect(getByText('50')).toBeInTheDocument()
})

View File

@ -4,7 +4,34 @@ import '@testing-library/jest-dom/extend-expect'
import React from 'react'
import { render } from '@testing-library/react'
import useTable from './useTable'
import { useTable } from '../useTable'
const data = [
{
firstName: 'tanner',
lastName: 'linsley',
age: 29,
visits: 100,
status: 'In Relationship',
progress: 50,
},
{
firstName: 'derek',
lastName: 'perkins',
age: 40,
visits: 40,
status: 'Single',
progress: 80,
},
{
firstName: 'joe',
lastName: 'bergevin',
age: 45,
visits: 20,
status: 'Complicated',
progress: 10,
},
]
function Table({ columns, data }) {
// Use the state and functions returned from useTable to build your UI
@ -82,25 +109,11 @@ function App() {
[]
)
const data = React.useMemo(
() => [
{
firstName: 'tanner',
lastName: 'linsley',
age: 29,
visits: 100,
status: 'In Relationship',
progress: 50,
},
],
[]
)
return <Table columns={columns} data={data} />
}
test('renders a basic table', () => {
const { getByText } = render(<App />)
const { getByText, asFragment } = render(<App />)
expect(getByText('tanner')).toBeInTheDocument()
expect(getByText('linsley')).toBeInTheDocument()
@ -108,4 +121,6 @@ test('renders a basic table', () => {
expect(getByText('100')).toBeInTheDocument()
expect(getByText('In Relationship')).toBeInTheDocument()
expect(getByText('50')).toBeInTheDocument()
expect(asFragment()).toMatchSnapshot()
})

View File

@ -136,7 +136,7 @@ export const useTable = (props, ...plugins) => {
// Process any subRows
const subRows = originalRow[subRowsKey]
? originalRow[subRowsKey].map((d, i) => accessRow(d, i, depth + 1))
: undefined
: []
const row = {
original,
@ -315,9 +315,6 @@ export const useTable = (props, ...plugins) => {
props
)
// need to apply any row specific hooks (useExpanded requires this)
applyHooks(instanceRef.current.hooks.prepareRow, row, instanceRef.current)
const visibleColumns = instanceRef.current.columns.filter(
column => column.visible
)
@ -332,7 +329,7 @@ export const useTable = (props, ...plugins) => {
// Give each cell a getCellProps base
cell.getCellProps = props => {
const columnPathStr = [path, column.id].join('_')
const columnPathStr = [...path, column.id].join('_')
return mergeProps(
{
key: ['cell', columnPathStr].join('_'),
@ -363,6 +360,9 @@ export const useTable = (props, ...plugins) => {
return cell
})
// need to apply any row specific hooks (useExpanded requires this)
applyHooks(instanceRef.current.hooks.prepareRow, row, instanceRef.current)
}
instanceRef.current.getTableProps = userProps =>

View File

@ -0,0 +1,252 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders a sortable table 1`] = `
"Snapshot Diff:
- First value
+ Second value
@@ -31,11 +31,13 @@
colspan=\\"1\\"
style=\\"cursor: pointer;\\"
title=\\"Toggle SortBy\\"
>
First Name
- <span />
+ <span>
+ 🔼
+ </span>
</th>
<th
class=\\"\\"
colspan=\\"1\\"
style=\\"cursor: pointer;\\"
@@ -87,104 +89,104 @@
class=\\"\\"
>
<td
class=\\"\\"
>
- firstName: tanner
+ firstName: derek
</td>
<td
class=\\"\\"
>
- lastName: linsley
+ lastName: perkins
</td>
<td
class=\\"\\"
>
- age: 29
+ age: 40
</td>
<td
class=\\"\\"
>
- visits: 100
+ visits: 40
</td>
<td
class=\\"\\"
>
- status: In Relationship
+ status: Single
</td>
<td
class=\\"\\"
>
- progress: 50
+ progress: 80
</td>
</tr>
<tr
class=\\"\\"
>
<td
class=\\"\\"
>
- firstName: derek
+ firstName: joe
</td>
<td
class=\\"\\"
>
- lastName: perkins
+ lastName: bergevin
</td>
<td
class=\\"\\"
>
- age: 40
+ age: 45
</td>
<td
class=\\"\\"
>
- visits: 40
+ visits: 20
</td>
<td
class=\\"\\"
>
- status: Single
+ status: Complicated
</td>
<td
class=\\"\\"
>
- progress: 80
+ progress: 10
</td>
</tr>
<tr
class=\\"\\"
>
<td
class=\\"\\"
>
- firstName: joe
+ firstName: tanner
</td>
<td
class=\\"\\"
>
- lastName: bergevin
+ lastName: linsley
</td>
<td
class=\\"\\"
>
- age: 45
+ age: 29
</td>
<td
class=\\"\\"
>
- visits: 20
+ visits: 100
</td>
<td
class=\\"\\"
>
- status: Complicated
+ status: In Relationship
</td>
<td
class=\\"\\"
>
- progress: 10
+ progress: 50
</td>
</tr>
</tbody>
</table>
</DocumentFragment>"
`;
exports[`renders a sortable table 2`] = `
"Snapshot Diff:
- First value
+ Second value
@@ -32,11 +32,11 @@
style=\\"cursor: pointer;\\"
title=\\"Toggle SortBy\\"
>
First Name
<span>
- 🔼
+ 🔽
</span>
</th>
<th
class=\\"\\"
colspan=\\"1\\"
@@ -89,36 +89,36 @@
class=\\"\\"
>
<td
class=\\"\\"
>
- firstName: derek
+ firstName: tanner
</td>
<td
class=\\"\\"
>
- lastName: perkins
+ lastName: linsley
</td>
<td
class=\\"\\"
>
- age: 40
+ age: 29
</td>
<td
class=\\"\\"
>
- visits: 40
+ visits: 100
</td>
<td
class=\\"\\"
>
- status: Single
+ status: In Relationship
</td>
<td
class=\\"\\"
>
- progress: 80
+ progress: 50
</td>
</tr>
<tr
class=\\"\\"
>
@@ -157,36 +157,36 @@
class=\\"\\"
>
<td
class=\\"\\"
>
- firstName: tanner
+ firstName: derek
</td>
<td
class=\\"\\"
>
- lastName: linsley
+ lastName: perkins
</td>
<td
class=\\"\\"
>
- age: 29
+ age: 40
</td>
<td
class=\\"\\"
>
- visits: 100
+ visits: 40
</td>
<td
class=\\"\\"
>
- status: In Relationship
+ status: Single
</td>
<td
class=\\"\\"
>
- progress: 50
+ progress: 80
</td>
</tr>
</tbody>
</table>
</DocumentFragment>"
`;

View File

@ -3,39 +3,36 @@ import '@testing-library/jest-dom/extend-expect'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
import React from 'react'
import { render } from '@testing-library/react'
import useTable from '../../hooks/useTable'
import useSortBy from '../useSortBy'
import { render, fireEvent } from '@testing-library/react'
import { useTable } from '../../hooks/useTable'
import { useSortBy } from '../useSortBy'
const data = React.useMemo(
() => [
{
firstName: 'tanner',
lastName: 'linsley',
age: 29,
visits: 100,
status: 'In Relationship',
progress: 50,
},
{
firstName: 'derek',
lastName: 'perkins',
age: 40,
visits: 40,
status: 'Single',
progress: 80,
},
{
firstName: 'joe',
lastName: 'bergevin',
age: 45,
visits: 20,
status: 'Complicated',
progress: 10,
},
],
[]
)
const data = [
{
firstName: 'tanner',
lastName: 'linsley',
age: 29,
visits: 100,
status: 'In Relationship',
progress: 50,
},
{
firstName: 'derek',
lastName: 'perkins',
age: 40,
visits: 40,
status: 'Single',
progress: 80,
},
{
firstName: 'joe',
lastName: 'bergevin',
age: 45,
visits: 20,
status: 'Complicated',
progress: 10,
},
]
const defaultColumn = {
Cell: ({ value, column: { id } }) => `${id}: ${value}`,
@ -131,16 +128,18 @@ function App() {
}
test('renders a sortable table', () => {
const { getAllByText } = render(<App />)
const { getByText, asFragment } = render(<App />)
const firstNames = getAllByText('firstName')
const beforeSort = asFragment()
console.log(firstNames)
fireEvent.click(getByText('First Name'))
// expect(getByText('tanner')).toBeInTheDocument()
// expect(getByText('linsley')).toBeInTheDocument()
// expect(getByText('29')).toBeInTheDocument()
// expect(getByText('100')).toBeInTheDocument()
// expect(getByText('In Relationship')).toBeInTheDocument()
// expect(getByText('50')).toBeInTheDocument()
const afterSort1 = asFragment()
fireEvent.click(getByText('First Name'))
const afterSort2 = asFragment()
expect(beforeSort).toMatchDiffSnapshot(afterSort1)
expect(afterSort1).toMatchDiffSnapshot(afterSort2)
})

View File

@ -14,8 +14,12 @@ const propTypes = {
paginateSubRows: PropTypes.bool,
}
export const useExpanded = props => {
PropTypes.checkPropTypes(propTypes, props, 'property', 'useExpanded')
export const useExpanded = hooks => {
hooks.useMain.push(useMain)
}
function useMain(instance) {
PropTypes.checkPropTypes(propTypes, instance, 'property', 'useExpanded')
const {
debug,
@ -24,7 +28,7 @@ export const useExpanded = props => {
hooks,
state: [{ expanded }, setState],
paginateSubRows = true,
} = props
} = instance
const toggleExpandedByPath = (path, set) => {
return setState(old => {
@ -52,23 +56,19 @@ export const useExpanded = props => {
// Here we do some mutation, but it's the last stage in the
// immutable process so this is safe
const handleRow = (row, depth = 0, parentPath = []) => {
// Compute some final state for the row
const path = [...parentPath, row.index]
row.path = path
row.depth = depth
const handleRow = row => {
row.isExpanded =
(row.original && row.original[manualExpandedKey]) ||
getBy(expanded, path)
getBy(expanded, row.path)
if (paginateSubRows || (!paginateSubRows && row.depth === 0)) {
expandedRows.push(row)
}
row.canExpand = row.subRows && !!row.subRows.length
if (row.isExpanded && row.subRows && row.subRows.length) {
row.subRows.forEach((row, i) => handleRow(row, depth + 1, path))
row.subRows.forEach((row, i) => handleRow(row))
}
return row
@ -84,7 +84,7 @@ export const useExpanded = props => {
const expandedDepth = findExpandedDepth(expanded)
return {
...props,
...instance,
toggleExpandedByPath,
expandedDepth,
rows: expandedRows,

View File

@ -1,4 +1,3 @@
import React from 'react'
import { useMemo } from 'react'
import PropTypes from 'prop-types'
@ -20,7 +19,13 @@ const propTypes = {
// General
columns: PropTypes.arrayOf(
PropTypes.shape({
aggregate: PropTypes.func,
aggregate: PropTypes.oneOfType([
PropTypes.func,
PropTypes.string,
PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.func, PropTypes.string])
),
]),
disableGrouping: PropTypes.bool,
Aggregated: PropTypes.any,
})
@ -31,33 +36,35 @@ const propTypes = {
aggregations: PropTypes.object,
}
export const useGroupBy = props => {
PropTypes.checkPropTypes(propTypes, props, 'property', 'useGroupBy')
export const useGroupBy = hooks => {
hooks.columnsBeforeHeaderGroups.push(columnsBeforeHeaderGroups)
hooks.useMain.push(useMain)
}
function columnsBeforeHeaderGroups(columns, { state: [{ groupBy }] }) {
// Sort grouped columns to the start of the column list
// before the headers are built
return [
...groupBy.map(g => columns.find(col => col.id === g)),
...columns.filter(col => !groupBy.includes(col.id)),
]
}
function useMain(instance) {
PropTypes.checkPropTypes(propTypes, instance, 'property', 'useGroupBy')
const {
debug,
rows,
columns,
headers,
groupByFn = defaultGroupByFn,
manualGroupBy,
disableGrouping,
aggregations: userAggregations = {},
hooks,
state: [{ groupBy }, setState],
} = props
// Sort grouped columns to the start of the column list
// before the headers are built
hooks.useColumnsBeforeHeaderGroups.push(columns => {
// eslint-disable-next-line react-hooks/rules-of-hooks
return React.useMemo(
() => [
...groupBy.map(g => columns.find(col => col.id === g)),
...columns.filter(col => !groupBy.includes(col.id)),
],
[columns]
)
})
} = instance
columns.forEach(column => {
const { id, accessor, disableGrouping: columnDisableGrouping } = column
@ -72,6 +79,10 @@ export const useGroupBy = props => {
)
: false
if (column.canGroupBy) {
column.toggleGroupBy = () => toggleGroupBy(column.id)
}
column.Aggregated = column.Aggregated || column.Cell
})
@ -92,100 +103,134 @@ export const useGroupBy = props => {
}, actions.toggleGroupBy)
}
hooks.useColumns.push(columns => {
columns.forEach(column => {
if (column.canGroupBy) {
column.toggleGroupBy = () => toggleGroupBy(column.id)
}
})
return columns
})
hooks.getGroupByToggleProps = []
const addGroupByToggleProps = (columns, api) => {
columns.forEach(column => {
const { canGroupBy } = column
column.getGroupByToggleProps = props => {
return mergeProps(
{
onClick: canGroupBy
? e => {
e.persist()
column.toggleGroupBy()
}
: undefined,
style: {
cursor: canGroupBy ? 'pointer' : undefined,
},
title: 'Toggle GroupBy',
//
;[...columns, ...headers].forEach(column => {
const { canGroupBy } = column
column.getGroupByToggleProps = props => {
return mergeProps(
{
onClick: canGroupBy
? e => {
e.persist()
column.toggleGroupBy()
}
: undefined,
style: {
cursor: canGroupBy ? 'pointer' : undefined,
},
applyPropHooks(api.hooks.getGroupByToggleProps, column, api),
props
)
}
})
return columns
}
title: 'Toggle GroupBy',
},
applyPropHooks(instance.hooks.getGroupByToggleProps, column, instance),
props
)
}
})
hooks.useColumns.push(addGroupByToggleProps)
hooks.useHeaders.push(addGroupByToggleProps)
hooks.prepareRow.push(row => {
row.cells.forEach(cell => {
// Grouped cells are in the groupBy and the pivot cell for the row
cell.grouped = cell.column.grouped && cell.column.id === row.groupByID
// Repeated cells are any columns in the groupBy that are not grouped
cell.repeatedValue = !cell.grouped && cell.column.grouped
// Aggregated cells are not grouped, not repeated, but still have subRows
cell.aggregated = !cell.grouped && !cell.repeatedValue && row.canExpand
})
return row
})
const groupedRows = useMemo(
() => {
if (manualGroupBy || !groupBy.length) {
return rows
}
if (debug) console.info('getGroupedRows')
// Find the columns that can or are aggregating
// Uses each column to aggregate rows into a single value
const aggregateRowsToValues = rows => {
const aggregateRowsToValues = (rows, isSourceRows) => {
const values = {}
columns.forEach(column => {
// Don't aggregate columns that are in the groupBy
if (groupBy.includes(column.id)) {
values[column.id] = rows[0] ? rows[0].values[column.id] : null
return
}
const columnValues = rows.map(d => d.values[column.id])
let aggregate =
userAggregations[column.aggregate] ||
aggregations[column.aggregate] ||
column.aggregate
if (typeof aggregate === 'function') {
values[column.id] = aggregate(columnValues, rows)
} else if (aggregate) {
let aggregator = column.aggregate
if (Array.isArray(aggregator)) {
if (aggregator.length !== 2) {
console.info({ column })
throw new Error(
`React Table: Complex aggregators must have 2 values, eg. aggregate: ['sum', 'count']. More info above...`
)
}
if (isSourceRows) {
aggregator = aggregator[1]
} else {
aggregator = aggregator[0]
}
}
let aggregateFn =
typeof aggregator === 'function'
? aggregator
: userAggregations[aggregator] || aggregations[aggregator]
if (aggregateFn) {
values[column.id] = aggregateFn(columnValues, rows)
} else if (aggregator) {
console.info({ column })
throw new Error(
`Invalid aggregate "${aggregate}" passed to column with ID: "${
column.id
}"`
`React Table: Invalid aggregate option for column listed above`
)
} else {
values[column.id] = columnValues[0]
values[column.id] = null
}
})
return values
}
// Recursively group the data
const groupRecursively = (rows, groupBy, depth = 0) => {
const groupRecursively = (rows, depth = 0, parentPath = []) => {
// This is the last level, just return the rows
if (depth >= groupBy.length) {
return rows
}
// Group the rows together for this level
let groupedRows = Object.entries(groupByFn(rows, groupBy[depth])).map(
([groupByVal, subRows], index) => {
// Recurse to sub rows before aggregation
subRows = groupRecursively(subRows, groupBy, depth + 1)
const columnID = groupBy[depth]
const values = aggregateRowsToValues(subRows)
// Group the rows together for this level
let groupedRows = groupByFn(rows, columnID)
// Recurse to sub rows before aggregation
groupedRows = Object.entries(groupedRows).map(
([groupByVal, subRows], index) => {
const path = [...parentPath, groupByVal]
subRows = groupRecursively(subRows, depth + 1, path)
const values = aggregateRowsToValues(
subRows,
depth + 1 >= groupBy.length
)
const row = {
groupByID: groupBy[depth],
groupByID: columnID,
groupByVal,
values,
subRows,
depth,
index,
path,
}
return row
}
)
@ -194,13 +239,13 @@ export const useGroupBy = props => {
}
// Assign the new data
return groupRecursively(rows, groupBy)
return groupRecursively(rows)
},
[manualGroupBy, groupBy, debug, rows, columns, userAggregations, groupByFn]
)
return {
...props,
...instance,
toggleGroupBy,
rows: groupedRows,
preGroupedRows: rows,

View File

@ -184,12 +184,11 @@ export function getFirstDefined(...args) {
}
}
export function defaultGroupByFn(rows, grouper) {
export function defaultGroupByFn(rows, columnID) {
return rows.reduce((prev, row, i) => {
const resKey =
typeof grouper === 'function'
? grouper(row.values, i)
: row.values[grouper]
// TODO: Might want to implement a key serializer here so
// irregular column values can still be grouped if needed?
const resKey = `${row.values[columnID]}`
prev[resKey] = Array.isArray(prev[resKey]) ? prev[resKey] : []
prev[resKey].push(row)
return prev

1545
yarn.lock

File diff suppressed because it is too large Load Diff