├── .gitignore ├── .storybook ├── addons.js ├── config.js ├── middleware.js └── webpack.config.js ├── .travis.yml ├── README.md ├── __story__ ├── basic.md ├── basic.stories.tsx ├── customSearch.stories.tsx ├── enableListSelection.md ├── enableListSelection.stories.tsx ├── maxVisibleFieldCount.md ├── maxVisibleFieldCount.stories.tsx ├── overview.stories.tsx ├── plugins.md ├── plugins.stories.tsx ├── rowActions.md ├── rowActions.stories.tsx └── share.tsx ├── docs ├── favicon.ico ├── iframe.html ├── index.html └── static │ ├── manager.245cddf35a822538d51e.bundle.js │ └── preview.8479b681f04cfd477583.bundle.js ├── package.json ├── src ├── SearchField.tsx ├── index.tsx └── renderer │ ├── datePicker.tsx │ ├── input.tsx │ ├── select.tsx │ └── treeSelect.tsx ├── test ├── __snapshots__ │ └── index.test.tsx.snap ├── index.test.tsx └── setup.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | coverage 4 | *.log 5 | lib/ 6 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import 'storybook-readme/register' 2 | import '@storybook/addon-actions/register' 3 | import '@storybook/addon-options/register' 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | import { setOptions } from '@storybook/addon-options' 3 | 4 | setOptions({ 5 | name: 'antd-data-table', 6 | url: 'https://github.com/NewbeeFE/antd-data-table', 7 | downPanelInRight: false 8 | }) 9 | 10 | /** Import ant design less style */ 11 | import 'antd/dist/antd.less' 12 | 13 | const req = require.context('../', true, /\.stories\.tsx$/) 14 | 15 | function loadStories() { 16 | req.keys().forEach((filename) => req(filename)) 17 | } 18 | 19 | configure(loadStories, module) 20 | -------------------------------------------------------------------------------- /.storybook/middleware.js: -------------------------------------------------------------------------------- 1 | const proxy = require('http-proxy-middleware') 2 | const packageJson = require('../package.json') 3 | 4 | module.exports = function expressMiddleware(router) { 5 | const proxyConfig = packageJson.proxy || {} 6 | 7 | for (let domain in proxyConfig) { 8 | if (typeof proxyConfig[domain] === 'string') { 9 | router.use(domain, proxy({ 10 | target: proxyConfig[domain], 11 | changeOrigin: true 12 | })) 13 | } else { 14 | router.use(domain, proxy(proxyConfig[domain])) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const DeclarationBundlerPlugin = require('declaration-bundler-webpack-plugin') 3 | 4 | module.exports = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.tsx?$/, 9 | loader: 'awesome-typescript-loader', 10 | exclude: /(node_modules)/ 11 | }, 12 | { 13 | test: /\.(less)/, 14 | use: [ 15 | 'style-loader', 16 | 'css-loader', 17 | 'less-loader' 18 | ] 19 | }, 20 | { 21 | test: /\.md$/, 22 | use: "raw-loader" 23 | } 24 | ] 25 | }, 26 | resolve: { 27 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] 28 | }, 29 | // plugins: [ 30 | // new DeclarationBundlerPlugin({ 31 | // moduleName: 'some.path.moduleName', 32 | // out: 'index.d.ts', 33 | // }) 34 | // ] 35 | } 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # antd-data-table 2 | 3 | [![npm](https://img.shields.io/npm/dm/antd-data-table.svg)](https://www.npmjs.com/package/antd-data-table) 4 | [![npm](https://img.shields.io/npm/v/antd-data-table.svg)](https://www.npmjs.com/package/antd-data-table) 5 | [![Build Status](https://travis-ci.org/NewbeeFE/antd-data-table.svg?branch=master)](https://travis-ci.org/NewbeeFE/antd-data-table) 6 | [![antd](https://img.shields.io/badge/antd-v2.x-yellowgreen.svg)](https://github.com/ant-design/ant-design) 7 | 8 | A component that combines antd's Table and Form to do the search, display, and operating jobs for data. 9 | 10 | ![](https://user-images.githubusercontent.com/914329/29578209-0af170f4-87a1-11e7-95d0-1b00a141b581.png) 11 | 12 | ## Feature 13 | 14 | Free from: 15 | 16 | - Handling pagination 17 | - Handling table row selection 18 | - Writing search field form item components 19 | - Writing row actions components 20 | 21 | Just focus on: 22 | 23 | - Doing the data fetching request and return the data 24 | - Rendering a specific data field if needed 25 | - Writing plugin to operate one or many data object(s) 26 | 27 | ## Install 28 | 29 | ```bash 30 | $ yarn add antd-data-table --save 31 | ``` 32 | 33 | ## Simplest data table 34 | 35 | [Demo](https://newbeefe.github.io/antd-data-table/?selectedKind=DataTable&selectedStory=basic) 36 | 37 | ```tsx 38 | import { DataTable } from 'antd-data-table' 39 | 40 | const searchFields: SearchField[] = [ 41 | { 42 | label: 'ID', 43 | name: 'id', 44 | type: 'input', 45 | payload: { 46 | props: { 47 | placeholder: 'placeholder' 48 | } 49 | } 50 | }, 51 | { 52 | label: 'Select', 53 | name: 'select', 54 | type: 'select', 55 | payload: { 56 | options: [ 57 | { key: '1', label: 'one', value: '1' }, 58 | { key: '2', label: 'two', value: '2' }, 59 | { key: '3', label: 'three', value: '3' } 60 | ] 61 | } 62 | } 63 | ] 64 | 65 | const columns: TableColumnConfig[] = [ 66 | { 67 | key: 'id', 68 | title: 'ID', 69 | dataIndex: 'id' 70 | }, { 71 | key: 'title', 72 | title: 'Title', 73 | dataIndex: 'title' 74 | } 75 | ] 76 | 77 | const expands: Expand[] = [ 78 | { 79 | title: 'Body', 80 | dataIndex: 'body', 81 | render (value) { 82 | return value && `${value.substr(0, 100)} ...` 83 | } 84 | }, 85 | { 86 | title: 'User ID', 87 | dataIndex: 'userId' 88 | } 89 | ] 90 | 91 | const onSearch = async ({ page, pageSize, values }) => { 92 | const res = await axios.get('http://jsonplaceholder.typicode.com/posts', { 93 | params: { 94 | _page: page, 95 | _limit: pageSize, 96 | ...values 97 | } 98 | }) 99 | return { 100 | dataSource: res.data, 101 | total: Number(res.headers['x-total-count']) 102 | } 103 | } 104 | render( 105 | record.id} 107 | searchFields={searchFields} 108 | initialColumns={columns} 109 | initialExpands={expands} 110 | onSearch={onSearch} 111 | /> 112 | , mountNode) 113 | ``` 114 | 115 | ## Guide 116 | 117 | ### Collapsable search field 118 | 119 | Sometimes there are many search fields, you could set a `maxVisibleFieldCount` to automatically have a collapsable form: 120 | 121 | [Demo](https://newbeefe.github.io/antd-data-table/?selectedKind=DataTable&selectedStory=maxVisibleFieldCount) 122 | 123 | ```diff 124 | import { DataTable } from 'antd-data-table' 125 | 126 | render( 127 | record.id} 129 | searchFields={searchFields} 130 | initialColumns={columns} 131 | onSearch={onSearch} 132 | + maxVisibleFieldCount={4} 133 | /> 134 | , mountNode) 135 | ``` 136 | 137 | ### Row actions 138 | 139 | We usually need to write some action buttons for operating a specific record. `antd-data-table` made it super easy: 140 | 141 | [Demo](https://newbeefe.github.io/antd-data-table/?selectedKind=DataTable&selectedStory=rowActions) 142 | 143 | ```tsx 144 | const actions: RowAction[] = [ 145 | { 146 | label: 'Edit', 147 | action (record) { 148 | action('onClick edit')(record) 149 | } 150 | }, 151 | { 152 | label: 'More', 153 | children: [ 154 | { 155 | label: 'Remove', 156 | action (record) { 157 | action('onClick remove')(record) 158 | } 159 | }, 160 | { 161 | label: 'Open', 162 | action (record) { 163 | action('onClick open')(record) 164 | } 165 | } 166 | ] 167 | } 168 | ] 169 | 170 | render( 171 | record.id} 173 | searchFields={searchFields} 174 | initialColumns={columns} 175 | initialExpands={expands} 176 | onSearch={onSearch} 177 | actions={actions} 178 | /> 179 | , mountNode) 180 | ``` 181 | 182 | ### Plugins 183 | 184 | Plugins are for operating multiple records. Every plugin will render a component at the top of table. 185 | 186 | [Demo](https://newbeefe.github.io/antd-data-table/?selectedKind=DataTable&selectedStory=plugins) 187 | 188 | Let's write a simplest plugin: A button that show current selected rows' ids: 189 | 190 | ```tsx 191 | const ShowIdsBtn = ({ selectedRows, clearSelection }) => { 192 | const showIds = () => { 193 | message.info(selectedRows.map(row => row.id).join(',')) 194 | // clear selection after the action is done 195 | clearSelection() 196 | } 197 | 198 | return 199 | } 200 | 201 | const plugins = [ 202 | renderer (selectedRowKeys, selectedRows, clearSelection) { 203 | return 204 | } 205 | ] 206 | 207 | render ( 208 | record.id} 210 | searchFields={searchFields} 211 | plugins={plugins} 212 | initialColumns={columns} 213 | initialExpands={expands} 214 | onSearch={onSearch} 215 | /> 216 | , mountNode) 217 | ``` 218 | 219 | ## Props 220 | 221 | ### name?: string 222 | 223 | Unique table name. 224 | 225 | ### `rowKey`: (record) => string 226 | 227 | The `key` value of a row. 228 | 229 | ### `searchFields: SearchField[]` 230 | 231 | SearchField is an object that contains: 232 | 233 | - **label: string** Pass to ``'s `label` property. 234 | - **name: string** Pass to `getFieldDecorator` as the decorator name. 235 | - **type?: RenderType** antd-data-table comes with some common form item type. Such as `input`, `select`. 236 | - **initialValue?: any** Inital form value. 237 | - **renderer?: (payload?: object) => React.ReactNode** When the form item types are not statisfied, your could write your own renderer. the `ReactNode` that returned will be wrapped by `getFieldDecorator`. 238 | - **validationRule?: ValidateionRule[]** antd validation rules. Pass to `getFieldDecorator(name, { rules })`. 239 | - **payload?: { props: any, [key: string]: any }** Some params that pass to the renderer. 240 | - **span?: number** Form Item Col span value. 6 by default. 241 | 242 | #### out of the box render type 243 | 244 | ##### input 245 | 246 | ```ts 247 | interface payload { 248 | props: object // antd Input props 249 | } 250 | ``` 251 | 252 | #### datePicker 253 | 254 | ```ts 255 | interface payload { 256 | props: object // antd DatePicker props 257 | } 258 | ``` 259 | 260 | #### treeSelect 261 | 262 | ```ts 263 | interface payload { 264 | props: object // antd TreeSelect props 265 | } 266 | ``` 267 | 268 | ##### select 269 | 270 | ```ts 271 | interface payload { 272 | props: object, // antd Select props 273 | options: { 274 | key: string, 275 | label: string, 276 | value: string 277 | }[] 278 | } 279 | ``` 280 | 281 | ### `initialColumns: TableColumnConfig[]` 282 | 283 | antd's TableColumnConfig. See more at https://ant.design/components/form/ 284 | 285 | ### `initialExpands: Expand[]` 286 | 287 | ```ts 288 | type Expand = { 289 | /** Title of this column **/ 290 | title: string, 291 | /** Display field of the data record, could be set like a.b.c **/ 292 | dataIndex: string, 293 | /** Renderer of the column in the expanded. The return value should be a ReactNode **/ 294 | render?: (text: any, record?: {}) => React.ReactNode 295 | } 296 | ``` 297 | 298 | ### `onSearch (info: SearchInfo): Promise>` 299 | 300 | `onSearch` property need a function that return a Promise, which resolves an object that contains `total` and `dataSource`. This function receive a `SearchInfo`: 301 | 302 | ```ts 303 | type SearchInfo = { 304 | /** values from `getFieldsValue()` */ 305 | values: any, 306 | /** current page */ 307 | page: number, 308 | /** page size */ 309 | pageSize: number 310 | } 311 | ``` 312 | 313 | ### `title?: React.ReactNode` 314 | 315 | ### `searchBtnText?: string` 316 | 317 | ### `clearBtnText?: string` 318 | 319 | ### `listSelectionBtnText?: string` 320 | 321 | ### `onError? (err): void` 322 | 323 | Error handler that trigger when onSearch throw error. 324 | 325 | ### `loadDataImmediately?: boolean` 326 | 327 | Load list data immediately, default is false 328 | 329 | ### `onValidateFailed?: (err: ValidateError) => void` 330 | 331 | Form validation failed handler 332 | 333 | ### `pageSize?: number` 334 | 335 | default is 10 336 | 337 | ### `plugins?: Plugin[]` 338 | 339 | ### `rowActions?: RowAction[]` 340 | 341 | ### `enableListSelection?`: boolean 342 | 343 | If `true`, a list selection button will display on table title. 344 | 345 | *Be sure to pass the `name` props if it is enable.* 346 | 347 | ### `rowSelection?`: TableRowSelection 348 | 349 | Custom `rowSelection`. 350 | 351 | ### `affixTarget?`: () => HTMLelement 352 | 353 | For `Affix`. Specifies the scrollable area dom node 354 | 355 | ### `affixOffsetTop?`: number 356 | 357 | Pixels to offset from top when calculating position of scroll 358 | 359 | ### `affixOffsetBottom?`: number 360 | 361 | Pixels to offset from bottom when calculating position of scroll 362 | 363 | ## FAQ 364 | 365 | ### How to trigger the `onSearch` action imperatively? 366 | 367 | There is a public `fetch` method in DataTable to do this action. So you could get it from `ref`: 368 | 369 | [Demo](https://newbeefe.github.io/antd-data-table/?selectedKind=DataTable&selectedStory=customSearch) 370 | 371 | ```jsx 372 | // ... 373 | render () { 374 | let dataTableRef: DataTable | null = null 375 | 376 | const saveDataTableRef = (ref: DataTable) => { 377 | dataTableRef = ref 378 | } 379 | 380 | const onClickCustomSearch = () => { 381 | if (dataTableRef) { 382 | dataTableRef.fetch(1) 383 | } 384 | } 385 | 386 | return ( 387 |
388 | record.id} 392 | searchFields={searchFields} 393 | initialColumns={columns} 394 | initialExpands={expands} 395 | onSearch={onSearch} 396 | pageSize={10} 397 | onError={onError} 398 | /> 399 | 400 |
401 | ) 402 | } 403 | ``` 404 | 405 | `fetch: async (page: number, values: object = this.state.currentValues, clearPagination: boolean = false)` 406 | 407 | ## Build 408 | 409 | ```bash 410 | $ yarn 411 | 412 | $ yarn start # start the storybook 413 | 414 | $ yarn test # run the test 415 | 416 | $ yarn run build # build the distribution file 417 | 418 | $ yarn run build:storybook # build storybook 419 | ``` 420 | 421 | ### Release workflow 422 | 423 | ```bash 424 | $ yarn run build:storybook # build storybook 425 | 426 | $ npm publish 427 | ``` 428 | 429 | # License 430 | 431 | MIT License 432 | -------------------------------------------------------------------------------- /__story__/basic.md: -------------------------------------------------------------------------------- 1 | # Basic usage 2 | 3 | A simplest data table: 4 | 5 | ```tsx 6 | const searchFields: SearchField[] = [ 7 | { 8 | label: 'ID', 9 | name: 'id', 10 | type: 'input', 11 | payload: { 12 | props: { 13 | placeholder: 'placeholder' 14 | } 15 | } 16 | }, 17 | { 18 | label: 'Select', 19 | name: 'select', 20 | type: 'select', 21 | payload: { 22 | props: { 23 | allowClear: true 24 | }, 25 | options: [ 26 | { key: '1', label: 'one', value: '1' }, 27 | { key: '2', label: 'two', value: '2' }, 28 | { key: '3', label: 'three', value: '3' } 29 | ] 30 | } 31 | }, 32 | { 33 | label: 'Multi Select', 34 | name: 'multi-select', 35 | type: 'select', 36 | payload: { 37 | props: { 38 | mode: 'multiple' 39 | }, 40 | options: [ 41 | { key: '1', label: 'one', value: '1' }, 42 | { key: '2', label: 'two', value: '2' }, 43 | { key: '3', label: 'three', value: '3' } 44 | ] 45 | } 46 | } 47 | ] 48 | 49 | const columns: TableColumnConfig[] = [ 50 | { 51 | key: 'id', 52 | title: 'ID', 53 | dataIndex: 'id' 54 | }, { 55 | key: 'title', 56 | title: 'Title', 57 | dataIndex: 'title' 58 | } 59 | ] 60 | 61 | const expands: Expand[] = [ 62 | { 63 | title: 'Body', 64 | dataIndex: 'body', 65 | render (value, record) { 66 | return value && `${value.substr(0, 100)} ...` 67 | } 68 | }, 69 | { 70 | title: 'User ID', 71 | dataIndex: 'userId' 72 | } 73 | ] 74 | 75 | const onSearch = async ({ page, pageSize, values }) => { 76 | const res = await axios.get('http://jsonplaceholder.typicode.com/posts', { 77 | params: { 78 | _page: page, 79 | _limit: pageSize, 80 | ...values 81 | } 82 | }) 83 | return { 84 | dataSource: res.data, 85 | total: Number(res.headers['x-total-count']) 86 | } 87 | } 88 | 89 | record.id} 91 | searchFields={searchFields} 92 | initialColumns={columns} 93 | onSearch={onSearch} 94 | /> 95 | ``` 96 | -------------------------------------------------------------------------------- /__story__/basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import withReadme from 'storybook-readme/with-readme' 3 | 4 | import { storiesOf } from '@storybook/react' 5 | import { message } from 'antd' 6 | import axios from 'axios' 7 | 8 | /** Import component */ 9 | import { DataTable, SearchField, SearchInfo, SearchResponse } from '../src' 10 | 11 | import { searchFields, columns, expands, onSearch, onError } from './share' 12 | 13 | storiesOf('DataTable', module) 14 | .addDecorator(withReadme(require('./basic.md'))) 15 | .add('basic', () => ( 16 |
17 | record.id} 19 | searchFields={searchFields} 20 | initialColumns={columns} 21 | onSearch={onSearch} 22 | loadDataImmediately={true} 23 | initialExpands={expands} 24 | /> 25 |
26 | )) 27 | -------------------------------------------------------------------------------- /__story__/customSearch.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import withReadme from 'storybook-readme/with-readme' 4 | import { message } from 'antd' 5 | import axios from 'axios' 6 | 7 | import { 8 | Button 9 | } from 'antd' 10 | 11 | /** Import component */ 12 | import { DataTable, SearchField, SearchInfo, SearchResponse } from '../src' 13 | import { searchFields, columns, onSearch, onError } from './share' 14 | 15 | storiesOf('DataTable', module) 16 | .add('custom search', () => { 17 | 18 | let dataTableRef: DataTable | null = null 19 | 20 | const saveDataTableRef = (ref: DataTable) => { 21 | dataTableRef = ref 22 | } 23 | 24 | const onClickCustomSearch = () => { 25 | if (dataTableRef) { 26 | dataTableRef.fetch(1) 27 | } 28 | } 29 | 30 | return ( 31 |
32 | record.id} 36 | searchFields={searchFields} 37 | initialColumns={columns} 38 | onSearch={onSearch} 39 | pageSize={10} 40 | onError={onError} 41 | /> 42 | 43 |
44 | ) 45 | }) 46 | -------------------------------------------------------------------------------- /__story__/enableListSelection.md: -------------------------------------------------------------------------------- 1 | # List selection 2 | 3 | When columns are too many to display, you could enable list selection. It will render a dropdown button at the top of table, which has some checkbox to toggle the visible of each columns. 4 | 5 | Be sure when the `enableListSelection` is true, you must pass a unique `name` to data table. Because it will save the visible columns key on localStorage. 6 | 7 | ```tsx 8 | record.id} 12 | searchFields={searchFields} 13 | initialColumns={columns} 14 | onSearch={onSearch} 15 | /> 16 | ``` -------------------------------------------------------------------------------- /__story__/enableListSelection.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import withReadme from 'storybook-readme/with-readme' 4 | import { message } from 'antd' 5 | import axios from 'axios' 6 | 7 | /** Import component */ 8 | import { DataTable, SearchField, SearchInfo, SearchResponse } from '../src' 9 | import { searchFields, columns, onSearch, onError } from './share' 10 | 11 | storiesOf('DataTable', module) 12 | .addDecorator(withReadme(require('./enableListSelection.md'))) 13 | .add('enableListSelection', () => ( 14 |
15 | record.id} 19 | searchFields={searchFields} 20 | initialColumns={columns} 21 | onSearch={onSearch} 22 | pageSize={10} 23 | onError={onError} 24 | /> 25 |
26 | )) 27 | -------------------------------------------------------------------------------- /__story__/maxVisibleFieldCount.md: -------------------------------------------------------------------------------- 1 | # maxVisibleFieldCount 2 | 3 | When search field form items are too many to display. You could set a `maxVisibleFieldCount`. When the length of search fields is large than the `maxVisibleFieldCount`, data table will automatically render a collapse button: 4 | 5 | ```tsx 6 | record.id} 8 | searchFields={searchFields} 9 | initialColumns={columns} 10 | onSearch={onSearch} 11 | maxVisibleFieldCount={3} 12 | /> 13 | ``` -------------------------------------------------------------------------------- /__story__/maxVisibleFieldCount.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import withReadme from 'storybook-readme/with-readme' 4 | 5 | import axios from 'axios' 6 | 7 | /** Import component */ 8 | import { DataTable, SearchField, SearchInfo } from '../src' 9 | 10 | import { searchFields, columns, onSearch, onError } from './share' 11 | 12 | storiesOf('DataTable', module) 13 | .addDecorator(withReadme(require('./maxVisibleFieldCount.md'))) 14 | .add('maxVisibleFieldCount', () => ( 15 |
16 | record.id} 18 | pageSize={10} 19 | searchFields={searchFields} 20 | initialColumns={columns} 21 | onSearch={onSearch} 22 | maxVisibleFieldCount={3} 23 | /> 24 |
25 | )) 26 | -------------------------------------------------------------------------------- /__story__/overview.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import withReadme from 'storybook-readme/with-readme' 3 | 4 | import { storiesOf } from '@storybook/react' 5 | import { message } from 'antd' 6 | import axios from 'axios' 7 | 8 | /** Import component */ 9 | import { DataTable, SearchField, SearchInfo, SearchResponse } from '../src' 10 | 11 | import { searchFields, columns, onSearch, onError, plugins, actions } from './share' 12 | 13 | storiesOf('DataTable', module) 14 | .addDecorator(withReadme(`Combines all features`)) 15 | .add('overview', () => ( 16 |
17 | record.id} 25 | searchFields={searchFields} 26 | initialColumns={columns} 27 | onSearch={onSearch} 28 | /> 29 |
30 | )) 31 | -------------------------------------------------------------------------------- /__story__/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Sometimes we need to select multiple rows and do some actions with them. Plugin is born for it: 4 | 5 | ```tsx 6 | const plugins: Plugin[] = [ 7 | { 8 | renderer (selectedRowKeys, selectedRows, clearSelectionCallback) { 9 | const onClick = () => { 10 | action('onClick test plugin')(selectedRowKeys) 11 | clearSelectionCallback() 12 | } 13 | return ( 14 | 15 | ) 16 | } 17 | }, 18 | { 19 | renderer (selectedRowKeys, selectedRows, clearSelectionCallback) { 20 | const onClick = () => { 21 | action('onClick test plugin')(selectedRowKeys) 22 | clearSelectionCallback() 23 | } 24 | return ( 25 | 26 | ) 27 | } 28 | } 29 | ] 30 | 31 | render( 32 | record.id} 34 | searchFields={searchFields} 35 | plugins={plugins} 36 | initialColumns={columns} 37 | onSearch={onSearch} 38 | /> 39 | , mountNode) 40 | 41 | ``` 42 | -------------------------------------------------------------------------------- /__story__/plugins.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import withReadme from 'storybook-readme/with-readme' 4 | import { action } from '@storybook/addon-actions' 5 | import { 6 | Button 7 | } from 'antd' 8 | 9 | import axios from 'axios' 10 | 11 | /** Import component */ 12 | import { DataTable, SearchField, SearchInfo, Plugin } from '../src' 13 | 14 | import { searchFields, columns, onSearch, onError, plugins } from './share' 15 | 16 | storiesOf('DataTable', module) 17 | .addDecorator(withReadme(require('./plugins.md'))) 18 | .add('plugins', () => ( 19 |
20 | record.id} 22 | searchFields={searchFields} 23 | plugins={plugins} 24 | initialColumns={columns} 25 | onSearch={onSearch} 26 | pageSize={10} 27 | /> 28 |
29 | )) 30 | -------------------------------------------------------------------------------- /__story__/rowActions.md: -------------------------------------------------------------------------------- 1 | # Row Actions 2 | 3 | Use when you wanna operate a specific row data. 4 | 5 | ```tsx 6 | const actions: RowAction[] = [ 7 | { 8 | label: 'Edit', 9 | action (record) { 10 | console.log('onClick edit', record) 11 | } 12 | }, 13 | { 14 | label: 'More', 15 | children: [ 16 | { 17 | label: 'Remove', 18 | action (record) { 19 | console.log('onClick remove', record) 20 | } 21 | }, 22 | { 23 | label: 'Open', 24 | action (record) { 25 | console.log('onClick open', record) 26 | } 27 | } 28 | ] 29 | } 30 | ] 31 | 32 | render ( 33 | record.id} 35 | searchFields={searchFields} 36 | initialColumns={columns} 37 | onSearch={onSearch} 38 | rowActions={actions} 39 | /> 40 | , mountNode) 41 | ``` 42 | -------------------------------------------------------------------------------- /__story__/rowActions.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { action } from '@storybook/addon-actions' 4 | import withReadme from 'storybook-readme/with-readme' 5 | 6 | import axios from 'axios' 7 | 8 | /** Import component */ 9 | import { DataTable, SearchField, SearchInfo, RowAction } from '../src' 10 | 11 | import { searchFields, columns, onSearch, onError } from './share' 12 | 13 | const actions: RowAction[] = [ 14 | { 15 | label: 'Edit', 16 | action (record) { 17 | action('onClick edit')(record) 18 | } 19 | }, 20 | { 21 | label: 'More', 22 | children: [ 23 | { 24 | label: 'Remove', 25 | action (record) { 26 | action('onClick remove')(record) 27 | } 28 | }, 29 | { 30 | label: 'Open', 31 | action (record) { 32 | action('onClick open')(record) 33 | } 34 | } 35 | ] 36 | } 37 | ] 38 | 39 | storiesOf('DataTable', module) 40 | .addDecorator(withReadme(require('./rowActions.md'))) 41 | .add('rowActions', () => ( 42 |
43 | record.id} 45 | searchFields={searchFields} 46 | initialColumns={columns} 47 | onSearch={onSearch} 48 | pageSize={10} 49 | rowActions={actions} 50 | /> 51 |
52 | )) 53 | -------------------------------------------------------------------------------- /__story__/share.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { message, Button } from 'antd' 3 | import axios from 'axios' 4 | import { action } from '@storybook/addon-actions' 5 | import { SearchInfo, SearchField, Plugin, RowAction, Expand } from '../src/index' 6 | 7 | export const onSearch = async (info: SearchInfo) => { 8 | const params = { 9 | _page: info.page, 10 | _limit: info.pageSize, 11 | ...info.values 12 | } 13 | const res = await axios.get('//jsonplaceholder.typicode.com/posts', { 14 | params 15 | }) 16 | return { 17 | dataSource: res.data, 18 | total: Number(res.headers['x-total-count']) 19 | } 20 | } 21 | 22 | export const onError = (e) => { 23 | message.error(e.message) 24 | } 25 | 26 | export const columns = [ 27 | { 28 | key: 'id', 29 | title: 'ID', 30 | dataIndex: 'id' 31 | }, { 32 | key: 'title', 33 | title: 'Title', 34 | dataIndex: 'title' 35 | } 36 | ] 37 | 38 | export const expands: Expand[] = [ 39 | { 40 | title: 'Body', 41 | dataIndex: 'body', 42 | render (value, record) { 43 | return value && `${value.substr(0, 100)} ...` 44 | } 45 | }, 46 | { 47 | title: 'User ID', 48 | dataIndex: 'userId' 49 | } 50 | ] 51 | 52 | export const searchFields: SearchField[] = [ 53 | { 54 | label: 'ID', 55 | name: 'id', 56 | type: 'input', 57 | payload: { 58 | props: { 59 | placeholder: 'placeholder' 60 | } 61 | } 62 | }, 63 | { 64 | label: 'Select', 65 | name: 'select', 66 | type: 'select', 67 | initialValue: '1', 68 | payload: { 69 | props: { 70 | allowClear: true 71 | }, 72 | options: [ 73 | { key: '1', label: 'one', value: '1' }, 74 | { key: '2', label: 'two', value: '2' }, 75 | { key: '3', label: 'three', value: '3' } 76 | ] 77 | } 78 | }, 79 | { 80 | label: 'Multi Select', 81 | name: 'multi-select', 82 | type: 'select', 83 | span: 12, 84 | payload: { 85 | props: { 86 | mode: 'multiple' 87 | }, 88 | options: [ 89 | { key: '1', label: 'one', value: '1' }, 90 | { key: '2', label: 'two', value: '2' }, 91 | { key: '3', label: 'three', value: '3' } 92 | ] 93 | } 94 | }, 95 | { 96 | label: 'Date Picker', 97 | name: 'datePicker', 98 | type: 'datePicker', 99 | payload: { 100 | 101 | } 102 | }, 103 | { 104 | label: 'Tree Select', 105 | name: 'treeselect', 106 | type: 'treeSelect', 107 | payload: { 108 | props: { 109 | treeData: [{ 110 | label: 'Node1', 111 | value: '0-0', 112 | key: '0-0', 113 | children: [{ 114 | label: 'Child Node1', 115 | value: '0-0-1', 116 | key: '0-0-1' 117 | }, { 118 | label: 'Child Node2', 119 | value: '0-0-2', 120 | key: '0-0-2' 121 | }] 122 | }, { 123 | label: 'Node2', 124 | value: '0-1', 125 | key: '0-1' 126 | }] 127 | } 128 | } 129 | }, 130 | { 131 | label: 'Foo', 132 | name: 'foo', 133 | type: 'input' 134 | }, 135 | { 136 | label: 'Bar', 137 | name: 'bar', 138 | type: 'input' 139 | } 140 | ] 141 | 142 | export const plugins: Plugin[] = [ 143 | { 144 | renderer(selectedRowKeys, selectedRows, clearSelectionCallback) { 145 | const onClick = () => { 146 | action('onClick test plugin')(selectedRowKeys) 147 | clearSelectionCallback() 148 | } 149 | return ( 150 | 151 | ) 152 | } 153 | }, 154 | { 155 | renderer(selectedRowKeys, selectedRows, clearSelectionCallback) { 156 | const onClick = () => { 157 | action('onClick test plugin')(selectedRowKeys) 158 | clearSelectionCallback() 159 | } 160 | return ( 161 | 162 | ) 163 | } 164 | } 165 | ] 166 | 167 | export const actions: RowAction[] = [ 168 | { 169 | label: 'Edit', 170 | action(record) { 171 | action('onClick edit')(record) 172 | } 173 | }, 174 | { 175 | label: 'More', 176 | children: [ 177 | { 178 | label: 'Remove', 179 | action(record) { 180 | action('onClick remove')(record) 181 | } 182 | }, 183 | { 184 | label: 'Open', 185 | action(record) { 186 | action('onClick open')(record) 187 | } 188 | } 189 | ] 190 | } 191 | ] 192 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NewbeeFE/antd-data-table/b624412d33f5973880f758d604345746538f6727/docs/favicon.ico -------------------------------------------------------------------------------- /docs/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | Storybook 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Storybook 10 | 39 | 40 | 41 | 42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "antd-data-table", 3 | "version": "1.0.0", 4 | "main": "lib/index.js", 5 | "files": [ 6 | "lib" 7 | ], 8 | "types": "lib/index.d.ts", 9 | "scripts": { 10 | "start": "start-storybook -c .storybook -p 9001", 11 | "test": "jest", 12 | "cov": "npm test -- --coverage", 13 | "lint": "tslint 'src/**/*.ts?(x)' --type-check --project tsconfig.json", 14 | "build": "tsc", 15 | "clean": "rimraf lib", 16 | "build:storybook": "build-storybook -c .storybook -o docs", 17 | "pre-release": "npm run lint && npm test && npm run build:storybook && npm run clean && npm run build", 18 | "prepublish": "npm run lint && npm test && npm run clean && npm run build", 19 | "patch": "npm run pre-release && npm version patch", 20 | "minor": "npm run pre-release && npm version minor" 21 | }, 22 | "devDependencies": { 23 | "@storybook/addon-options": "^3.2.4", 24 | "@storybook/addon-storyshots": "^3.2.5", 25 | "@storybook/react": "^3.2.5", 26 | "@types/enzyme": "^3", 27 | "@types/immutability-helper": "^2.0.15", 28 | "@types/jest": "^20.0.7", 29 | "@types/node": "^8.0.23", 30 | "@types/react": "^16", 31 | "@types/react-dom": "^16", 32 | "antd": "^3", 33 | "awesome-typescript-loader": "^3.2.3", 34 | "axios": "^0.16.2", 35 | "css-loader": "^0.28.5", 36 | "declaration-bundler-webpack-plugin": "^1.0.3", 37 | "enzyme": "^3", 38 | "http-proxy-middleware": "^0.17.4", 39 | "jest": "^20.0.4", 40 | "less": "^2.7.2", 41 | "less-loader": "^4.0.5", 42 | "raw-loader": "^0.5.1", 43 | "react": "^16", 44 | "react-dom": "^16", 45 | "react-test-renderer": "^16", 46 | "rimraf": "^2.6.1", 47 | "storybook-readme": "^3.0.6", 48 | "style-loader": "^0.18.2", 49 | "ts-jest": "^20.0.10", 50 | "tslint": "^5.5.0", 51 | "tslint-config-standard": "^6.0.1", 52 | "typescript": "^2.4.2", 53 | "webpack": "^3.5.5" 54 | }, 55 | "dependencies": { 56 | "immutability-helper": "^2.3.1" 57 | }, 58 | "peerDependencies": { 59 | "antd": ">=3", 60 | "react": ">=16" 61 | }, 62 | "jest": { 63 | "transform": { 64 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 65 | }, 66 | "collectCoverageFrom": [ 67 | "src/**/*.{ts,tsx}" 68 | ], 69 | "setupTestFrameworkScriptFile": "./test/setup.ts", 70 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(tsx?|jsx?)$", 71 | "moduleFileExtensions": [ 72 | "ts", 73 | "tsx", 74 | "js", 75 | "json", 76 | "jsx" 77 | ] 78 | }, 79 | "proxy": {} 80 | } 81 | -------------------------------------------------------------------------------- /src/SearchField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Button, 4 | Form, 5 | Row, 6 | Col, 7 | Icon 8 | } from 'antd' 9 | import { WrappedFormUtils, FormComponentProps } from 'antd/lib/form/Form' // tslint:disable-line 10 | import { IDataTableProps, SearchFunc } from './' 11 | const FormItem = Form.Item 12 | 13 | import InputRenderer from './renderer/input' 14 | import SelectRenderer from './renderer/select' 15 | import DatePickerRenderer from './renderer/datePicker' 16 | import TreeSelectRenderer from './renderer/treeSelect' 17 | 18 | const comesWithRenderer = { 19 | input: InputRenderer, 20 | select: SelectRenderer, 21 | datePicker: DatePickerRenderer, 22 | treeSelect: TreeSelectRenderer 23 | } 24 | 25 | /** Your component's props */ 26 | export interface ISearchFieldProps extends IDataTableProps, FormComponentProps { 27 | /** antd form instance */ 28 | fetch: SearchFunc, 29 | btnLoading: boolean 30 | } 31 | 32 | /** Your component's state */ 33 | export interface ISearchFieldState { 34 | expand: boolean 35 | } 36 | 37 | /** Your component */ 38 | export class SearchField extends React.Component { 39 | 40 | state = { 41 | expand: false 42 | } 43 | 44 | private shouldHandleCollapse = this.props.maxVisibleFieldCount && this.props.searchFields.length > this.props.maxVisibleFieldCount 45 | 46 | componentDidMount () { 47 | if (this.props.loadDataImmediately) { 48 | this.onSearch() 49 | } 50 | } 51 | toggleExpand = () => { 52 | const { expand } = this.state 53 | this.setState({ expand: !expand }) 54 | } 55 | 56 | getFields = () => { 57 | const { form, maxVisibleFieldCount, searchFields } = this.props 58 | if (!form) { return false } 59 | const { getFieldDecorator } = form 60 | const formItemLayout = { 61 | wrapperCol: { span: 24 } 62 | } 63 | const count = this.state.expand ? searchFields.length : maxVisibleFieldCount || searchFields.length 64 | return this.props.searchFields.map((searchField, i) => { 65 | const renderComponent = () => { 66 | if (searchField.renderer) { 67 | // 自定义 renderer 68 | return searchField.renderer(searchField.payload) 69 | } else { 70 | // 自带 renderer 71 | if (searchField.type) { 72 | if (comesWithRenderer[searchField.type]) { 73 | return comesWithRenderer[searchField.type](searchField.payload) 74 | } else { 75 | console.warn('Unknown renderer:', searchField.type) 76 | return false 77 | } 78 | } else { 79 | // 既没有 type 又没有 renderer 80 | console.warn('Renderer or Type should exist in search field') 81 | return false 82 | } 83 | } 84 | } 85 | return ( 86 | 87 | 88 | {getFieldDecorator(searchField.name, { rules: searchField.validationRule, initialValue: searchField.initialValue })( 89 | renderComponent() 90 | )} 91 | 92 | 93 | ) 94 | }) 95 | } 96 | 97 | clearField = () => { 98 | const { form } = this.props 99 | if (!form) { return false } 100 | 101 | form.resetFields() 102 | } 103 | 104 | onSearch = () => { 105 | const { form, onValidateFailed, fetch } = this.props 106 | if (!form) { return false } 107 | const { validateFields } = form 108 | 109 | validateFields((err, values) => { 110 | // 删除空字段 111 | for (let key in values) { 112 | if (!values[key]) { 113 | delete values[key] 114 | } 115 | } 116 | if (err) { 117 | onValidateFailed && onValidateFailed(err) 118 | return 119 | } 120 | // 从 search field 搜索从第 1 页开始 121 | fetch(1, values, true) // tslint:disable-line 122 | }) 123 | } 124 | 125 | render () { 126 | return ( 127 |
131 | {this.getFields()} 132 | 133 | 134 | 135 | 138 | {this.shouldHandleCollapse && ( 139 | 140 | Collapse 141 | 142 | )} 143 | 144 | 145 |
146 | ) 147 | } 148 | } 149 | 150 | /** Export as default */ 151 | export default Form.create()(SearchField as any) as any 152 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Button, 4 | Table, 5 | Row, 6 | Col, 7 | Icon, 8 | Dropdown, 9 | Card, 10 | Checkbox, 11 | Menu, 12 | Affix 13 | } from 'antd' 14 | import update from 'immutability-helper' 15 | import { PaginationProps } from 'antd/lib/pagination/Pagination' 16 | import { ValidationRule } from 'antd/lib/form/Form' 17 | import SearchField from './SearchField' 18 | import { TableRowSelection, ColumnProps } from 'antd/lib/table' 19 | 20 | export type ValidateError = { 21 | [fieldName: string]: { 22 | errors: { 23 | message: string, 24 | field: string 25 | }[] 26 | } 27 | } 28 | 29 | export type SearchInfo = { 30 | values: any, 31 | page: number, 32 | pageSize: number 33 | } 34 | 35 | export type SearchFunc = (page: number, values?: object, clearPagination?: boolean) => Promise 36 | 37 | export type SearchResponse = { 38 | dataSource: T[], 39 | total: number 40 | } 41 | 42 | export type FieldRenderer = (payload?: object) => React.ReactNode 43 | 44 | export type RendererType = 'input' | 'select' | 'datePicker' | 'treeSelect' 45 | 46 | export type SearchField = { 47 | /** 条件名称 */ 48 | label: string, 49 | /** 条件别名,会作为 onSearch 时 values 的 key 名 */ 50 | name: string, 51 | /** 渲染的组件类型 */ 52 | type?: RendererType, 53 | /** 当不使用自带的组件类型时,可以自己写 renderer */ 54 | renderer?: FieldRenderer, 55 | /** antd 的表单验证规则 */ 56 | validationRule?: ValidationRule[], 57 | /** 初始值 */ 58 | initialValue?: any, 59 | /** 表单项的 span 值, 默认 6 */ 60 | span?: number, 61 | /** 传给渲染的组件的参数 */ 62 | payload?: SearchFieldPayload 63 | } 64 | 65 | export type SearchFieldPayload = { 66 | /** props that pass to the main component */ 67 | props?: object, 68 | [key: string]: any 69 | } 70 | 71 | export type RowAction = { 72 | label: string, 73 | children?: RowAction[], 74 | action?: (record) => void 75 | } 76 | 77 | export type Plugin = { 78 | colSpan?: number, 79 | renderer: (selectedRowKeys: string[], selectedRows: any[], clearSelectionCallback: () => void) => React.ReactNode 80 | } 81 | 82 | export type Expand = { 83 | title: string, 84 | dataIndex: string, 85 | render?: (text: any, record?: {}) => React.ReactNode 86 | } 87 | 88 | /** Your component's props */ 89 | export interface IDataTableProps { 90 | name?: string, 91 | initialColumns: ColumnProps[], 92 | searchFields: SearchField[], 93 | rowActions?: RowAction[], 94 | enableListSelection?: boolean, 95 | plugins?: Plugin[], 96 | /** 表格行 key 的取值 */ 97 | rowKey: (record: any) => string, 98 | title?: React.ReactNode, 99 | searchBtnText?: string, 100 | clearBtnText?: string, 101 | listSelectionBtnText?: string, 102 | /** 最大的表单项显示数,当表单项超过此数值时,会自动出现 collapse 按钮 */ 103 | maxVisibleFieldCount?: number, 104 | pageSize?: number, 105 | /** handle form validate error */ 106 | onValidateFailed?: (err: ValidateError) => void, 107 | /** 页面加载完成后是否立即加载数据 */ 108 | loadDataImmediately?: boolean, 109 | /** 执行 search 动作,返回一个 AxiosPromis */ 110 | onSearch (info: SearchInfo): Promise>, 111 | /** reject handler */ 112 | onError? (err): void, 113 | rowSelection?: TableRowSelection, 114 | affixTarget?: () => HTMLElement, 115 | affixOffsetTop?: number, 116 | affixOffsetBottom?: number, 117 | initialExpands?: Expand[] 118 | } 119 | 120 | /** Your component's state */ 121 | export interface IDataTableState { 122 | columns: ColumnProps[], 123 | data: any[], 124 | page: number, 125 | currentValues: object, 126 | pagination: PaginationProps, 127 | tableLoading: boolean, 128 | searchButtonLoading: boolean, 129 | selectedRowKeys: string[], 130 | selectedRows: any[] 131 | } 132 | 133 | const renderActions = (actions: RowAction[], record) => { 134 | return ( 135 | 136 | {actions.map((action, i) => { 137 | if (action.children) { 138 | const menu = ( 139 | 140 | {action.children.map(child => { 141 | const onClick = () => { 142 | child.action && child.action(record) 143 | } 144 | return ( 145 | 146 | {child.label} 147 | 148 | ) 149 | })} 150 | 151 | ) 152 | return ( 153 | 154 | 155 | {action.label} 156 | 157 | 158 | ) 159 | } else { 160 | const onClick = () => { 161 | action.action && action.action(record) 162 | } 163 | return [ 164 | {action.label}, 165 | i === 0 && 166 | ] 167 | } 168 | })} 169 | 170 | ) 171 | } 172 | 173 | /** Your component */ 174 | export class DataTable extends React.Component { 175 | 176 | static storageKey = 'antd-data-table' 177 | 178 | static defaultProps = { 179 | pageSize: 10, 180 | searchBtnText: 'Search', 181 | clearBtnText: 'Clear', 182 | listSelectionBtnText: 'List selection' 183 | } 184 | 185 | readonly actionsColumn = this.props.rowActions && { key: 'actions', title: 'Actions', render: (record) => { return renderActions(this.props.rowActions as RowAction[], record) } } as ColumnProps 186 | 187 | readonly shouldShowTableTitle = this.props.title || this.props.enableListSelection 188 | 189 | readonly initialColumns = this.actionsColumn ? [...this.props.initialColumns, this.actionsColumn] : this.props.initialColumns 190 | 191 | readonly visibleColumnKeys = localStorage.getItem(`${DataTable.storageKey}-${this.props.name}-columnIds`) 192 | 193 | readonly visibleColumns = (this.props.enableListSelection === true) && this.visibleColumnKeys ? this.initialColumns.filter(column => (this.visibleColumnKeys as string).indexOf(column.key as string) !== -1) : this.initialColumns 194 | 195 | state = { 196 | columns: [] = this.visibleColumns, 197 | data: [], 198 | page: 1, 199 | pagination: { 200 | pageSize: this.props.pageSize 201 | } as PaginationProps, 202 | currentValues: {}, 203 | tableLoading: false, 204 | searchButtonLoading: false, 205 | selectedRows: [], 206 | selectedRowKeys: [] 207 | } 208 | 209 | private filterPannel = ( 210 | {this.initialColumns.map(column => { 211 | const isSelected = this.state.columns.find(c => c.key === column.key) !== undefined 212 | const onChange = (e) => { 213 | if (e.target.checked) { 214 | this.showColumn(column.key) 215 | } else { 216 | this.hideColumn(column.key) 217 | } 218 | } 219 | return ( 220 |

221 | {column.title as any} 222 |

223 | ) 224 | })} 225 |
) 226 | 227 | constructor (props) { 228 | super(props) 229 | 230 | if (this.props.enableListSelection && !this.props.name) { 231 | console.warn('`name` is required while `enableListSelection` is true!') 232 | } 233 | } 234 | 235 | fetch: SearchFunc = async (page: number, values: object = this.state.currentValues, clearPagination: boolean = false) => { 236 | const { onError } = this.props 237 | this.applyValues(values, async () => { 238 | try { 239 | // 这里先简单认为 clearPagination 为 true 就是从 Search button 触发的 fetch 240 | clearPagination && this.startSearchButtonLoading() 241 | this.startTableLoading() 242 | const pager = { ...this.state.pagination } 243 | const response = await this.props.onSearch({ 244 | page: page, 245 | // pageSize 有 default 246 | pageSize: this.props.pageSize as number, 247 | values: this.state.currentValues 248 | }) 249 | pager.total = response.total 250 | this.setState({ 251 | pagination: pager 252 | }) 253 | this.applyData(response.dataSource) 254 | clearPagination && this.clearPagination() 255 | } catch (e) { 256 | onError && onError(e) 257 | } finally { 258 | clearPagination && this.stopSearchButtonLoading() 259 | this.stopTableLoading() 260 | } 261 | }) 262 | } 263 | 264 | private saveVisibleColumnKeysToStorage = (columns: ColumnProps[]) => { 265 | localStorage.setItem(`${DataTable.storageKey}-${this.props.name}-columnIds`, columns.map(column => column.key).join(',')) 266 | } 267 | 268 | private applyData = (data: any[]) => { 269 | this.setState({ data }) 270 | } 271 | 272 | private applyValues = (values, cb) => { 273 | this.setState({ currentValues: values }, cb) 274 | } 275 | 276 | private clearPagination = () => { 277 | const pager = { ...this.state.pagination } 278 | pager.current = 1 279 | this.setState({ pagination: pager }) 280 | } 281 | 282 | private handleChange = (pagination: PaginationProps) => { 283 | const pager = { ...this.state.pagination } 284 | pager.current = pagination.current 285 | this.setState({ pagination: pager }) 286 | this.fetch(pager.current || 1) // tslint:disable-line 287 | } 288 | 289 | private hideColumn = (key?: string | number) => { 290 | this.state.columns.forEach((column, i) => { 291 | if (column.key === key) { 292 | const columns = update(this.state.columns, { $splice: [[i, 1]] }) as any 293 | this.setState({ 294 | columns 295 | }, () => this.saveVisibleColumnKeysToStorage(columns)) 296 | } 297 | }) 298 | } 299 | 300 | private clearSelection = () => { 301 | this.setState({ 302 | selectedRows: [], 303 | selectedRowKeys: [] 304 | }) 305 | } 306 | 307 | private showColumn = (key?: string | number) => { 308 | this.initialColumns.forEach((column, i) => { 309 | if (column.key === key) { 310 | const columns = update(this.state.columns, { $splice: [[i, 0, column]] }) as any 311 | this.setState({ 312 | columns 313 | }, () => this.saveVisibleColumnKeysToStorage(columns)) 314 | } 315 | }) 316 | } 317 | 318 | private startTableLoading = () => { 319 | this.setState({ tableLoading: true }) 320 | } 321 | 322 | private stopTableLoading = () => { 323 | this.setState({ tableLoading: false }) 324 | } 325 | 326 | private startSearchButtonLoading = () => { 327 | this.setState({ searchButtonLoading: true }) 328 | } 329 | 330 | private stopSearchButtonLoading = () => { 331 | this.setState({ searchButtonLoading: false }) 332 | } 333 | 334 | private tableTitle = (currentPageData) => { 335 | if (this.shouldShowTableTitle) { 336 | return ( 337 | 338 | 339 | {this.props.title} 340 | 341 | 342 | 343 | 344 | {this.props.enableListSelection && ( 345 | 346 | 347 | 348 | )} 349 | 350 | 351 | 352 | 353 | ) 354 | } 355 | } 356 | 357 | private accordingly = (dataIndex: string, record: Object) => { 358 | const indexArr = dataIndex.split('.') 359 | const key = indexArr.shift() || '' 360 | const index = indexArr.join('.') 361 | const subData = record[key] || {} 362 | const value = record[key] || '' 363 | return indexArr.length ? this.accordingly(index, subData) : value 364 | } 365 | 366 | private renderExpandedRow = (record) => { 367 | const { initialExpands } = this.props 368 | const expandTitleStyle: Object = { 369 | textAlign: 'right', 370 | color: 'rgba(0, 0, 0, 0.85)', 371 | fontWeight: 500 372 | } 373 | if (initialExpands) { 374 | return ( 375 | 376 | {initialExpands.map(col => { 377 | const value = this.accordingly(col.dataIndex, record) 378 | return ( 379 | 380 | 381 | {col.title} 382 | {col.render ? col.render(value, record) : value} 383 | 384 | 385 | ) 386 | })} 387 | 388 | ) 389 | } 390 | } 391 | 392 | render () { 393 | const rowSelection = Object.assign({}, { 394 | selectedRowKeys: this.state.selectedRowKeys, 395 | onChange: (selectedRowKeys, selectedRows) => { 396 | this.setState({ 397 | selectedRowKeys, selectedRows 398 | }) 399 | } 400 | }, this.props.rowSelection) 401 | 402 | const ActionPanel = this.props.plugins && ( 403 | 404 | {(this.props.plugins).map(plugin => { 405 | return ( 406 | 407 | {plugin.renderer(this.state.selectedRowKeys, this.state.selectedRows, this.clearSelection)} 408 | 409 | ) 410 | })} 411 | 412 | ) 413 | 414 | return ( 415 |
416 |
417 | 418 |
419 |
420 | {this.props.affixTarget ? ( 421 | 426 | {ActionPanel} 427 | 428 | ) : ActionPanel} 429 | 430 | 443 | 444 | 445 | 446 | ) 447 | } 448 | } 449 | 450 | /** Export as default */ 451 | export default DataTable 452 | -------------------------------------------------------------------------------- /src/renderer/datePicker.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { 4 | DatePicker 5 | } from 'antd' 6 | import { SearchFieldPayload } from '../' 7 | 8 | export default (payload?: SearchFieldPayload) => { 9 | return ( 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Input 4 | } from 'antd' 5 | import { SearchFieldPayload } from '../' 6 | 7 | export default (payload?: SearchFieldPayload) => { 8 | return ( 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Select 4 | } from 'antd' 5 | import { SearchFieldPayload } from '../' 6 | 7 | type SelectOption = { 8 | key: string, 9 | label: string, 10 | value: string 11 | } 12 | 13 | export default (payload?: SearchFieldPayload) => { 14 | if (!payload || !payload.options) { 15 | console.warn('select renderere expected `options`') 16 | return null 17 | } 18 | const options = payload.options as SelectOption[] 19 | return ( 20 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/treeSelect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { 4 | TreeSelect 5 | } from 'antd' 6 | import { SearchFieldPayload } from '../' 7 | 8 | export default (payload?: SearchFieldPayload) => { 9 | return ( 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DataTable should render correctly 1`] = ` 4 |
5 |
6 |
15 |
24 |
34 |
38 |
41 | 49 |
50 |
53 |
56 | 59 | 91 | 92 |
93 |
94 |
95 |
96 |
106 |
110 |
113 | 121 |
122 |
125 |
128 | 131 |
143 |
153 |
156 |
166 | one 167 |
168 |
169 | 181 | 192 | 193 | 194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
211 |
215 |
218 | 226 |
227 |
230 |
233 | 236 |
248 |
256 |
259 |
    263 |
  • 266 |
    269 | 278 | 281 | 282 |   283 | 284 |
    285 |
  • 286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
305 |
309 |
312 | 320 |
321 |
324 |
327 | 330 | 337 |
348 | 355 | 358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
375 |
379 |
382 | 390 |
391 |
394 |
397 | 400 | 412 | 422 | 425 | 428 | 429 | 430 | 431 | 439 | 440 | 441 | 442 | 443 | 444 |
445 |
446 |
447 |
448 |
458 |
462 |
465 | 473 |
474 |
477 |
480 | 483 | 513 | 514 |
515 |
516 |
517 |
518 |
528 |
532 |
535 | 543 |
544 |
547 |
550 | 553 | 583 | 584 |
585 |
586 |
587 |
588 |
589 |
593 |
601 | 610 | 624 |
625 |
626 | 627 |
628 |
629 |
633 |
637 |
641 |
644 |
649 |
652 |
658 |
662 | 663 | 671 | 679 | 687 | 688 | 691 | 698 | 739 | 746 | 753 | 754 | 755 | 758 |
701 | 702 |
705 | 736 |
737 |
738 |
742 | 743 | ID 744 | 745 | 749 | 750 | Title 751 | 752 |
759 |
760 |
763 | No data 764 |
765 |
766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | `; 774 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as renderer from 'react-test-renderer' 3 | import { shallow, render } from 'enzyme' 4 | import { DataTable, SearchField, SearchInfo } from '../src' 5 | import { storiesOf } from '@storybook/react' 6 | import { searchFields, columns, onSearch } from '../__story__/share' 7 | 8 | import axios from 'axios' 9 | 10 | import { TableColumnConfig } from 'antd/lib/table/Table' 11 | 12 | describe('DataTable', () => { 13 | 14 | /** Snapshot testing */ 15 | it('should render correctly', () => { 16 | let tree = renderer.create( 17 | item.id} 19 | searchFields={searchFields} 20 | initialColumns={columns} 21 | onSearch={onSearch} 22 | pageSize={10} 23 | /> 24 | ).toJSON() 25 | 26 | expect(tree).toMatchSnapshot() 27 | }) 28 | 29 | /** Enzyme shallow testing */ 30 | it('should have one div', () => { 31 | // let wrapper = shallow() 32 | // expect(wrapper.find('div').length).toEqual(1) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a file that will run before your test suit. 3 | * 4 | * You may need to do some mocking or another work here. 5 | */ 6 | window.matchMedia = window.matchMedia || (() => { return { matches: false, addListener: () => { }, removeListener: () => { } } }) 7 | class LocalStorageMock { 8 | 9 | store = {} 10 | 11 | clear() { 12 | this.store = {} 13 | } 14 | 15 | getItem(key) { 16 | return this.store[key] 17 | } 18 | 19 | setItem(key, value) { 20 | this.store[key] = value.toString() 21 | } 22 | 23 | removeItem(key) { 24 | delete this.store[key] 25 | } 26 | } 27 | declare var global 28 | global.localStorage = new LocalStorageMock() 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react", 5 | "strictNullChecks": true, 6 | "target": "es5", 7 | "experimentalDecorators": true, 8 | "declaration": true, 9 | "rootDirs": [ 10 | "src", 11 | "__story__" 12 | ], 13 | "outDir": "lib", 14 | "lib": [ 15 | "es5", 16 | "es2015", 17 | "dom", 18 | "scripthost", 19 | "es2016.array.include" 20 | ] 21 | }, 22 | "include": [ 23 | "src" 24 | ], 25 | "exclude": [ 26 | "__story__", 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard" 4 | ], 5 | "rules": { 6 | "member-ordering": false 7 | } 8 | } --------------------------------------------------------------------------------