├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── dependabot.yml │ ├── publish.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TODO.md ├── babel.config.js ├── docs └── index.html ├── eslint.config.mjs ├── example └── index.js ├── jest.config.js ├── jest.setup.ts ├── lib ├── SmartDataTable.tsx ├── __mocks__ │ └── fileMock.ts ├── components │ ├── CellValue.test.tsx │ ├── CellValue.tsx │ ├── ErrorBoundary.tsx │ ├── HighlightValue.tsx │ ├── Paginator.test.tsx │ ├── Paginator.tsx │ ├── PaginatorItem.tsx │ ├── SelectAll.test.tsx │ ├── SelectAll.tsx │ ├── Table.tsx │ ├── Toggles.tsx │ └── helpers │ │ └── with-pagination.tsx ├── css │ ├── basic.css │ ├── paginate.css │ ├── paginator.css │ └── toggles.css ├── helpers │ ├── constants.ts │ ├── context.ts │ ├── default-state.ts │ ├── file-extensions.ts │ ├── functions.helpers.test.ts │ ├── functions.table.test.ts │ ├── functions.ts │ └── tests.ts ├── index.ts └── types.ts ├── package.json ├── pnpm-lock.yaml ├── tsconfig.declaration.json ├── tsconfig.eslint.json ├── tsconfig.json ├── webpack.config.js └── webpack.dev.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.md] 10 | indent_style = space 11 | indent_size = 2 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml,json}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.{js,jsx,ts,tsx}] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @global-owner1 and @global-owner2 will be requested for 4 | # review when someone opens a pull request. 5 | * @joaocarmo 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | 4 | 5 | ### Actual Behavior 6 | 7 | 8 | 9 | ### Steps to Reproduce 10 | 11 | 12 | 13 | ### Screenshot(s) 14 | 15 | 16 | 17 | ### Your Environment 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Request type 4 | 5 | 6 | 7 | - [ ] Chore 8 | - [ ] Feature 9 | - [ ] Fix 10 | - [ ] Refactor 11 | - [ ] Tests 12 | - [ ] Documentation 13 | 14 | ### Summary 15 | 16 | 17 | 18 | {Please write here what's the main purpose of this PR} 19 | 20 | ### Change description 21 | 22 | 23 | 24 | {Please write here a summary of the change} 25 | 26 | ### Check lists 27 | 28 | 29 | 30 | - [ ] Tests passed 31 | - [ ] Coding style respected 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for each package manager 2 | version: 2 3 | updates: 4 | # Check for updates to GitHub Actions 5 | - package-ecosystem: 'github-actions' 6 | directory: '/' 7 | schedule: 8 | interval: 'weekly' 9 | # Maintain dependencies for npm packages 10 | - package-ecosystem: 'npm' 11 | directory: '/' 12 | schedule: 13 | interval: 'weekly' 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | schedule: 9 | - cron: '3 10 * * 4' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: '/language:${{ matrix.language }}' 42 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | dependabot: 9 | runs-on: ubuntu-latest 10 | if: ${{ github.actor == 'dependabot[bot]' }} 11 | 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.3.0 16 | with: 17 | github-token: '${{ secrets.GITHUB_TOKEN }}' 18 | 19 | - name: Enable auto-merge for Dependabot PRs 20 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish-npm: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | token: ${{ secrets.GH_PAT }} 15 | ref: 'main' 16 | 17 | - name: Enable corepack 18 | run: | 19 | corepack enable pnpm 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | cache: 'pnpm' 26 | registry-url: https://registry.npmjs.org/ 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Build 32 | run: | 33 | pnpm build 34 | pnpm build:types 35 | 36 | - name: Publish to NPM 37 | run: pnpm publish 38 | env: 39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Version 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | bump-version: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | token: ${{ secrets.GH_PAT }} 16 | ref: 'main' 17 | 18 | - name: Enable corepack 19 | run: | 20 | corepack enable pnpm 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '20' 26 | cache: 'pnpm' 27 | registry-url: https://registry.npmjs.org/ 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Get release 33 | id: get_release 34 | uses: bruceadams/get-release@v1.3.2 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 37 | 38 | - name: Bump the version 39 | run: | 40 | git config --local user.name "${{ secrets.GIT_AUTHOR_NAME }}" 41 | git config --local user.email "${{ secrets.GIT_AUTHOR_EMAIL }}" 42 | npm version --no-commit-hooks --no-git-tag-version ${{ steps.get_release.outputs.tag_name }} 43 | 44 | - name: Commit and push the version change 45 | run: | 46 | git add . 47 | git commit -m "Release ${{ steps.get_release.outputs.tag_name }}" --no-gpg-sign --no-verify --signoff 48 | git push 49 | 50 | - name: Trigger the Publish Action 51 | uses: benc-uk/workflow-dispatch@v1.2.4 52 | with: 53 | workflow: Publish 54 | token: ${{ secrets.GH_PAT }} 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Enable corepack 18 | run: | 19 | corepack enable pnpm 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | cache: 'pnpm' 26 | registry-url: https://registry.npmjs.org/ 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Check the typings 32 | run: pnpm type-check 33 | 34 | - name: Execute the tests 35 | run: pnpm test 36 | 37 | - name: Build the library and the example 38 | run: pnpm prd 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | .eslintcache 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # Mac files 41 | .DS_Store 42 | 43 | # Custom function testers 44 | test/*.func.* 45 | 46 | # Build directory 47 | dist 48 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | pnpm type-check 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Mac files 40 | .DS_Store 41 | 42 | # Development helpers 43 | npm-reinstall 44 | 45 | # Test, deployment and development stuff 46 | dev/ 47 | dist/example.* 48 | docs/ 49 | example/ 50 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | test/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsxSingleQuote": false, 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.16.0 4 | 5 | > Jun 21, 2022 6 | 7 | - Creates and implements a SmartDataTableContext 8 | - Removes the `dev` and `dist` dirs 9 | - Upgrades the example to use the React 18 API 10 | - Moves the example to gh-pages 11 | 12 | ## 0.15.0 13 | 14 | > Apr 3, 2022 15 | 16 | - Adds support for a custom sort `compareFn` per column which can be used to 17 | leverage [localeCompare](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare) 18 | 19 | ## no-release (2022-02-06) 20 | 21 | > Feb 6, 2022 22 | 23 | - Moved the docs to this repo 24 | 25 | ## 0.14.0 26 | 27 | > Jan 7, 2022 28 | 29 | - Adds data attributes to columns that allow individual column targeting, e.g. 30 | by CSS 31 | 32 | ## 0.13.1 33 | 34 | > Nov 14, 2021 35 | 36 | - Removed the compiled example from the npm package to remove the overall 37 | bundle/download size 38 | 39 | ## 0.13.0 40 | 41 | > Nov 13, 2021 42 | 43 | - Ported the tests to TypeScript 44 | - Added a `SelectAll` component to the `Toggles` component 45 | ([Issue \#49](https://github.com/joaocarmo/react-smart-data-table/issues/49)) 46 | 47 | ## no-release (2021-11-10) 48 | 49 | > Nov 10, 2021 50 | 51 | - Updated some of the dependencies 52 | 53 | ## no-release (2021-11-09) 54 | 55 | > Nov 9, 2021 56 | 57 | - Migrated the react testing library from enzyme to @testing-library/react 58 | 59 | ## 0.12.0 60 | 61 | > Apr 24, 2021 62 | 63 | - Added a `dataSampling` prop which tells the sampling algorithm how much of the 64 | data to sample in order to compute the headers, useful for data which is not 65 | uniform 66 | 67 | ## 0.11.0 68 | 69 | > Apr 17, 2021 70 | 71 | - Converted the codebase to TypeScript 72 | - Fixed an issue where columns might get duplicated 73 | [Issue \#39](https://github.com/joaocarmo/react-smart-data-table/issues/39) 74 | 75 | ## 0.10.1 76 | 77 | > Mar 22, 2021 78 | 79 | - Improved the CI workflows 80 | - Updated the documentation 81 | - Updated the required React version 82 | 83 | ## 0.10.0 84 | 85 | > Mar 2, 2021 86 | 87 | - Added a new `dataRequestOptions` to allow passing options directly to the 88 | underlying `fetch` API call 89 | 90 | ## 0.9.0 91 | 92 | > Feb 17, 2021 93 | 94 | - Added a new `dataKeyResolver` prop that accepts custom function which takes 95 | the response as its only argument and returns the data 96 | - Fixed a bug rendering the cell value introduced in the previous refactoring 97 | - Fixed a long lasting bug regarding the `headers` prop overriding behavior 98 | - Fixed the loader not appearing if the data was empty 99 | - Added more tests 100 | 101 | ## no-release (2021-02-15) 102 | 103 | > Feb 15, 2021 104 | 105 | - Converted the CellValue component to a FC and added `React.memo` to try and 106 | get some performance gains 107 | 108 | ## no-release (2021-02-14) 109 | 110 | > Feb 14, 2021 111 | 112 | - Upgraded the codebase to the new [JSX transform](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) 113 | - Removed the `memoize-one` dependency 114 | - Cleaned up the internal code 115 | - Refactored the dev workflow 116 | - Changed the files with `JSX` syntax to use the `.jsx` extension 117 | - Converted some Class Components to Functional Components 118 | - Toggles 119 | - Converted some `div` elements to more semantic HTML elements 120 | - Converted Promises to async-await 121 | - Improved the pagination's basic CSS 122 | - Added custom API URL to the example 123 | 124 | ### Breaking Changes 125 | 126 | - `TableCell` was renamed to `CellValue` 127 | 128 | ## 0.8.0 129 | 130 | > Sep 1, 2020 131 | 132 | ### Breaking Changes 133 | 134 | - Removed the styling to a dedicated file 135 | 136 | ## no-release (2020-06-07) 137 | 138 | > Jun 7, 2020 139 | 140 | - Started to add component testing using _enzyme_ 141 | 142 | ## no-release (2020-05-18) 143 | 144 | > May 18, 2020 145 | 146 | - Switched from _npm_ to _yarn_ 147 | - Updated the dependencies 148 | - Fixed the example 149 | - Added _GitHub_ workflows for _push_ and _PR_ to the main branch 150 | 151 | ## 0.7.4 152 | 153 | > Mar 20, 2020 154 | 155 | - Improved the tests 156 | - Fixes 157 | [Issue \#22](https://github.com/joaocarmo/react-smart-data-table/issues/22) 158 | - Updated the dependencies due to: 159 | - [CVE-2020-7598](https://nvd.nist.gov/vuln/detail/CVE-2020-7598) 160 | 161 | ## no-release (2020-03-08) 162 | 163 | > Mar 8, 2020 164 | 165 | - Added some unit tests 166 | - Added [husky](https://github.com/typicode/husky) for pre-commit hooks 167 | 168 | ## no-release (2020-03-07) 169 | 170 | > Mar 7, 2020 171 | 172 | - Updated the dependencies due to: 173 | - [CVE-2019-16769](https://nvd.nist.gov/vuln/detail/CVE-2019-16769) 174 | - Improved the docs 175 | - Added a `code of conduct` 176 | - Added `contributing guidelines` 177 | - Added a `pull request template` 178 | 179 | ## 0.7.3 180 | 181 | > Oct 18, 2019 182 | 183 | - Merged 184 | [Pull \#20](https://github.com/joaocarmo/react-smart-data-table/pull/20) 185 | to fix 186 | [Issue \#19](https://github.com/joaocarmo/react-smart-data-table/issues/19) 187 | ([\@tisoap](https://github.com/tisoap)) 188 | - Added support for the _parseImg_ option to parse Data URLs as well 189 | 190 | ## 0.7.2 191 | 192 | > Sep 29, 2019 193 | 194 | - Removed the deprecation warning for _footer_ and _withHeaders_ 195 | - Added the _orderedHeaders_ prop that allows to customize the headers order 196 | - Added the _hideUnordered_ prop that allows to hide unordered headers 197 | 198 | ## 0.7.1 199 | 200 | > Jun 20, 2019 201 | 202 | - Updated the dependencies due to: 203 | - [CVE-2019-11358](https://nvd.nist.gov/vuln/detail/CVE-2019-11358) 204 | - [WS-2019-0032](https://github.com/nodeca/js-yaml/issues/475) 205 | - Replaced _@babel/polyfill_ with _core-js/stable_ 206 | 207 | ## 0.7.0 208 | 209 | > Feb 4, 2019 210 | 211 | - Added the possibility of passing a custom _Paginator_ component to render the 212 | pagination 213 | - Removed the _segmentize_ dependency 214 | 215 | ## 0.6.7 216 | 217 | > Dec 25, 2018 218 | 219 | - Removed the _lodash_ dependency completely 220 | - Fixed a bug where the rows, when filtered, would cause the sorting to not work 221 | - Didn't change the behavior where the _index_ passed down to _transform_ 222 | function in the _headers_ does not correspond to the index of the original data, 223 | but of the sorted data instead, because a different algorithm can be used to 224 | achieve the same result (example in the documentation) 225 | 226 | ## 0.6.6 227 | 228 | > Dec 20, 2018 229 | 230 | - Fixes [Issue \#14](https://github.com/joaocarmo/react-smart-data-table/issues/14) 231 | 232 | ## 0.6.5 233 | 234 | > Oct 10, 2018 235 | 236 | - Added the prop _emptyTable_ to display a message when the table is empty 237 | (Fixes [Issue \#13](https://github.com/joaocarmo/react-smart-data-table/issues/13)) 238 | 239 | ## 0.6.4 240 | 241 | > Sep 28, 2018 242 | 243 | - Added _transform_ and _isImage_ properties to the _headers_ prop accepted 244 | options 245 | 246 | ## 0.6.3 247 | 248 | > Sep 28, 2018 249 | 250 | - Added prop to pass custom _header_ prop with options to override individual 251 | columns default behavior 252 | - Added the _dynamic_ prop 253 | - Added a _.npmignore_ file to reduce the package size by excluding examples 254 | and tests 255 | 256 | ## 0.6.2 257 | 258 | > Sep 6, 2018 259 | 260 | - Removed the _Python_ dependency and replaced the development server with 261 | _webpack-dev-server_ 262 | - Updated the _webpack_ configuration for the new _babel-loader_ 263 | - Helper functions improvements 264 | 265 | ## 0.6.1 266 | 267 | > Sep 5, 2018 268 | 269 | - Fixes [Issue \#12](https://github.com/joaocarmo/react-smart-data-table/issues/12) 270 | 271 | ## 0.6.0 272 | 273 | > Aug 29, 2018 274 | 275 | - Webpack reorganization 276 | - Package structure reorganization 277 | 278 | ### Breaking Changes 279 | 280 | - Removed the _styled_ prop deprecation warning 281 | - Added the _footer_ deprecation warning 282 | - Added the _withHeaders_ deprecation warning 283 | - Added the _withFooter_ prop as the flag to render the footer in convergence 284 | with the _withHeader_ prop 285 | 286 | _Note_: This version is exactly the same as `0.5.15` with some props name 287 | changes. If this breaks your app, keep using the previous version. 288 | 289 | ## 0.5.15 290 | 291 | > Aug 19, 2018 292 | 293 | - Added _className_ to options that can be provided to _parseImg_ to be passed 294 | down to the _img_ tag 295 | - Several minor enhancements, bug fixes and code reduction 296 | - Added memoization through [memoize-one](https://github.com/alexreardon/memoize-one) 297 | 298 | ## 0.5.14 299 | 300 | > Aug 19, 2018 301 | 302 | - Added a parser for images and the possibility to render the image instead of 303 | displaying the URL which also accepts an object with a _style_ key containing a 304 | _style object_ which will be passed down to the `` tag with the CSS 305 | attributes as defined in 306 | [Common CSS Properties Reference](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Properties_Reference) 307 | 308 | ### Bug Fixes 309 | 310 | - Stopped the event propagation to _onRowClick_ when links rendered with 311 | _withLinks_ are clicked 312 | 313 | ## 0.5.13 314 | 315 | > Aug 14, 2018 316 | 317 | - Added the possibility to convert _true_ and _false_ to _[Yes Word]_ and 318 | _[No Word]_ when the value is of _Boolean_ type where each can be customized 319 | by supplying an object to the _parseBool_ prop 320 | 321 | ## 0.5.12 322 | 323 | > Aug 14, 2018 324 | 325 | - Added the possibility to convert _true_ and _false_ to _Yes_ and _No_ when the 326 | value is of _Boolean_ type through the _parseBool_ prop 327 | 328 | ## 0.5.11 329 | 330 | > Aug 12, 2018 331 | 332 | - Added _onRowClick_ prop, check the [README](README.md) for the function 333 | signature (courtesy of [occult](https://github.com/occult)) 334 | 335 | ## 0.5.10 336 | 337 | > Aug 1, 2018 338 | 339 | ## Bug fixes 340 | 341 | - When filtering by value, reset the page (pagination) back to 1 342 | 343 | ## 0.5.9 344 | 345 | > Jul 23, 2018 346 | 347 | - Added a condition to reload the async data if the URL changes 348 | 349 | ## 0.5.8 350 | 351 | > Jul 18, 2018 352 | 353 | - The RSDT now correctly re-renders when data is changed in props and the loader 354 | is correctly called, it also correctly re-renders even when the data type is 355 | changed 356 | 357 | ## 0.5.7 358 | 359 | > Jun 24, 2018 360 | 361 | - Added ESLint with babel-eslint and eslint-config-airbnb 362 | - Added a new prop for a _loader_ component to be rendered while fetching async 363 | data 364 | - Added intelligence to parse boolean values 365 | 366 | ## 0.5.6 367 | 368 | > Jun 10, 2018 369 | 370 | - Added async data loading from remote url 371 | 372 | ## 0.5.5 373 | 374 | > Apr 30, 2018 375 | 376 | - Added an [Error Boundary](https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html#introducing-error-boundaries) 377 | 378 | ## 0.5.4 379 | 380 | > Apr 25, 2018 381 | 382 | - Exposed the library as a compiled bundle in order to avoid import errors due 383 | to the ES6 syntax 384 | 385 | ## 0.5.3 386 | 387 | > Apr 2, 2018 388 | 389 | - Added the prop _withHeaders_ (courtesy of [Derush](https://github.com/Derush)) 390 | 391 | ## 0.5.2 392 | 393 | > Mar 18, 2018 394 | 395 | - Tested and updated the dependencies, will bring improvements very soon 396 | 397 | ## 0.5.1 398 | 399 | > Aug 2, 2017 400 | 401 | - Highlighting text now works with _withLinks_ 402 | - Added pagination with ellipsis for large amounts of data 403 | - Added deprecation warning for _styled_ prop 404 | 405 | ## 0.5.0 406 | 407 | > Jul 25, 2017 408 | 409 | - Complete re-write of the whole component, makes the internal gears more 410 | flexible for future improvements 411 | - Removed the _styled_ prop and the ability to render the table using _div_'s 412 | - Removed sorting by clicking on table header 413 | - Added sorting by clicking on table header sorting icon 414 | - Added different icons to represent sorting directions 415 | - Added string highlight to search filter 416 | - Added _withLinks_ prop that detects and converts links to `` tags 417 | - Column toggles no longer require the custom component, it's built-in 418 | - Added example with per page dropdown selection 419 | - Converted pagination _span_ tags to _a_ tags 420 | 421 | ## 0.4.1 422 | 423 | > May 27, 2017 424 | 425 | - Fixed a bug where the visibility toggles wouldn't function introduced by the 426 | pagination support 427 | 428 | ## 0.4.0 429 | 430 | > May 26, 2017 431 | 432 | - Added pagination support 433 | 434 | ## 0.3.4 435 | 436 | > May 6, 2017 437 | 438 | - Package dependencies updated 439 | 440 | ## 0.3.3 441 | 442 | > Apr 25, 2017 443 | 444 | - Fixed the toggles and sorting compatibility bug 445 | - Added documentation for toggles 446 | 447 | ## 0.3.2 448 | 449 | > Apr 24, 2017 450 | 451 | - Added column visibility toggles 452 | - Bug: need to fix compatibility with sorting 453 | 454 | ## 0.3.1 455 | 456 | > Apr 23, 2017 457 | 458 | - Fixed a bug where sorting would reset the filtering 459 | - Added a filtering example to _README.md_ 460 | 461 | ## 0.3.0 462 | 463 | > Apr 21, 2017 464 | 465 | - Added filtering of all columns through a new prop _filterValue_ that accepts 466 | a string input as the value to use for the filter 467 | 468 | ## 0.2.3 469 | 470 | > Apr 15, 2017 471 | 472 | - Added live examples to _README.md_ 473 | 474 | ## 0.2.2 475 | 476 | > Apr 15, 2017 477 | 478 | - Added PropTypes from the _prop-types_ npm module instead of the main _react_ 479 | 480 | ## 0.2.1 481 | 482 | > Mar 26, 2017 483 | 484 | - Added SmartDataTable as a default export 485 | 486 | ## 0.2.0 487 | 488 | > Mar 25, 2017 489 | 490 | - Added sortable option to make the table sortable by individual columns 491 | 492 | ## 0.1.0 493 | 494 | > Mar 11, 2017 495 | 496 | - Added support for nested objects and for more header formats 497 | - Added _lodash_ dependency 498 | - Started to document the code, updated the _README.md_ 499 | 500 | ## 0.0.1 501 | 502 | > Feb 12, 2017 503 | 504 | - Wrote most of the logic for the smart data table 505 | 506 | ## 0.0.0 507 | 508 | > Jan 30, 2017 509 | 510 | - Created the index export and wrote the basic react component structure 511 | - Created the environment for proper development and testing 512 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | info@joaocarmo.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome, even without prior discussion. In the event of 4 | the later, please make sure to create a very clear and summarized _pull 5 | request_. 6 | 7 | ## Pull Request Process 8 | 9 | Make use of the _pull request template_ if you don't know where to start. 10 | 11 | 1. Let's try to follow the versioning scheme of [SemVer][1]. 12 | 13 | 2. Update the relevant docs whenever necessary, e.g. _README.md_. 14 | 15 | 3. When introducing breaking changes, make a _very good_ case for it. 16 | 17 | 4. Write unit/functional tests, if possible. 18 | 19 | ## Code of conduct 20 | 21 | Respect the community and follow the guidelines in the [code of conduct][2]. 22 | 23 | [1]: http://semver.org/ 24 | [2]: ./CODE_OF_CONDUCT.md 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 João Carmo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-smart-data-table 2 | 3 | [![npm version](https://badge.fury.io/js/react-smart-data-table.svg)][npm] 4 | [![jest](https://jestjs.io/img/jest-badge.svg)][jest] 5 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg)][contributor] 6 | ![Workflow Status](https://github.com/joaocarmo/react-smart-data-table/workflows/Tests/badge.svg) 7 | 8 | A smart data table component for React.js meant to be configuration free, 9 | batteries included. 10 | 11 | ## About 12 | 13 | This is meant to be a _zero configuration_ data table component for React.js 14 | in the spirit of _plug and play_. 15 | 16 | Just feed it an array of equal JSON objects and it will create a template free 17 | table that can be customized easily with any framework (or custom CSS). 18 | 19 | If you want more control over the data rendering process or don't need the 20 | _smarts_, check out 21 | [react-very-simple-data-table][react-very-simple-data-table]. 22 | 23 | ## Features 24 | 25 | It currently supports: 26 | 27 | 1. Humanized column names based on object keys 28 | 1. Sortable columns 29 | - Accepts a custom sort `compareFn` function 30 | 1. Rows filtering / searchable 31 | 1. Search term highlight in the results 32 | 1. Column visibility toggles 33 | 1. Automatic pagination 34 | 1. Server-side/remote data 35 | 1. Control over row clicks 36 | 1. Smart data rendering 37 | - URLs and E-Mail addresses rendered as the _href_ in an _anchor_ tag 38 | `` 39 | - _boolean_ value parsing to yes/no word 40 | - Image URLs rendered as the _src_ for an image tag `` 41 | 1. Custom override if the default behavior is unwanted for some columns 42 | 1. Custom components 43 | - Paginator 44 | 1. Control the order of the columns 45 | - Using the above, it's also possible to select which columns to display 46 | 47 | ## Installation 48 | 49 | ```sh 50 | yarn add react-smart-data-table 51 | 52 | # or 53 | 54 | npm install react-smart-data-table 55 | ``` 56 | 57 | There is some very basic styling you can use to get started, but since `v0.8.0` 58 | you need to import it specifically. You can also copy the file and use it as the 59 | basis for your own theme. 60 | 61 | ```js 62 | // Import basic styling 63 | import 'react-smart-data-table/dist/react-smart-data-table.css' 64 | ``` 65 | 66 | ## Context 67 | 68 | You can access the SmartDataTable's internal context in your own component by 69 | using the `useSmartDataTableContext` hook. 70 | 71 | **Note:** You must be within the context of `SmartDataTable`, e.g. passing a 72 | custom component to `emptyTable`, `loading`, or `paginator`. 73 | 74 | ```jsx 75 | import { useSmartDataTableContext } from 'react-smart-data-table' 76 | 77 | const MyComponent = () => { 78 | const { columns, data } = useSmartDataTableContext() 79 | 80 | return ( 81 |
82 |

My Component

83 |

Columns: {columns.length}

84 |

Rows: {data.length}

85 |
86 | ) 87 | } 88 | ``` 89 | 90 | ## Props 91 | 92 | | Name | Default | Type | Description | 93 | | :----------------- | :-------------------- | :-------------------- | :---------------------------------------------------------------- | 94 | | data | [] | {array|string} | An array of plain objects (can be nested) or a URL | 95 | | dataKey | 'data' | {string} | The object key where the async data is available | 96 | | dataKeyResolver | _null_ | {function} | Supply a function to extract the data from the async response | 97 | | dataRequestOptions | {} | {object} | Fetch API options passed directly into the async request call | 98 | | dataSampling | 0 | {number} | **Percentage** of the data to sample before computing the headers | 99 | | dynamic | false | {boolean} | Use this if your column structure changes dynamically | 100 | | emptyTable | _null_ | {element} | Pass a renderable object to render when there is no data | 101 | | filterValue | '' | {string} | Filters all columns by its value | 102 | | headers | {} | {object} | The object that overrides default column behavior | 103 | | hideUnordered | false | {boolean} | Hides all the columns not passed to _orderedHeaders_ | 104 | | loader | _null_ | {element} | Element to be rendered while fetching async data | 105 | | name | 'reactsmartdatatable' | {string} | The name for the table | 106 | | onRowClick | _undefined_ | {function} | If present, it will execute on every row click | 107 | | orderedHeaders | [] | {array} | An ordered array of the column keys | 108 | | paginator | _elements_ | {element} | Pass a renderable object to handle the table pagination | 109 | | parseBool | false | {boolean|object} | When true, boolean values will be converted to Yes/No | 110 | | parseImg | false | {boolean|object} | When true, image URLs will be rendered as an _img_ tag | 111 | | perPage | 0 | {number} | Paginates the results with the value as rows per page | 112 | | sortable | false | {boolean} | Enables the columns of the table to be sortable | 113 | | withFooter | false | {boolean} | Copy the header to the footer | 114 | | withHeader | true | {boolean} | Can be used to disable the rendering of column headers | 115 | | withLinks | false | {boolean} | Converts e-mails and url addresses to links | 116 | | withToggles | false | {boolean|object} | Enables the column visibility toggles | 117 | 118 | ### emptyTable 119 | 120 | ```jsx 121 | // Any renderable object can be passed 122 | const emptyTable =
There is no data available at the time.
123 | ``` 124 | 125 | ### headers 126 | 127 | ```js 128 | /* 129 | Use the following structure to overwrite the default behavior for columns 130 | Undefined column keys will present the default behavior 131 | text: Humanized text based on the column key name 132 | invisible: Columns are visible by default 133 | sortable: Columns are sortable by default 134 | filterable: Columns are filterable by default 135 | isImg: Will force the render as an image, e.g. for dynamic URLs 136 | transform: Allows the custom rendering of the cells content 137 | Should be a function and these are the arguments passed: 138 | (value, index, row) 139 | The index is the position of the row as being rendered and 140 | not the index of the row in the original data 141 | Nested structures can be defined by a string-dot representation 142 | 'key1.key2.key3.[...].key99' 143 | */ 144 | const headers = { 145 | columnKey: { 146 | text: 'Column 1', 147 | invisible: false, 148 | sortable: true, 149 | filterable: true, 150 | }, 151 | 'nested.columnKey': { 152 | text: 'Nested Column', 153 | invisible: false, 154 | sortable: (a, b) => b['nested.columnKey'] - a['nested.columnKey'], 155 | filterable: true, 156 | }, 157 | // If a dummy column is inserted into the data, it can be used to customize 158 | // the table by allowing actions per row to be implemented, for example 159 | tableActions: { 160 | text: 'Actions', 161 | invisible: false, 162 | sortable: false, 163 | filterable: false, 164 | transform: (value, index, row) => { 165 | // The following results should be identical 166 | console.log(value, row.tableActions) 167 | // Example of table actions: Delete row from data by row index 168 | return 169 | }, 170 | }, 171 | } 172 | ``` 173 | 174 | ### onRowClick() 175 | 176 | ```js 177 | const onRowClick = (event, { rowData, rowIndex, tableData }) => { 178 | // The following results should be identical 179 | console.log(rowData, tableData[rowIndex]) 180 | } 181 | ``` 182 | 183 | ### paginator 184 | 185 | The _CustomComponent_ passed down as a prop will be rendered with the following 186 | props which can be used to perform all the necessary calculations and makes it 187 | fully compatible with Semantic UI's [Pagination][pagination] 188 | component. 189 | 190 | ```jsx 191 | const CustomComponent = ({ 192 | activePage, totalPages, onPageChange, 193 | }) => (/* ... */) 194 | 195 | 199 | 200 | // To change the page, call the onPageChange function with the next activePage 201 | 202 | this.onPageChange(e, { activePage: nextActivePage })} 205 | /> 206 | ``` 207 | 208 | ### parseBool 209 | 210 | ```js 211 | // Default 212 | const parseBool = { 213 | yesWord: 'Yes', 214 | noWord: 'No', 215 | } 216 | ``` 217 | 218 | ### parseImg 219 | 220 | ```js 221 | // You can pass a regular style object that will be passed down to 222 | // Or a Class Name 223 | const parseImg = { 224 | style: { 225 | border: '1px solid #ddd', 226 | borderRadius: '4px', 227 | padding: '5px', 228 | width: '150px', 229 | }, 230 | className: 'my-custom-image-style', 231 | } 232 | ``` 233 | 234 | ### orderedHeaders / hideUnordered 235 | 236 | If you want to control the order of the columns, simply pass an array containing 237 | the keys in the desired order. All the omitted headers will be appended 238 | afterwards unpredictably. Additionally, you can pass the _hideUnordered_ in 239 | order to render only the headers in _orderedHeaders_ and hide the remaining. 240 | 241 | ```js 242 | const hideUnordered = true 243 | 244 | const orderedHeaders = [ 245 | 'key1', 246 | 'key2.subkey3', 247 | ... 248 | ] 249 | ``` 250 | 251 | ### withToggles 252 | 253 | You can control the _Toggles_ by passing an object with the following structure: 254 | 255 | ```ts 256 | // Default toggles enabled 257 | const withToggles = true 258 | 259 | // Default toggles enabled with default select all 260 | const withToggles = { 261 | selectAll: true, 262 | } 263 | 264 | // Toggles with a custom locale 265 | const withToggles = { 266 | // The options to be passed as props to the `SelectAll` component 267 | selectAll: { 268 | // The text to be displayed in the Select All input 269 | locale: { 270 | // The default label for the `unchecked` state 271 | selectAllWord: 'Select All', 272 | // The default label for the `checked` state 273 | unSelectAllWord: 'Unselect All', 274 | }, 275 | // You should not need to use this, but it is here for completeness 276 | handleToggleAll: (isChecked: boolean): void => { 277 | // ... 278 | }, 279 | }, 280 | } 281 | ``` 282 | 283 | ## Examples 284 | 285 | ### Async data loading (fetch) 286 | 287 | By passing a string to the `data` prop, the component will interpret it as an 288 | URL and try to load the data from that location using _[fetch][fetch]_. If a 289 | successful request is returned, the data will be extracted from the response 290 | object. By default, it will grab the `data` key from the response. If it's in a 291 | different key, you can specify it with the `dataKey` prop. Just in case it's 292 | not a first-level attribute, you can supply a custom function to locate the 293 | data using the `dataKeyResolver` prop. 294 | 295 | `response from /api/v1/user` 296 | 297 | ```json 298 | { 299 | "status": "success", 300 | "message": "", 301 | "users": [{ "id": 0, "other": "..." }, { "id": 1, "other": "..." }, "..."] 302 | } 303 | ``` 304 | 305 | `response from /api/v1/post` 306 | 307 | ```json 308 | { 309 | "status": "success", 310 | "message": "", 311 | "results": { 312 | "posts": [{ "id": 0, "other": "..." }, { "id": 1, "other": "..." }, "..."] 313 | } 314 | } 315 | ``` 316 | 317 | `component` 318 | 319 | ```jsx 320 | // Using `dataKey` 321 | 326 | 327 | // Using `dataKeyResolver` 328 | response.results.posts} 331 | name="test-table" 332 | /> 333 | ``` 334 | 335 | ### Simple sortable table (with Semantic UI) 336 | 337 | ```jsx 338 | import React from 'react' 339 | import ReactDOM from 'react-dom' 340 | import faker from 'faker' 341 | import SmartDataTable from 'react-smart-data-table' 342 | 343 | const testData = [] 344 | const numResults = 100 345 | 346 | for (let i = 0; i < numResults; i++) { 347 | testData.push({ 348 | _id: i, 349 | fullName: faker.name.findName(), 350 | 'email.address': faker.internet.email(), 351 | phone_number: faker.phone.phoneNumber(), 352 | address: { 353 | city: faker.address.city(), 354 | state: faker.address.state(), 355 | country: faker.address.country(), 356 | }, 357 | }) 358 | } 359 | 360 | ReactDOM.render( 361 | , 367 | document.getElementById('app'), 368 | ) 369 | ``` 370 | 371 | ## Demos 372 | 373 | You can try _react-smart-data-table_ with different UI libraries in the demo 374 | pages below. You can experiment with different features as well. 375 | 376 | - [Semantic UI: All Features][semantic] 377 | 378 | Take a look at the full featured example's [source code][example-source]. 379 | 380 | Also, see it in full integration with a simple user/group management dashboard 381 | application. Feel free to play around with it, it's built with hot reloading. 382 | 383 | - [Somewhere I Belong][somewhere-i-belong] 384 | 385 | If you want to play around, check out this [codepen][codepen]. 386 | 387 | ## FAQ 388 | 389 | If you're having trouble with _react-smart-data-table_, please check out the 390 | answers below. Otherwise, feel free to open a new issue! 391 | 392 | - Check [this answer][hide-pagination] to see how to hide the pagination for an 393 | empty table 394 | - Check [this answer][ssr-integration] if you're integrating with Server Side 395 | Rendering (SSR) 396 | - Check [this answer][double-click] if you want to implement a double click 397 | event on a row 398 | - Check [this answer][control-page] if you want to control the active page 399 | manually (e.g., based on a URL parameter) 400 | - Check [this answer][column-selector] if you want to style individual columns 401 | differently 402 | 403 | ## Forking / Contributing 404 | 405 | If you want to fork or [contribute][contribute], it's easy to test your changes. 406 | Just run the following development commands. The _start_ instruction will start 407 | a development HTTP server in your computer accessible from your browser at the 408 | address `http://localhost:3000/`. 409 | 410 | ```sh 411 | pnpm start 412 | ``` 413 | 414 | 415 | 416 | [codepen]: https://codepen.io/joaocarmo/pen/oNBNZBO 417 | [column-selector]: https://github.com/joaocarmo/react-smart-data-table/issues/62#issuecomment-1002973644 418 | [contribute]: ./CONTRIBUTING.md 419 | [contributor]: ./CODE_OF_CONDUCT.md 420 | [control-page]: https://github.com/joaocarmo/react-smart-data-table/issues/60#issuecomment-974718595 421 | [double-click]: https://github.com/joaocarmo/react-smart-data-table/issues/59#issuecomment-969371513 422 | [example-source]: ./example/index.js 423 | [fetch]: https://fetch.spec.whatwg.org/ 424 | [hide-pagination]: https://github.com/joaocarmo/react-smart-data-table/issues/42#issuecomment-828593880 425 | [jest]: https://github.com/facebook/jest 426 | [lgtm-alerts]: https://lgtm.com/projects/g/joaocarmo/react-smart-data-table/alerts/ 427 | [lgtm-context]: https://lgtm.com/projects/g/joaocarmo/react-smart-data-table/context:javascript 428 | [npm]: https://badge.fury.io/js/react-smart-data-table 429 | [pagination]: https://react.semantic-ui.com/addons/pagination/ 430 | [react-very-simple-data-table]: https://github.com/joaocarmo/react-very-simple-data-table 431 | [semantic]: http://joaocarmo.com/react-smart-data-table/ 432 | [somewhere-i-belong]: https://github.com/joaocarmo/somewhere-i-belong 433 | [ssr-integration]: https://github.com/joaocarmo/react-smart-data-table/issues/50#issuecomment-963060887 434 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Add horizontal scrolling (top and bottom) 4 | - Add multiple column sorting 5 | - Add sticky headers 6 | - Allow custom elements: _ErrorBoundary_, _Toggles_, _CellValue_ 7 | - Improve performance 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | corejs: '3', 7 | modules: 'umd', 8 | useBuiltIns: 'usage', 9 | targets: { 10 | browsers: 11 | process.env.NODE_ENV === 'development' 12 | ? 'last 2 versions' 13 | : '> 0.25%, not dead', 14 | }, 15 | }, 16 | ], 17 | [ 18 | '@babel/preset-react', 19 | { 20 | runtime: 'automatic', 21 | }, 22 | ], 23 | '@babel/preset-typescript', 24 | ], 25 | plugins: ['@babel/plugin-transform-runtime'], 26 | } 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Smart Data Table 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | 21 | 22 |
Loading...
23 | 24 | 25 | 26 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import globals from 'globals' 3 | import eslint from '@eslint/js' 4 | import tseslint from 'typescript-eslint' 5 | import reactPlugin from 'eslint-plugin-react' 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | reactPlugin.configs.flat.recommended, 11 | { 12 | files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], 13 | }, 14 | { 15 | ignores: [ 16 | '.eslintrc.js', 17 | 'babel.config.js', 18 | 'jest.config.js', 19 | 'webpack.config.js', 20 | 'webpack.dev.js', 21 | ], 22 | }, 23 | { 24 | languageOptions: { 25 | parserOptions: { 26 | ecmaFeatures: { 27 | jsx: true, 28 | }, 29 | }, 30 | globals: { 31 | ...globals.browser, 32 | }, 33 | }, 34 | }, 35 | { 36 | rules: { 37 | '@typescript-eslint/no-unused-vars': 'off', 38 | 'no-unused-vars': 'off', 39 | 'react/prop-types': 'off', 40 | 'react/react-in-jsx-scope': 'off', 41 | }, 42 | }, 43 | { 44 | settings: { 45 | react: { 46 | version: 'detect', 47 | }, 48 | }, 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { imgb64 } from '../lib/helpers/tests' 4 | import SmartDataTable from 'react-smart-data-table-dev' 5 | import 'react-smart-data-table-dev.css' 6 | 7 | const sematicUI = { 8 | change: 'ui labeled secondary icon button', 9 | changeIcon: 'exchange icon', 10 | checkbox: 'ui toggle checkbox', 11 | deleteIcon: 'trash red icon', 12 | input: 'ui input', 13 | iconInput: 'ui icon input', 14 | labeledInput: 'ui right labeled input', 15 | loader: 'ui active text loader', 16 | message: 'ui message', 17 | refresh: 'ui labeled primary icon button', 18 | refreshIcon: 'sync alternate icon', 19 | rowsIcon: 'numbered list icon', 20 | searchIcon: 'search icon', 21 | segment: 'ui segment', 22 | select: 'ui dropdown', 23 | table: 'ui compact selectable table', 24 | } 25 | 26 | const generateData = async (numResults = 0) => { 27 | const faker = await import('@withshepherd/faker') 28 | 29 | let total = numResults || 0 30 | 31 | if (typeof numResults === 'string') { 32 | total = parseInt(numResults, 10) 33 | } 34 | 35 | const data = [] 36 | 37 | for (let i = 0; i < total; i += 1) { 38 | const row = { 39 | _id: i, 40 | address: { 41 | city: faker.address.city(), 42 | state: faker.address.state(), 43 | country: faker.address.country(), 44 | }, 45 | url: faker.internet.url(), 46 | isMarried: faker.datatype.boolean(), 47 | actions: null, 48 | avatar: imgb64, 49 | fullName: faker.name.findName(), 50 | _username: faker.internet.userName(), 51 | password_: faker.internet.password(), 52 | 'email.address': faker.internet.email(), 53 | phone_number: faker.phone.phoneNumber(), 54 | } 55 | 56 | // Add random attributes to random rows (after the first) 57 | if (i > 0 && faker.datatype.boolean()) { 58 | const column = faker.database.column() 59 | 60 | if (!row[column]) { 61 | row[column] = faker.datatype.number() 62 | } 63 | } 64 | 65 | data.push(row) 66 | } 67 | 68 | return data 69 | } 70 | 71 | const emptyTable = ( 72 |
73 | There is no data available to display. 74 |
75 | ) 76 | 77 | const loader =
Loading...
78 | 79 | const DeleteButton = ({ handleDelete }) => ( 80 | 89 | ) 90 | 91 | class AppDemo extends PureComponent { 92 | constructor(props) { 93 | super(props) 94 | 95 | this.state = { 96 | useApi: false, 97 | apiUrl: 'https://randomuser.me/api/?results=100', 98 | apiUrlNew: 'https://randomuser.me/api/?results=100', 99 | dataKey: 'results', 100 | numResults: 10, 101 | data: [], 102 | dataSampling: 0, 103 | filterValue: '', 104 | perPage: 0, 105 | showOnRowClick: true, 106 | changeOrder: false, 107 | orderedHeaders: [ 108 | '_id', 109 | 'avatar', 110 | 'fullName', 111 | '_username', 112 | 'password_', 113 | 'email.address', 114 | 'phone_number', 115 | 'address.city', 116 | 'address.state', 117 | 'address.country', 118 | 'url', 119 | 'isMarried', 120 | 'actions', 121 | ], 122 | hideUnordered: false, 123 | } 124 | 125 | this.changeData = this.changeData.bind(this) 126 | this.handleCheckboxChange = this.handleCheckboxChange.bind(this) 127 | this.handleNewApiUrl = this.handleNewApiUrl.bind(this) 128 | this.handleOnChange = this.handleOnChange.bind(this) 129 | this.handleOnChangeOrder = this.handleOnChangeOrder.bind(this) 130 | this.handleOnPerPage = this.handleOnPerPage.bind(this) 131 | this.onRowClick = this.onRowClick.bind(this) 132 | this.setNewData = this.setNewData.bind(this) 133 | } 134 | 135 | componentDidMount() { 136 | this.setNewData() 137 | } 138 | 139 | async setNewData() { 140 | const { numResults } = this.state 141 | 142 | const data = await generateData(numResults) 143 | 144 | this.setState({ data }) 145 | } 146 | 147 | handleNewApiUrl() { 148 | const { apiUrlNew } = this.state 149 | this.setState({ apiUrl: apiUrlNew }) 150 | } 151 | 152 | handleDelete(event, idx, _row) { 153 | event.preventDefault() 154 | event.stopPropagation() 155 | 156 | const { data } = this.state 157 | const newData = JSON.parse(JSON.stringify(data)) 158 | 159 | newData.splice(idx, 1) 160 | 161 | this.setState({ data: newData }) 162 | } 163 | 164 | getHeaders() { 165 | return { 166 | _id: { 167 | text: 'Identifier', 168 | invisible: true, 169 | filterable: false, 170 | transform: (value) => `Row #${value + 1}`, 171 | }, 172 | avatar: { 173 | text: 'Profile Pic', 174 | sortable: false, 175 | filterable: false, 176 | }, 177 | _username: { 178 | invisible: true, 179 | }, 180 | password_: { 181 | invisible: true, 182 | }, 183 | fullName: { 184 | sortable: (a, b) => a.fullName.localeCompare(b.fullName), 185 | }, 186 | phone_number: { 187 | sortable: false, 188 | }, 189 | 'address.city': { 190 | text: 'City', 191 | }, 192 | 'address.state': { 193 | text: 'State', 194 | }, 195 | 'address.country': { 196 | text: 'Country', 197 | }, 198 | url: { 199 | text: 'Web Page', 200 | sortable: (a, b) => 201 | a.url 202 | .replace(/^https?:\/\//, '') 203 | .localeCompare(b.url.replace(/^https?:\/\//, '')), 204 | }, 205 | actions: { 206 | text: 'Actions', 207 | sortable: false, 208 | filterable: false, 209 | transform: (value, idx, row) => ( 210 | this.handleDelete(e, idx, row)} /> 211 | ), 212 | }, 213 | } 214 | } 215 | 216 | handleOnChange({ target: { name, value } }) { 217 | this.setState({ [name]: value }, () => { 218 | if (name === 'numResults') this.setNewData() 219 | }) 220 | } 221 | 222 | handleOnChangeOrder(now, next) { 223 | const { orderedHeaders } = this.state 224 | const N = orderedHeaders.length 225 | let nextPos = next 226 | if (next < 0) { 227 | nextPos = N 228 | } 229 | if (next >= N) { 230 | nextPos = 0 231 | } 232 | const newOrderedHeaders = [...orderedHeaders] 233 | const mvElement = newOrderedHeaders.splice(now, 1)[0] 234 | newOrderedHeaders.splice(nextPos, 0, mvElement) 235 | this.setState({ orderedHeaders: newOrderedHeaders }) 236 | } 237 | 238 | handleOnPerPage({ target: { name, value } }) { 239 | this.setState({ [name]: parseInt(value, 10) }) 240 | } 241 | 242 | changeData() { 243 | const { useApi } = this.state 244 | this.setState({ 245 | useApi: !useApi, 246 | filterValue: '', 247 | perPage: 0, 248 | }) 249 | } 250 | 251 | handleCheckboxChange({ target: { name, checked } }) { 252 | this.setState({ [name]: checked }) 253 | } 254 | 255 | onRowClick(event, { rowData, rowIndex, tableData }) { 256 | const { showOnRowClick } = this.state 257 | if (showOnRowClick) { 258 | const { fullName, name, id } = rowData 259 | let value = fullName || name || id 260 | if (!value) { 261 | const [key] = Object.keys(rowData) 262 | value = `${key}: ${rowData[key]}` 263 | } 264 | 265 | window.alert(`You clicked ${value}'s row !`) 266 | } else { 267 | // The following results should be identical 268 | 269 | console.log(rowData, tableData[rowIndex]) 270 | } 271 | } 272 | 273 | render() { 274 | const { 275 | apiUrl, 276 | apiUrlNew, 277 | changeOrder, 278 | data, 279 | dataKey, 280 | dataSampling, 281 | filterValue, 282 | hideUnordered, 283 | numResults, 284 | orderedHeaders, 285 | perPage, 286 | showOnRowClick, 287 | useApi, 288 | } = this.state 289 | const divider = 290 | const headers = this.getHeaders() 291 | 292 | return ( 293 | <> 294 |
295 |
296 | 303 | 304 |
305 | {divider} 306 | 318 | {divider} 319 | {!useApi && ( 320 | <> 321 | 329 | {divider} 330 | 331 | )} 332 | 340 | {!useApi && ( 341 | 342 | {divider} 343 |
344 | 352 | 353 |
354 |
355 | )} 356 | {divider} 357 |
358 | 364 | 365 |
366 | {divider} 367 | {!useApi && ( 368 |
369 | 375 | 376 |
377 | )} 378 | {divider} 379 | {!useApi && ( 380 | 381 | 390 | 393 | 394 | )} 395 |
396 | {useApi && ( 397 |
398 |
399 | 406 |
407 | {divider} 408 |
409 | 416 |
417 | {divider} 418 | 426 |
427 | )} 428 | {changeOrder && ( 429 |
430 | {orderedHeaders.map((header, idx) => ( 431 |
432 |
436 | 444 |
{header}
445 |
446 | 452 | 458 |
459 | ))} 460 |
461 | )} 462 |
463 |

464 | {useApi 465 | ? 'While using async data, the state is controlled internally by the table' 466 | : `Total rows in the table: ${data.length}`} 467 |

468 |
469 | {useApi && ( 470 | 488 | )} 489 | {!useApi && ( 490 | 522 | )} 523 | 532 | 533 | ) 534 | } 535 | } 536 | 537 | const container = document.getElementById('app') 538 | const root = createRoot(container) 539 | root.render() 540 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const esmModules = ['change-case', 'escape-string-regexp', 'flat'].join('|') 3 | 4 | /** @type {import('jest').Config} **/ 5 | const config = { 6 | moduleNameMapper: { 7 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 8 | '/lib/__mocks__/fileMock.ts', 9 | '\\.(css|less)$': 'identity-obj-proxy', 10 | }, 11 | resetMocks: false, 12 | setupFilesAfterEnv: ['/jest.setup.ts'], 13 | testEnvironment: 'jsdom', 14 | testRegex: '\\.test\\.[jt]sx?$', 15 | transformIgnorePatterns: [ 16 | `/node_modules/(?!(?:.pnpm/)?(${esmModules}))`, 17 | ], 18 | } 19 | 20 | module.exports = config 21 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { enableFetchMocks } from 'jest-fetch-mock' 3 | 4 | enableFetchMocks() 5 | -------------------------------------------------------------------------------- /lib/SmartDataTable.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import type { MouseEvent, ReactNode } from 'react' 3 | import cx from 'clsx' 4 | import CellValue from './components/CellValue' 5 | import ErrorBoundary from './components/ErrorBoundary' 6 | import Paginator from './components/Paginator' 7 | import Table from './components/Table' 8 | import Toggles from './components/Toggles' 9 | import withPagination from './components/helpers/with-pagination' 10 | import { SmartDataTableContext } from './helpers/context' 11 | import type { SmartDataTableProps, SmartDataTableState } from './types' 12 | import * as constants from './helpers/constants' 13 | import * as utils from './helpers/functions' 14 | import defaultState from './helpers/default-state' 15 | import './css/basic.css' 16 | 17 | class SmartDataTable extends Component< 18 | SmartDataTableProps, 19 | SmartDataTableState 20 | > { 21 | static defaultProps: SmartDataTableProps = { 22 | className: '', 23 | data: undefined, 24 | dataKey: constants.DEFAULT_DATA_KEY, 25 | dataKeyResolver: null, 26 | dataRequestOptions: {}, 27 | dataSampling: 0, 28 | dynamic: false, 29 | emptyTable: null, 30 | filterValue: '', 31 | headers: {}, 32 | hideUnordered: false, 33 | loader: null, 34 | name: 'reactsmartdatatable', 35 | onRowClick: () => null, 36 | orderedHeaders: [], 37 | paginator: Paginator, 38 | parseBool: false, 39 | parseImg: false, 40 | perPage: 0, 41 | sortable: false, 42 | withFooter: false, 43 | withHeader: true, 44 | withLinks: false, 45 | withToggles: false, 46 | } 47 | 48 | constructor(props: SmartDataTableProps) { 49 | super(props) 50 | 51 | const { headers: colProperties = {} } = props 52 | 53 | this.state = { ...defaultState, colProperties } 54 | } 55 | 56 | static getDerivedStateFromProps( 57 | props: SmartDataTableProps, 58 | state: SmartDataTableState, 59 | ): SmartDataTableState | null { 60 | const { filterValue } = props 61 | const { prevFilterValue } = state 62 | 63 | if (filterValue !== prevFilterValue) { 64 | return { 65 | ...state, 66 | activePage: 1, 67 | prevFilterValue: filterValue, 68 | } 69 | } 70 | 71 | return null 72 | } 73 | 74 | componentDidMount() { 75 | void this.fetchData() 76 | } 77 | 78 | componentDidUpdate(prevProps: SmartDataTableProps) { 79 | const { data } = this.props 80 | const { data: prevData } = prevProps 81 | 82 | if ( 83 | utils.isString(data) && 84 | (typeof data !== typeof prevData || data !== prevData) 85 | ) { 86 | void this.fetchData() 87 | } 88 | } 89 | 90 | handleRowClick = ( 91 | event: MouseEvent, 92 | rowData: T, 93 | rowIndex: number, 94 | tableData: T[], 95 | ) => { 96 | const { onRowClick } = this.props 97 | 98 | if (typeof onRowClick === 'function') { 99 | onRowClick(event, { rowData, rowIndex, tableData }) 100 | } 101 | } 102 | 103 | handleColumnToggle = (key: string) => { 104 | const { colProperties } = this.state 105 | const newColProperties = { ...colProperties } 106 | 107 | if (!newColProperties[key]) { 108 | newColProperties[key] = { 109 | ...constants.defaultHeader, 110 | key, 111 | } 112 | } 113 | 114 | newColProperties[key].invisible = !newColProperties[key].invisible 115 | 116 | this.setState({ colProperties: newColProperties }) 117 | } 118 | 119 | handleColumnToggleAll = 120 | (columns: utils.Column[]) => (isChecked: boolean) => { 121 | const { colProperties } = this.state 122 | const newColProperties = { ...colProperties } 123 | 124 | for (const { key } of columns) { 125 | if (!newColProperties[key]) { 126 | newColProperties[key] = { 127 | ...constants.defaultHeader, 128 | key, 129 | } 130 | } 131 | 132 | newColProperties[key].invisible = isChecked 133 | } 134 | 135 | this.setState({ colProperties: newColProperties }) 136 | } 137 | 138 | handleOnPageChange = ( 139 | event: MouseEvent, 140 | { activePage }: { activePage: number }, 141 | ) => { 142 | this.setState({ activePage }) 143 | } 144 | 145 | handleSortChange(column: utils.Column) { 146 | const { sorting } = this.state 147 | const { key } = column 148 | let dir = '' 149 | 150 | if (key !== sorting.key) { 151 | sorting.dir = '' 152 | } 153 | 154 | if (sorting.dir) { 155 | if (sorting.dir === constants.ORDER_ASC) { 156 | dir = constants.ORDER_DESC 157 | } else { 158 | dir = '' 159 | } 160 | } else { 161 | dir = constants.ORDER_ASC 162 | } 163 | 164 | this.setState({ 165 | sorting: { 166 | key, 167 | dir, 168 | }, 169 | }) 170 | } 171 | 172 | getColumns(force = false): utils.Column[] { 173 | const { asyncData, columns } = this.state 174 | const { 175 | data: propsData, 176 | dataSampling, 177 | headers, 178 | hideUnordered, 179 | orderedHeaders, 180 | } = this.props 181 | 182 | if (!force && !utils.isEmpty(columns)) { 183 | return columns 184 | } 185 | 186 | let data = propsData as T[] 187 | 188 | if (utils.isString(data)) { 189 | data = asyncData 190 | } 191 | 192 | return utils.parseDataForColumns( 193 | data, 194 | headers, 195 | orderedHeaders, 196 | hideUnordered, 197 | dataSampling, 198 | ) 199 | } 200 | 201 | getRows(): T[] { 202 | const { asyncData, colProperties, sorting } = this.state 203 | const { data: propsData, filterValue } = this.props 204 | 205 | let data = propsData as T[] 206 | 207 | if (utils.isString(data)) { 208 | data = asyncData 209 | } 210 | 211 | return utils.sortData( 212 | filterValue, 213 | colProperties, 214 | sorting, 215 | utils.parseDataForRows(data), 216 | ) 217 | } 218 | 219 | async fetchData(): Promise { 220 | const { 221 | data, 222 | dataKey, 223 | dataKeyResolver, 224 | dataRequestOptions: options, 225 | } = this.props 226 | 227 | if (utils.isString(data)) { 228 | this.setState({ isLoading: true }) 229 | 230 | try { 231 | const asyncData = await utils.fetchData(data, { 232 | dataKey, 233 | dataKeyResolver, 234 | options, 235 | }) 236 | 237 | this.setState({ 238 | asyncData, 239 | isLoading: false, 240 | columns: this.getColumns(true), 241 | }) 242 | } catch (err) { 243 | this.setState({ 244 | isLoading: false, 245 | }) 246 | 247 | throw new Error(String(err)) 248 | } 249 | } 250 | } 251 | 252 | renderSorting(column: utils.Column): ReactNode { 253 | const { 254 | sorting: { key, dir }, 255 | } = this.state 256 | let sortingIcon = 'rsdt-sortable-icon' 257 | 258 | if (key === column.key) { 259 | if (dir) { 260 | if (dir === constants.ORDER_ASC) { 261 | sortingIcon = 'rsdt-sortable-asc' 262 | } else { 263 | sortingIcon = 'rsdt-sortable-desc' 264 | } 265 | } 266 | } 267 | 268 | return ( 269 | this.handleSortChange(column)} 272 | onKeyDown={() => this.handleSortChange(column)} 273 | role="button" 274 | tabIndex={0} 275 | aria-label="sorting column" 276 | /> 277 | ) 278 | } 279 | 280 | renderHeader(columns: utils.Column[]): ReactNode { 281 | const { colProperties } = this.state 282 | const { sortable } = this.props 283 | const headers = columns.map((column) => { 284 | const thisColProps = colProperties[column.key] 285 | const showCol = !thisColProps || !thisColProps.invisible 286 | 287 | if (!showCol) { 288 | return null 289 | } 290 | 291 | return ( 292 | 293 | {column.text} 294 | 295 | {sortable && column.sortable ? this.renderSorting(column) : null} 296 | 297 | 298 | ) 299 | }) 300 | 301 | return {headers} 302 | } 303 | 304 | renderRow(columns: utils.Column[], row: T, i: number): ReactNode { 305 | const { colProperties } = this.state 306 | const { withLinks, filterValue, parseBool, parseImg } = this.props 307 | 308 | return columns.map((column, j) => { 309 | const thisColProps = { ...colProperties[column.key] } 310 | const showCol = !thisColProps.invisible 311 | const transformFn = thisColProps.transform 312 | 313 | if (!showCol) { 314 | return null 315 | } 316 | 317 | return ( 318 | 319 | {utils.isFunction(transformFn) ? ( 320 | transformFn(row[column.key], i, row) 321 | ) : ( 322 | 323 | 331 | {row[column.key]} 332 | 333 | 334 | )} 335 | 336 | ) 337 | }) 338 | } 339 | 340 | renderBody(columns: utils.Column[], rows: T[]): ReactNode { 341 | const { perPage } = this.props 342 | const { activePage } = this.state 343 | const visibleRows = utils.sliceRowsPerPage(rows, activePage, perPage) 344 | const tableRows = visibleRows.map((row, idx) => ( 345 | ) => 348 | this.handleRowClick(event, row, idx, rows) 349 | } 350 | > 351 | {this.renderRow(columns, row, idx)} 352 | 353 | )) 354 | 355 | return {tableRows} 356 | } 357 | 358 | renderToggles(columns: utils.Column[]): ReactNode { 359 | const { colProperties } = this.state 360 | const { withToggles } = this.props 361 | 362 | const togglesProps = typeof withToggles === 'object' ? withToggles : {} 363 | 364 | if (!withToggles) { 365 | return null 366 | } 367 | 368 | return ( 369 | 370 | 371 | columns={columns} 372 | colProperties={colProperties} 373 | handleColumnToggle={this.handleColumnToggle} 374 | handleColumnToggleAll={this.handleColumnToggleAll(columns)} 375 | selectAll={togglesProps?.selectAll} 376 | /> 377 | 378 | ) 379 | } 380 | 381 | renderPagination(rows: T[]): ReactNode { 382 | const { perPage, paginator: PaginatorComponent } = this.props 383 | const { activePage } = this.state 384 | const Paginate = withPagination(PaginatorComponent) 385 | 386 | if (!perPage || perPage <= 0) { 387 | return null 388 | } 389 | 390 | return ( 391 | 392 | 398 | 399 | ) 400 | } 401 | 402 | render() { 403 | const { 404 | className, 405 | dynamic, 406 | emptyTable, 407 | loader, 408 | name, 409 | withFooter, 410 | withHeader, 411 | } = this.props 412 | const { isLoading } = this.state 413 | const columns = this.getColumns(dynamic) 414 | const rows = this.getRows() 415 | 416 | if (isLoading) { 417 | return loader 418 | } 419 | 420 | if (utils.isEmpty(rows)) { 421 | return emptyTable 422 | } 423 | 424 | return ( 425 | 426 |
427 | {this.renderToggles(columns)} 428 | 429 | {withHeader && ( 430 | {this.renderHeader(columns)} 431 | )} 432 | {this.renderBody(columns, rows)} 433 | {withFooter && ( 434 | {this.renderHeader(columns)} 435 | )} 436 |
437 | {this.renderPagination(rows)} 438 |
439 |
440 | ) 441 | } 442 | } 443 | 444 | export default SmartDataTable 445 | -------------------------------------------------------------------------------- /lib/__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub' 2 | -------------------------------------------------------------------------------- /lib/components/CellValue.test.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import CellValue from './CellValue' 4 | import type { CellValueProps } from './CellValue' 5 | 6 | const setup = (props: PropsWithChildren) => { 7 | const { children, content, ...otherProps } = props 8 | const utils = render( 9 | 10 | {children} 11 | , 12 | ) 13 | 14 | const cellValue: HTMLInputElement = 15 | screen.queryByText(String(children || content)) || 16 | screen.queryByTestId('cell-value') 17 | 18 | return { 19 | ...utils, 20 | cellValue, 21 | } 22 | } 23 | 24 | describe('CellValue', () => { 25 | it('should render children correctly', () => { 26 | const { cellValue } = setup({ children: 'test children' }) 27 | 28 | expect(cellValue).toBeInTheDocument() 29 | }) 30 | 31 | it('should render content correctly', () => { 32 | const { cellValue } = setup({ content: 'test content' }) 33 | 34 | expect(cellValue).toBeInTheDocument() 35 | }) 36 | 37 | it('should render numbers correctly', () => { 38 | const { cellValue } = setup({ children: 1 }) 39 | 40 | expect(cellValue).toBeInTheDocument() 41 | }) 42 | 43 | it('should render booleans correctly', () => { 44 | const { cellValue } = setup({ children: true }) 45 | 46 | expect(cellValue).toBeInTheDocument() 47 | }) 48 | 49 | it('should render filterables correctly', () => { 50 | const { cellValue } = setup({ 51 | children: 'alice went down the rabbit hole', 52 | filterable: true, 53 | filterValue: 'rabbit', 54 | }) 55 | 56 | expect(cellValue).toBeInTheDocument() 57 | 58 | const highlightedValue = screen.queryByText('rabbit') 59 | 60 | expect(highlightedValue).toBeInTheDocument() 61 | expect(highlightedValue).toHaveClass('rsdt-highlight') 62 | }) 63 | 64 | it('should render URLs correctly', () => { 65 | const url = 'https://example.com' 66 | const { cellValue } = setup({ 67 | children: url, 68 | withLinks: true, 69 | }) 70 | 71 | expect(cellValue).toBeInTheDocument() 72 | expect(cellValue).toHaveAttribute('href', url) 73 | }) 74 | 75 | it('should render images correctly', () => { 76 | const birdImg = 'https://example.com/img/bird.jpg' 77 | const { cellValue } = setup({ 78 | children: birdImg, 79 | parseImg: true, 80 | }) 81 | 82 | expect(cellValue).toBeInTheDocument() 83 | expect(cellValue).toHaveAttribute('src', birdImg) 84 | }) 85 | 86 | it('should render images with links correctly', () => { 87 | const planeImg = 'https://example.com/img/plane.jpg' 88 | const { cellValue } = setup({ 89 | children: planeImg, 90 | parseImg: true, 91 | withLinks: true, 92 | }) 93 | 94 | expect(cellValue).toBeInTheDocument() 95 | expect(cellValue).toHaveAttribute('src', planeImg) 96 | expect(cellValue.parentElement).toHaveAttribute('href', planeImg) 97 | }) 98 | 99 | it('should render data URLs correctly', () => { 100 | const dataURL = `\ 101 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI1\ 102 | 2P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==\ 103 | ` 104 | const { cellValue } = setup({ 105 | children: dataURL, 106 | parseImg: true, 107 | }) 108 | 109 | expect(cellValue).toBeInTheDocument() 110 | expect(cellValue).toHaveAttribute('src', dataURL) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /lib/components/CellValue.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useMemo } from 'react' 2 | import type { FC, PropsWithChildren, ReactNode } from 'react' 3 | import { find as linkifyFind } from 'linkifyjs' 4 | import HighlightValue from './HighlightValue' 5 | import * as utils from '../helpers/functions' 6 | import * as constants from '../helpers/constants' 7 | 8 | export interface CellValueProps { 9 | content?: ReactNode 10 | filterable?: boolean 11 | filterValue?: string 12 | isImg?: boolean 13 | parseBool?: boolean | utils.ParseBool 14 | parseImg?: boolean | utils.ParseImg 15 | withLinks?: boolean 16 | } 17 | 18 | const CellValue: FC> = ({ 19 | children, 20 | content = '', 21 | filterable = true, 22 | filterValue = '', 23 | isImg = false, 24 | parseBool = false, 25 | parseImg = false, 26 | withLinks = false, 27 | }) => { 28 | const value = useMemo( 29 | () => utils.getRenderValue({ children, content, parseBool }), 30 | [children, content, parseBool], 31 | ) 32 | 33 | const highlightValue = useCallback(() => { 34 | if (!filterable) { 35 | return value 36 | } 37 | 38 | return ( 39 | 40 | {value} 41 | 42 | ) 43 | }, [filterValue, filterable, value]) 44 | 45 | const renderImage = useCallback( 46 | ({ bypass = false }: { bypass?: boolean } = {}) => { 47 | const shouldBeAnImg = isImg || bypass || utils.isImage(value) 48 | 49 | if (shouldBeAnImg) { 50 | const { style, className } = 51 | typeof parseImg === 'boolean' 52 | ? { style: undefined, className: undefined } 53 | : parseImg 54 | 55 | return ( 56 | {constants.DEFAULT_IMG_ALT} 63 | ) 64 | } 65 | 66 | return null 67 | }, 68 | [isImg, parseImg, value], 69 | ) 70 | 71 | const parseURLs = useCallback(() => { 72 | const grabLinks = linkifyFind(value) 73 | const highlightedValue = highlightValue() 74 | 75 | if (utils.isEmpty(grabLinks)) { 76 | if (utils.isDataURL(value)) { 77 | return renderImage({ bypass: true }) 78 | } 79 | 80 | return highlightedValue 81 | } 82 | 83 | const firstLink = utils.head(grabLinks) 84 | let image = null 85 | 86 | if (parseImg && firstLink.type === 'url') { 87 | image = renderImage() 88 | } 89 | 90 | return ( 91 | e.stopPropagation()}> 92 | {image || highlightedValue} 93 | 94 | ) 95 | }, [highlightValue, parseImg, renderImage, value]) 96 | 97 | if (withLinks) { 98 | return parseURLs() 99 | } 100 | 101 | if (parseImg) { 102 | return renderImage() || highlightValue() 103 | } 104 | 105 | return highlightValue() 106 | } 107 | 108 | export default memo(CellValue) 109 | -------------------------------------------------------------------------------- /lib/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import type { PropsWithChildren, ReactNode } from 'react' 3 | import * as constants from '../helpers/constants' 4 | 5 | interface ErrorInfo { 6 | componentStack: string 7 | } 8 | 9 | interface ErrorBoundaryProps { 10 | logError?: (error: Error, errorInfo: ErrorInfo) => void 11 | fbComponent?: ReactNode 12 | } 13 | 14 | interface ErrorBoundaryState { 15 | hasError: boolean 16 | error: Error 17 | errorInfo: ErrorInfo 18 | } 19 | 20 | class ErrorBoundary extends Component< 21 | PropsWithChildren, 22 | ErrorBoundaryState 23 | > { 24 | static defaultProps: ErrorBoundaryProps = { 25 | logError: () => null, 26 | fbComponent: null, 27 | } 28 | 29 | constructor(props: ErrorBoundaryProps) { 30 | super(props) 31 | 32 | this.state = { 33 | hasError: false, 34 | error: null, 35 | errorInfo: null, 36 | } 37 | } 38 | 39 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 40 | const { logError } = this.props 41 | 42 | // Display fallback UI 43 | this.setState({ 44 | hasError: true, 45 | error, 46 | errorInfo, 47 | }) 48 | 49 | // Log the error to an error handling function 50 | if (typeof logError === 'function') { 51 | logError(error, errorInfo) 52 | } 53 | } 54 | 55 | renderDefaultFB(): ReactNode { 56 | const { error, errorInfo } = this.state 57 | 58 | return ( 59 |
60 |

{constants.GENERIC_ERROR_MESSAGE}

61 |
62 | {error && error.toString()} 63 |

{errorInfo.componentStack}

64 |
65 |
66 | ) 67 | } 68 | 69 | render(): ReactNode { 70 | const { hasError } = this.state 71 | const { children, fbComponent } = this.props 72 | 73 | if (hasError) { 74 | // Render the fallback UI 75 | if (fbComponent) { 76 | return fbComponent 77 | } 78 | 79 | return this.renderDefaultFB() 80 | } 81 | 82 | return children 83 | } 84 | } 85 | 86 | export default ErrorBoundary 87 | -------------------------------------------------------------------------------- /lib/components/HighlightValue.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { FC, PropsWithChildren } from 'react' 3 | import * as utils from '../helpers/functions' 4 | 5 | interface HighlightValueProps { 6 | filterValue: string 7 | 'data-testid'?: string 8 | } 9 | 10 | const HighlightValue: FC> = ({ 11 | children, 12 | filterValue = '', 13 | 'data-testid': testId = 'highlight-value', 14 | }) => { 15 | const { first, highlight, last } = useMemo( 16 | () => utils.highlightValueParts(String(children), filterValue), 17 | [children, filterValue], 18 | ) 19 | 20 | if (!first && !highlight && !last) { 21 | return children as JSX.Element 22 | } 23 | 24 | return ( 25 | 26 | {first} 27 | {highlight} 28 | {last} 29 | 30 | ) 31 | } 32 | 33 | export default HighlightValue 34 | -------------------------------------------------------------------------------- /lib/components/Paginator.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import Paginator from './Paginator' 4 | import type { PaginatorProps } from './Paginator' 5 | 6 | const mockPageChange = jest.fn() 7 | 8 | const testCases = [ 9 | // activePage, totalPages, renderedItems, activeItem 10 | [3, 10, 10, 4], 11 | [1, 1, 5], 12 | [1, 2, 6], 13 | [1, 3, 7], 14 | [1, 4, 8], 15 | [1, 5, 9], 16 | [2, 5, 9], 17 | [3, 5, 9], 18 | [1, 6, 8], 19 | [2, 6, 9], 20 | [3, 6, 10], 21 | [4, 6, 10], 22 | [1, 7, 8], 23 | [2, 7, 9], 24 | [3, 7, 10], 25 | [4, 7, 11], 26 | [5, 7, 10], 27 | [6, 7, 9], 28 | [7, 7, 8], 29 | [1, 8, 8], 30 | [1, 9, 8], 31 | [1, 10, 8], 32 | [1, 20, 8], 33 | [1, 100, 8], 34 | [2, 100, 9], 35 | [3, 100, 10], 36 | [4, 100, 11], 37 | [5, 100, 11], 38 | [50, 100, 11], 39 | [500, 1000, 11], 40 | [5000, 10000, 11], 41 | [97, 100, 11], 42 | [98, 100, 10], 43 | [99, 100, 9], 44 | [100, 100, 8], 45 | ] 46 | 47 | const setup = ({ activePage, totalPages, onPageChange }: PaginatorProps) => { 48 | const utils = render( 49 | , 54 | ) 55 | 56 | return { 57 | ...utils, 58 | } 59 | } 60 | 61 | describe('Paginator', () => { 62 | it.each(testCases)( 63 | 'renders correctly the inner elements for active %s and %s pages', 64 | (activePage, totalPages, renderedItems) => { 65 | setup({ activePage, totalPages, onPageChange: mockPageChange }) 66 | 67 | expect(screen.getAllByTestId('paginator-item').length).toBe(renderedItems) 68 | }, 69 | ) 70 | 71 | it('activePage has active prop', () => { 72 | const [activePage, totalPages, , activeItem] = testCases[0] 73 | 74 | setup({ activePage, totalPages, onPageChange: mockPageChange }) 75 | 76 | expect(screen.getAllByTestId('paginator-item')[activeItem]).toHaveClass( 77 | 'active', 78 | ) 79 | }) 80 | 81 | it('calls onPageChange when clicking PaginatorItem', async () => { 82 | const [activePage, totalPages, , activeItem] = testCases[0] 83 | 84 | setup({ activePage, totalPages, onPageChange: mockPageChange }) 85 | 86 | await userEvent.click(screen.getAllByTestId('paginator-item')[activeItem]) 87 | 88 | expect(mockPageChange).toHaveBeenCalled() 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /lib/components/Paginator.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo } from 'react' 2 | import PaginatorItem, { PageChangeFn } from './PaginatorItem' 3 | import * as utils from '../helpers/functions' 4 | import '../css/paginator.css' 5 | 6 | export interface PaginatorProps { 7 | activePage: number 8 | totalPages: number 9 | onPageChange: PageChangeFn 10 | } 11 | 12 | const areEqual = ( 13 | { activePage: prevActivePage, totalPages: prevTotalPages }, 14 | { activePage: nextActivePage, totalPages: nextTotalPages }, 15 | ) => prevActivePage === nextActivePage && prevTotalPages === nextTotalPages 16 | 17 | const Paginator = ({ 18 | activePage, 19 | totalPages, 20 | onPageChange, 21 | }: PaginatorProps) => { 22 | const paginatorItems = useMemo( 23 | () => utils.generatePagination(activePage, totalPages), 24 | [activePage, totalPages], 25 | ) 26 | const onPageChangeFn = useMemo( 27 | () => (typeof onPageChange === 'function' ? onPageChange : () => null), 28 | [onPageChange], 29 | ) 30 | 31 | return ( 32 |
33 | {paginatorItems.map(({ active, value, text }, idx) => ( 34 | 41 | ))} 42 |
43 | ) 44 | } 45 | 46 | export default memo(Paginator, areEqual) 47 | -------------------------------------------------------------------------------- /lib/components/PaginatorItem.tsx: -------------------------------------------------------------------------------- 1 | import { memo, MouseEvent, useCallback } from 'react' 2 | import type { FC } from 'react' 3 | import cx from 'clsx' 4 | import * as utils from '../helpers/functions' 5 | import '../css/paginator.css' 6 | 7 | export type PageChangeFnOpts = { 8 | activePage: number 9 | } 10 | 11 | export type PageChangeFn = ( 12 | event: MouseEvent, 13 | options: PageChangeFnOpts, 14 | ) => void 15 | 16 | interface PaginatorItemProps { 17 | active: boolean 18 | value?: number 19 | text: string 20 | onPageChange: PageChangeFn 21 | } 22 | 23 | const areEqual = ( 24 | { active: prevActive, value: prevValue, text: prevText }: PaginatorItemProps, 25 | { active: nextActive, value: nextValue, text: nextText }: PaginatorItemProps, 26 | ) => 27 | prevActive === nextActive && prevValue === nextValue && prevText === nextText 28 | 29 | const PaginatorItem: FC = ({ 30 | active, 31 | value, 32 | text, 33 | onPageChange, 34 | }) => { 35 | const handleOnPageChange = useCallback( 36 | (event: MouseEvent) => { 37 | onPageChange(event, { activePage: value }) 38 | }, 39 | [onPageChange, value], 40 | ) 41 | 42 | return ( 43 | 52 | ) 53 | } 54 | 55 | export default memo(PaginatorItem, areEqual) 56 | -------------------------------------------------------------------------------- /lib/components/SelectAll.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import SelectAll from './SelectAll' 4 | 5 | const selectAllWord = 'selectAllWord' 6 | const unSelectAllWord = 'unSelectAllWord' 7 | 8 | describe('SelectAll', () => { 9 | it('should function correctly', async () => { 10 | render( 11 | , 18 | ) 19 | 20 | const selectAll: HTMLInputElement = screen.getByTestId('select-all') 21 | 22 | expect(selectAll).toBeInTheDocument() 23 | 24 | expect(selectAll.checked).toBe(false) 25 | 26 | expect(screen.getByText(selectAllWord)).toBeInTheDocument() 27 | 28 | await userEvent.click(selectAll) 29 | 30 | expect(selectAll.checked).toBe(true) 31 | 32 | expect(screen.getByText(unSelectAllWord)).toBeInTheDocument() 33 | 34 | await userEvent.click(selectAll) 35 | 36 | expect(selectAll.checked).toBe(false) 37 | 38 | expect(screen.getByText(selectAllWord)).toBeInTheDocument() 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /lib/components/SelectAll.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback, useImperativeHandle, useState } from 'react' 2 | import * as constants from '../helpers/constants' 3 | 4 | export type ColumnToggleAllFn = (isChecked: boolean) => void 5 | 6 | export interface SelectAllProps { 7 | locale?: { 8 | selectAllWord?: string 9 | unSelectAllWord?: string 10 | } 11 | handleToggleAll: ColumnToggleAllFn 12 | } 13 | 14 | interface SelectAllHandle { 15 | setUnchecked: () => void 16 | } 17 | 18 | const SelectAll = forwardRef( 19 | ( 20 | { 21 | locale: { 22 | selectAllWord = constants.DEFAULT_SELECT_ALL_WORD, 23 | unSelectAllWord = constants.DEFAULT_UNSELECT_ALL_WORD, 24 | } = {}, 25 | handleToggleAll, 26 | }: SelectAllProps, 27 | ref, 28 | ) => { 29 | const [isChecked, setIsChecked] = useState(false) 30 | 31 | const toggleIsChecked = useCallback(() => { 32 | if (typeof handleToggleAll === 'function') { 33 | handleToggleAll(isChecked) 34 | } 35 | 36 | setIsChecked(!isChecked) 37 | }, [handleToggleAll, isChecked]) 38 | 39 | useImperativeHandle(ref, () => ({ 40 | setUnchecked: () => { 41 | if (isChecked) { 42 | setIsChecked(false) 43 | } 44 | }, 45 | })) 46 | 47 | return ( 48 | 49 | 61 | 62 | ) 63 | }, 64 | ) 65 | 66 | SelectAll.displayName = 'SelectAll' 67 | 68 | export default SelectAll 69 | -------------------------------------------------------------------------------- /lib/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | 3 | interface TableComponent 4 | extends FC> { 5 | Body: FC> 6 | Cell: FC> 7 | Footer: FC> 8 | Header: FC> 9 | HeaderCell: FC> 10 | Row: FC> 11 | } 12 | 13 | const Table: TableComponent = ({ children, ...props }) => ( 14 | {children}
15 | ) 16 | 17 | const TableBody: TableComponent['Body'] = ({ children, ...props }) => ( 18 | {children} 19 | ) 20 | 21 | const TableCell: TableComponent['Cell'] = ({ children, ...props }) => ( 22 | {children} 23 | ) 24 | 25 | const TableFooter: TableComponent['Footer'] = ({ children, ...props }) => ( 26 | {children} 27 | ) 28 | 29 | const TableHeader: TableComponent['Header'] = ({ children, ...props }) => ( 30 | {children} 31 | ) 32 | 33 | const TableHeaderCell: TableComponent['HeaderCell'] = ({ 34 | children, 35 | ...props 36 | }) => {children} 37 | 38 | const TableRow: TableComponent['Row'] = ({ children, ...props }) => ( 39 | {children} 40 | ) 41 | 42 | Table.Body = TableBody 43 | Table.Cell = TableCell 44 | Table.Footer = TableFooter 45 | Table.Header = TableHeader 46 | Table.HeaderCell = TableHeaderCell 47 | Table.Row = TableRow 48 | 49 | export default Table 50 | -------------------------------------------------------------------------------- /lib/components/Toggles.tsx: -------------------------------------------------------------------------------- 1 | import { ElementRef, useCallback, useRef } from 'react' 2 | import type { FC } from 'react' 3 | import SelectAll, { ColumnToggleAllFn, SelectAllProps } from './SelectAll' 4 | import * as utils from '../helpers/functions' 5 | import * as constants from '../helpers/constants' 6 | import '../css/toggles.css' 7 | 8 | type ColumnToggleFn = (key: string) => void 9 | 10 | export type TogglesSelectAllProps = boolean | SelectAllProps 11 | 12 | interface TogglesProps { 13 | columns: utils.Column[] 14 | colProperties: utils.Headers 15 | handleColumnToggle: ColumnToggleFn 16 | handleColumnToggleAll: ColumnToggleAllFn 17 | selectAll?: TogglesSelectAllProps 18 | } 19 | 20 | type SelectAllElement = ElementRef 21 | 22 | const Toggles = ({ 23 | columns, 24 | colProperties, 25 | handleColumnToggle, 26 | handleColumnToggleAll, 27 | selectAll = false, 28 | }: TogglesProps): ReturnType => { 29 | const selectAllProps: Partial = 30 | typeof selectAll === 'object' ? selectAll : {} 31 | const selectAllRef = useRef(null) 32 | 33 | const handleToggleClick = useCallback( 34 | ({ target: { value } }) => { 35 | handleColumnToggle(String(value)) 36 | 37 | if (selectAllRef?.current && value) { 38 | selectAllRef.current.setUnchecked() 39 | } 40 | }, 41 | [handleColumnToggle], 42 | ) 43 | 44 | const isColumnVisible = useCallback( 45 | (key: string) => { 46 | const thisColProps = colProperties[key] 47 | 48 | return !thisColProps || !thisColProps.invisible 49 | }, 50 | [colProperties], 51 | ) 52 | 53 | return ( 54 | 82 | ) 83 | } 84 | 85 | export default Toggles 86 | -------------------------------------------------------------------------------- /lib/components/helpers/with-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentType } from 'react' 2 | import { PageChangeFn } from '../PaginatorItem' 3 | import * as utils from '../../helpers/functions' 4 | 5 | export interface WrappedComponentProps { 6 | activePage: number 7 | onPageChange: PageChangeFn 8 | totalPages: number 9 | } 10 | 11 | interface PaginationWrapperProps { 12 | rows: T[] 13 | perPage: number 14 | activePage: number 15 | onPageChange: PageChangeFn 16 | } 17 | 18 | interface PaginationWrapperState { 19 | totalPages: number 20 | } 21 | 22 | const withPagination = ( 23 | WrappedComponent: ComponentType, 24 | ): ComponentType> => { 25 | class PaginationWrapper extends Component< 26 | PaginationWrapperProps, 27 | PaginationWrapperState 28 | > { 29 | static defaultProps: PaginationWrapperProps = { 30 | rows: [], 31 | perPage: 10, 32 | activePage: 1, 33 | onPageChange: () => null, 34 | } 35 | 36 | constructor(props: PaginationWrapperProps) { 37 | super(props) 38 | 39 | this.state = { totalPages: 0 } 40 | } 41 | 42 | componentDidMount() { 43 | const { rows, perPage } = this.props 44 | const totalPages = Math.ceil(rows.length / +perPage) 45 | 46 | this.setState({ totalPages }) 47 | } 48 | 49 | render() { 50 | const { activePage, onPageChange } = this.props 51 | const { totalPages } = this.state 52 | 53 | if (!totalPages) { 54 | return null 55 | } 56 | 57 | return ( 58 | 63 | ) 64 | } 65 | } 66 | 67 | return PaginationWrapper 68 | } 69 | 70 | export default withPagination 71 | -------------------------------------------------------------------------------- /lib/css/basic.css: -------------------------------------------------------------------------------- 1 | .rsdt { 2 | box-sizing: border-box; 3 | } 4 | 5 | .rsdt *, 6 | .rsdt *::before, 7 | .rsdt *::after { 8 | box-sizing: inherit; 9 | } 10 | 11 | .rsdt.rsdt-container { 12 | overflow: auto; 13 | } 14 | 15 | .rsdt.rsdt-highlight { 16 | background-color: yellow; 17 | } 18 | 19 | .rsdt.rsdt-sortable { 20 | margin-left: 10px; 21 | } 22 | 23 | .rsdt.rsdt-sortable-icon::after { 24 | font-size: 20px; 25 | font-style: normal; 26 | content: '\21D5'; 27 | cursor: pointer; 28 | } 29 | 30 | .rsdt.rsdt-sortable-asc::after { 31 | font-size: 20px; 32 | font-style: normal; 33 | content: '\2193'; 34 | cursor: pointer; 35 | } 36 | 37 | .rsdt.rsdt-sortable-desc::after { 38 | font-size: 20px; 39 | font-style: normal; 40 | content: '\2191'; 41 | cursor: pointer; 42 | } 43 | -------------------------------------------------------------------------------- /lib/css/paginate.css: -------------------------------------------------------------------------------- 1 | .rsdt.rsdt-paginate { 2 | text-align: center; 3 | margin-top: 5px; 4 | margin-bottom: 5px; 5 | padding: 5px; 6 | } 7 | 8 | .rsdt.rsdt-paginate.first, 9 | .rsdt.rsdt-paginate.last, 10 | .rsdt.rsdt-paginate.previous, 11 | .rsdt.rsdt-paginate.next, 12 | .rsdt.rsdt-paginate.link, 13 | .rsdt.rsdt-paginate.current { 14 | padding: 5px; 15 | } 16 | -------------------------------------------------------------------------------- /lib/css/paginator.css: -------------------------------------------------------------------------------- 1 | .rsdt-paginate { 2 | text-align: center; 3 | margin-top: 5px; 4 | margin-bottom: 5px; 5 | padding: 5px; 6 | } 7 | 8 | button.rsdt-paginate.button { 9 | font-family: inherit; 10 | font-size: 100%; 11 | padding: 0.5em 1em; 12 | color: #666 !important; 13 | color: rgba(0, 0, 0, 0.8); 14 | border: 1px solid #999; 15 | border: transparent; 16 | background-color: #f9f9f9; 17 | text-decoration: none; 18 | border-radius: 2px; 19 | display: inline-block; 20 | zoom: 1; 21 | white-space: nowrap; 22 | vertical-align: middle; 23 | text-align: center; 24 | -webkit-user-drag: none; 25 | -webkit-user-select: none; 26 | -moz-user-select: none; 27 | -ms-user-select: none; 28 | user-select: none; 29 | } 30 | 31 | button.rsdt-paginate.button:not(:disabled) { 32 | cursor: pointer; 33 | } 34 | 35 | button.rsdt-paginate.button:hover:not(:disabled) { 36 | color: #444 !important; 37 | background-color: #dfdfdf; 38 | } 39 | 40 | button.rsdt-paginate.active { 41 | color: #333 !important; 42 | background-color: #e9e9e9; 43 | } 44 | -------------------------------------------------------------------------------- /lib/css/toggles.css: -------------------------------------------------------------------------------- 1 | .rsdt.rsdt-column-toggles { 2 | margin-bottom: 20px; 3 | } 4 | 5 | .rsdt.rsdt-column-toggles.toggle { 6 | display: inline-block; 7 | margin-left: 5px; 8 | margin-right: 10px; 9 | margin-bottom: 0; 10 | } 11 | 12 | .rsdt.rsdt-column-toggles.toggle > label { 13 | margin-left: 5px; 14 | cursor: pointer !important; 15 | cursor: hand !important; 16 | user-select: none; 17 | } 18 | -------------------------------------------------------------------------------- /lib/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_DATA_KEY = 'data' 2 | export const DEFAULT_IMG_ALT = 'URL detected by the renderer' 3 | export const DEFAULT_NO_WORD = 'No' 4 | export const DEFAULT_YES_WORD = 'Yes' 5 | export const DEFAULT_SELECT_ALL_WORD = 'Select All' 6 | export const DEFAULT_UNSELECT_ALL_WORD = 'Unselect All' 7 | export const ERROR_INVALID_DATA = 8 | 'Data type is invalid, expected string or array !' 9 | export const ERROR_INVALID_RESPONSE = 10 | 'Response type is invalid, expected JSON !' 11 | export const ERROR_INVALID_SAMPLING_RANGE = 12 | 'Sampling percentage should be withing the [0, 100] range.' 13 | export const GENERIC_ERROR_MESSAGE = 'Something went wrong !' 14 | export const ORDER_ASC = 'ASC' 15 | export const ORDER_DESC = 'DESC' 16 | export const PAGINATION_ELLIPSIS = '...' 17 | export const PAGINATION_FIRST = '<<' 18 | export const PAGINATION_LAST = '>>' 19 | export const PAGINATION_NEXT = '>' 20 | export const PAGINATION_PREVIOUS = '<' 21 | export const STR_FALSE = 'false' 22 | export const STR_ZERO = '0' 23 | 24 | export const defaultHeader = { 25 | key: 'default', 26 | text: '', 27 | invisible: false, 28 | sortable: false, 29 | filterable: false, 30 | isImg: false, 31 | } 32 | -------------------------------------------------------------------------------- /lib/helpers/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import defaultState from './default-state' 3 | import type { SmartDataTableState } from '../types' 4 | 5 | export const SmartDataTableContext = 6 | createContext>(defaultState) 7 | 8 | export const useSmartDataTableContext = () => useContext(SmartDataTableContext) 9 | -------------------------------------------------------------------------------- /lib/helpers/default-state.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | activePage: 1, 3 | asyncData: [], 4 | colProperties: {}, 5 | columns: [], 6 | isLoading: false, 7 | prevFilterValue: '', 8 | sorting: { 9 | key: '', 10 | dir: '', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /lib/helpers/file-extensions.ts: -------------------------------------------------------------------------------- 1 | const fileImgExtensions = [ 2 | 'ase', 3 | 'art', 4 | 'bmp', 5 | 'blp', 6 | 'cd5', 7 | 'cit', 8 | 'cpt', 9 | 'cr2', 10 | 'cut', 11 | 'dds', 12 | 'dib', 13 | 'djvu', 14 | 'egt', 15 | 'exif', 16 | 'gif', 17 | 'gpl', 18 | 'grf', 19 | 'icns', 20 | 'ico', 21 | 'iff', 22 | 'jng', 23 | 'jpeg', 24 | 'jpg', 25 | 'jfif', 26 | 'jp2', 27 | 'jps', 28 | 'lbm', 29 | 'max', 30 | 'miff', 31 | 'mng', 32 | 'msp', 33 | 'nitf', 34 | 'ota', 35 | 'pbm', 36 | 'pc1', 37 | 'pc2', 38 | 'pc3', 39 | 'pcf', 40 | 'pcx', 41 | 'pdn', 42 | 'pgm', 43 | 'PI1', 44 | 'PI2', 45 | 'PI3', 46 | 'pict', 47 | 'pct', 48 | 'pnm', 49 | 'pns', 50 | 'ppm', 51 | 'psb', 52 | 'psd', 53 | 'pdd', 54 | 'psp', 55 | 'px', 56 | 'pxm', 57 | 'pxr', 58 | 'qfx', 59 | 'raw', 60 | 'rle', 61 | 'sct', 62 | 'sgi', 63 | 'rgb', 64 | 'int', 65 | 'bw', 66 | 'tga', 67 | 'tiff', 68 | 'tif', 69 | 'vtf', 70 | 'xbm', 71 | 'xcf', 72 | 'xpm', 73 | '3dv', 74 | 'amf', 75 | 'ai', 76 | 'awg', 77 | 'cgm', 78 | 'cdr', 79 | 'cmx', 80 | 'dxf', 81 | 'e2d', 82 | 'egt', 83 | 'eps', 84 | 'fs', 85 | 'gbr', 86 | 'odg', 87 | 'svg', 88 | 'stl', 89 | 'vrml', 90 | 'x3d', 91 | 'sxd', 92 | 'v2d', 93 | 'vnd', 94 | 'wmf', 95 | 'emf', 96 | 'art', 97 | 'xar', 98 | 'png', 99 | 'webp', 100 | 'jxr', 101 | 'hdp', 102 | 'wdp', 103 | 'cur', 104 | 'ecw', 105 | 'iff', 106 | 'lbm', 107 | 'liff', 108 | 'nrrd', 109 | 'pam', 110 | 'pcx', 111 | 'pgf', 112 | 'sgi', 113 | 'rgb', 114 | 'rgba', 115 | 'bw', 116 | 'int', 117 | 'inta', 118 | 'sid', 119 | 'ras', 120 | 'sun', 121 | 'tga', 122 | ] 123 | 124 | export default fileImgExtensions 125 | -------------------------------------------------------------------------------- /lib/helpers/functions.helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | head, 3 | tail, 4 | isString, 5 | isArray, 6 | isObject, 7 | isEmpty, 8 | isFunction, 9 | isNumber, 10 | isUndefined, 11 | } from './functions' 12 | import { getRandomInt } from './tests' 13 | 14 | // Set different object types 15 | const objectTypes = { 16 | string: 'string', 17 | number: 1, 18 | array: [], 19 | object: {}, 20 | function: () => null, 21 | undefined, 22 | } 23 | 24 | const objectTypesTests = Object.entries(objectTypes) 25 | 26 | // Set empty / non-empty object types 27 | const emptyNotEmpty = { 28 | emptyArray: [], 29 | emptyObject: {}, 30 | notEmptyArray: [1], 31 | notEmptyObject: { one: 1 }, 32 | } 33 | 34 | const emptyNotEmptyTests = Object.entries(emptyNotEmpty) 35 | 36 | test('head(), should return the first element in an array', () => { 37 | const randArr = Array(getRandomInt(100)) 38 | const randVal = getRandomInt(100) 39 | randArr[0] = randVal 40 | expect(head(randArr)).toBe(randVal) 41 | }) 42 | 43 | test('tail(), should return the last element in an array', () => { 44 | const randArr = Array(getRandomInt(100)) 45 | const randVal = getRandomInt(100) 46 | randArr[randArr.length - 1] = randVal 47 | expect(tail(randArr)).toBe(randVal) 48 | }) 49 | 50 | describe('isString(), should be true only for strings', () => { 51 | test.each(objectTypesTests)('Testing %s', (type, value) => { 52 | expect(type !== 'string' || (type === 'string' && isString(value))).toBe( 53 | true, 54 | ) 55 | expect(type !== 'string' && isString(value)).toBe(false) 56 | }) 57 | }) 58 | 59 | describe('isArray(), should be true only for arrays', () => { 60 | test.each(objectTypesTests)('Testing %s', (type, value) => { 61 | expect(type !== 'array' || (type === 'array' && isArray(value))).toBe(true) 62 | expect(type !== 'array' && isArray(value)).toBe(false) 63 | }) 64 | }) 65 | 66 | describe('isObject(), should be true only for objects', () => { 67 | test.each(objectTypesTests)('Testing %s', (type, value) => { 68 | expect(type !== 'object' || (type === 'object' && isObject(value))).toBe( 69 | true, 70 | ) 71 | expect(type !== 'object' && isObject(value)).toBe(false) 72 | }) 73 | }) 74 | 75 | describe('isFunction(), should be true only for functions', () => { 76 | test.each(objectTypesTests)('Testing %s', (type, value) => { 77 | expect( 78 | type !== 'function' || 79 | (type === 'function' && 80 | isFunction(value as (...args: unknown[]) => unknown)), 81 | ).toBe(true) 82 | expect( 83 | type !== 'function' && 84 | isFunction(value as (...args: unknown[]) => unknown), 85 | ).toBe(false) 86 | }) 87 | }) 88 | 89 | describe('isNumber(), should be true only for numbers', () => { 90 | test.each(objectTypesTests)('Testing %s', (type, value) => { 91 | expect(type !== 'number' || (type === 'number' && isNumber(value))).toBe( 92 | true, 93 | ) 94 | expect(type !== 'number' && isNumber(value)).toBe(false) 95 | }) 96 | }) 97 | 98 | describe('isUndefined(), should be true only for undefined', () => { 99 | test.each(objectTypesTests)('Testing %s', (type, value) => { 100 | expect( 101 | type !== 'undefined' || (type === 'undefined' && isUndefined(value)), 102 | ).toBe(true) 103 | expect(type !== 'undefined' && isUndefined(value)).toBe(false) 104 | }) 105 | }) 106 | 107 | describe('isEmpty(), should be true only for empty arrays/objects', () => { 108 | test.each(emptyNotEmptyTests)('Testing %s is empty', (type, value) => { 109 | expect( 110 | !type.startsWith('empty') || (type.startsWith('empty') && isEmpty(value)), 111 | ).toBe(true) 112 | expect(!type.startsWith('empty') && isEmpty(value)).toBe(false) 113 | }) 114 | 115 | test.each(emptyNotEmptyTests)('Testing %s is not empty', (type, value) => { 116 | expect( 117 | !type.startsWith('notEmpty') || 118 | (type.startsWith('notEmpty') && !isEmpty(value)), 119 | ).toBe(true) 120 | expect(!type.startsWith('notEmpty') && !isEmpty(value)).toBe(false) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /lib/helpers/functions.table.test.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { 3 | capitalizeAll, 4 | columnObject, 5 | fetchData, 6 | filterRowsByValue, 7 | generatePagination, 8 | getNestedObject, 9 | getRenderValue, 10 | getSampleElement, 11 | highlightValueParts, 12 | isDataURL, 13 | isImage, 14 | parseDataForColumns, 15 | parseDataForRows, 16 | parseHeader, 17 | sliceRowsPerPage, 18 | sortData, 19 | valueOrDefault, 20 | FetchDataOptions, 21 | Headers, 22 | Highlight, 23 | RenderOptions, 24 | Sorting, 25 | UnknownObject, 26 | } from './functions' 27 | import { DEFAULT_NO_WORD, DEFAULT_YES_WORD } from './constants' 28 | import { getRandomInt, imgb64 } from './tests' 29 | 30 | const negativeImgTests: [unknown, boolean][] = [ 31 | [null, false], 32 | [1, false], 33 | [true, false], 34 | [{}, false], 35 | [() => null, false], 36 | ['', false], 37 | ['https://github.com/', false], 38 | ['data:image/svg+xml;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', false], 39 | ] 40 | 41 | describe('fetchData(), should return remote data', () => { 42 | const staticData = [{ foo: 'foo', bar: 'bar' }] as unknown[] 43 | const remoteData = { results: staticData } 44 | 45 | beforeEach(() => { 46 | fetchMock.mockResponseOnce(JSON.stringify(remoteData), { 47 | headers: { 'content-type': 'application/json' }, 48 | }) 49 | }) 50 | 51 | const tests: [ 52 | string, 53 | string | unknown[], 54 | FetchDataOptions | undefined, 55 | unknown, 56 | ][] = [ 57 | ['static data', staticData, undefined, staticData], 58 | ['remote data', 'https://example.com/api/v1/users', undefined, undefined], 59 | [ 60 | 'remote data', 61 | 'https://example.com/api/v1/users', 62 | { dataKey: '' }, 63 | remoteData, 64 | ], 65 | [ 66 | 'remote data', 67 | 'https://example.com/api/v1/users', 68 | { dataKey: 'results' }, 69 | remoteData.results, 70 | ], 71 | [ 72 | 'remote data', 73 | 'https://example.com/api/v1/users', 74 | { 75 | dataKeyResolver: (response) => response.results as UnknownObject[], 76 | }, 77 | remoteData.results, 78 | ], 79 | [ 80 | 'remote data with options', 81 | 'https://example.com/api/v1/users', 82 | { 83 | dataKeyResolver: (response) => response.results as UnknownObject[], 84 | options: { 85 | method: 'post', 86 | body: JSON.stringify({ page: 2 }), 87 | }, 88 | }, 89 | remoteData.results, 90 | ], 91 | ] 92 | 93 | it.each(tests)('Testing %s', async (name, data, options, expected) => { 94 | await expect(fetchData(data, options)).resolves.toEqual(expected) 95 | }) 96 | 97 | it('Checks the fetch calls', () => { 98 | const numRemoteCalls = tests.filter( 99 | ([, data]) => typeof data === 'string', 100 | ).length 101 | expect(fetchMock.mock.calls.length).toEqual(numRemoteCalls) 102 | }) 103 | }) 104 | 105 | describe('generatePagination(), should return an array of objects with specific keys', () => { 106 | const total = getRandomInt(100) 107 | const active = getRandomInt(total) 108 | const pagination = generatePagination(active, total) 109 | 110 | test.each(pagination)('Page %#', (page) => { 111 | const { value } = page 112 | 113 | expect(page).toMatchObject({ 114 | active: expect.any(Boolean) as boolean, 115 | text: expect.any(String) as string, 116 | }) 117 | expect(page).toHaveProperty('value') 118 | expect( 119 | typeof value === 'undefined' || 120 | (typeof value !== 'undefined' && typeof value === 'number'), 121 | ).toBe(true) 122 | }) 123 | }) 124 | 125 | test('getNestedObject(), should return a nested value or null', () => { 126 | const nestedObject = { 127 | one: 1, 128 | two: { 129 | twoOne: 1, 130 | twoTwo: { 131 | twoTwoOne: 1, 132 | }, 133 | }, 134 | } 135 | const oneLevelValue = getNestedObject(nestedObject, ['one']) 136 | const twoLevelValue = getNestedObject(nestedObject, ['two', 'twoOne']) 137 | const threeLevelValue = getNestedObject(nestedObject, [ 138 | 'two', 139 | 'twoTwo', 140 | 'twoTwoOne', 141 | ]) 142 | const fourLevelValue = getNestedObject(nestedObject, [ 143 | 'two, twoTwo', 144 | 'twoTwoTwo', 145 | 'twoTwoTwo', 146 | ]) 147 | expect(oneLevelValue).toBe(nestedObject.one) 148 | expect(twoLevelValue).toBe(nestedObject.two.twoOne) 149 | expect(threeLevelValue).toBe(nestedObject.two.twoTwo.twoTwoOne) 150 | expect(fourLevelValue).toBeUndefined() 151 | }) 152 | 153 | test('capitalizeAll(), should capitalize the first letter in a word', () => { 154 | const testString = 'all existence is futile'.split(' ') 155 | const expectedString = 'All Existence Is Futile' 156 | expect(capitalizeAll(testString)).toBe(expectedString) 157 | }) 158 | 159 | describe('parseHeader(), should parse the header correctly', () => { 160 | const tests = [ 161 | [ 162 | '--this_IS aSomehow -very.Wrong._header', 163 | 'This Is A Somehow Very Wrong Header', 164 | ], 165 | ['one.0.two.1.three.99.fromArray', 'One Two Three From Array'], 166 | ['last_1k_p99_requests', 'Last 1K P99 Requests'], 167 | ] 168 | 169 | test.each(tests)('Testing key: %s', (testString, expectedString) => { 170 | expect(parseHeader(testString)).toBe(expectedString) 171 | }) 172 | }) 173 | 174 | test('valueOrDefault(), should return the default on undefined', () => { 175 | expect(valueOrDefault(undefined, 'default')).toBe('default') 176 | expect(valueOrDefault(1, 100)).toBe(1) 177 | }) 178 | 179 | describe('columnObject(), should return a well defined column object', () => { 180 | const properties = ['key', 'text', 'invisible', 'sortable', 'filterable'] 181 | const column = columnObject('columnKey') 182 | const columns = Object.keys(column) 183 | 184 | test.each(properties)( 185 | `Testing %s in ${JSON.stringify(columns)}`, 186 | (property) => { 187 | expect(column).toHaveProperty(property) 188 | }, 189 | ) 190 | 191 | test(`Testing foo not in ${JSON.stringify(columns)}`, () => { 192 | expect(column).not.toHaveProperty('foo') 193 | }) 194 | }) 195 | 196 | test('parseDataForColumns(), should build the columns based on the object', () => { 197 | const sample = [ 198 | { 199 | _id: 'id', 200 | address: { 201 | city: 'city', 202 | state: 'state', 203 | country: 'country', 204 | }, 205 | url: 'url', 206 | isMarried: 'isMarried', 207 | actions: 'actions', 208 | avatar: 'avatar', 209 | fullName: 'fullName', 210 | _username: 'username', 211 | password_: 'password', 212 | 'email.address': 'emailAddress', 213 | phone_number: 'phoneNumber', 214 | last_1k_p99_requests: 'last1KP99Requests', 215 | }, 216 | ] 217 | const expectedData = [ 218 | { 219 | key: '_id', 220 | text: 'Id', 221 | invisible: false, 222 | isImg: false, 223 | sortable: true, 224 | filterable: true, 225 | }, 226 | { 227 | key: 'avatar', 228 | text: 'Avatar', 229 | invisible: false, 230 | isImg: false, 231 | sortable: true, 232 | filterable: true, 233 | }, 234 | { 235 | key: 'fullName', 236 | text: 'Full Name', 237 | invisible: false, 238 | isImg: false, 239 | sortable: true, 240 | filterable: true, 241 | }, 242 | { 243 | key: '_username', 244 | text: 'Username', 245 | invisible: false, 246 | isImg: false, 247 | sortable: true, 248 | filterable: true, 249 | }, 250 | { 251 | key: 'password_', 252 | text: 'Password', 253 | invisible: false, 254 | isImg: false, 255 | sortable: true, 256 | filterable: true, 257 | }, 258 | { 259 | key: 'email.address', 260 | text: 'Email Address', 261 | invisible: false, 262 | isImg: false, 263 | sortable: true, 264 | filterable: true, 265 | }, 266 | { 267 | key: 'phone_number', 268 | text: 'Phone Number', 269 | invisible: false, 270 | isImg: false, 271 | sortable: true, 272 | filterable: true, 273 | }, 274 | { 275 | key: 'address.city', 276 | text: 'Address City', 277 | invisible: false, 278 | isImg: false, 279 | sortable: true, 280 | filterable: true, 281 | }, 282 | { 283 | key: 'address.state', 284 | text: 'Address State', 285 | invisible: false, 286 | isImg: false, 287 | sortable: true, 288 | filterable: true, 289 | }, 290 | { 291 | key: 'address.country', 292 | text: 'Address Country', 293 | invisible: false, 294 | isImg: false, 295 | sortable: true, 296 | filterable: true, 297 | }, 298 | { 299 | key: 'url', 300 | text: 'Url', 301 | invisible: false, 302 | isImg: false, 303 | sortable: true, 304 | filterable: true, 305 | }, 306 | { 307 | key: 'isMarried', 308 | text: 'Is Married', 309 | invisible: false, 310 | isImg: false, 311 | sortable: true, 312 | filterable: true, 313 | }, 314 | { 315 | key: 'actions', 316 | text: 'Actions', 317 | invisible: false, 318 | isImg: false, 319 | sortable: true, 320 | filterable: true, 321 | }, 322 | { 323 | key: 'one.0.two.1.three.99.fromArray', 324 | text: 'One Two Three From Array', 325 | invisible: false, 326 | isImg: false, 327 | sortable: true, 328 | filterable: true, 329 | }, 330 | { 331 | key: 'last_1k_p99_requests', 332 | text: 'Last 1K P99 Requests', 333 | invisible: false, 334 | isImg: false, 335 | sortable: true, 336 | filterable: true, 337 | }, 338 | ] 339 | const headers = {} 340 | const orderedHeaders = [ 341 | '_id', 342 | 'avatar', 343 | 'fullName', 344 | '_username', 345 | 'password_', 346 | 'email.address', 347 | 'phone_number', 348 | 'address.city', 349 | 'address.state', 350 | 'address.country', 351 | 'url', 352 | 'isMarried', 353 | 'actions', 354 | 'one.0.two.1.three.99.fromArray', 355 | 'last_1k_p99_requests', 356 | ] 357 | const hideUnordered = true 358 | const parsedData = parseDataForColumns( 359 | sample, 360 | headers, 361 | orderedHeaders, 362 | hideUnordered, 363 | ) 364 | expect(parsedData).toEqual(expectedData) 365 | }) 366 | 367 | test('parseDataForRows(), should clear empty and non-object rows and flatten the remaining', () => { 368 | const originalObjArr = [ 369 | undefined, 370 | null, 371 | 0, 372 | 1, 373 | 'string', 374 | [], 375 | () => null, 376 | {}, 377 | { 378 | one: 1, 379 | two: 2, 380 | three: 3, 381 | nested: { 382 | four: 4, 383 | five: 5, 384 | six: 6, 385 | array: [7, 8, 9], 386 | }, 387 | }, 388 | { 389 | one: 1, 390 | two: 2, 391 | three: 3, 392 | nested: { 393 | four: 4, 394 | five: 5, 395 | six: 6, 396 | array: [7, 8, 9], 397 | }, 398 | }, 399 | ] as UnknownObject[] 400 | const parsedObjArr = [ 401 | { 402 | one: 1, 403 | two: 2, 404 | three: 3, 405 | 'nested.four': 4, 406 | 'nested.five': 5, 407 | 'nested.six': 6, 408 | 'nested.array.0': 7, 409 | 'nested.array.1': 8, 410 | 'nested.array.2': 9, 411 | }, 412 | { 413 | one: 1, 414 | two: 2, 415 | three: 3, 416 | 'nested.four': 4, 417 | 'nested.five': 5, 418 | 'nested.six': 6, 419 | 'nested.array.0': 7, 420 | 'nested.array.1': 8, 421 | 'nested.array.2': 9, 422 | }, 423 | ] as UnknownObject[] 424 | expect(parseDataForRows(originalObjArr)).toEqual(parsedObjArr) 425 | }) 426 | 427 | test('filterRowsByValue(), should return only the entries which match the search string', () => { 428 | const originalData = [ 429 | { 430 | one: 1, 431 | two: 2, 432 | three: 3, 433 | 'nested.four': 4, 434 | 'nested.five': 5, 435 | 'nested.six': 6, 436 | 'nested.array.0': 7, 437 | 'nested.array.1': 8, 438 | 'nested.array.2': 9, 439 | }, 440 | { 441 | ten: 10, 442 | eleven: 11, 443 | }, 444 | { 445 | string: 'string', 446 | zero: 0, 447 | }, 448 | { 449 | twenty: 20, 450 | thirty: 30, 451 | }, 452 | ] 453 | const filteredData = [ 454 | { 455 | one: 1, 456 | two: 2, 457 | three: 3, 458 | 'nested.four': 4, 459 | 'nested.five': 5, 460 | 'nested.six': 6, 461 | 'nested.array.0': 7, 462 | 'nested.array.1': 8, 463 | 'nested.array.2': 9, 464 | }, 465 | { 466 | ten: 10, 467 | eleven: 11, 468 | }, 469 | ] 470 | const search = '1' 471 | const allKeys = originalData.reduce( 472 | (acc: string[], curr) => [...acc, ...Object.keys(curr)], 473 | [], 474 | ) 475 | const opts: Headers = Object.fromEntries( 476 | allKeys.map((key) => [ 477 | key, 478 | { 479 | filterable: true, 480 | invisible: false, 481 | isImg: false, 482 | key: 'name', 483 | sortable: false, 484 | text: 'Name', 485 | }, 486 | ]), 487 | ) 488 | expect(filterRowsByValue(search, originalData, opts)).toEqual(filteredData) 489 | }) 490 | 491 | test('sliceRowsPerPage(), should return a properly sized array', () => { 492 | const N = getRandomInt(100) 493 | const data = Array(N).fill({}) as UnknownObject[] 494 | const currentPage = 1 495 | const perPage = [10, 25, 50, 100][getRandomInt(4)] 496 | const slicedData = sliceRowsPerPage(data, currentPage, perPage) 497 | expect(Array.isArray(slicedData)).toBe(true) 498 | expect(slicedData).toHaveLength(Math.min(N, perPage)) 499 | }) 500 | 501 | test('sortData(), should return a properly sorted array', () => { 502 | type Data = { 503 | name: string 504 | } 505 | const filter = '' 506 | const opts: Headers = { 507 | name: { 508 | filterable: false, 509 | invisible: false, 510 | isImg: false, 511 | key: 'name', 512 | sortable: false, 513 | text: 'Name', 514 | }, 515 | } 516 | const customSortOpts: Headers = { 517 | name: { 518 | ...opts.name, 519 | sortable: (a, b) => a.name.length - b.name.length, 520 | }, 521 | } 522 | const sorting: Sorting = { key: 'name', dir: 'ASC' } 523 | const data = [ 524 | { name: 'john' }, 525 | { name: 'benedict' }, 526 | { name: 'peter' }, 527 | { name: 'ana' }, 528 | { name: 'yasmin' }, 529 | ] 530 | const sortedDataAsc = [ 531 | { name: 'ana' }, 532 | { name: 'benedict' }, 533 | { name: 'john' }, 534 | { name: 'peter' }, 535 | { name: 'yasmin' }, 536 | ] 537 | const sortedDataCustom = [ 538 | { name: 'benedict' }, 539 | { name: 'yasmin' }, 540 | { name: 'peter' }, 541 | { name: 'john' }, 542 | { name: 'ana' }, 543 | ] 544 | const sortedDataDesc = [...sortedDataAsc].reverse() 545 | expect(sortData(filter, opts, sorting, data)).toEqual(sortedDataAsc) 546 | sorting.dir = 'DESC' 547 | expect(sortData(filter, opts, sorting, data)).toEqual(sortedDataDesc) 548 | expect(sortData(filter, customSortOpts, sorting, data)).toEqual( 549 | sortedDataCustom, 550 | ) 551 | }) 552 | 553 | describe('isDataURL(), should return true if data is an enconded image', () => { 554 | const tests: [unknown, boolean][] = [...negativeImgTests, [imgb64, true]] 555 | 556 | test.each(tests)('Testing data type %#', (data, expected) => { 557 | expect(isDataURL(data)).toBe(expected) 558 | }) 559 | }) 560 | 561 | describe('isImage(), should return true if string is an image url', () => { 562 | const tests: [unknown, boolean][] = [ 563 | ...negativeImgTests, 564 | ['https://domain.ext/path/to/image.jpg', true], 565 | ] 566 | 567 | test.each(tests)('Testing data type %#', (data, expected) => { 568 | expect(isImage(data)).toBe(expected) 569 | }) 570 | }) 571 | 572 | describe('highlightValueParts(), should return an object of split parts', () => { 573 | const tests: [[string, string], Highlight][] = [ 574 | [ 575 | ['', ''], 576 | { 577 | first: undefined, 578 | highlight: undefined, 579 | last: undefined, 580 | value: '', 581 | }, 582 | ], 583 | [ 584 | ['value', ''], 585 | { 586 | first: undefined, 587 | highlight: undefined, 588 | last: undefined, 589 | value: 'value', 590 | }, 591 | ], 592 | [ 593 | ['value', 'zero'], 594 | { 595 | first: undefined, 596 | highlight: undefined, 597 | last: undefined, 598 | value: 'value', 599 | }, 600 | ], 601 | [ 602 | ['thisisaverybigword', 'very'], 603 | { 604 | first: 'thisisa', 605 | highlight: 'very', 606 | last: 'bigword', 607 | value: 'thisisaverybigword', 608 | }, 609 | ], 610 | ] 611 | 612 | test.each(tests)('Testing case %#', (data, expected) => { 613 | expect(highlightValueParts(...data)).toEqual(expected) 614 | }) 615 | }) 616 | 617 | describe('getRenderValue(), should decide which value to render', () => { 618 | const parseBool = { 619 | noWord: 'No', 620 | yesWord: 'Yes', 621 | } 622 | const tests: [string, RenderOptions, string][] = [ 623 | ['empty', {}, ''], 624 | ['boolean (true)', { content: true }, 'true'], 625 | ['boolean (false)', { children: false }, 'false'], 626 | [ 627 | 'parse boolean (true)', 628 | { children: true, parseBool: true }, 629 | DEFAULT_YES_WORD, 630 | ], 631 | [ 632 | 'parse boolean (false)', 633 | { content: false, parseBool: true }, 634 | DEFAULT_NO_WORD, 635 | ], 636 | ['custom boolean (true)', { children: true, parseBool }, parseBool.yesWord], 637 | ['custom boolean (false)', { content: false, parseBool }, parseBool.noWord], 638 | ['number (0)', { content: 0 }, '0'], 639 | ['number (11)', { children: 11 }, '11'], 640 | ['string (hello)', { content: 'hello' }, 'hello'], 641 | ['array ([])', { content: [] }, ''], 642 | ['array ([1])', { content: [1] }, '1'], 643 | ["array ([1, 'a'])", { children: [1, 'a'] }, '1,a'], 644 | ['object ({})', { content: {} as ReactNode }, '[object Object]'], 645 | ] 646 | 647 | test.each(tests)('Testing %s', (name, data, expected) => { 648 | expect(getRenderValue(data)).toBe(expected) 649 | }) 650 | }) 651 | 652 | describe('getSampleElement(), should sample the data and return a proper example', () => { 653 | const testCases = [ 654 | { 655 | gender: 'female', 656 | name: { 657 | first: 'Mileah', 658 | }, 659 | email: 'mileah.tandstad@example.com', 660 | cell: '48243014', 661 | id: { 662 | value: '17015102098', 663 | }, 664 | picture: { 665 | thumbnail: 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 666 | }, 667 | }, 668 | { 669 | name: { 670 | first: 'Mileah', 671 | }, 672 | location: { 673 | street: { 674 | name: 'Engveien', 675 | }, 676 | city: 'Vanvikan', 677 | state: 'Sør-Trøndelag', 678 | country: 'Norway', 679 | postcode: '4006', 680 | }, 681 | email: 'mileah.tandstad@example.com', 682 | cell: '48243014', 683 | id: { 684 | value: '17015102098', 685 | }, 686 | picture: { 687 | thumbnail: 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 688 | }, 689 | }, 690 | { 691 | name: { 692 | first: 'Mileah', 693 | }, 694 | location: { 695 | street: { 696 | name: 'Engveien', 697 | }, 698 | city: 'Vanvikan', 699 | state: 'Sør-Trøndelag', 700 | country: 'Norway', 701 | postcode: '4006', 702 | }, 703 | phone: '74670178', 704 | cell: '48243014', 705 | id: { 706 | value: '17015102098', 707 | }, 708 | picture: { 709 | thumbnail: 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 710 | }, 711 | }, 712 | ].flatMap((row) => 713 | Array.from(Array(10) as unknown[]).fill(row), 714 | ) as UnknownObject[] 715 | const inputOutput = [ 716 | [ 717 | 0, 718 | { 719 | gender: 'female', 720 | 'name.first': 'Mileah', 721 | email: 'mileah.tandstad@example.com', 722 | cell: '48243014', 723 | 'id.value': '17015102098', 724 | 'picture.thumbnail': 725 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 726 | }, 727 | ], 728 | [ 729 | 1, 730 | { 731 | gender: 'female', 732 | 'name.first': 'Mileah', 733 | email: 'mileah.tandstad@example.com', 734 | cell: '48243014', 735 | 'id.value': '17015102098', 736 | 'picture.thumbnail': 737 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 738 | }, 739 | ], 740 | [ 741 | 10, 742 | { 743 | gender: 'female', 744 | 'name.first': 'Mileah', 745 | email: 'mileah.tandstad@example.com', 746 | cell: '48243014', 747 | 'id.value': '17015102098', 748 | 'picture.thumbnail': 749 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 750 | }, 751 | ], 752 | [ 753 | 25, 754 | { 755 | gender: 'female', 756 | 'name.first': 'Mileah', 757 | email: 'mileah.tandstad@example.com', 758 | cell: '48243014', 759 | 'id.value': '17015102098', 760 | 'picture.thumbnail': 761 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 762 | }, 763 | ], 764 | [ 765 | 50, 766 | { 767 | gender: 'female', 768 | 'name.first': 'Mileah', 769 | email: 'mileah.tandstad@example.com', 770 | cell: '48243014', 771 | 'id.value': '17015102098', 772 | 'picture.thumbnail': 773 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 774 | 'location.city': 'Vanvikan', 775 | 'location.country': 'Norway', 776 | 'location.postcode': '4006', 777 | 'location.state': 'Sør-Trøndelag', 778 | 'location.street.name': 'Engveien', 779 | }, 780 | ], 781 | [ 782 | 75, 783 | { 784 | gender: 'female', 785 | 'name.first': 'Mileah', 786 | email: 'mileah.tandstad@example.com', 787 | phone: '74670178', 788 | cell: '48243014', 789 | 'id.value': '17015102098', 790 | 'picture.thumbnail': 791 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 792 | 'location.city': 'Vanvikan', 793 | 'location.country': 'Norway', 794 | 'location.postcode': '4006', 795 | 'location.state': 'Sør-Trøndelag', 796 | 'location.street.name': 'Engveien', 797 | }, 798 | ], 799 | [ 800 | 100, 801 | { 802 | gender: 'female', 803 | 'name.first': 'Mileah', 804 | email: 'mileah.tandstad@example.com', 805 | phone: '74670178', 806 | cell: '48243014', 807 | 'id.value': '17015102098', 808 | 'picture.thumbnail': 809 | 'https://randomuser.me/api/portraits/thumb/women/43.jpg', 810 | 'location.city': 'Vanvikan', 811 | 'location.country': 'Norway', 812 | 'location.postcode': '4006', 813 | 'location.state': 'Sør-Trøndelag', 814 | 'location.street.name': 'Engveien', 815 | }, 816 | ], 817 | ] as [number, UnknownObject][] 818 | const tests = inputOutput.map(([dataSampling, output]) => [ 819 | `Data Sampling: ${JSON.stringify(dataSampling)}%`, 820 | testCases, 821 | dataSampling, 822 | output, 823 | ]) 824 | 825 | test.each(tests)('Testing %s', (name, data, dataSampling, expected) => { 826 | expect( 827 | getSampleElement( 828 | data as UnknownObject[], 829 | dataSampling as number, 830 | ), 831 | ).toEqual(expected) 832 | }) 833 | 834 | test('Throws an error if the dataSampling is outside [0, 100]', () => { 835 | expect(() => { 836 | getSampleElement(testCases, -1) 837 | }).toThrow() 838 | 839 | expect(() => { 840 | getSampleElement(testCases, 101) 841 | }).toThrow() 842 | }) 843 | }) 844 | -------------------------------------------------------------------------------- /lib/helpers/functions.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, MouseEvent, ReactNode } from 'react' 2 | import { flatten } from 'flat' 3 | import escapeStringRegexp from 'escape-string-regexp' 4 | import { snakeCase } from 'change-case' 5 | import fileImgExtensions from './file-extensions' 6 | import * as constants from './constants' 7 | 8 | export type UnknownObject = Record 9 | 10 | export type ParseBool = { 11 | noWord: string 12 | yesWord: string 13 | } 14 | 15 | export type ParseImg = { 16 | style: CSSProperties 17 | className: string 18 | } 19 | 20 | export type TransformFN = ( 21 | value: unknown, 22 | index: number, 23 | row: T, 24 | ) => ReactNode 25 | 26 | export type RowClickFN = ( 27 | event: MouseEvent, 28 | { 29 | rowData, 30 | rowIndex, 31 | tableData, 32 | }: { rowData: T; rowIndex: number; tableData: T[] }, 33 | ) => void 34 | 35 | export type CompareFunction = (a: T, b: T) => number 36 | 37 | export type HeaderSortable = boolean | CompareFunction 38 | 39 | export interface Column { 40 | key: string 41 | text: string 42 | invisible: boolean 43 | sortable: HeaderSortable 44 | filterable: boolean 45 | isImg: boolean 46 | transform?: TransformFN 47 | } 48 | 49 | export type Headers = Record> 50 | 51 | export type Sorting = { 52 | key: string 53 | dir: string 54 | } 55 | 56 | export interface Highlight { 57 | first: string | undefined 58 | highlight: string | undefined 59 | last: string | undefined 60 | value: string 61 | } 62 | 63 | export interface RenderOptions { 64 | children?: ReactNode 65 | content?: ReactNode 66 | parseBool?: boolean | ParseBool 67 | } 68 | 69 | export type KeyResolverFN = (args: T) => T[] 70 | 71 | export interface FetchDataOptions { 72 | dataKey?: string 73 | dataKeyResolver?: KeyResolverFN 74 | options?: RequestInit 75 | } 76 | 77 | export const head = ([first]: T[]): T => first 78 | 79 | export const tail = (arr: T[]): T => arr[arr.length - 1] 80 | 81 | export const isString = (str: unknown): boolean => 82 | typeof str === 'string' || str instanceof String 83 | 84 | export const isArray = (obj: T): boolean => Array.isArray(obj) 85 | 86 | export const isObject = (obj: T): boolean => 87 | (obj && typeof obj === 'object' && obj.constructor === Object) || false 88 | 89 | export const isEmpty = (obj: unknown[] | T): boolean => { 90 | if (Array.isArray(obj) && 'length' in obj) { 91 | return !obj.length 92 | } 93 | 94 | if (isObject(obj)) { 95 | return !Object.keys(obj).length 96 | } 97 | 98 | return false 99 | } 100 | 101 | export const isFunction = (fn: (...args: unknown[]) => unknown): boolean => 102 | typeof fn === 'function' 103 | 104 | export const isNumber = (num: T): boolean => 105 | typeof num === 'number' && Number.isFinite(num) 106 | 107 | export const isUndefined = (undef: T): boolean => 108 | typeof undef === 'undefined' 109 | 110 | export const capitalize = (str: string): string => { 111 | if (isString(str)) { 112 | const regex = /[^a-z]*[a-z]/ 113 | const [first = ''] = regex.exec(str) 114 | 115 | return first.toUpperCase() + str.substring(first.length) 116 | } 117 | 118 | return '' 119 | } 120 | 121 | export const sortBy = ( 122 | arr: T[], 123 | key: string, 124 | compareFn: HeaderSortable, 125 | ): T[] => { 126 | const defaultSort: CompareFunction = (a, b) => { 127 | if (a[key] > b[key]) { 128 | return 1 129 | } 130 | 131 | if (b[key] > a[key]) { 132 | return -1 133 | } 134 | 135 | return 0 136 | } 137 | 138 | const sortFn = typeof compareFn === 'function' ? compareFn : defaultSort 139 | 140 | return [...arr].sort(sortFn) 141 | } 142 | 143 | export const cleanLonelyInt = (val: string): boolean => 144 | !(val && /^\d+$/.test(val)) 145 | 146 | export const debugPrint = (...args: unknown[]): void => { 147 | if (process.env.NODE_ENV !== 'production') { 148 | console.log(...args) 149 | } 150 | } 151 | 152 | export const errorPrint = (...args: unknown[]): void => { 153 | console.error(...args) 154 | } 155 | 156 | export function generatePagination( 157 | activePage = 1, 158 | totalPages = 1, 159 | margin = 1, 160 | ): { 161 | active: boolean 162 | value: number | undefined 163 | text: string 164 | }[] { 165 | const previousPage = activePage - 1 > 0 ? activePage - 1 : 1 166 | const nextPage = activePage + 1 > totalPages ? totalPages : activePage + 1 167 | const gap = 1 + 2 * margin 168 | const numPagesShow = 2 + gap 169 | const pagination = [ 170 | { active: false, value: 1, text: constants.PAGINATION_FIRST }, 171 | { active: false, value: previousPage, text: constants.PAGINATION_PREVIOUS }, 172 | ] 173 | 174 | if (totalPages > numPagesShow) { 175 | if (activePage >= gap) { 176 | pagination.push({ active: false, value: 1, text: '1' }) 177 | 178 | if (activePage > gap) { 179 | pagination.push({ 180 | active: false, 181 | value: undefined, 182 | text: constants.PAGINATION_ELLIPSIS, 183 | }) 184 | } 185 | } 186 | 187 | for (let i = -margin; i < 2 * margin; i += 1) { 188 | const page = activePage + i 189 | 190 | if (page > 0 && page <= totalPages) { 191 | pagination.push({ 192 | active: activePage === page, 193 | value: page, 194 | text: `${page}`, 195 | }) 196 | } 197 | } 198 | 199 | if (totalPages - activePage > margin) { 200 | if (totalPages - activePage - 1 > margin) { 201 | pagination.push({ 202 | active: false, 203 | value: undefined, 204 | text: constants.PAGINATION_ELLIPSIS, 205 | }) 206 | } 207 | 208 | pagination.push({ 209 | active: false, 210 | value: totalPages, 211 | text: `${totalPages}`, 212 | }) 213 | } 214 | } else { 215 | for (let i = 1; i <= totalPages; i += 1) { 216 | pagination.push({ 217 | active: activePage === i, 218 | value: i, 219 | text: `${i}`, 220 | }) 221 | } 222 | } 223 | pagination.push({ 224 | active: false, 225 | value: nextPage, 226 | text: constants.PAGINATION_NEXT, 227 | }) 228 | pagination.push({ 229 | active: false, 230 | value: totalPages, 231 | text: constants.PAGINATION_LAST, 232 | }) 233 | 234 | return pagination 235 | } 236 | 237 | export function getNestedObject( 238 | nestedObj: T, 239 | pathArr: string[], 240 | ): unknown { 241 | if (isObject(nestedObj) && !isEmpty(nestedObj)) { 242 | let path = [] 243 | 244 | if (isString(pathArr)) { 245 | path.push(pathArr) 246 | } else if (isArray(pathArr)) { 247 | path = pathArr 248 | } 249 | 250 | const reducerFn = (obj: T, key: string): unknown => 251 | obj && !isUndefined(obj[key]) ? obj[key] : undefined 252 | 253 | return path.reduce(reducerFn, nestedObj) 254 | } 255 | 256 | return undefined 257 | } 258 | 259 | export async function fetchData( 260 | data: string | unknown[], 261 | { 262 | dataKey = constants.DEFAULT_DATA_KEY, 263 | dataKeyResolver, 264 | options = {}, 265 | }: FetchDataOptions = {}, 266 | ): Promise { 267 | if (isArray(data)) { 268 | return data as T[] 269 | } 270 | 271 | if (isString(data)) { 272 | const response = await fetch(data as string, options) 273 | 274 | const { headers, ok, status, statusText } = response 275 | 276 | if (ok) { 277 | const contentType = headers.get('content-type') 278 | 279 | if (contentType && contentType.includes('application/json')) { 280 | const jsonBody = (await response.json()) as T 281 | 282 | if (typeof dataKeyResolver === 'function') { 283 | return dataKeyResolver(jsonBody) 284 | } 285 | 286 | return (dataKey ? jsonBody[dataKey] : jsonBody) as T[] 287 | } 288 | 289 | throw new Error(constants.ERROR_INVALID_RESPONSE) 290 | } 291 | 292 | throw new Error(`${status} - ${statusText}`) 293 | } else { 294 | throw new Error(constants.ERROR_INVALID_DATA) 295 | } 296 | } 297 | 298 | export function capitalizeAll(arr: string[]): string { 299 | return arr.map(capitalize).join(' ').trim() 300 | } 301 | 302 | export function parseHeader(val: string): string { 303 | if (isString(val)) { 304 | const toSnakeCase = snakeCase(val) 305 | 306 | return capitalizeAll(toSnakeCase.split('_').filter(cleanLonelyInt)) 307 | } 308 | 309 | return '' 310 | } 311 | 312 | export function valueOrDefault(value: T, defaultValue: T): T { 313 | if (isUndefined(value)) { 314 | return defaultValue 315 | } 316 | 317 | return value 318 | } 319 | 320 | export function columnObject( 321 | key: string, 322 | headers: Headers = {}, 323 | ): Column { 324 | const { text, invisible, sortable, filterable } = { ...headers[key] } 325 | 326 | return { 327 | key, 328 | text: valueOrDefault(text, parseHeader(key)), 329 | invisible: valueOrDefault(invisible, false), 330 | sortable: valueOrDefault(sortable, true), 331 | filterable: valueOrDefault(filterable, true), 332 | isImg: valueOrDefault(invisible, false), 333 | } 334 | } 335 | 336 | export function getSampleElement( 337 | data: T[] = [], 338 | dataSampling = 0, 339 | ): T { 340 | if (!dataSampling) { 341 | return flatten(head(data)) 342 | } 343 | 344 | if (dataSampling < 0 || dataSampling > 100) { 345 | throw new Error(constants.ERROR_INVALID_SAMPLING_RANGE) 346 | } 347 | 348 | const sampleElement = data 349 | .slice(0, Math.ceil((dataSampling / 100) * data.length)) 350 | .reduce((merged, row) => ({ ...merged, ...row }), {} as T) 351 | 352 | return flatten(sampleElement) 353 | } 354 | 355 | export function parseDataForColumns( 356 | data: T[] = [], 357 | headers: Headers = {}, 358 | orderedHeaders: string[] = [], 359 | hideUnordered = false, 360 | dataSampling = 0, 361 | ): Column[] { 362 | const columnsAdded: string[] = [] 363 | const columns: Column[] = [] 364 | 365 | if (data && isArray(data) && !isEmpty(data)) { 366 | // Clear falsy values from the data 367 | const filteredData = data.filter((row) => !!row) 368 | // Get a non-empty sample value from the data 369 | const sampleElement = getSampleElement(filteredData, dataSampling) 370 | 371 | // First, attach the ordered headers 372 | if (!isEmpty(orderedHeaders)) { 373 | orderedHeaders.forEach((key) => { 374 | if (!columnsAdded.includes(key)) { 375 | columns.push(columnObject(key, headers)) 376 | columnsAdded.push(key) 377 | } 378 | }) 379 | } 380 | 381 | // Then, add all the remaining headers 382 | if (!hideUnordered && isObject(sampleElement)) { 383 | const headKeys = [...Object.keys(sampleElement), ...Object.keys(headers)] 384 | 385 | for (const key of headKeys) { 386 | if (!columnsAdded.includes(key)) { 387 | columns.push(columnObject(key, headers)) 388 | columnsAdded.push(key) 389 | } 390 | } 391 | } 392 | } 393 | 394 | return columns 395 | } 396 | 397 | export function parseDataForRows(data: T[] = []): T[] { 398 | let rows: T[] = [] 399 | 400 | if (data && isArray(data) && !isEmpty(data)) { 401 | const filteredData = data.filter((row) => isObject(row) && !isEmpty(row)) 402 | rows = filteredData.map((row) => flatten(row)) 403 | } 404 | 405 | return rows 406 | } 407 | 408 | export function filterRowsByValue( 409 | value: string, 410 | rows: T[], 411 | colProperties: Headers, 412 | ): T[] { 413 | return rows.filter((row) => { 414 | const regex = new RegExp(`.*?${escapeStringRegexp(value)}.*?`, 'i') 415 | let hasMatch = false 416 | const rowKeys = Object.keys(row) 417 | 418 | for (let i = 0, N = rowKeys.length; i < N; i += 1) { 419 | const key = rowKeys[i] 420 | const val = row[key] as string 421 | const colProps = { ...colProperties[key] } 422 | 423 | if (colProps.filterable !== false) { 424 | hasMatch = hasMatch || regex.test(val) 425 | } 426 | } 427 | 428 | return hasMatch 429 | }) 430 | } 431 | 432 | export function filterRows( 433 | value: string, 434 | rows: T[], 435 | colProperties: Headers, 436 | ): T[] { 437 | if (!value) { 438 | return rows 439 | } 440 | 441 | return filterRowsByValue(value, rows, colProperties) 442 | } 443 | 444 | export function sliceRowsPerPage( 445 | rows: T[], 446 | currentPage: number, 447 | perPage: number, 448 | ): T[] { 449 | if (isNumber(perPage) && Math.sign(perPage)) { 450 | const start = perPage * (currentPage - 1) 451 | const end = perPage * currentPage 452 | 453 | return rows.slice(start, end) 454 | } 455 | 456 | return rows 457 | } 458 | 459 | export function sortData( 460 | filterValue: string, 461 | colProperties: Headers, 462 | sorting: Sorting, 463 | data: T[], 464 | ): T[] { 465 | let sortedRows: T[] = [] 466 | const { dir, key } = sorting 467 | const compareFn = 468 | typeof colProperties[key]?.sortable === 'function' && 469 | colProperties[key].sortable 470 | 471 | if (dir) { 472 | if (dir === 'ASC') { 473 | sortedRows = sortBy(data, key, compareFn) 474 | } else { 475 | sortedRows = sortBy(data, key, compareFn).reverse() 476 | } 477 | } else { 478 | sortedRows = data.slice(0) 479 | } 480 | 481 | return filterRows(filterValue, sortedRows, colProperties) 482 | } 483 | 484 | export function isDataURL(url: unknown): boolean { 485 | // Checks if the data is a valid base64 enconded string 486 | const regex = 487 | /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/ 488 | 489 | if (typeof url === 'string') { 490 | const [initData, restData] = url.split(':') 491 | 492 | if (initData && initData === 'data') { 493 | if (restData) { 494 | const [mediaType, b64Data] = restData.split(';') 495 | 496 | if (mediaType) { 497 | const [mimeType, mimeSubTypes] = mediaType.split('/') 498 | const [mimeSubType] = mimeSubTypes.split('+') 499 | 500 | if (mimeType === 'image' && typeof mimeSubType === 'string') { 501 | if (b64Data) { 502 | const [type, imgData] = b64Data.split(',') 503 | 504 | if (type === 'base64' && regex.test(imgData)) { 505 | return fileImgExtensions.includes(mimeSubType.toLowerCase()) 506 | } 507 | } 508 | } 509 | } 510 | } 511 | } 512 | } 513 | 514 | return false 515 | } 516 | 517 | export function isImage(url: unknown): boolean { 518 | const isImgDataURL = isDataURL(url) 519 | 520 | if (isImgDataURL) { 521 | return isImgDataURL 522 | } 523 | 524 | const parser = document.createElement('a') 525 | parser.href = String(url) 526 | let { pathname } = parser 527 | const last = pathname.search(/[:?&#]/) 528 | 529 | if (last !== -1) { 530 | pathname = pathname.substring(0, last) 531 | } 532 | 533 | const ext = pathname.split('.').pop().toLowerCase() 534 | 535 | return fileImgExtensions.includes(ext) 536 | } 537 | 538 | export function highlightValueParts( 539 | value: string, 540 | filterValue: string, 541 | ): Highlight { 542 | const defaultReturn = { 543 | first: undefined, 544 | highlight: undefined, 545 | last: undefined, 546 | value, 547 | } 548 | 549 | if (!filterValue) { 550 | return defaultReturn 551 | } 552 | 553 | const regex = new RegExp(`.*?${escapeStringRegexp(filterValue)}.*?`, 'i') 554 | 555 | if (regex.test(value)) { 556 | const splitStr = value.toLowerCase().split(filterValue.toLowerCase()) 557 | const nFirst = head(splitStr).length 558 | const nHighlight = filterValue.length 559 | const first = value.substring(0, nFirst) 560 | const highlight = value.substring(nFirst, nFirst + nHighlight) 561 | const last = value.substring(nFirst + nHighlight) 562 | 563 | return { 564 | first, 565 | highlight, 566 | last, 567 | value, 568 | } 569 | } 570 | 571 | return defaultReturn 572 | } 573 | 574 | export function getRenderValue({ 575 | children, 576 | content, 577 | parseBool, 578 | }: RenderOptions = {}): string { 579 | if (parseBool) { 580 | const { 581 | noWord = constants.DEFAULT_NO_WORD, 582 | yesWord = constants.DEFAULT_YES_WORD, 583 | } = typeof parseBool === 'object' ? parseBool : {} 584 | 585 | if (content === true || children === true) { 586 | return yesWord 587 | } 588 | 589 | if (content === false || children === false) { 590 | return noWord 591 | } 592 | } 593 | 594 | if (content === 0 || children === 0) { 595 | return constants.STR_ZERO 596 | } 597 | 598 | if (content === false || children === false) { 599 | return constants.STR_FALSE 600 | } 601 | 602 | let value = '' 603 | 604 | if (content) { 605 | value = content as string 606 | } else if (children) { 607 | value = children as string 608 | } 609 | 610 | return `${value}` 611 | } 612 | -------------------------------------------------------------------------------- /lib/helpers/tests.ts: -------------------------------------------------------------------------------- 1 | export const getRandomInt = (max = 10): number => 2 | Math.floor(Math.random() * Math.floor(max)) 3 | 4 | export const imgb64 = 5 | 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDI0IDI0IiBoZWlnaHQ9IjI0cHgiIGlkPSJMYXllcl8xIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnPjxwYXRoIGQ9Ik0xMiwwQzUuNCwwLDAsNS40LDAsMTJzNS40LDEyLDEyLDEyczEyLTUuNCwxMi0xMlMxOC42LDAsMTIsMHogTTEyLDIyQzYuNSwyMiwyLDE3LjUsMiwxMlM2LjUsMiwxMiwyczEwLDQuNSwxMCwxMCAgIFMxNy41LDIyLDEyLDIyeiIvPjxjaXJjbGUgY3g9IjguNSIgY3k9IjkuNSIgcj0iMS41Ii8+PHBhdGggZD0iTTE3LjUsOWgtM0MxNC4yLDksMTQsOS4yLDE0LDkuNXMwLjIsMC41LDAuNSwwLjVoM2MwLjMsMCwwLjUtMC4yLDAuNS0wLjVTMTcuOCw5LDE3LjUsOXoiLz48cGF0aCBkPSJNMTIsMTh2MWMzLjMsMCw2LTIuNyw2LTZoLTFDMTcsMTUuOCwxNC44LDE4LDEyLDE4eiIvPjwvZz48L3N2Zz4=' 6 | 7 | export const truncateStr = (str: string, maxLen = 20): string => 8 | str.substring(0, Math.min(maxLen, str.length)) 9 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | // Main export 2 | import SmartDataTable from './SmartDataTable' 3 | 4 | export default SmartDataTable 5 | 6 | // Export the context 7 | export { useSmartDataTableContext } from './helpers/context' 8 | 9 | // Named component exports 10 | export { default as CellValue } from './components/CellValue' 11 | export { default as ErrorBoundary } from './components/ErrorBoundary' 12 | export { default as HighlightValue } from './components/HighlightValue' 13 | export { default as Paginator } from './components/Paginator' 14 | export { default as SelectAll } from './components/SelectAll' 15 | export { default as TableCell } from './components/CellValue' 16 | export { default as Toggles } from './components/Toggles' 17 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType, ReactNode } from 'react' 2 | import type { TogglesSelectAllProps } from './components/Toggles' 3 | import type { WrappedComponentProps } from './components/helpers/with-pagination' 4 | import * as utils from './helpers/functions' 5 | 6 | export interface SmartDataTableProps { 7 | className: string 8 | data: string | T[] 9 | dataKey: string 10 | dataKeyResolver: utils.KeyResolverFN 11 | dataRequestOptions: RequestInit 12 | dataSampling: number 13 | dynamic: boolean 14 | emptyTable: ReactNode 15 | filterValue: string 16 | headers: utils.Headers 17 | hideUnordered: boolean 18 | loader: ReactNode 19 | name: string 20 | onRowClick: utils.RowClickFN 21 | orderedHeaders: string[] 22 | paginator: ComponentType 23 | parseBool: boolean | utils.ParseBool 24 | parseImg: boolean | utils.ParseImg 25 | perPage: number 26 | sortable: boolean 27 | withFooter: boolean 28 | withHeader: boolean 29 | withLinks: boolean 30 | withToggles: boolean | { selectAll?: TogglesSelectAllProps } 31 | } 32 | 33 | export interface SmartDataTableState { 34 | activePage: number 35 | asyncData: T[] 36 | colProperties: utils.Headers 37 | columns: utils.Column[] 38 | isLoading: boolean 39 | prevFilterValue: string 40 | sorting: utils.Sorting 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-smart-data-table", 3 | "version": "0.16.0", 4 | "description": "A smart data table component for React.js meant to be configuration free", 5 | "private": false, 6 | "main": "dist/react-smart-data-table.js", 7 | "exports": { 8 | ".": "./dist/react-smart-data-table.js" 9 | }, 10 | "types": "./dist/react-smart-data-table.d.ts", 11 | "directories": { 12 | "lib": "lib" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build:dev": "NODE_ENV=development webpack", 19 | "build:docs": "pnpm clean && pnpm prd && rimraf dist/react-smart-data-table.* && cp docs/* dist/", 20 | "build:example-dev": "NODE_ENV=development webpack --config webpack.dev.js", 21 | "build:example": "NODE_ENV=production webpack --config webpack.dev.js", 22 | "build:types": "tsc -p tsconfig.declaration.json && mv dist/index.d.ts dist/react-smart-data-table.d.ts", 23 | "build": "NODE_ENV=production webpack", 24 | "clean": "rimraf dist/", 25 | "deploy": "gh-pages -d dist", 26 | "dev": "pnpm build:dev && pnpm build:example-dev", 27 | "lint:fix": "eslint . --fix", 28 | "lint": "eslint .", 29 | "prd": "pnpm build && pnpm build:example", 30 | "prepare": "husky", 31 | "pretty:fix": "prettier --write .", 32 | "pretty": "prettier --check .", 33 | "start": "NODE_ENV=development webpack serve --config webpack.dev.js", 34 | "test": "jest", 35 | "type-check": "tsc --noEmit --project tsconfig.eslint.json" 36 | }, 37 | "lint-staged": { 38 | "*.{js,jsx,ts,tsx}": [ 39 | "eslint --cache --fix", 40 | "prettier --write" 41 | ] 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/joaocarmo/react-smart-data-table.git" 46 | }, 47 | "keywords": [ 48 | "configuration", 49 | "data", 50 | "react", 51 | "simple", 52 | "smart", 53 | "table" 54 | ], 55 | "author": "João Carmo", 56 | "license": "MIT", 57 | "bugs": { 58 | "url": "https://github.com/joaocarmo/react-smart-data-table/issues" 59 | }, 60 | "homepage": "https://github.com/joaocarmo/react-smart-data-table#readme", 61 | "dependencies": { 62 | "clsx": "^2.1.1", 63 | "escape-string-regexp": "^5.0.0", 64 | "flat": "^6.0.1", 65 | "linkifyjs": "^4.1.3" 66 | }, 67 | "peerDependencies": { 68 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 69 | }, 70 | "devDependencies": { 71 | "@babel/core": "^7.18.5", 72 | "@babel/plugin-transform-runtime": "^7.18.5", 73 | "@babel/preset-env": "^7.18.2", 74 | "@babel/preset-react": "^7.17.12", 75 | "@babel/preset-typescript": "^7.17.12", 76 | "@babel/runtime": "^7.18.3", 77 | "@eslint/js": "^9.10.0", 78 | "@testing-library/dom": "^10.4.0", 79 | "@testing-library/jest-dom": "^6.5.0", 80 | "@testing-library/react": "^16.0.1", 81 | "@testing-library/user-event": "^14.2.1", 82 | "@types/eslint__js": "^8.42.3", 83 | "@types/flat": "^5.0.2", 84 | "@types/jest": "^29.5.13", 85 | "@types/linkifyjs": "^2.1.4", 86 | "@types/react": "^18.0.14", 87 | "@types/react-dom": "^18.0.5", 88 | "@withshepherd/faker": "^5.5.5", 89 | "babel-jest": "^29.7.0", 90 | "babel-loader": "^9.1.3", 91 | "change-case": "^5.4.4", 92 | "core-js": "^3.23.2", 93 | "css-loader": "^7.1.2", 94 | "eslint": "^9.10.0", 95 | "eslint-plugin-react": "^7.36.1", 96 | "gh-pages": "^6.1.1", 97 | "globals": "^15.9.0", 98 | "husky": "^9.1.6", 99 | "identity-obj-proxy": "^3.0.0", 100 | "jest": "^29.7.0", 101 | "jest-environment-jsdom": "^29.7.0", 102 | "jest-fetch-mock": "^3.0.3", 103 | "lint-staged": "^15.2.10", 104 | "mini-css-extract-plugin": "^2.6.1", 105 | "prettier": "^3.3.3", 106 | "react": "^18.2.0", 107 | "react-dom": "^18.2.0", 108 | "rimraf": "^6.0.1", 109 | "style-loader": "^4.0.0", 110 | "typescript": "^5.6.2", 111 | "typescript-eslint": "^8.5.0", 112 | "webpack": "^5.73.0", 113 | "webpack-cli": "^5.1.4", 114 | "webpack-dev-server": "^5.1.0", 115 | "webpack-merge": "^6.0.1" 116 | }, 117 | "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" 118 | } 119 | -------------------------------------------------------------------------------- /tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "isolatedModules": false, 7 | "module": "amd", 8 | "moduleResolution": "node", 9 | "noEmit": false, 10 | "outDir": "dist" 11 | }, 12 | "files": ["lib/index.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true 5 | }, 6 | "include": [ 7 | ".eslintrc.js", 8 | "babel.config.js", 9 | "jest.config.js", 10 | "jest.setup.ts", 11 | "lib/**/*", 12 | "webpack.config.js", 13 | "webpack.dev.js" 14 | ], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": "./lib", 6 | "jsx": "react-jsx", 7 | "lib": ["es2020", "dom"], 8 | "paths": { 9 | "~*": ["./*"] 10 | } 11 | }, 12 | "include": ["./lib/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | const babelOptions = require('./babel.config') 4 | const pkg = require('./package.json') 5 | 6 | const libDir = path.join(__dirname, 'lib') 7 | const distDir = path.join(__dirname, 'dist') 8 | 9 | const { NODE_ENV } = process.env 10 | 11 | const mode = NODE_ENV || 'development' 12 | 13 | module.exports = { 14 | mode, 15 | context: libDir, 16 | entry: './index', 17 | output: { 18 | path: distDir, 19 | filename: `${pkg.name}.js`, 20 | library: 'SmartDataTable', 21 | libraryTarget: 'umd', 22 | }, 23 | resolve: { 24 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 25 | }, 26 | externals: [ 27 | { 28 | clsx: 'clsx', 29 | 'escape-string-regexp': 'escape-string-regexp', 30 | flat: 'flat', 31 | linkifyjs: 'linkifyjs', 32 | 'change-case': 'change-case', 33 | react: { 34 | root: 'React', 35 | commonjs2: 'react', 36 | commonjs: 'react', 37 | amd: 'react', 38 | }, 39 | }, 40 | ], 41 | module: { 42 | rules: [ 43 | { 44 | test: /.(j|t)sx?$/, 45 | exclude: /node_modules/, 46 | use: { 47 | loader: 'babel-loader', 48 | options: babelOptions, 49 | }, 50 | }, 51 | { 52 | test: /\.css$/, 53 | use: [ 54 | { 55 | loader: MiniCssExtractPlugin.loader, 56 | }, 57 | 'css-loader', 58 | ], 59 | }, 60 | ], 61 | }, 62 | plugins: [ 63 | new MiniCssExtractPlugin({ 64 | filename: `${pkg.name}.css`, 65 | }), 66 | ], 67 | } 68 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const babelOptions = require('./babel.config') 3 | const pkg = require('./package.json') 4 | 5 | const devDir = path.join(__dirname, 'docs') 6 | const distDir = path.join(__dirname, 'dist') 7 | const exampleDir = path.join(__dirname, 'example') 8 | const libDir = path.join(__dirname, 'lib') 9 | 10 | const { NODE_ENV } = process.env 11 | 12 | const mode = NODE_ENV || 'development' 13 | 14 | module.exports = { 15 | mode, 16 | context: exampleDir, 17 | entry: `./index`, 18 | output: { 19 | path: distDir, 20 | filename: 'example.js', 21 | }, 22 | resolve: { 23 | alias: { 24 | 'react-smart-data-table-dev': mode === 'development' ? libDir : __dirname, 25 | 'react-smart-data-table-dev.css': 26 | mode === 'development' ? false : path.join(distDir, `${pkg.name}.css`), 27 | }, 28 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /.(j|t)sx?$/, 34 | exclude: /node_modules/, 35 | use: { 36 | loader: 'babel-loader', 37 | options: babelOptions, 38 | }, 39 | }, 40 | { 41 | test: /\.css$/, 42 | use: ['style-loader', 'css-loader'], 43 | }, 44 | ], 45 | }, 46 | devServer: { 47 | compress: true, 48 | hot: true, 49 | port: 3000, 50 | static: { 51 | directory: devDir, 52 | serveIndex: true, 53 | }, 54 | }, 55 | } 56 | --------------------------------------------------------------------------------