├── .all-contributorsrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── coverage.yaml │ ├── main.yaml │ └── nodejs.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── build └── node-13-exports.js ├── examples └── readme.md ├── index.ts ├── jest.config.js ├── l10n ├── ar_SA.ts ├── cn_CN.ts ├── cs_CZ.ts ├── de_DE.ts ├── es_ES.ts ├── fa_IR.ts ├── fr_FR.ts ├── id_ID.ts ├── index.ts ├── it_IT.ts ├── ja_JP.ts ├── ko_KR.ts ├── nb_NO.ts ├── package-lock.json ├── package.json ├── pt_BR.ts ├── pt_PT.ts ├── ru_RU.ts ├── sv_SE.ts ├── tr_TR.ts ├── tsconfig.json ├── tsconfig.release.json ├── tsconfig.test.json └── ua_UA.ts ├── package-lock.json ├── package.json ├── plugins └── selection │ ├── README.md │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ └── rowSelection │ │ ├── actions.ts │ │ └── rowSelection.tsx │ ├── tests │ └── rowSelection │ │ └── actions.test.ts │ ├── tsconfig.json │ ├── tsconfig.release.json │ └── tsconfig.test.json ├── postcss.config.js ├── src ├── base.ts ├── cell.ts ├── config.ts ├── events.ts ├── grid.ts ├── header.ts ├── hooks │ ├── useConfig.ts │ ├── useSelector.ts │ └── useStore.ts ├── i18n │ ├── en_US.ts │ ├── es_LA.ts │ ├── language.ts │ ├── ms_MY.ts │ └── th_TH.ts ├── operator │ └── search.ts ├── pipeline │ ├── extractor │ │ └── storage.ts │ ├── filter │ │ ├── globalSearch.ts │ │ └── serverGlobalSearch.ts │ ├── initiator │ │ └── server.ts │ ├── limit │ │ ├── pagination.ts │ │ └── serverPagination.ts │ ├── pipeline.ts │ ├── pipelineUtils.ts │ ├── processor.ts │ ├── sort │ │ ├── native.ts │ │ └── server.ts │ └── transformer │ │ ├── arrayToTabular.ts │ │ └── storageResponseToArray.ts ├── plugin.ts ├── row.ts ├── state │ └── store.ts ├── storage │ ├── memory.ts │ ├── server.ts │ ├── storage.ts │ └── storageUtils.ts ├── tabular.ts ├── theme │ ├── declarations.d.ts │ └── mermaid │ │ ├── button.scss │ │ ├── checkbox.scss │ │ ├── colors.scss │ │ ├── container.scss │ │ ├── footer.scss │ │ ├── head.scss │ │ ├── index.scss │ │ ├── input.scss │ │ ├── loadingBar.scss │ │ ├── pagination.scss │ │ ├── resizable.scss │ │ ├── search.scss │ │ ├── shadow.scss │ │ ├── sort.scss │ │ ├── table.scss │ │ ├── tbody.scss │ │ ├── td.scss │ │ ├── th.scss │ │ ├── thead.scss │ │ ├── tr.scss │ │ └── wrapper.scss ├── types.ts ├── util │ ├── array.ts │ ├── className.ts │ ├── debounce.ts │ ├── deepEqual.ts │ ├── eventEmitter.ts │ ├── getConfig.ts │ ├── html.ts │ ├── id.ts │ ├── log.ts │ ├── string.ts │ ├── table.ts │ ├── throttle.ts │ └── width.ts └── view │ ├── actions.ts │ ├── container.tsx │ ├── events.ts │ ├── footerContainer.tsx │ ├── headerContainer.tsx │ ├── htmlElement.tsx │ ├── plugin │ ├── pagination.tsx │ ├── resize │ │ └── resize.tsx │ ├── search │ │ ├── actions.ts │ │ └── search.tsx │ └── sort │ │ ├── actions.ts │ │ └── sort.tsx │ └── table │ ├── events.ts │ ├── messageRow.tsx │ ├── shadow.tsx │ ├── table.tsx │ ├── tbody.tsx │ ├── td.tsx │ ├── th.tsx │ ├── thead.tsx │ └── tr.tsx ├── tests ├── cypress.json ├── cypress │ ├── integration │ │ └── table.spec.ts │ ├── plugins │ │ └── index.ts │ ├── support │ │ ├── commands.ts │ │ └── index.ts │ └── tsconfig.json ├── dev-server │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── index.js │ │ ├── style.css │ │ ├── sw.js │ │ └── template.html └── jest │ ├── __snapshots__ │ └── plugin.test.tsx.snap │ ├── base.test.ts │ ├── cell.test.ts │ ├── config.test.ts │ ├── global.d.ts │ ├── grid.test.ts │ ├── header.test.ts │ ├── operator │ └── search.test.ts │ ├── pipeline │ ├── extractor │ │ └── storage.test.ts │ ├── filter │ │ └── globalSearch.test.ts │ ├── limit │ │ └── pagination.test.ts │ ├── pipeline.test.ts │ ├── sort │ │ └── native.test.ts │ └── transformer │ │ ├── arrayToTabular.test.ts │ │ └── storageResponseToArray.test.ts │ ├── plugin.test.tsx │ ├── row.test.ts │ ├── setup.ts │ ├── state │ └── store.test.ts │ ├── storage │ ├── memory.test.ts │ └── server.test.ts │ ├── tabular.test.ts │ ├── testUtil.ts │ ├── util │ ├── array.test.ts │ ├── className.test.ts │ ├── deepEqual.test.ts │ ├── eventEmitter.test.ts │ ├── id.test.ts │ ├── string.test.ts │ └── throttle.test.ts │ └── view │ ├── __snapshots__ │ └── container.test.tsx.snap │ ├── container.test.tsx │ ├── plugin │ ├── __snapshots__ │ │ └── pagination.test.tsx.snap │ ├── pagination.test.tsx │ └── search │ │ ├── __snapshots__ │ │ └── search.test.tsx.snap │ │ └── search.test.tsx │ └── table │ ├── __snapshots__ │ ├── message-row.test.tsx.snap │ ├── table.test.tsx.snap │ ├── td.test.tsx.snap │ └── tr.test.tsx.snap │ ├── message-row.test.tsx │ ├── table.test.tsx │ ├── td.test.tsx │ └── tr.test.tsx ├── tsconfig.json ├── tsconfig.release.json └── tsconfig.test.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "afshinm", 10 | "name": "Afshin Mehrabani", 11 | "avatar_url": "https://avatars3.githubusercontent.com/u/314326?v=4", 12 | "profile": "http://afshinm.name", 13 | "contributions": [ 14 | "code", 15 | "doc" 16 | ] 17 | }, 18 | { 19 | "login": "selfagency", 20 | "name": "Daniel Sieradski", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/2541728?v=4", 22 | "profile": "https://self.agency", 23 | "contributions": [ 24 | "plugin" 25 | ] 26 | }, 27 | { 28 | "login": "salamaashoush", 29 | "name": "Salama Ashoush", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/13505298?v=4", 31 | "profile": "http://salamaashoush.com", 32 | "contributions": [ 33 | "plugin" 34 | ] 35 | }, 36 | { 37 | "login": "daniel-werner", 38 | "name": "Daniel Werner", 39 | "avatar_url": "https://avatars.githubusercontent.com/u/38726367?v=4", 40 | "profile": "https://www.danielwerner.dev/", 41 | "contributions": [ 42 | "plugin" 43 | ] 44 | }, 45 | { 46 | "login": "Aloysb", 47 | "name": "Aloysb", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/17796338?v=4", 49 | "profile": "https://aloys.dev", 50 | "contributions": [ 51 | "code", 52 | "doc" 53 | ] 54 | } 55 | ], 56 | "contributorsPerLine": 7, 57 | "projectName": "gridjs", 58 | "projectOwner": "grid-js", 59 | "repoType": "github", 60 | "repoHost": "https://github.com", 61 | "skipCi": true 62 | } 63 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.md] 10 | insert_final_newline = false 11 | trim_trailing_whitespace = false 12 | 13 | [*.{js,jsx,json,ts,tsx,yml}] 14 | indent_size = 2 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /**/*.js 2 | examples/ 3 | dist/ 4 | build/ 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "project": ["tsconfig.json", "*/tsconfig.json", "plugins/*/tsconfig.json", "tests/cypress/tsconfig.json"], 10 | "sourceType": "module" 11 | }, 12 | "plugins": [ 13 | "@typescript-eslint", 14 | "jest" 15 | ], 16 | "extends": [ 17 | "eslint:recommended", 18 | "plugin:@typescript-eslint/eslint-recommended", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:jest/recommended", 21 | "prettier" 22 | ], 23 | "rules": {} 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | patreon: # Replace with a single Patreon username 5 | open_collective: gridjs 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: axe-core 10 | versions: 11 | - 4.1.2 12 | - 4.1.4 13 | - dependency-name: rollup 14 | versions: 15 | - 2.38.0 16 | - 2.38.1 17 | - 2.38.3 18 | - 2.38.4 19 | - 2.39.0 20 | - 2.39.1 21 | - 2.40.0 22 | - 2.41.0 23 | - 2.41.3 24 | - 2.41.4 25 | - 2.41.5 26 | - 2.42.0 27 | - 2.42.2 28 | - 2.42.3 29 | - 2.43.1 30 | - 2.44.0 31 | - 2.45.0 32 | - 2.45.1 33 | - dependency-name: eslint-plugin-jest 34 | versions: 35 | - 24.1.5 36 | - 24.1.8 37 | - 24.1.9 38 | - 24.2.0 39 | - 24.3.1 40 | - 24.3.4 41 | - dependency-name: jsdom 42 | versions: 43 | - 16.5.2 44 | - dependency-name: "@types/node" 45 | versions: 46 | - 14.14.24 47 | - 14.14.26 48 | - 14.14.28 49 | - 14.14.30 50 | - 14.14.31 51 | - 14.14.32 52 | - 14.14.34 53 | - 14.14.35 54 | - 14.14.37 55 | - dependency-name: "@types/jest" 56 | versions: 57 | - 26.0.21 58 | - 26.0.22 59 | - dependency-name: lerna 60 | versions: 61 | - 4.0.0 62 | - dependency-name: typescript 63 | versions: 64 | - 3.9.8 65 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 10 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - security 8 | - feature 9 | - roadmap 10 | - bug 11 | - feature request 12 | - new feature 13 | - bug fix 14 | # Label to use when marking an issue as stale 15 | staleLabel: wontfix 16 | # Comment to post when marking an issue as stale. Set to `false` to disable 17 | markComment: > 18 | This issue has been automatically marked as stale because it has not had 19 | recent activity. It will be closed if no further activity occurs. Thank you 20 | for your contributions. 21 | # Comment to post when closing a stale issue. Set to `false` to disable 22 | closeComment: false 23 | only: issues 24 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: 'coverage' 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | jobs: 8 | coverage: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - run: npm install 13 | - run: npm run install:plugins 14 | - uses: ArtiomTr/jest-coverage-report-action@v2 15 | id: coverage 16 | with: 17 | output: report-markdown 18 | test-script: npm run test:jest 19 | - uses: marocchino/sticky-pull-request-comment@v2 20 | with: 21 | message: ${{ steps.coverage.outputs.report }} 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - run: npm install 13 | - run: npm run install:plugins 14 | - uses: preactjs/compressed-size-action@v2 15 | with: 16 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 17 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [14.x, 16.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run install:plugins 22 | - run: npm run lint 23 | - run: npm run build 24 | - run: npm test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependencies 7 | node_modules/ 8 | 9 | # Coverage 10 | coverage 11 | 12 | # Transpiled files 13 | dist/ 14 | 15 | # VS Code 16 | .vscode 17 | !.vscode/tasks.js 18 | 19 | # JetBrains IDEs 20 | .idea/ 21 | *.iml 22 | 23 | # Optional npm cache directory 24 | .npm 25 | 26 | # Optional eslint cache 27 | .eslintcache 28 | 29 | # Misc 30 | .DS_Store 31 | 32 | # Lerna 33 | .changelog 34 | 35 | tests/dev-server/build 36 | tests/dev-server/size-plugin.json 37 | tests/cypress/snapshots 38 | tests/cypress/videos 39 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.d.ts 2 | dist 3 | build 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": "*.ts", 7 | "options": { 8 | "parser": "typescript" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Afshin Mehrabani 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 | # Grid.js 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) 4 | 5 | 6 |

7 | 8 | Grid.js 9 | 10 |

11 |

Advanced table plugin

12 | 13 | **A table library that works everywhere** 14 | 15 | - Simple and lightweight implementation 16 | - No vendor lock-in. Grid.js can be used with any JavaScript frameworks (React, Angular, Preact or VanillaJS) 17 | - Written in TypeScript 18 | - Supports all modern browsers and IE11+ 19 | 20 | ## Example 21 | 22 | ```js 23 | new Grid({ 24 | data: [ 25 | ['Mike', 33, 'mike@murphy.com'], 26 | ['John', 82, 'john@conway.com'], 27 | ['Sara', 26, 'sara@keegan.com'] 28 | ], 29 | columns: ['Name', 'Age', 'Email'] 30 | }).render(document.getElementById('wrapper')); 31 | ``` 32 | 33 | Piece of :cake: 34 | 35 | ## Getting Started 36 | 37 | - [Install](https://gridjs.io/docs/install) 38 | - [Getting Started](https://gridjs.io/docs/) 39 | - [Examples](https://gridjs.io/docs/examples/hello-world) 40 | 41 | ## Documentation :book: 42 | 43 | Full documentation of Grid.js installation, config, API and examples are available 44 | on Grid.js website [grid.js/docs](https://gridjs.io/docs/index). 45 | 46 | ## Community 47 | 48 | - Join our [Discord channel](https://discord.gg/K55BwDY) 49 | - Take a look at [gridjs](https://stackoverflow.com/questions/tagged/gridjs) tag on StackOverflow or ask your own question! 50 | - Read our [blog](https://gridjs.io/blog) for the latest updates and announcements 51 | - Follow our Twitter account [@grid_js](https://twitter.com/grid_js) 52 | 53 | ## Contributors ✨ 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |

Afshin Mehrabani

💻 📖

Daniel Sieradski

🔌

Salama Ashoush

🔌

Daniel Werner

🔌

Aloysb

💻 📖
67 | 68 | 69 | 70 | 71 | 72 | 73 | ## License 74 | 75 | [MIT](https://github.com/grid-js/gridjs/blob/master/LICENSE) 76 | -------------------------------------------------------------------------------- /build/node-13-exports.js: -------------------------------------------------------------------------------- 1 | // source: https://github.com/preactjs/preact/blob/master/config/node-13-exports.js 2 | 3 | const fs = require('fs'); 4 | 5 | const subRepositories = ['l10n', 'plugins/selection']; 6 | const snakeCaseToCamelCase = str => 7 | str.replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '')); 8 | 9 | const copyGridJS = () => { 10 | // Copy .module.js --> .mjs for Node 13 11 | fs.writeFileSync( 12 | `${process.cwd()}/dist/gridjs.mjs`, 13 | fs.readFileSync(`${process.cwd()}/dist/gridjs.module.js`) 14 | ); 15 | }; 16 | 17 | const copyGridJSLegacy = () => { 18 | fs.writeFileSync( 19 | `${process.cwd()}/dist/gridjs.production.min.js`, 20 | fs.readFileSync(`${process.cwd()}/dist/gridjs.umd.js`) 21 | ); 22 | 23 | fs.writeFileSync( 24 | `${process.cwd()}/dist/gridjs.production.es.min.js`, 25 | fs.readFileSync(`${process.cwd()}/dist/gridjs.module.js`) 26 | ); 27 | }; 28 | 29 | const copy = name => { 30 | // Copy .module.js --> .mjs for Node 13 compat. 31 | const filename = (name.includes('-') ? snakeCaseToCamelCase(name) : name).split('/').splice(-1); 32 | 33 | fs.writeFileSync( 34 | `${process.cwd()}/${name}/dist/${filename}.mjs`, 35 | fs.readFileSync(`${process.cwd()}/${name}/dist/${filename}.module.js`) 36 | ); 37 | }; 38 | 39 | copyGridJS(); 40 | copyGridJSLegacy(); 41 | subRepositories.forEach(copy); 42 | -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | ## Moved to [gridjs.io/docs/examples](https://gridjs.io/docs/examples/hello-world) 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import Grid from './src/grid'; 2 | import { html } from './src/util/html'; 3 | import { h, createElement, Component, createRef } from 'preact'; 4 | import { useEffect, useRef, useState } from 'preact/hooks'; 5 | import { Config } from './src/config'; 6 | import { PluginPosition } from './src/plugin'; 7 | import { ID } from './src/util/id'; 8 | import { className } from './src/util/className'; 9 | import Row from './src/row'; 10 | import Cell from './src/cell'; 11 | import { useConfig } from './src/hooks/useConfig'; 12 | import { useStore } from './src/hooks/useStore'; 13 | import useSelector from './src/hooks/useSelector'; 14 | import { useTranslator } from './src/i18n/language'; 15 | 16 | export { 17 | Grid, 18 | ID, 19 | Row, 20 | Cell, 21 | className, 22 | html, 23 | Config, 24 | PluginPosition, 25 | h, 26 | createElement, 27 | Component, 28 | createRef, 29 | useEffect, 30 | useRef, 31 | useStore, 32 | useConfig, 33 | useState, 34 | useSelector, 35 | useTranslator, 36 | }; 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const exps = require('./package.json').exports; 2 | const roots = Object.entries(exps) 3 | .map((x) => !/legacy|package\.json|^\.\/$/gi.test(x[0]) ? x[0] : null) 4 | .filter(x => x); 5 | 6 | module.exports = { 7 | testEnvironment: 'node', 8 | setupFilesAfterEnv: ["jest-extended/all"], 9 | roots: roots, 10 | transform: { 11 | '^.+\\.tsx?$': ['ts-jest', { 12 | ...require('./tsconfig.test.json') 13 | }], 14 | }, 15 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 16 | setupFiles: ['./tests/jest/setup.ts'], 17 | moduleNameMapper: { 18 | '\\.(css|less|scss|sass)$': 'identity-obj-proxy', 19 | }, 20 | testRegex: '(/__tests__/.*|(\\.|/)(test))\\.(ts|js)x?$', 21 | coverageDirectory: 'coverage', 22 | collectCoverageFrom: [ 23 | 'src/**/*.{ts,tsx,js,jsx}', 24 | '!src/**/*.d.ts', 25 | '!src/**/*.test.ts', 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /l10n/ar_SA.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'ابدأ البحث', 4 | }, 5 | sort: { 6 | sortAsc: 'الترتيب تصاعدي', 7 | sortDesc: 'الترتيب تنازلي', 8 | }, 9 | pagination: { 10 | previous: 'السابق', 11 | next: 'التالي', 12 | navigate: (page, pages) => `الصفحة ${page} من ${pages}`, 13 | page: (page) => `الصفحة ${page}`, 14 | showing: 'المعروض', 15 | of: 'من', 16 | to: 'إلى', 17 | results: 'النتائج', 18 | }, 19 | loading: 'جاري التحميل...', 20 | noRecordsFound: 'لم نجد ما تبحث عنه', 21 | error: 'حصل خطأ ما أثناء جلب البيانات', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/cn_CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: '输入关键字...', 4 | }, 5 | sort: { 6 | sortAsc: '升序排列', 7 | sortDesc: '降序排列', 8 | }, 9 | pagination: { 10 | previous: '上一页', 11 | next: '下一页', 12 | navigate: (page, pages) => `第${page}页,共${pages}页`, 13 | page: (page) => `第${page}页`, 14 | showing: '第', 15 | of: '到', 16 | to: '条记录,共', 17 | results: '条', 18 | }, 19 | loading: '玩命加载中...', 20 | noRecordsFound: '没找到匹配的项', 21 | error: '获取数据时发生了错误', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/cs_CZ.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Napište klíčové slovo...', 4 | }, 5 | sort: { 6 | sortAsc: 'Seřadit sloupec vzestupně', 7 | sortDesc: 'Seřadit sloupec sestupně', 8 | }, 9 | pagination: { 10 | previous: 'Předchozí', 11 | next: 'Další', 12 | navigate: (page, pages) => `Stránka ${page} z ${pages}`, 13 | page: (page) => `Stránka ${page}`, 14 | showing: 'Zobrazeno', 15 | of: 'z', 16 | to: 'až', 17 | results: 'výsledků', 18 | }, 19 | loading: 'Načítám...', 20 | noRecordsFound: 'Nebyly nalezeny žádné odpovídající záznamy', 21 | error: 'Při načítání dat došlo k chybě', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/de_DE.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Suchbegriff eingeben...', 4 | }, 5 | sort: { 6 | sortAsc: 'Spalte aufsteigend sortieren', 7 | sortDesc: 'Spalte absteigend sortieren', 8 | }, 9 | pagination: { 10 | previous: 'Zurück', 11 | next: 'Nächste', 12 | navigate: (page, pages) => `Seite ${page} von ${pages}`, 13 | page: (page) => `Seite ${page}`, 14 | showing: 'Datensätze', 15 | of: 'von', 16 | to: 'bis', 17 | results: 'Ergebnissen', 18 | }, 19 | loading: 'Wird geladen...', 20 | noRecordsFound: 'Keine passenden Daten gefunden', 21 | error: 'Beim Abrufen der Daten ist ein Fehler aufgetreten', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/es_ES.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Buscar...', 4 | }, 5 | sort: { 6 | sortAsc: 'Ordenar la columna en orden ascendente', 7 | sortDesc: 'Ordenar la columna en orden descendente', 8 | }, 9 | pagination: { 10 | previous: 'Anterior', 11 | next: 'Siguiente', 12 | navigate: (page, pages) => `Página ${page} de ${pages}`, 13 | page: (page) => `Página ${page}`, 14 | showing: 'Mostrando registros del', 15 | of: 'de un total de', 16 | to: 'al', 17 | results: 'registros', 18 | }, 19 | loading: 'Cargando...', 20 | noRecordsFound: 'No se encontraron registros', 21 | error: 'Se produjo un error al recuperar datos', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/fa_IR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'جست‌جو برای...', 4 | }, 5 | sort: { 6 | sortAsc: 'مرتب سازی صعودی', 7 | sortDesc: 'مرتب سازی نزولی', 8 | }, 9 | pagination: { 10 | previous: 'قبلی', 11 | next: 'بعدی', 12 | navigate: (page, pages) => `صفحه ${page} از ${pages}`, 13 | page: (page) => `صفحه ${page}`, 14 | showing: 'نمایش', 15 | of: 'از', 16 | to: 'تا', 17 | results: 'نتایج', 18 | }, 19 | loading: 'در حال دریافت...', 20 | noRecordsFound: 'نتیجه‌ای یافت نشد.', 21 | error: 'دریافت اطلاعات با خطا مواجه شد.', 22 | }; -------------------------------------------------------------------------------- /l10n/fr_FR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: '🔍 Recherche...', 4 | }, 5 | sort: { 6 | sortAsc: "Trier la colonne dans l'ordre croissant", 7 | sortDesc: "Trier la colonne dans l'ordre décroissant", 8 | }, 9 | pagination: { 10 | previous: 'Précédent', 11 | next: 'Suivant', 12 | navigate: (page, pages) => `Page ${page} de ${pages}`, 13 | page: (page) => `Page ${page}`, 14 | showing: 'Affichage des résultats', 15 | of: 'sur', 16 | to: 'à', 17 | results: '', 18 | }, 19 | loading: 'Chargement...', 20 | noRecordsFound: 'Aucun résultat trouvé', 21 | error: 'Une erreur est survenue lors de la récupération des données', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/id_ID.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Cari pada tabel...', 4 | }, 5 | sort: { 6 | sortAsc: 'Sortir kolom naik', 7 | sortDesc: 'Sortir kolom turun', 8 | }, 9 | pagination: { 10 | previous: 'Sebelumnya', 11 | next: 'Berikutnya', 12 | navigate: (page, pages) => `Halaman ${page} dari ${pages}`, 13 | page: (page) => `Halaman ${page}`, 14 | showing: 'Menampilkan', 15 | of: 'dari', 16 | to: 'sampai', 17 | results: 'hasil', 18 | }, 19 | loading: 'Memuat...', 20 | noRecordsFound: 'Tidak ada data yang ditemukan', 21 | error: 'Terjadi error saat memuat data', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/index.ts: -------------------------------------------------------------------------------- 1 | import esES from './es_ES'; 2 | import frFR from './fr_FR'; 3 | import itIT from './it_IT'; 4 | import ptPT from './pt_PT'; 5 | import trTR from './tr_TR'; 6 | import koKR from './ko_KR'; 7 | import ruRU from './ru_RU'; 8 | import idID from './id_ID'; 9 | import jaJP from './ja_JP'; 10 | import cnCN from './cn_CN'; 11 | import arSA from './ar_SA'; 12 | import deDE from './de_DE'; 13 | import ptBR from './pt_BR'; 14 | import faIR from './fa_IR'; 15 | import nbNO from './nb_NO'; 16 | import uaUA from './ua_UA'; 17 | import csCZ from './cs_CZ'; 18 | import svSE from './sv_SE'; 19 | 20 | export { 21 | esES, 22 | frFR, 23 | itIT, 24 | ptPT, 25 | trTR, 26 | koKR, 27 | ruRU, 28 | idID, 29 | jaJP, 30 | cnCN, 31 | arSA, 32 | deDE, 33 | ptBR, 34 | faIR, 35 | nbNO, 36 | uaUA, 37 | csCZ, 38 | svSE, 39 | }; 40 | -------------------------------------------------------------------------------- /l10n/it_IT.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Ricerca...', 4 | }, 5 | sort: { 6 | sortAsc: 'Ordina ascendente', 7 | sortDesc: 'Ordina discendente', 8 | }, 9 | pagination: { 10 | previous: 'Precedente', 11 | next: 'Successivo', 12 | navigate: (page, pages) => `Pagina ${page} di ${pages}`, 13 | page: (page) => `Pagina ${page}`, 14 | showing: 'Mostra', 15 | of: 'dei', 16 | to: 'di', 17 | results: 'risultati', 18 | }, 19 | loading: 'Caricamento...', 20 | noRecordsFound: 'Nessun risultato trovato.', 21 | error: 'Errore durante il caricamento dei dati.', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/ja_JP.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: '検索ワードを入力...', 4 | }, 5 | sort: { 6 | sortAsc: '昇順でソート', 7 | sortDesc: '降順でソート', 8 | }, 9 | pagination: { 10 | previous: '前へ', 11 | next: '次へ', 12 | navigate: (page, pages) => `${page} / ${pages} ページ`, 13 | page: (page) => `${page} ページ`, 14 | showing: '現在', 15 | of: '件目を表示中(全', 16 | to: '~', 17 | results: '件)', 18 | }, 19 | loading: 'ロード中...', 20 | noRecordsFound: '一致する検索結果がありません', 21 | error: 'データ取得中にエラーが発生しました', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/ko_KR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: '검색어 입력...', 4 | }, 5 | sort: { 6 | sortAsc: '내림차순 정렬', 7 | sortDesc: '오름차순 정렬', 8 | }, 9 | pagination: { 10 | previous: '이전', 11 | next: '다음', 12 | navigate: (page, pages) => `${pages} 중 ${page} 페이지`, 13 | page: (page) => `${page} 페이지`, 14 | showing: '결과보기:', 15 | of: '까지 총', 16 | to: '에서', 17 | results: '개', 18 | }, 19 | loading: '로딩중...', 20 | noRecordsFound: '일치하는 레코드 없음', 21 | error: '데이터 조회 오류', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/nb_NO.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Søk...', 4 | }, 5 | sort: { 6 | sortAsc: 'Sorter kolonne i stigende rekkefølge', 7 | sortDesc: 'Sorter kolonne i synkende rekkefølge', 8 | }, 9 | pagination: { 10 | previous: 'Forrige', 11 | next: 'Neste', 12 | navigate: (page, pages) => `Side ${page} av ${pages}`, 13 | page: (page) => `Side ${page}`, 14 | showing: 'Viser', 15 | of: 'av', 16 | to: 'til', 17 | results: 'resultater', 18 | }, 19 | loading: 'Laster inn...', 20 | noRecordsFound: 'Ingen resultater funnet', 21 | error: 'Det oppsto en feil under henting av data', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "l10n", 3 | "version": "0.1.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "0.1.0", 9 | "license": "MIT" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /l10n/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "l10n", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "MIT", 6 | "description": "Localization libs for Grid.js", 7 | "source": "index.ts", 8 | "main": "dist/l10n.js", 9 | "module": "dist/l10n.module.js", 10 | "umd:main": "dist/l10n.umd.js", 11 | "types": "dist/index.d.ts", 12 | "amdName": "gridjs.l10n" 13 | } 14 | -------------------------------------------------------------------------------- /l10n/pt_BR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Digite uma palavra-chave...', 4 | }, 5 | sort: { 6 | sortAsc: 'Coluna em ordem crescente', 7 | sortDesc: 'Coluna em ordem decrescente', 8 | }, 9 | pagination: { 10 | previous: 'Anterior', 11 | next: 'Próxima', 12 | navigate: (page, pages) => `Página ${page} de ${pages}`, 13 | page: (page) => `Página ${page}`, 14 | showing: 'Mostrando', 15 | of: 'de', 16 | to: 'até', 17 | results: 'resultados', 18 | }, 19 | loading: 'Carregando...', 20 | noRecordsFound: 'Nenhum registro encontrado', 21 | error: 'Ocorreu um erro ao buscar os dados', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/pt_PT.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Pesquisar...', 4 | }, 5 | sort: { 6 | sortAsc: 'Ordenar por ordem crescente', 7 | sortDesc: 'Ordenar por ordem descendente', 8 | }, 9 | pagination: { 10 | previous: 'Anterior', 11 | next: 'Próxima', 12 | navigate: (page, pages) => `Página ${page} de ${pages}`, 13 | page: (page) => `Página ${page}`, 14 | showing: 'A mostrar', 15 | of: 'de', 16 | to: 'até', 17 | results: 'registos', 18 | }, 19 | loading: 'A carregar...', 20 | noRecordsFound: 'Nenhum registro encontrado', 21 | error: 'Ocorreu um erro a obter os dados', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/ru_RU.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Поиск...', 4 | }, 5 | sort: { 6 | sortAsc: 'Сортировка по возрастанию', 7 | sortDesc: 'Сортировка по убыванию', 8 | }, 9 | pagination: { 10 | previous: 'Назад', 11 | next: 'Вперед', 12 | navigate: (page, pages) => `Страница ${page} из ${pages}`, 13 | page: (page) => `Страница ${page}`, 14 | showing: 'Отображение с', 15 | of: 'из', 16 | to: 'по', 17 | results: 'записей', 18 | }, 19 | loading: 'Загрузка...', 20 | noRecordsFound: 'Не найдено подходящих записей', 21 | error: 'Ошибка при загрузке данных', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/sv_SE.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Sök...', 4 | }, 5 | sort: { 6 | sortAsc: 'Sortera kolumn stigande', 7 | sortDesc: 'Sortera kolumn fallande', 8 | }, 9 | pagination: { 10 | previous: 'Föregående', 11 | next: 'Nästa', 12 | navigate: (page, pages) => `Sida ${page} av ${pages}`, 13 | page: (page) => `Sida ${page}`, 14 | showing: 'Visar', 15 | of: 'av', 16 | to: 'till', 17 | results: 'resultat', 18 | }, 19 | loading: 'Laddar...', 20 | noRecordsFound: 'Inga matchande poster hittades', 21 | error: 'Ett fel uppstod vid hämtning av data', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/tr_TR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Anahtar kelime girin...', 4 | }, 5 | sort: { 6 | sortAsc: 'Artan şekilde sırala', 7 | sortDesc: 'Azalan şekilde sırala', 8 | }, 9 | pagination: { 10 | previous: 'Önceki', 11 | next: 'Sonraki', 12 | navigate: (page, pages) => `Sayfa ${page}/${pages}`, 13 | page: (page) => `Sayfa ${page}`, 14 | showing: 'Gösteriliyor', 15 | of: 'nın', 16 | to: 'göre', 17 | results: 'Sonuçlar', 18 | }, 19 | loading: 'Bekleniyor...', 20 | noRecordsFound: 'Eşleşen kayıt bulunamadı', 21 | error: 'Veriler alınırken bir hata oluştu', 22 | }; 23 | -------------------------------------------------------------------------------- /l10n/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "removeComments": true 6 | }, 7 | "include": [ 8 | "index.ts", 9 | "src/**/*", 10 | "tests/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /l10n/tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "removeComments": true 6 | }, 7 | "include": [ 8 | "index.ts", 9 | "src/**/*" 10 | ], 11 | "exclude": [ 12 | "tests/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /l10n/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest"], 5 | "esModuleInterop": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /l10n/ua_UA.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Пошук...', 4 | }, 5 | sort: { 6 | sortAsc: 'Сортування за зростанням', 7 | sortDesc: 'Сортування за зменшенням', 8 | }, 9 | pagination: { 10 | previous: 'Назад', 11 | next: 'Далі', 12 | navigate: (page, pages) => `Сторінка ${page} з ${pages}`, 13 | page: (page) => `Сторінка ${page}`, 14 | showing: 'Відображення з', 15 | of: 'з', 16 | to: 'до', 17 | results: 'записів', 18 | }, 19 | loading: 'Завантаження...', 20 | noRecordsFound: 'Не знайдено відповідних записів', 21 | error: 'Помилка при завантаженні даних', 22 | }; 23 | -------------------------------------------------------------------------------- /plugins/selection/README.md: -------------------------------------------------------------------------------- 1 | # `gridjs-selection` 2 | 3 | > Row/cell selection plugin for Grid.js 4 | 5 | ## Documentation :book: 6 | 7 | See [Selection plugin](https://gridjs.io/docs/examples/selection) docs on gridjs.io. 8 | 9 | Full documentation of Grid.js installation, config, API and examples are available 10 | on Grid.js website [grid.js/docs](https://gridjs.io/docs/index). 11 | 12 | ## License 13 | 14 | [MIT](https://github.com/grid-js/gridjs/blob/master/LICENSE) 15 | -------------------------------------------------------------------------------- /plugins/selection/index.ts: -------------------------------------------------------------------------------- 1 | import { RowSelection } from './src/rowSelection/rowSelection'; 2 | import * as RowSelectionActions from './src/rowSelection/actions'; 3 | 4 | export { RowSelection, RowSelectionActions }; 5 | -------------------------------------------------------------------------------- /plugins/selection/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "selection", 3 | "version": "4.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "4.0.0", 9 | "license": "MIT", 10 | "dependencies": { 11 | "gridjs": "../../" 12 | } 13 | }, 14 | "../..": { 15 | "version": "5.0.2", 16 | "license": "MIT", 17 | "dependencies": { 18 | "preact": "^10.5.12", 19 | "tslib": "^2.0.1" 20 | }, 21 | "devDependencies": { 22 | "@types/enzyme": "^3.10.5", 23 | "@types/jest": "^26.0.0", 24 | "@types/jest-axe": "^3.2.2", 25 | "@types/node": "^15.3.0", 26 | "@typescript-eslint/eslint-plugin": "~4.28.0", 27 | "@typescript-eslint/parser": "~4.28.0", 28 | "autoprefixer": "^9.8.0", 29 | "axe-core": "^4.0.0", 30 | "check-export-map": "^1.1.1", 31 | "cssnano": "^5.0.5", 32 | "cypress": "^7.4.0", 33 | "cypress-visual-regression": "^1.5.7", 34 | "enzyme": "^3.11.0", 35 | "enzyme-adapter-preact-pure": "^3.0.0", 36 | "eslint": "~7.29.0", 37 | "eslint-config-prettier": "^6.11.0", 38 | "eslint-plugin-jest": "~24.3.2", 39 | "identity-obj-proxy": "^3.0.0", 40 | "jest": "~27.0.6", 41 | "jest-axe": "^5.0.1", 42 | "jest-extended": "^0.11.5", 43 | "jsdom": "^16.2.2", 44 | "jsdom-global": "^3.0.2", 45 | "lerna-changelog": "^1.0.1", 46 | "microbundle": "^0.13.0", 47 | "node-sass": "^6.0.1", 48 | "node-sass-chokidar": "^1.5.0", 49 | "npm-run-all": "^4.1.5", 50 | "postcss": "^8.3.0", 51 | "postcss-cli": "^8.3.1", 52 | "postcss-nested": "^5.0.5", 53 | "postcss-scss": "^4.0.0", 54 | "postcss-sort-media-queries": "^3.10.11", 55 | "prettier": "~2.3.1", 56 | "rimraf": "~3.0.2", 57 | "source-map-loader": "^2.0.1", 58 | "start-server-and-test": "^1.12.3", 59 | "ts-jest": "^27.0.3", 60 | "ts-loader": "^9.1.1", 61 | "tsutils": "~3.21.0", 62 | "typescript": "^4.2.4" 63 | } 64 | }, 65 | "node_modules/gridjs": { 66 | "resolved": "../..", 67 | "link": true 68 | } 69 | }, 70 | "dependencies": { 71 | "gridjs": { 72 | "version": "file:../..", 73 | "requires": { 74 | "@types/enzyme": "^3.10.5", 75 | "@types/jest": "^26.0.0", 76 | "@types/jest-axe": "^3.2.2", 77 | "@types/node": "^15.3.0", 78 | "@typescript-eslint/eslint-plugin": "~4.28.0", 79 | "@typescript-eslint/parser": "~4.28.0", 80 | "autoprefixer": "^9.8.0", 81 | "axe-core": "^4.0.0", 82 | "check-export-map": "^1.1.1", 83 | "cssnano": "^5.0.5", 84 | "cypress": "^7.4.0", 85 | "cypress-visual-regression": "^1.5.7", 86 | "enzyme": "^3.11.0", 87 | "enzyme-adapter-preact-pure": "^3.0.0", 88 | "eslint": "~7.29.0", 89 | "eslint-config-prettier": "^6.11.0", 90 | "eslint-plugin-jest": "~24.3.2", 91 | "identity-obj-proxy": "^3.0.0", 92 | "jest": "~27.0.6", 93 | "jest-axe": "^5.0.1", 94 | "jest-extended": "^0.11.5", 95 | "jsdom": "^16.2.2", 96 | "jsdom-global": "^3.0.2", 97 | "lerna-changelog": "^1.0.1", 98 | "microbundle": "^0.13.0", 99 | "node-sass": "^6.0.1", 100 | "node-sass-chokidar": "^1.5.0", 101 | "npm-run-all": "^4.1.5", 102 | "postcss": "^8.3.0", 103 | "postcss-cli": "^8.3.1", 104 | "postcss-nested": "^5.0.5", 105 | "postcss-scss": "^4.0.0", 106 | "postcss-sort-media-queries": "^3.10.11", 107 | "preact": "^10.5.12", 108 | "prettier": "~2.3.1", 109 | "rimraf": "~3.0.2", 110 | "source-map-loader": "^2.0.1", 111 | "start-server-and-test": "^1.12.3", 112 | "ts-jest": "^27.0.3", 113 | "ts-loader": "^9.1.1", 114 | "tslib": "^2.0.1", 115 | "tsutils": "~3.21.0", 116 | "typescript": "^4.2.4" 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /plugins/selection/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "selection", 3 | "version": "4.0.0", 4 | "private": true, 5 | "description": "Adds row and cell selection to Grid.js", 6 | "author": "Afshin Mehrabani ", 7 | "homepage": "https://gridjs.io/docs/examples/selection", 8 | "license": "MIT", 9 | "source": "index.ts", 10 | "main": "dist/selection.js", 11 | "module": "dist/selection.module.js", 12 | "umd:main": "dist/selection.umd.js", 13 | "amdName": "gridjs.plugins.selection", 14 | "types": "dist/selection.d.ts", 15 | "files": [ 16 | "dist/*" 17 | ], 18 | "dependencies": { 19 | "gridjs": "../../" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /plugins/selection/src/rowSelection/actions.ts: -------------------------------------------------------------------------------- 1 | export const CheckRow = (rowId: string) => (state) => { 2 | const rowIds = state.rowSelection?.rowIds || []; 3 | 4 | // rowId already exists 5 | if (rowIds.indexOf(rowId) > -1) return state; 6 | 7 | return { 8 | ...state, 9 | rowSelection: { 10 | rowIds: [rowId, ...rowIds], 11 | }, 12 | }; 13 | }; 14 | 15 | export const UncheckRow = (rowId: string) => (state) => { 16 | const rowIds = state.rowSelection?.rowIds || []; 17 | const index = rowIds.indexOf(rowId); 18 | 19 | // rowId doesn't exist 20 | if (index === -1) return state; 21 | 22 | const cloned = [...rowIds]; 23 | cloned.splice(index, 1); 24 | 25 | return { 26 | ...state, 27 | rowSelection: { 28 | rowIds: cloned, 29 | }, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /plugins/selection/src/rowSelection/rowSelection.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import * as actions from './actions'; 3 | import { useStore, className, useEffect, useState, useSelector } from 'gridjs'; 4 | import { Row } from 'gridjs'; 5 | import { Cell } from 'gridjs'; 6 | 7 | interface RowSelectionProps { 8 | // it's optional because thead doesn't have a row 9 | row?: Row; 10 | cell?: Cell; 11 | } 12 | 13 | export function RowSelection(props: RowSelectionProps) { 14 | const { dispatch } = useStore(); 15 | const state = useSelector((state) => state.rowSelection); 16 | const [isChecked, setIsChecked] = useState(false); 17 | const selectedClassName = className('tr', 'selected'); 18 | const checkboxClassName = className('checkbox'); 19 | const isDataCell = (props) => props.row !== undefined; 20 | const getParentTR = () => 21 | this.base && 22 | this.base.parentElement && 23 | (this.base.parentElement.parentElement as Element); 24 | 25 | useEffect(() => { 26 | // store/dispatcher is required only if we are rendering a TD (not a TH) 27 | if (props.cell?.data && isDataCell(props)) { 28 | // mark this checkbox as checked if cell.data is true 29 | check(); 30 | } 31 | }, []); 32 | 33 | useEffect(() => { 34 | const parent = getParentTR(); 35 | 36 | if (!parent) return; 37 | 38 | const rowIds = state?.rowIds || []; 39 | const isChecked = rowIds.indexOf(props.row.id) > -1; 40 | setIsChecked(isChecked); 41 | 42 | if (isChecked) { 43 | parent.classList.add(selectedClassName); 44 | } else { 45 | parent.classList.remove(selectedClassName); 46 | } 47 | }, [state]); 48 | 49 | const check = () => { 50 | dispatch(actions.CheckRow(props.row.id)); 51 | props.cell?.update(true); 52 | }; 53 | 54 | const uncheck = () => { 55 | dispatch(actions.UncheckRow(props.row.id)); 56 | props.cell?.update(false); 57 | }; 58 | 59 | const toggle = () => { 60 | if (isChecked) { 61 | uncheck(); 62 | } else { 63 | check(); 64 | } 65 | }; 66 | 67 | if (!isDataCell(props)) return null; 68 | 69 | return ( 70 | toggle()} 74 | className={checkboxClassName} 75 | /> 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /plugins/selection/tests/rowSelection/actions.test.ts: -------------------------------------------------------------------------------- 1 | import * as Actions from '../../src/rowSelection/actions'; 2 | 3 | describe('Actions', () => { 4 | it('should trigger CHECK', () => { 5 | const state = Actions.CheckRow('42')({}); 6 | 7 | expect(state).toStrictEqual({ 8 | rowSelection: { 9 | rowIds: ['42'], 10 | }, 11 | }); 12 | }); 13 | 14 | it('should trigger CHECK when rowIds already exists', () => { 15 | const state = Actions.CheckRow('42')({ 16 | rowSelection: { 17 | rowIds: ['24'], 18 | }, 19 | }); 20 | 21 | expect(state).toStrictEqual({ 22 | rowSelection: { 23 | rowIds: ['42', '24'], 24 | }, 25 | }); 26 | }); 27 | 28 | it('should trigger UNCHECK', () => { 29 | const state = Actions.UncheckRow('42')({ 30 | rowSelection: { 31 | rowIds: ['42'], 32 | }, 33 | }); 34 | 35 | expect(state).toStrictEqual({ 36 | rowSelection: { 37 | rowIds: [], 38 | }, 39 | }); 40 | }); 41 | 42 | it('should UNCHECK the correct item', () => { 43 | const state = Actions.UncheckRow('42')({ 44 | rowSelection: { 45 | rowIds: ['22', '11'], 46 | }, 47 | }); 48 | 49 | expect(state).toStrictEqual({ 50 | rowSelection: { 51 | rowIds: ['22', '11'], 52 | }, 53 | }); 54 | }); 55 | 56 | it('should UNCHECK when rowIds is null', () => { 57 | const state = Actions.UncheckRow('42')({}); 58 | 59 | expect(state).toStrictEqual({}); 60 | }); 61 | 62 | it('should trigger UNCHECK when rowIds is empty', () => { 63 | const state = Actions.UncheckRow('42')({ 64 | rowSelection: { 65 | rowIds: [], 66 | }, 67 | }); 68 | 69 | expect(state).toStrictEqual({ 70 | rowSelection: { 71 | rowIds: [], 72 | }, 73 | }); 74 | }); 75 | 76 | it('should CHECK and UNCHECK', () => { 77 | let state = {}; 78 | state = Actions.CheckRow('42')(state); 79 | state = Actions.CheckRow('24')(state); 80 | state = Actions.UncheckRow('42')(state); 81 | 82 | expect(state).toStrictEqual({ 83 | rowSelection: { 84 | rowIds: ['24'], 85 | }, 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /plugins/selection/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "removeComments": true 6 | }, 7 | "include": [ 8 | "index.ts", 9 | "src/**/*", 10 | "tests/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /plugins/selection/tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "removeComments": true 6 | }, 7 | "include": [ 8 | "index.ts", 9 | "src/**/*" 10 | ], 11 | "exclude": [ 12 | "tests/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /plugins/selection/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest"], 5 | "esModuleInterop": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = cfg => { 2 | const dev = cfg.env === 'development'; 3 | 4 | return { 5 | map: dev ? { inline: false } : false, 6 | syntax: 'postcss-scss', 7 | plugins: [ 8 | require('postcss-nested')(), 9 | require('postcss-sort-media-queries')(), 10 | require('autoprefixer')(), 11 | dev ? null : require('cssnano')() 12 | ] 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { generateUUID, ID } from './util/id'; 2 | 3 | class Base { 4 | private readonly _id: ID; 5 | 6 | constructor(id?: ID) { 7 | this._id = id || generateUUID(); 8 | } 9 | 10 | get id(): ID { 11 | return this._id; 12 | } 13 | } 14 | 15 | export default Base; 16 | -------------------------------------------------------------------------------- /src/cell.ts: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | import { TCell } from './types'; 3 | import { html } from './util/html'; 4 | import { ComponentChild } from 'preact'; 5 | 6 | class Cell extends Base { 7 | // because a Cell is a subset of TCell type 8 | public data: number | string | boolean | ComponentChild; 9 | 10 | constructor(data: TCell) { 11 | super(); 12 | 13 | this.update(data); 14 | } 15 | 16 | private cast(data: TCell): number | string | boolean | ComponentChild { 17 | if (data instanceof HTMLElement) { 18 | return html(data.outerHTML); 19 | } 20 | 21 | return data; 22 | } 23 | 24 | /** 25 | * Updates the Cell's data 26 | * 27 | * @param data 28 | */ 29 | public update(data: TCell): Cell { 30 | this.data = this.cast(data); 31 | return this; 32 | } 33 | } 34 | 35 | export default Cell; 36 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { TableEvents } from './view/table/events'; 2 | import { ContainerEvents } from './view/events'; 3 | 4 | export type GridEvents = ContainerEvents & TableEvents; 5 | -------------------------------------------------------------------------------- /src/grid.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './config'; 2 | import { h, render, VNode } from 'preact'; 3 | import { Container } from './view/container'; 4 | import log from './util/log'; 5 | import { EventEmitter } from './util/eventEmitter'; 6 | import { GridEvents } from './events'; 7 | import { PluginManager } from './plugin'; 8 | import { ConfigContext } from './config'; 9 | 10 | class Grid extends EventEmitter { 11 | public config: Config; 12 | public plugin: PluginManager; 13 | 14 | constructor(config?: Partial) { 15 | super(); 16 | this.config = new Config() 17 | .assign({ instance: this, eventEmitter: this }) 18 | .update(config); 19 | this.plugin = this.config.plugin; 20 | } 21 | 22 | public updateConfig(config: Partial): this { 23 | this.config.update(config); 24 | return this; 25 | } 26 | 27 | createElement(): VNode { 28 | return h(ConfigContext.Provider, { 29 | value: this.config, 30 | children: h(Container, {}), 31 | }); 32 | } 33 | 34 | /** 35 | * Uses the existing container and tries to clear the cache 36 | * and re-render the existing Grid.js instance again. This is 37 | * useful when a new config is set/updated. 38 | * 39 | */ 40 | forceRender(): this { 41 | if (!this.config || !this.config.container) { 42 | log.error( 43 | 'Container is empty. Make sure you call render() before forceRender()', 44 | true, 45 | ); 46 | } 47 | 48 | this.destroy(); 49 | 50 | // recreate the Grid instance 51 | render(this.createElement(), this.config.container); 52 | 53 | return this; 54 | } 55 | 56 | /** 57 | * Deletes the Grid.js instance 58 | */ 59 | destroy(): void { 60 | this.config.pipeline.clearCache(); 61 | // TODO: not sure if it's a good idea to render a null element but I couldn't find a better way 62 | render(null, this.config.container); 63 | } 64 | 65 | /** 66 | * Mounts the Grid.js instance to the container 67 | * and renders the instance 68 | * 69 | * @param container 70 | */ 71 | render(container: Element): this { 72 | if (!container) { 73 | log.error('Container element cannot be null', true); 74 | } 75 | 76 | if (container.childNodes.length > 0) { 77 | log.error( 78 | `The container element ${container} is not empty. Make sure the container is empty and call render() again`, 79 | ); 80 | return this; 81 | } 82 | 83 | this.config.container = container; 84 | render(this.createElement(), container); 85 | 86 | return this; 87 | } 88 | } 89 | 90 | export default Grid; 91 | -------------------------------------------------------------------------------- /src/hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'preact/hooks'; 2 | import { Config, ConfigContext } from '../config'; 3 | 4 | export function useConfig(): Config { 5 | return useContext(ConfigContext); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'preact/hooks'; 2 | import { useStore } from './useStore'; 3 | 4 | export default function useSelector(selector: (state) => T) { 5 | const store = useStore(); 6 | const [current, setCurrent] = useState(selector(store.getState())); 7 | 8 | useEffect(() => { 9 | const unsubscribe = store.subscribe(() => { 10 | const updated = selector(store.getState()); 11 | 12 | if (current !== updated) { 13 | setCurrent(updated); 14 | } 15 | }); 16 | 17 | return unsubscribe; 18 | }, []); 19 | 20 | return current; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useConfig } from './useConfig'; 2 | 3 | export function useStore() { 4 | const config = useConfig(); 5 | return config.store; 6 | } 7 | -------------------------------------------------------------------------------- /src/i18n/en_US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Type a keyword...', 4 | }, 5 | sort: { 6 | sortAsc: 'Sort column ascending', 7 | sortDesc: 'Sort column descending', 8 | }, 9 | pagination: { 10 | previous: 'Previous', 11 | next: 'Next', 12 | navigate: (page, pages) => `Page ${page} of ${pages}`, 13 | page: (page) => `Page ${page}`, 14 | showing: 'Showing', 15 | of: 'of', 16 | to: 'to', 17 | results: 'results', 18 | }, 19 | loading: 'Loading...', 20 | noRecordsFound: 'No matching records found', 21 | error: 'An error happened while fetching the data', 22 | }; 23 | -------------------------------------------------------------------------------- /src/i18n/es_LA.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Escribe para buscar...', 4 | }, 5 | sort: { 6 | sortAsc: 'Orden de columna ascendente.', 7 | sortDesc: 'Orden de columna descendente.', 8 | }, 9 | pagination: { 10 | previous: 'Anterior', 11 | next: 'Siguiente', 12 | navigate: (page, pages) => `Página ${page} de ${pages}`, 13 | page: (page) => `Página ${page}`, 14 | showing: 'Mostrando del', 15 | of: 'de', 16 | to: 'al', 17 | results: 'registros', 18 | }, 19 | loading: 'Cargando...', 20 | noRecordsFound: 'Sin coincidencias encontradas.', 21 | error: 'Ocurrió un error al obtener los datos.', 22 | }; 23 | -------------------------------------------------------------------------------- /src/i18n/language.ts: -------------------------------------------------------------------------------- 1 | import { useConfig } from '../hooks/useConfig'; 2 | import enUS from './en_US'; 3 | type MessageFormat = (...args) => string; 4 | type Message = string | MessageFormat; 5 | export type Language = { [key: string]: Message | Language }; 6 | 7 | export class Translator { 8 | private readonly _language: Language; 9 | private readonly _defaultLanguage: Language; 10 | 11 | constructor(language?: Language) { 12 | this._language = language; 13 | this._defaultLanguage = enUS; 14 | } 15 | 16 | /** 17 | * Tries to split the message with "." and find 18 | * the key in the given language 19 | * 20 | * @param message 21 | * @param lang 22 | */ 23 | getString(message: string, lang: Language): MessageFormat { 24 | if (!lang || !message) return null; 25 | 26 | const splitted = message.split('.'); 27 | const key = splitted[0]; 28 | 29 | if (lang[key]) { 30 | const val = lang[key]; 31 | 32 | if (typeof val === 'string') { 33 | return (): string => val; 34 | } else if (typeof val === 'function') { 35 | return val; 36 | } else { 37 | return this.getString(splitted.slice(1).join('.'), val); 38 | } 39 | } 40 | 41 | return null; 42 | } 43 | 44 | translate(message: string, ...args): string { 45 | const translated = this.getString(message, this._language); 46 | let messageFormat; 47 | 48 | if (translated) { 49 | messageFormat = translated; 50 | } else { 51 | messageFormat = this.getString(message, this._defaultLanguage); 52 | } 53 | 54 | if (messageFormat) { 55 | return messageFormat(...args); 56 | } 57 | 58 | return message; 59 | } 60 | } 61 | 62 | export function useTranslator() { 63 | const config = useConfig(); 64 | 65 | return function (message: string, ...args): string { 66 | return config.translator.translate(message, ...args); 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/i18n/ms_MY.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'Tulis katakunci...', 4 | }, 5 | sort: { 6 | sortAsc: 'Susun lajur menaik', 7 | sortDesc: 'Susun lajur menurun', 8 | }, 9 | pagination: { 10 | previous: 'Sebelumnya', 11 | next: 'Seterusnya', 12 | navigate: (page, pages) => `Halaman ${page} daripada ${pages}`, 13 | page: (page) => `Mukasurat ${page}`, 14 | showing: 'Memaparkan', 15 | of: 'daripada', 16 | to: 'hingga', 17 | results: 'hasil', 18 | }, 19 | loading: 'Memuatkan...', 20 | noRecordsFound: 'Tiada data dijumpai', 21 | error: 'Terdapat kesulitan semasa mendapatkan data', 22 | }; 23 | -------------------------------------------------------------------------------- /src/i18n/th_TH.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | search: { 3 | placeholder: 'พิมพ์คีย์เวิร์ด...', 4 | }, 5 | sort: { 6 | sortAsc: 'เรียงคอลัมน์จากน้อยไปมาก', 7 | sortDesc: 'เรียงคอลัมน์จากมากไปน้อย', 8 | }, 9 | pagination: { 10 | previous: 'ก่อนหน้า', 11 | next: 'ถัดไป', 12 | navigate: (page, pages) => `หน้า ${page} ของ ${pages}`, 13 | page: (page) => `หน้า ${page}`, 14 | showing: 'แสดง', 15 | of: 'ของ', 16 | to: 'ถึง', 17 | results: 'ผลลัพธ์', 18 | }, 19 | loading: 'กำลังโหลด...', 20 | noRecordsFound: 'ไม่พบข้อมูล', 21 | error: 'เกิดข้อผิดพลาดขณะดึงข้อมูล', 22 | }; 23 | -------------------------------------------------------------------------------- /src/operator/search.ts: -------------------------------------------------------------------------------- 1 | import Tabular from '../tabular'; 2 | import { VNode } from 'preact'; 3 | import { HTMLContentProps } from '../view/htmlElement'; 4 | import { OneDArray, TCell, TColumn } from '../types'; 5 | 6 | export default function ( 7 | keyword: string, 8 | columns: OneDArray, 9 | ignoreHiddenColumns: boolean, 10 | tabular: Tabular, 11 | selector?: (cell: TCell, rowIndex: number, cellIndex: number) => string, 12 | ): Tabular { 13 | // escape special regex chars 14 | keyword = keyword.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 15 | 16 | return new Tabular( 17 | tabular.rows.filter((row, rowIndex) => 18 | row.cells.some((cell, cellIndex) => { 19 | if (!cell) { 20 | return false; 21 | } 22 | 23 | if (ignoreHiddenColumns) { 24 | if ( 25 | columns && 26 | columns[cellIndex] && 27 | typeof columns[cellIndex] === 'object' 28 | ) { 29 | const typedColumn = columns[cellIndex] as TColumn; 30 | if (typedColumn.hidden) { 31 | return false; 32 | } 33 | } 34 | } 35 | 36 | let data = ''; 37 | 38 | if (typeof selector === 'function') { 39 | data = selector(cell.data, rowIndex, cellIndex); 40 | } else if (typeof cell.data === 'object') { 41 | // HTMLContent element 42 | const element = cell.data as VNode; 43 | if (element && element.props && element.props.content) { 44 | // TODO: we should only search in the content of the element. props.content is the entire HTML element 45 | data = element.props.content; 46 | } 47 | } else { 48 | // primitive types 49 | data = String(cell.data); 50 | } 51 | 52 | return new RegExp(keyword, 'gi').test(data); 53 | }), 54 | ), 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/pipeline/extractor/storage.ts: -------------------------------------------------------------------------------- 1 | import Storage, { StorageResponse } from '../../storage/storage'; 2 | import { 3 | PipelineProcessor, 4 | PipelineProcessorProps, 5 | ProcessorType, 6 | } from '../processor'; 7 | 8 | interface StorageExtractorProps extends PipelineProcessorProps { 9 | storage: Storage; 10 | } 11 | 12 | class StorageExtractor extends PipelineProcessor< 13 | StorageResponse, 14 | StorageExtractorProps 15 | > { 16 | get type(): ProcessorType { 17 | return ProcessorType.Extractor; 18 | } 19 | 20 | async _process(opts: any): Promise { 21 | return await this.props.storage.get(opts); 22 | } 23 | } 24 | 25 | export default StorageExtractor; 26 | -------------------------------------------------------------------------------- /src/pipeline/filter/globalSearch.ts: -------------------------------------------------------------------------------- 1 | import search from '../../operator/search'; 2 | import Tabular from '../../tabular'; 3 | import { 4 | PipelineProcessor, 5 | PipelineProcessorProps, 6 | ProcessorType, 7 | } from '../processor'; 8 | import { OneDArray, TCell, TColumn } from '../../types'; 9 | 10 | interface GlobalSearchFilterProps extends PipelineProcessorProps { 11 | keyword: string; 12 | columns: OneDArray; 13 | ignoreHiddenColumns: boolean; 14 | selector?: (cell: TCell, rowIndex: number, cellIndex: number) => string; 15 | } 16 | 17 | class GlobalSearchFilter extends PipelineProcessor< 18 | Tabular, 19 | GlobalSearchFilterProps 20 | > { 21 | get type(): ProcessorType { 22 | return ProcessorType.Filter; 23 | } 24 | 25 | _process(data: Tabular): Tabular { 26 | if (this.props.keyword) { 27 | return search( 28 | String(this.props.keyword).trim(), 29 | this.props.columns, 30 | this.props.ignoreHiddenColumns, 31 | data, 32 | this.props.selector, 33 | ); 34 | } 35 | 36 | return data; 37 | } 38 | } 39 | 40 | export default GlobalSearchFilter; 41 | -------------------------------------------------------------------------------- /src/pipeline/filter/serverGlobalSearch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipelineProcessor, 3 | PipelineProcessorProps, 4 | ProcessorType, 5 | } from '../processor'; 6 | import { ServerStorageOptions } from '../../storage/server'; 7 | 8 | interface ServerGlobalSearchFilterProps extends PipelineProcessorProps { 9 | keyword?: string; 10 | url?: (prevUrl: string, keyword: string) => string; 11 | body?: (prevBody: BodyInit, keyword: string) => BodyInit; 12 | } 13 | 14 | class ServerGlobalSearchFilter extends PipelineProcessor< 15 | ServerStorageOptions, 16 | ServerGlobalSearchFilterProps 17 | > { 18 | get type(): ProcessorType { 19 | return ProcessorType.ServerFilter; 20 | } 21 | 22 | _process(options?: ServerStorageOptions): ServerStorageOptions { 23 | if (!this.props.keyword) return options; 24 | 25 | const updates = {}; 26 | 27 | if (this.props.url) { 28 | updates['url'] = this.props.url(options.url, this.props.keyword); 29 | } 30 | 31 | if (this.props.body) { 32 | updates['body'] = this.props.body(options.body, this.props.keyword); 33 | } 34 | 35 | return { 36 | ...options, 37 | ...updates, 38 | }; 39 | } 40 | } 41 | 42 | export default ServerGlobalSearchFilter; 43 | -------------------------------------------------------------------------------- /src/pipeline/initiator/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipelineProcessor, 3 | PipelineProcessorProps, 4 | ProcessorType, 5 | } from '../processor'; 6 | import { ServerStorageOptions } from '../../storage/server'; 7 | 8 | interface ServerInitiatorProps extends PipelineProcessorProps { 9 | serverStorageOptions: ServerStorageOptions; 10 | } 11 | 12 | class ServerInitiator extends PipelineProcessor< 13 | ServerStorageOptions, 14 | ServerInitiatorProps 15 | > { 16 | get type(): ProcessorType { 17 | return ProcessorType.Initiator; 18 | } 19 | 20 | _process(): ServerStorageOptions { 21 | return Object.entries(this.props.serverStorageOptions) 22 | .filter(([_, val]) => typeof val !== 'function') 23 | .reduce( 24 | (acc, [k, v]) => ({ ...acc, [k]: v }), 25 | {}, 26 | ) as ServerStorageOptions; 27 | } 28 | } 29 | 30 | export default ServerInitiator; 31 | -------------------------------------------------------------------------------- /src/pipeline/limit/pagination.ts: -------------------------------------------------------------------------------- 1 | import Tabular from '../../tabular'; 2 | import { 3 | PipelineProcessor, 4 | PipelineProcessorProps, 5 | ProcessorType, 6 | } from '../processor'; 7 | 8 | interface PaginationLimitProps extends PipelineProcessorProps { 9 | page: number; 10 | limit: number; 11 | } 12 | 13 | class PaginationLimit extends PipelineProcessor { 14 | protected validateProps(): void { 15 | if (isNaN(Number(this.props.limit)) || isNaN(Number(this.props.page))) { 16 | throw Error('Invalid parameters passed'); 17 | } 18 | } 19 | 20 | get type(): ProcessorType { 21 | return ProcessorType.Limit; 22 | } 23 | 24 | protected _process(data: Tabular): Tabular { 25 | const page = this.props.page; 26 | const start = page * this.props.limit; 27 | const end = (page + 1) * this.props.limit; 28 | 29 | return new Tabular(data.rows.slice(start, end)); 30 | } 31 | } 32 | 33 | export default PaginationLimit; 34 | -------------------------------------------------------------------------------- /src/pipeline/limit/serverPagination.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipelineProcessor, 3 | PipelineProcessorProps, 4 | ProcessorType, 5 | } from '../processor'; 6 | import { ServerStorageOptions } from '../../storage/server'; 7 | 8 | interface ServerPaginationLimitProps extends PipelineProcessorProps { 9 | page: number; 10 | limit: number; 11 | url?: (prevUrl: string, page: number, limit: number) => string; 12 | body?: (prevBody: BodyInit, page: number, limit: number) => BodyInit; 13 | } 14 | 15 | class ServerPaginationLimit extends PipelineProcessor< 16 | ServerStorageOptions, 17 | ServerPaginationLimitProps 18 | > { 19 | get type(): ProcessorType { 20 | return ProcessorType.ServerLimit; 21 | } 22 | 23 | _process(options?: ServerStorageOptions): ServerStorageOptions { 24 | const updates = {}; 25 | 26 | if (this.props.url) { 27 | updates['url'] = this.props.url( 28 | options.url, 29 | this.props.page, 30 | this.props.limit, 31 | ); 32 | } 33 | 34 | if (this.props.body) { 35 | updates['body'] = this.props.body( 36 | options.body, 37 | this.props.page, 38 | this.props.limit, 39 | ); 40 | } 41 | 42 | return { 43 | ...options, 44 | ...updates, 45 | }; 46 | } 47 | } 48 | 49 | export default ServerPaginationLimit; 50 | -------------------------------------------------------------------------------- /src/pipeline/pipelineUtils.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../config'; 2 | import Pipeline from './pipeline'; 3 | import Tabular from '../tabular'; 4 | import StorageExtractor from './extractor/storage'; 5 | import ArrayToTabularTransformer from './transformer/arrayToTabular'; 6 | import ServerStorage from '../storage/server'; 7 | import ServerInitiator from './initiator/server'; 8 | import StorageResponseToArrayTransformer from './transformer/storageResponseToArray'; 9 | 10 | class PipelineUtils { 11 | static createFromConfig(config: Config): Pipeline { 12 | const pipeline = new Pipeline(); 13 | 14 | if (config.storage instanceof ServerStorage) { 15 | pipeline.register( 16 | new ServerInitiator({ 17 | serverStorageOptions: config.server, 18 | }), 19 | ); 20 | } 21 | 22 | pipeline.register(new StorageExtractor({ storage: config.storage })); 23 | pipeline.register( 24 | new StorageResponseToArrayTransformer({ header: config.header }), 25 | ); 26 | pipeline.register(new ArrayToTabularTransformer()); 27 | 28 | return pipeline; 29 | } 30 | } 31 | 32 | export default PipelineUtils; 33 | -------------------------------------------------------------------------------- /src/pipeline/processor.ts: -------------------------------------------------------------------------------- 1 | // The order of enum items define the processing order of the processor type 2 | // e.g. Extractor = 0 will be processed before Transformer = 1 3 | import { generateUUID, ID } from '../util/id'; 4 | import { EventEmitter } from '../util/eventEmitter'; 5 | import { deepEqual } from '../util/deepEqual'; 6 | 7 | export enum ProcessorType { 8 | Initiator, 9 | ServerFilter, 10 | ServerSort, 11 | ServerLimit, 12 | Extractor, 13 | Transformer, 14 | Filter, 15 | Sort, 16 | Limit, 17 | } 18 | 19 | interface PipelineProcessorEvents { 20 | propsUpdated: (processor: PipelineProcessor) => void; 21 | beforeProcess: (...args) => void; 22 | afterProcess: (...args) => void; 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 26 | export interface PipelineProcessorProps {} 27 | 28 | export abstract class PipelineProcessor< 29 | T, 30 | P extends Partial, 31 | > extends EventEmitter { 32 | public readonly id: ID; 33 | private _props: P; 34 | 35 | abstract get type(): ProcessorType; 36 | protected abstract _process(...args): T | Promise; 37 | protected validateProps?(...args): void; 38 | 39 | constructor(props?: Partial

) { 40 | super(); 41 | 42 | this._props = {} as P; 43 | this.id = generateUUID(); 44 | 45 | if (props) this.setProps(props); 46 | } 47 | 48 | /** 49 | * process is used to call beforeProcess and afterProcess callbacks 50 | * This function is just a wrapper that calls _process() 51 | * 52 | * @param args 53 | */ 54 | process(...args): T | Promise { 55 | if (this.validateProps instanceof Function) { 56 | this.validateProps(...args); 57 | } 58 | 59 | this.emit('beforeProcess', ...args); 60 | const result = this._process(...args); 61 | this.emit('afterProcess', ...args); 62 | return result; 63 | } 64 | 65 | setProps(props: Partial

): this { 66 | const updatedProps = { 67 | ...this._props, 68 | ...props, 69 | }; 70 | 71 | if (!deepEqual(updatedProps, this._props)) { 72 | this._props = updatedProps; 73 | this.emit('propsUpdated', this); 74 | } 75 | 76 | return this; 77 | } 78 | 79 | get props(): P { 80 | return this._props; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/pipeline/sort/native.ts: -------------------------------------------------------------------------------- 1 | import { Comparator, TCell } from '../../types'; 2 | import Tabular from '../../tabular'; 3 | import { 4 | PipelineProcessor, 5 | PipelineProcessorProps, 6 | ProcessorType, 7 | } from '../processor'; 8 | import Row from '../../row'; 9 | import log from '../../util/log'; 10 | 11 | interface NativeSortProps extends PipelineProcessorProps { 12 | columns: { 13 | index: number; 14 | // 1 ascending, -1 descending 15 | direction?: 1 | -1; 16 | compare?: Comparator; 17 | }[]; 18 | } 19 | 20 | class NativeSort extends PipelineProcessor { 21 | protected validateProps(): void { 22 | for (const condition of this.props.columns) { 23 | if (condition.direction === undefined) { 24 | condition.direction = 1; 25 | } 26 | 27 | if (condition.direction !== 1 && condition.direction !== -1) { 28 | log.error(`Invalid sort direction ${condition.direction}`); 29 | } 30 | } 31 | } 32 | 33 | get type(): ProcessorType { 34 | return ProcessorType.Sort; 35 | } 36 | 37 | private compare(cellA: TCell, cellB: TCell): number { 38 | if (cellA > cellB) { 39 | return 1; 40 | } else if (cellA < cellB) { 41 | return -1; 42 | } 43 | 44 | return 0; 45 | } 46 | 47 | private compareWrapper(a: Row, b: Row): number { 48 | let cmp = 0; 49 | 50 | for (const column of this.props.columns) { 51 | if (cmp === 0) { 52 | const cellA = a.cells[column.index].data; 53 | const cellB = b.cells[column.index].data; 54 | 55 | if (typeof column.compare === 'function') { 56 | cmp |= column.compare(cellA, cellB) * column.direction; 57 | } else { 58 | cmp |= this.compare(cellA, cellB) * column.direction; 59 | } 60 | } else { 61 | break; 62 | } 63 | } 64 | 65 | return cmp; 66 | } 67 | 68 | protected _process(data: Tabular): Tabular { 69 | const sortedRows = [...data.rows]; 70 | sortedRows.sort(this.compareWrapper.bind(this)); 71 | 72 | const sorted = new Tabular(sortedRows); 73 | // we have to set the tabular length manually 74 | // because of the server-side storage 75 | sorted.length = data.length; 76 | 77 | return sorted; 78 | } 79 | } 80 | 81 | export default NativeSort; 82 | -------------------------------------------------------------------------------- /src/pipeline/sort/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipelineProcessor, 3 | PipelineProcessorProps, 4 | ProcessorType, 5 | } from '../processor'; 6 | import { ServerStorageOptions } from '../../storage/server'; 7 | import { TColumnSort } from '../../types'; 8 | 9 | interface ServerSortProps extends PipelineProcessorProps { 10 | columns: TColumnSort[]; 11 | url?: (prevUrl: string, columns: TColumnSort[]) => string; 12 | body?: (prevBody: BodyInit, columns: TColumnSort[]) => BodyInit; 13 | } 14 | 15 | class ServerSort extends PipelineProcessor< 16 | ServerStorageOptions, 17 | ServerSortProps 18 | > { 19 | get type(): ProcessorType { 20 | return ProcessorType.ServerSort; 21 | } 22 | 23 | _process(options?: ServerStorageOptions): ServerStorageOptions { 24 | const updates = {}; 25 | 26 | if (this.props.url) { 27 | updates['url'] = this.props.url(options.url, this.props.columns); 28 | } 29 | 30 | if (this.props.body) { 31 | updates['body'] = this.props.body(options.body, this.props.columns); 32 | } 33 | 34 | return { 35 | ...options, 36 | ...updates, 37 | }; 38 | } 39 | } 40 | 41 | export default ServerSort; 42 | -------------------------------------------------------------------------------- /src/pipeline/transformer/arrayToTabular.ts: -------------------------------------------------------------------------------- 1 | import { PipelineProcessor, ProcessorType } from '../processor'; 2 | import Tabular from '../../tabular'; 3 | import { ArrayResponse } from './storageResponseToArray'; 4 | 5 | class ArrayToTabularTransformer extends PipelineProcessor< 6 | Tabular, 7 | Record 8 | > { 9 | get type(): ProcessorType { 10 | return ProcessorType.Transformer; 11 | } 12 | 13 | _process(arrayResponse: ArrayResponse): Tabular { 14 | const tabular = Tabular.fromArray(arrayResponse.data); 15 | 16 | // for server-side storage 17 | tabular.length = arrayResponse.total; 18 | 19 | return tabular; 20 | } 21 | } 22 | 23 | export default ArrayToTabularTransformer; 24 | -------------------------------------------------------------------------------- /src/pipeline/transformer/storageResponseToArray.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipelineProcessor, 3 | PipelineProcessorProps, 4 | ProcessorType, 5 | } from '../processor'; 6 | import { StorageResponse } from '../../storage/storage'; 7 | import { TCell, TData, TDataArray, TDataObject, TwoDArray } from '../../types'; 8 | import Header from '../../header'; 9 | import logger from '../../util/log'; 10 | 11 | export interface ArrayResponse { 12 | data: TwoDArray; 13 | total: number; 14 | } 15 | 16 | interface StorageResponseToArrayTransformerProps 17 | extends PipelineProcessorProps { 18 | header: Header; 19 | } 20 | 21 | class StorageResponseToArrayTransformer extends PipelineProcessor< 22 | ArrayResponse, 23 | StorageResponseToArrayTransformerProps 24 | > { 25 | get type(): ProcessorType { 26 | return ProcessorType.Transformer; 27 | } 28 | 29 | private castData(data: TData): TwoDArray { 30 | if (!data || !data.length) { 31 | return []; 32 | } 33 | 34 | if (!this.props.header || !this.props.header.columns) { 35 | return data as TwoDArray; 36 | } 37 | 38 | const columns = Header.leafColumns(this.props.header.columns); 39 | 40 | // if it's a 2d array already 41 | if (data[0] instanceof Array) { 42 | return (data as TDataArray).map((row) => { 43 | let pad = 0; 44 | 45 | return columns.map((column, i) => { 46 | // default `data` is provided for this column 47 | if (column.data !== undefined) { 48 | pad++; 49 | 50 | if (typeof column.data === 'function') { 51 | return column.data(row); 52 | } else { 53 | return column.data; 54 | } 55 | } 56 | 57 | return row[i - pad]; 58 | }); 59 | }); 60 | } 61 | 62 | // if it's an array of objects (but not array of arrays, i.e JSON payload) 63 | if (typeof data[0] === 'object' && !(data[0] instanceof Array)) { 64 | return (data as TDataObject).map((row) => 65 | columns.map((column, i) => { 66 | if (column.data !== undefined) { 67 | if (typeof column.data === 'function') { 68 | return column.data(row); 69 | } else { 70 | return column.data; 71 | } 72 | } else if (column.id) { 73 | return row[column.id]; 74 | } else { 75 | logger.error(`Could not find the correct cell for column at position ${i}. 76 | Make sure either 'id' or 'selector' is defined for all columns.`); 77 | return null; 78 | } 79 | }), 80 | ); 81 | } 82 | 83 | return []; 84 | } 85 | 86 | _process(storageResponse: StorageResponse): ArrayResponse { 87 | return { 88 | data: this.castData(storageResponse.data), 89 | total: storageResponse.total, 90 | }; 91 | } 92 | } 93 | 94 | export default StorageResponseToArrayTransformer; 95 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Fragment, FunctionComponent, h } from 'preact'; 2 | import { useConfig } from './hooks/useConfig'; 3 | import log from './util/log'; 4 | 5 | export enum PluginPosition { 6 | Header, 7 | Footer, 8 | Cell, 9 | } 10 | 11 | export interface Plugin { 12 | id: string; 13 | position: PluginPosition; 14 | component: T; 15 | order?: number; 16 | } 17 | 18 | export class PluginManager { 19 | private readonly plugins: Plugin[]; 20 | 21 | constructor() { 22 | this.plugins = []; 23 | } 24 | 25 | get(id: string): Plugin | undefined { 26 | return this.plugins.find((p) => p.id === id); 27 | } 28 | 29 | add>(plugin: Plugin): this { 30 | if (!plugin.id) { 31 | log.error('Plugin ID cannot be empty'); 32 | return this; 33 | } 34 | 35 | if (this.get(plugin.id)) { 36 | log.error(`Duplicate plugin ID: ${plugin.id}`); 37 | return this; 38 | } 39 | 40 | this.plugins.push(plugin); 41 | return this; 42 | } 43 | 44 | remove(id: string): this { 45 | const plugin = this.get(id); 46 | 47 | if (plugin) { 48 | this.plugins.splice(this.plugins.indexOf(plugin), 1); 49 | } 50 | 51 | return this; 52 | } 53 | 54 | list(position?: PluginPosition): Plugin[] { 55 | let plugins: Plugin[]; 56 | 57 | if (position != null || position != undefined) { 58 | plugins = this.plugins.filter((p) => p.position === position); 59 | } else { 60 | plugins = this.plugins; 61 | } 62 | 63 | return plugins.sort((a, b) => (a.order && b.order ? a.order - b.order : 1)); 64 | } 65 | } 66 | 67 | export function PluginRenderer(props: { 68 | props?: any; 69 | // to render a single plugin 70 | pluginId?: string; 71 | // to render all plugins in this PluginPosition 72 | position?: PluginPosition; 73 | }) { 74 | const config = useConfig(); 75 | 76 | if (props.pluginId) { 77 | // render a single plugin 78 | const plugin = config.plugin.get(props.pluginId); 79 | 80 | if (!plugin) return null; 81 | 82 | return h( 83 | Fragment, 84 | {}, 85 | h(plugin.component, { 86 | plugin: plugin, 87 | ...props.props, 88 | }), 89 | ); 90 | } else if (props.position !== undefined) { 91 | // render using a specific plugin position 92 | return h( 93 | Fragment, 94 | {}, 95 | config.plugin.list(props.position).map((p) => { 96 | return h(p.component, { plugin: p, ...this.props.props }); 97 | }), 98 | ); 99 | } 100 | 101 | return null; 102 | } 103 | -------------------------------------------------------------------------------- /src/row.ts: -------------------------------------------------------------------------------- 1 | import Cell from './cell'; 2 | import Base from './base'; 3 | import { TCell } from './types'; 4 | 5 | class Row extends Base { 6 | private _cells: Cell[]; 7 | 8 | constructor(cells?: Cell[]) { 9 | super(); 10 | 11 | this.cells = cells || []; 12 | } 13 | 14 | public cell(index: number): Cell { 15 | return this._cells[index]; 16 | } 17 | 18 | public get cells(): Cell[] { 19 | return this._cells; 20 | } 21 | 22 | public set cells(cells: Cell[]) { 23 | this._cells = cells; 24 | } 25 | 26 | public toArray(): TCell[] { 27 | return this.cells.map((cell) => cell.data); 28 | } 29 | 30 | /** 31 | * Creates a new Row from an array of Cell(s) 32 | * This method generates a new ID for the Row and all nested elements 33 | * 34 | * @param cells 35 | * @returns Row 36 | */ 37 | static fromCells(cells: Cell[]): Row { 38 | return new Row(cells.map((cell) => new Cell(cell.data))); 39 | } 40 | 41 | get length(): number { 42 | return this.cells.length; 43 | } 44 | } 45 | 46 | export default Row; 47 | -------------------------------------------------------------------------------- /src/state/store.ts: -------------------------------------------------------------------------------- 1 | export class Store> { 2 | private state: S; 3 | private listeners: ((current?: S, prev?: S) => void)[] = []; 4 | private isDispatching = false; 5 | 6 | constructor(initialState: S) { 7 | this.state = initialState; 8 | } 9 | 10 | getState = () => this.state; 11 | getListeners = () => this.listeners; 12 | 13 | dispatch = (reducer: (state: S) => S) => { 14 | if (typeof reducer !== 'function') 15 | throw new Error('Reducer is not a function'); 16 | if (this.isDispatching) 17 | throw new Error('Reducers may not dispatch actions'); 18 | 19 | this.isDispatching = true; 20 | 21 | const prevState = this.state; 22 | try { 23 | this.state = reducer(this.state); 24 | } finally { 25 | this.isDispatching = false; 26 | } 27 | 28 | for (const listener of this.listeners) { 29 | listener(this.state, prevState); 30 | } 31 | 32 | return this.state; 33 | }; 34 | 35 | subscribe = (listener: (current?: S, prev?: S) => void): (() => void) => { 36 | if (typeof listener !== 'function') 37 | throw new Error('Listener is not a function'); 38 | 39 | this.listeners = [...this.listeners, listener]; 40 | return () => 41 | (this.listeners = this.listeners.filter((lis) => lis !== listener)); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/storage/memory.ts: -------------------------------------------------------------------------------- 1 | import Storage, { StorageResponse } from './storage'; 2 | import { TData } from '../types'; 3 | 4 | class MemoryStorage extends Storage { 5 | private data: (() => TData) | (() => Promise); 6 | 7 | constructor(data: TData | (() => TData) | (() => Promise)) { 8 | super(); 9 | this.set(data); 10 | } 11 | 12 | public async get(): Promise { 13 | const data = await this.data(); 14 | 15 | return { 16 | data: data, 17 | total: data.length, 18 | }; 19 | } 20 | 21 | public set(data: TData | (() => TData) | (() => Promise)): this { 22 | if (data instanceof Array) { 23 | this.data = (): TData => data; 24 | } else if (data instanceof Function) { 25 | this.data = data; 26 | } 27 | 28 | return this; 29 | } 30 | } 31 | 32 | export default MemoryStorage; 33 | -------------------------------------------------------------------------------- /src/storage/server.ts: -------------------------------------------------------------------------------- 1 | import Storage, { StorageResponse } from './storage'; 2 | import log from '../util/log'; 3 | 4 | export interface ServerStorageOptions extends RequestInit { 5 | url: string; 6 | // to format the data and columns 7 | then?: (data: any) => any[][]; 8 | // to handle the response from the server. `handle` will 9 | // be called first and then `then` callback will be invoked 10 | // The purpose of this function is to handle the behaviour 11 | // of server and either reject and resolve the initial response 12 | // before calling the `then` function 13 | handle?: (response: Response) => Promise; 14 | total?: (data: any) => number; 15 | // to bypass the current implementation of ServerStorage and process the 16 | // request manually (e.g. when user wants to connect their own SDK/HTTP Client) 17 | data?: (opts: ServerStorageOptions) => Promise; 18 | } 19 | 20 | class ServerStorage extends Storage { 21 | private readonly options: ServerStorageOptions; 22 | 23 | constructor(options: ServerStorageOptions) { 24 | super(); 25 | this.options = options; 26 | } 27 | 28 | private handler(response: Response): Promise { 29 | if (typeof this.options.handle === 'function') { 30 | return this.options.handle(response); 31 | } 32 | 33 | if (response.ok) { 34 | return response.json(); 35 | } else { 36 | log.error( 37 | `Could not fetch data: ${response.status} - ${response.statusText}`, 38 | true, 39 | ); 40 | return null; 41 | } 42 | } 43 | 44 | public get(options?: ServerStorageOptions): Promise { 45 | // this.options is the initial config object 46 | // options is the runtime config passed by the pipeline (e.g. search component) 47 | const opts = { 48 | ...this.options, 49 | ...options, 50 | }; 51 | 52 | // if `options.data` is provided, the current ServerStorage 53 | // implementation will be ignored and we let options.data to 54 | // handle the request. Useful when HTTP client needs to be 55 | // replaced with something else 56 | if (typeof opts.data === 'function') { 57 | return opts.data(opts); 58 | } 59 | 60 | return fetch(opts.url, opts) 61 | .then(this.handler.bind(this)) 62 | .then((res) => { 63 | return { 64 | data: opts.then(res), 65 | total: typeof opts.total === 'function' ? opts.total(res) : undefined, 66 | }; 67 | }); 68 | } 69 | } 70 | 71 | export default ServerStorage; 72 | -------------------------------------------------------------------------------- /src/storage/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Storage class. All storage implementation must inherit this class 3 | */ 4 | import { TData } from '../types'; 5 | 6 | abstract class Storage { 7 | /** 8 | * Returns all rows based on ...args 9 | * @param args 10 | */ 11 | abstract get(...args): Promise; 12 | 13 | /** 14 | * To set all rows 15 | * 16 | * @param data 17 | */ 18 | set?(data: I | ((...args) => void)): this; 19 | } 20 | 21 | export interface StorageResponse { 22 | data: TData; 23 | total: number; 24 | } 25 | 26 | export default Storage; 27 | -------------------------------------------------------------------------------- /src/storage/storageUtils.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../config'; 2 | import MemoryStorage from './memory'; 3 | import Storage from './storage'; 4 | import ServerStorage from './server'; 5 | import log from '../util/log'; 6 | import { decode, html } from '../util/html'; 7 | 8 | class StorageUtils { 9 | /** 10 | * Accepts a Config object and tries to guess and return a Storage type 11 | * 12 | * @param config 13 | */ 14 | public static createFromConfig(config: Config): Storage { 15 | let storage = null; 16 | // `data` array is provided 17 | if (config.data) { 18 | storage = new MemoryStorage(config.data); 19 | } 20 | 21 | if (config.from) { 22 | storage = new MemoryStorage(this.tableElementToArray(config.from)); 23 | // remove the source table element from the DOM 24 | config.from.style.display = 'none'; 25 | } 26 | 27 | if (config.server) { 28 | storage = new ServerStorage(config.server); 29 | } 30 | 31 | if (!storage) { 32 | log.error('Could not determine the storage type', true); 33 | } 34 | 35 | return storage; 36 | } 37 | 38 | /** 39 | * Accepts a HTML table element and converts it into a 2D array of data 40 | * 41 | * TODO: This function can be a step in the pipeline: Convert Table -> Load into a memory storage -> ... 42 | * 43 | * @param element 44 | */ 45 | static tableElementToArray(element: HTMLElement): any[][] { 46 | const arr = []; 47 | const tbody = element.querySelector('tbody'); 48 | const rows = tbody.querySelectorAll('tr'); 49 | 50 | for (const row of rows as any) { 51 | const cells: HTMLElement[] = row.querySelectorAll('td'); 52 | const parsedRow = []; 53 | 54 | for (const cell of cells) { 55 | // try to capture a TD with single text element first 56 | if ( 57 | cell.childNodes.length === 1 && 58 | cell.childNodes[0].nodeType === Node.TEXT_NODE 59 | ) { 60 | parsedRow.push(decode(cell.innerHTML)); 61 | } else { 62 | parsedRow.push(html(cell.innerHTML)); 63 | } 64 | } 65 | 66 | arr.push(parsedRow); 67 | } 68 | 69 | return arr; 70 | } 71 | } 72 | 73 | export default StorageUtils; 74 | -------------------------------------------------------------------------------- /src/tabular.ts: -------------------------------------------------------------------------------- 1 | import Base from './base'; 2 | import Row from './row'; 3 | import Cell from './cell'; 4 | import { OneDArray, TCell, TwoDArray } from './types'; 5 | import { oneDtoTwoD } from './util/array'; 6 | 7 | class Tabular extends Base { 8 | private _rows: Row[]; 9 | private _length: number; 10 | 11 | constructor(rows?: Row[] | Row) { 12 | super(); 13 | 14 | if (rows instanceof Array) { 15 | this.rows = rows; 16 | } else if (rows instanceof Row) { 17 | this.rows = [rows]; 18 | } else { 19 | this.rows = []; 20 | } 21 | } 22 | 23 | get rows(): Row[] { 24 | return this._rows; 25 | } 26 | 27 | set rows(rows: Row[]) { 28 | this._rows = rows; 29 | } 30 | 31 | get length(): number { 32 | return this._length || this.rows.length; 33 | } 34 | 35 | // we want to sent the length when storage is ServerStorage 36 | set length(len: number) { 37 | this._length = len; 38 | } 39 | 40 | public toArray(): TCell[][] { 41 | return this.rows.map((row) => row.toArray()); 42 | } 43 | 44 | /** 45 | * Creates a new Tabular from an array of Row(s) 46 | * This method generates a new ID for the Tabular and all nested elements 47 | * 48 | * @param rows 49 | * @returns Tabular 50 | */ 51 | static fromRows(rows: Row[]): Tabular { 52 | return new Tabular(rows.map((row) => Row.fromCells(row.cells))); 53 | } 54 | 55 | /** 56 | * Creates a new Tabular from a 2D array 57 | * This method generates a new ID for the Tabular and all nested elements 58 | * 59 | * @param data 60 | * @returns Tabular 61 | */ 62 | static fromArray( 63 | data: OneDArray | TwoDArray, 64 | ): Tabular { 65 | data = oneDtoTwoD(data); 66 | 67 | return new Tabular( 68 | data.map((row) => new Row(row.map((cell) => new Cell(cell)))), 69 | ); 70 | } 71 | } 72 | 73 | export default Tabular; 74 | -------------------------------------------------------------------------------- /src/theme/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { [className: string]: string }; 3 | export = content; 4 | } 5 | -------------------------------------------------------------------------------- /src/theme/mermaid/button.scss: -------------------------------------------------------------------------------- 1 | .gridjs { 2 | &-head, 3 | &-footer { 4 | button { 5 | cursor: pointer; 6 | background-color: transparent; 7 | background-image: none; 8 | padding: 0; 9 | margin: 0; 10 | border: none; 11 | outline: none; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/theme/mermaid/checkbox.scss: -------------------------------------------------------------------------------- 1 | .gridjs { 2 | &-td { 3 | .gridjs-checkbox { 4 | display: block; 5 | margin: auto; 6 | cursor: pointer; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/theme/mermaid/colors.scss: -------------------------------------------------------------------------------- 1 | $white: #fff; 2 | $black: #000; 3 | 4 | $gray1: #f9fafb; 5 | $gray2: #f7f7f7; 6 | $gray3: #e5e7eb; 7 | $gray4: #d2d6dc; 8 | $gray5: #6b7280; 9 | $gray6: #3d4044; 10 | 11 | $darkBlue1: rgb(60, 66, 87); 12 | 13 | $blue1: #9bc2f7; 14 | $blue2: #ebf5ff; 15 | -------------------------------------------------------------------------------- /src/theme/mermaid/container.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-container { 5 | overflow: hidden; 6 | display: inline-block; 7 | padding: 2px; 8 | color: $black; 9 | position: relative; 10 | z-index: 0; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/theme/mermaid/footer.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-footer { 5 | display: block; 6 | position: relative; 7 | width: 100%; 8 | z-index: 5; 9 | padding: 12px 24px; 10 | border-top: 1px solid $gray3; 11 | background-color: $white; 12 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.26); 13 | border-radius: 0 0 8px 8px; 14 | border-bottom-width: 1px; 15 | border-color: $gray3; 16 | } 17 | 18 | &-footer:empty { 19 | padding: 0; 20 | border: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/theme/mermaid/head.scss: -------------------------------------------------------------------------------- 1 | .gridjs { 2 | &-head { 3 | width: 100%; 4 | margin-bottom: 5px; 5 | padding: 5px 1px; 6 | 7 | &::after { 8 | content: ''; 9 | display: block; 10 | clear: both; 11 | } 12 | } 13 | 14 | &-head:empty { 15 | padding: 0; 16 | border: none; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/theme/mermaid/index.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | @import './button.scss'; 3 | @import './shadow.scss'; 4 | @import './head.scss'; 5 | @import './container.scss'; 6 | @import './footer.scss'; 7 | @import './input.scss'; 8 | @import './pagination.scss'; 9 | @import './sort.scss'; 10 | @import './table.scss'; 11 | @import './tbody.scss'; 12 | @import './td.scss'; 13 | @import './th.scss'; 14 | @import './tr.scss'; 15 | @import './thead.scss'; 16 | @import './wrapper.scss'; 17 | @import './search.scss'; 18 | @import './loadingBar.scss'; 19 | @import './checkbox.scss'; 20 | @import './resizable.scss'; 21 | -------------------------------------------------------------------------------- /src/theme/mermaid/input.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | input.gridjs-input { 4 | outline: none; 5 | background-color: $white; 6 | border: 1px solid $gray4; 7 | border-radius: 5px; 8 | padding: 10px 13px; 9 | font-size: 14px; 10 | line-height: 1.45; 11 | -webkit-appearance: none; 12 | -moz-appearance: none; 13 | appearance: none; 14 | 15 | &:focus { 16 | box-shadow: 0 0 0 3px rgba(149, 189, 243, 0.5); 17 | border-color: $blue1; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/theme/mermaid/loadingBar.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-loading-bar { 5 | z-index: 10; 6 | position: absolute; 7 | left: 0; 8 | right: 0; 9 | top: 0; 10 | bottom: 0; 11 | background-color: $white; 12 | opacity: 0.5; 13 | 14 | &::after { 15 | position: absolute; 16 | top: 0; 17 | right: 0; 18 | bottom: 0; 19 | left: 0; 20 | transform: translateX(-100%); 21 | background-image: linear-gradient( 22 | 90deg, 23 | rgba(#ccc, 0) 0, 24 | rgba(#ccc, 0.2) 20%, 25 | rgba(#ccc, 0.5) 60%, 26 | rgba(#ccc, 0) 27 | ); 28 | animation: shimmer 2s infinite; 29 | content: ''; 30 | } 31 | 32 | @keyframes shimmer { 33 | 100% { 34 | transform: translateX(100%); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/theme/mermaid/pagination.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs-pagination { 4 | color: $gray6; 5 | 6 | &::after { 7 | content: ''; 8 | display: block; 9 | clear: both; 10 | } 11 | 12 | .gridjs-summary { 13 | float: left; 14 | margin-top: 5px; 15 | } 16 | 17 | .gridjs-pages { 18 | float: right; 19 | 20 | button { 21 | padding: 5px 14px; 22 | border: 1px solid $gray4; 23 | background-color: $white; 24 | border-right: none; 25 | outline: none; 26 | user-select: none; 27 | } 28 | 29 | button:focus { 30 | box-shadow: 0 0 0 2px rgba(149, 189, 243, 0.5); 31 | position: relative; 32 | margin-right: -1px; 33 | border-right: 1px solid $gray4; 34 | } 35 | 36 | button:hover { 37 | background-color: $gray2; 38 | color: $darkBlue1; 39 | outline: none; 40 | } 41 | 42 | button:disabled, 43 | button[disabled], 44 | button:hover:disabled { 45 | cursor: default; 46 | background-color: $white; 47 | color: $gray5; 48 | } 49 | 50 | button.gridjs-spread { 51 | cursor: default; 52 | box-shadow: none; 53 | background-color: $white; 54 | } 55 | 56 | button.gridjs-currentPage { 57 | background-color: $gray2; 58 | font-weight: bold; 59 | } 60 | 61 | button:last-child { 62 | border-bottom-right-radius: 6px; 63 | border-top-right-radius: 6px; 64 | border-right: 1px solid $gray4; 65 | } 66 | 67 | button:first-child { 68 | border-bottom-left-radius: 6px; 69 | border-top-left-radius: 6px; 70 | } 71 | 72 | button:last-child:focus { 73 | margin-right: 0; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/theme/mermaid/resizable.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-resizable { 5 | position: absolute; 6 | top: 0; 7 | bottom: 0; 8 | right: 0; 9 | width: 5px; 10 | 11 | &:hover { 12 | cursor: ew-resize; 13 | background-color: $blue1; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/theme/mermaid/search.scss: -------------------------------------------------------------------------------- 1 | .gridjs { 2 | &-search { 3 | float: left; 4 | 5 | &-input { 6 | width: 250px; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/theme/mermaid/shadow.scss: -------------------------------------------------------------------------------- 1 | .gridjs-temp { 2 | position: relative; 3 | } 4 | -------------------------------------------------------------------------------- /src/theme/mermaid/sort.scss: -------------------------------------------------------------------------------- 1 | button.gridjs { 2 | &-sort { 3 | float: right; 4 | height: 24px; 5 | width: 13px; 6 | background-color: transparent; 7 | background-repeat: no-repeat; 8 | background-position-x: center; 9 | cursor: pointer; 10 | padding: 0; 11 | margin: 0; 12 | border: none; 13 | outline: none; 14 | background-size: contain; 15 | 16 | &-neutral { 17 | opacity: 0.3; 18 | background-image: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI0MDEuOTk4cHgiIGhlaWdodD0iNDAxLjk5OHB4IiB2aWV3Qm94PSIwIDAgNDAxLjk5OCA0MDEuOTk4IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA0MDEuOTk4IDQwMS45OTg7IgoJIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxnPgoJCTxwYXRoIGQ9Ik03My4wOTIsMTY0LjQ1MmgyNTUuODEzYzQuOTQ5LDAsOS4yMzMtMS44MDcsMTIuODQ4LTUuNDI0YzMuNjEzLTMuNjE2LDUuNDI3LTcuODk4LDUuNDI3LTEyLjg0NwoJCQljMC00Ljk0OS0xLjgxMy05LjIyOS01LjQyNy0xMi44NUwyMTMuODQ2LDUuNDI0QzIxMC4yMzIsMS44MTIsMjA1Ljk1MSwwLDIwMC45OTksMHMtOS4yMzMsMS44MTItMTIuODUsNS40MjRMNjAuMjQyLDEzMy4zMzEKCQkJYy0zLjYxNywzLjYxNy01LjQyNCw3LjkwMS01LjQyNCwxMi44NWMwLDQuOTQ4LDEuODA3LDkuMjMxLDUuNDI0LDEyLjg0N0M2My44NjMsMTYyLjY0NSw2OC4xNDQsMTY0LjQ1Miw3My4wOTIsMTY0LjQ1MnoiLz4KCQk8cGF0aCBkPSJNMzI4LjkwNSwyMzcuNTQ5SDczLjA5MmMtNC45NTIsMC05LjIzMywxLjgwOC0xMi44NSw1LjQyMWMtMy42MTcsMy42MTctNS40MjQsNy44OTgtNS40MjQsMTIuODQ3CgkJCWMwLDQuOTQ5LDEuODA3LDkuMjMzLDUuNDI0LDEyLjg0OEwxODguMTQ5LDM5Ni41N2MzLjYyMSwzLjYxNyw3LjkwMiw1LjQyOCwxMi44NSw1LjQyOHM5LjIzMy0xLjgxMSwxMi44NDctNS40MjhsMTI3LjkwNy0xMjcuOTA2CgkJCWMzLjYxMy0zLjYxNCw1LjQyNy03Ljg5OCw1LjQyNy0xMi44NDhjMC00Ljk0OC0xLjgxMy05LjIyOS01LjQyNy0xMi44NDdDMzM4LjEzOSwyMzkuMzUzLDMzMy44NTQsMjM3LjU0OSwzMjguOTA1LDIzNy41NDl6Ii8+Cgk8L2c+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+'); 19 | background-position-y: center; 20 | } 21 | 22 | &-asc { 23 | background-image: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyOTIuMzYycHgiIGhlaWdodD0iMjkyLjM2MXB4IiB2aWV3Qm94PSIwIDAgMjkyLjM2MiAyOTIuMzYxIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyOTIuMzYyIDI5Mi4zNjE7IgoJIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIGQ9Ik0yODYuOTM1LDE5Ny4yODdMMTU5LjAyOCw2OS4zODFjLTMuNjEzLTMuNjE3LTcuODk1LTUuNDI0LTEyLjg0Ny01LjQyNHMtOS4yMzMsMS44MDctMTIuODUsNS40MjRMNS40MjQsMTk3LjI4NwoJCUMxLjgwNywyMDAuOTA0LDAsMjA1LjE4NiwwLDIxMC4xMzRzMS44MDcsOS4yMzMsNS40MjQsMTIuODQ3YzMuNjIxLDMuNjE3LDcuOTAyLDUuNDI1LDEyLjg1LDUuNDI1aDI1NS44MTMKCQljNC45NDksMCw5LjIzMy0xLjgwOCwxMi44NDgtNS40MjVjMy42MTMtMy42MTMsNS40MjctNy44OTgsNS40MjctMTIuODQ3UzI5MC41NDgsMjAwLjkwNCwyODYuOTM1LDE5Ny4yODd6Ii8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+'); 24 | background-position-y: 35%; 25 | background-size: 10px; 26 | } 27 | 28 | &-desc { 29 | background-image: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJDYXBhXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyOTIuMzYycHgiIGhlaWdodD0iMjkyLjM2MnB4IiB2aWV3Qm94PSIwIDAgMjkyLjM2MiAyOTIuMzYyIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyOTIuMzYyIDI5Mi4zNjI7IgoJIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIGQ9Ik0yODYuOTM1LDY5LjM3N2MtMy42MTQtMy42MTctNy44OTgtNS40MjQtMTIuODQ4LTUuNDI0SDE4LjI3NGMtNC45NTIsMC05LjIzMywxLjgwNy0xMi44NSw1LjQyNAoJCUMxLjgwNyw3Mi45OTgsMCw3Ny4yNzksMCw4Mi4yMjhjMCw0Ljk0OCwxLjgwNyw5LjIyOSw1LjQyNCwxMi44NDdsMTI3LjkwNywxMjcuOTA3YzMuNjIxLDMuNjE3LDcuOTAyLDUuNDI4LDEyLjg1LDUuNDI4CgkJczkuMjMzLTEuODExLDEyLjg0Ny01LjQyOEwyODYuOTM1LDk1LjA3NGMzLjYxMy0zLjYxNyw1LjQyNy03Ljg5OCw1LjQyNy0xMi44NDdDMjkyLjM2Miw3Ny4yNzksMjkwLjU0OCw3Mi45OTgsMjg2LjkzNSw2OS4zNzd6Ii8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+'); 30 | background-position-y: 65%; 31 | background-size: 10px; 32 | } 33 | } 34 | 35 | &-sort:focus { 36 | outline: none; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/theme/mermaid/table.scss: -------------------------------------------------------------------------------- 1 | table.gridjs { 2 | &-table { 3 | width: 100%; 4 | max-width: 100%; 5 | border-collapse: collapse; 6 | text-align: left; 7 | display: table; 8 | margin: 0; 9 | padding: 0; 10 | overflow: auto; 11 | table-layout: fixed; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/theme/mermaid/tbody.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-tbody { 5 | background-color: $white; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/theme/mermaid/td.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | td.gridjs { 4 | &-td { 5 | border: 1px solid $gray3; 6 | padding: 12px 24px; 7 | background-color: $white; 8 | box-sizing: content-box; 9 | } 10 | 11 | &-td:first-child { 12 | border-left: none; 13 | } 14 | 15 | &-td:last-child { 16 | border-right: none; 17 | } 18 | 19 | &-message { 20 | text-align: center; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/theme/mermaid/th.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | th.gridjs { 4 | &-th { 5 | position: relative; 6 | color: $gray5; 7 | background-color: $gray1; 8 | border: 1px solid $gray3; 9 | border-top: none; 10 | padding: 14px 24px; 11 | user-select: none; 12 | box-sizing: border-box; 13 | white-space: nowrap; 14 | outline: none; 15 | vertical-align: middle; 16 | 17 | .gridjs-th-content { 18 | text-overflow: ellipsis; 19 | overflow: hidden; 20 | width: 100%; 21 | float: left; 22 | } 23 | 24 | &-sort { 25 | cursor: pointer; 26 | 27 | .gridjs-th-content { 28 | width: calc(100% - 15px); 29 | } 30 | } 31 | 32 | &-sort:hover { 33 | background-color: $gray3; 34 | } 35 | 36 | &-sort:focus { 37 | background-color: $gray3; 38 | } 39 | 40 | &-fixed { 41 | position: sticky; 42 | box-shadow: 0 1px 0 0 $gray3; 43 | 44 | @supports (-moz-appearance: none) { 45 | box-shadow: 0 0 0 1px $gray3; 46 | } 47 | } 48 | } 49 | 50 | &-th:first-child { 51 | border-left: none; 52 | } 53 | 54 | &-th:last-child { 55 | border-right: none; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/theme/mermaid/thead.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | *, 5 | :after, 6 | :before { 7 | box-sizing: border-box; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/theme/mermaid/tr.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-tr { 5 | border: none; 6 | 7 | &-selected td { 8 | background-color: $blue2; 9 | } 10 | } 11 | 12 | &-tr:last-child td { 13 | border-bottom: 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/theme/mermaid/wrapper.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | 3 | .gridjs { 4 | &-wrapper { 5 | position: relative; 6 | z-index: 1; 7 | overflow: auto; 8 | width: 100%; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.26); 12 | border-radius: 8px 8px 0 0; 13 | display: block; 14 | 15 | border-top-width: 1px; 16 | border-color: $gray3; 17 | } 18 | 19 | &-wrapper:nth-last-of-type(2) { 20 | border-radius: 8px; 21 | border-bottom-width: 1px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentChild } from 'preact'; 2 | import Row from './row'; 3 | import { SortConfig } from './view/plugin/sort/sort'; 4 | import { JSXInternal } from 'preact/src/jsx'; 5 | import { Plugin } from './plugin'; 6 | 7 | export type OneDArray = T[]; 8 | export type TwoDArray = T[][]; 9 | 10 | /** 11 | * Table cell types 12 | */ 13 | export type TCell = number | string | boolean | ComponentChild | HTMLElement; 14 | // Array of Arrays 15 | export type TDataArrayRow = OneDArray; 16 | export type TDataArray = OneDArray; 17 | // Array of Objects 18 | export type TDataObjectRow = { [key: string]: TCell }; 19 | export type TDataObject = OneDArray; 20 | // (Array of Arrays) and (Array of Objects) 21 | export type TData = TDataArray | TDataObject; 22 | 23 | // Table header cell type 24 | export interface TColumn { 25 | id?: string; 26 | // default data for all columns 27 | data?: ((row: TDataArrayRow | TDataObjectRow) => TCell) | TCell; 28 | // column label 29 | name?: string | ComponentChild; 30 | plugin?: Plugin; 31 | // column width 32 | width?: string; 33 | minWidth?: string; 34 | sort?: SortConfig; 35 | columns?: OneDArray; 36 | resizable?: boolean; 37 | hidden?: boolean; 38 | formatter?: (cell: TCell, row: Row, column: TColumn) => ComponentChild; 39 | // HTML attributes to be added to all cells and header of this column 40 | attributes?: 41 | | (( 42 | // this is null when `attributes` is called for a th 43 | cell: TCell | null, 44 | row: Row | null, 45 | column: TColumn, 46 | ) => JSXInternal.HTMLAttributes) 47 | | JSXInternal.HTMLAttributes; 48 | } 49 | 50 | // Comparator function for the sorting plugin 51 | export type Comparator = (a: T, b: T) => number; 52 | 53 | export interface TColumnSort { 54 | index: number; 55 | // 1 ascending, -1 descending 56 | direction?: 1 | -1; 57 | } 58 | 59 | // container status 60 | export enum Status { 61 | Init, 62 | Loading, 63 | Loaded, 64 | Rendered, 65 | Error, 66 | } 67 | 68 | export type CSSDeclaration = { 69 | [key: string]: string | number; 70 | }; 71 | -------------------------------------------------------------------------------- /src/util/array.ts: -------------------------------------------------------------------------------- 1 | import { OneDArray, TwoDArray } from '../types'; 2 | 3 | export function oneDtoTwoD(data: OneDArray | TwoDArray): TwoDArray { 4 | if (data[0] && !(data[0] instanceof Array)) { 5 | return [data] as TwoDArray; 6 | } 7 | 8 | return data as TwoDArray; 9 | } 10 | 11 | export function flatten(arrays: TwoDArray): OneDArray { 12 | return arrays.reduce((prev, x) => prev.concat(x), []); 13 | } 14 | -------------------------------------------------------------------------------- /src/util/className.ts: -------------------------------------------------------------------------------- 1 | import { JSXInternal } from 'preact/src/jsx'; 2 | 3 | export function className(...args: string[]): string { 4 | const prefix = 'gridjs'; 5 | 6 | return `${prefix}${args.reduce( 7 | (prev: string, cur: string) => `${prev}-${cur}`, 8 | '', 9 | )}`; 10 | } 11 | 12 | export function classJoin( 13 | ...classNames: (undefined | string | JSXInternal.SignalLike)[] 14 | ): string { 15 | return classNames 16 | .map((x) => (x ? x.toString() : '')) 17 | .filter((x) => x) 18 | .reduce((className, prev) => `${className || ''} ${prev}`, '') 19 | .trim(); 20 | } 21 | -------------------------------------------------------------------------------- /src/util/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = any>( 2 | func: F, 3 | waitFor: number, 4 | ) => { 5 | let timeout; 6 | 7 | return (...args: Parameters): Promise> => 8 | new Promise((resolve) => { 9 | if (timeout) { 10 | clearTimeout(timeout); 11 | } 12 | 13 | timeout = setTimeout(() => resolve(func(...args)), waitFor); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/util/deepEqual.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true if both objects are equal 3 | * @param a left object 4 | * @param b right object 5 | * @returns 6 | */ 7 | export function deepEqual(obj1: A, obj2: B) { 8 | // If objects are not the same type, return false 9 | if (typeof obj1 !== typeof obj2) { 10 | return false; 11 | } 12 | // If objects are both null or undefined, return true 13 | if (obj1 === null && obj2 === null) { 14 | return true; 15 | } 16 | // If objects are both primitive types, compare them directly 17 | if (typeof obj1 !== 'object') { 18 | // eslint-disable-next-line 19 | // @ts-ignore 20 | return obj1 === obj2; 21 | } 22 | // If objects are arrays, compare their elements recursively 23 | if (Array.isArray(obj1) && Array.isArray(obj2)) { 24 | if (obj1.length !== obj2.length) { 25 | return false; 26 | } 27 | for (let i = 0; i < obj1.length; i++) { 28 | if (!deepEqual(obj1[i], obj2[i])) { 29 | return false; 30 | } 31 | } 32 | return true; 33 | } 34 | // If objects are VNodes, compare their props only 35 | if ( 36 | // eslint-disable-next-line no-prototype-builtins 37 | obj1.hasOwnProperty('constructor') && 38 | // eslint-disable-next-line no-prototype-builtins 39 | obj2.hasOwnProperty('constructor') && 40 | // eslint-disable-next-line no-prototype-builtins 41 | obj1.hasOwnProperty('props') && 42 | // eslint-disable-next-line no-prototype-builtins 43 | obj2.hasOwnProperty('props') && 44 | // eslint-disable-next-line no-prototype-builtins 45 | obj1.hasOwnProperty('key') && 46 | // eslint-disable-next-line no-prototype-builtins 47 | obj2.hasOwnProperty('key') && 48 | // eslint-disable-next-line no-prototype-builtins 49 | obj1.hasOwnProperty('ref') && 50 | // eslint-disable-next-line no-prototype-builtins 51 | obj2.hasOwnProperty('ref') && 52 | // eslint-disable-next-line no-prototype-builtins 53 | obj1.hasOwnProperty('type') && 54 | // eslint-disable-next-line no-prototype-builtins 55 | obj2.hasOwnProperty('type') 56 | ) { 57 | return deepEqual(obj1['props'], obj2['props']); 58 | } 59 | // If objects are both objects, compare their properties recursively 60 | const keys1 = Object.keys(obj1); 61 | const keys2 = Object.keys(obj2); 62 | if (keys1.length !== keys2.length) { 63 | return false; 64 | } 65 | for (const key of keys1) { 66 | // eslint-disable-next-line no-prototype-builtins 67 | if (!obj2.hasOwnProperty(key) || !deepEqual(obj1[key], obj2[key])) { 68 | return false; 69 | } 70 | } 71 | return true; 72 | } 73 | -------------------------------------------------------------------------------- /src/util/eventEmitter.ts: -------------------------------------------------------------------------------- 1 | type EventArgs = [T] extends [(...args: infer U) => any] 2 | ? U 3 | : [T] extends [void] 4 | ? [] 5 | : [T]; 6 | 7 | /** 8 | * Example: 9 | * 10 | * export interface BaseEvents { 11 | * SET_STATE: (component: BaseComponent, state: S) => void; 12 | * } 13 | */ 14 | 15 | export interface EventEmitter { 16 | addListener( 17 | event: EventName, 18 | listener: (...args: EventArgs) => void, 19 | ): EventEmitter; 20 | 21 | on( 22 | event: EventName, 23 | listener: (...args: EventArgs) => void, 24 | ): EventEmitter; 25 | 26 | off( 27 | event: EventName, 28 | listener: (...args: EventArgs) => void, 29 | ): EventEmitter; 30 | 31 | emit( 32 | event: EventName, 33 | ...args: EventArgs 34 | ): boolean; 35 | } 36 | 37 | export class EventEmitter { 38 | private callbacks: { [event: string]: ((...args) => void)[] }; 39 | 40 | // because we are using EventEmitter as a mixin and the 41 | // constructor won't be called by the applyMixins function 42 | // see src/base.ts and src/util/applyMixin.ts 43 | private init(event?: string): void { 44 | if (!this.callbacks) { 45 | this.callbacks = {}; 46 | } 47 | 48 | if (event && !this.callbacks[event]) { 49 | this.callbacks[event] = []; 50 | } 51 | } 52 | 53 | listeners(): { [event: string]: ((...args) => void)[] } { 54 | return this.callbacks; 55 | } 56 | 57 | on( 58 | event: EventName, 59 | listener: (...args: EventArgs) => void, 60 | ): EventEmitter { 61 | this.init(event as string); 62 | this.callbacks[event as string].push(listener); 63 | return this; 64 | } 65 | 66 | off( 67 | event: EventName, 68 | listener: (...args: EventArgs) => void, 69 | ): EventEmitter { 70 | const eventName = event as string; 71 | 72 | this.init(); 73 | 74 | if (!this.callbacks[eventName] || this.callbacks[eventName].length === 0) { 75 | // there is no callbacks with this key 76 | return this; 77 | } 78 | 79 | this.callbacks[eventName] = this.callbacks[eventName].filter( 80 | (value) => value != listener, 81 | ); 82 | 83 | return this; 84 | } 85 | 86 | emit( 87 | event: EventName, 88 | ...args: EventArgs 89 | ): boolean { 90 | const eventName = event as string; 91 | 92 | this.init(eventName); 93 | 94 | if (this.callbacks[eventName].length > 0) { 95 | this.callbacks[eventName].forEach((value) => value(...args)); 96 | return true; 97 | } 98 | 99 | return false; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/util/getConfig.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'preact'; 2 | import { Config } from '../config'; 3 | 4 | /** 5 | * This is a hack to get the current global config from Preact context. 6 | * My assumption is that we only need one global context which is the ConfigContext 7 | * 8 | * @param context 9 | */ 10 | export default function getConfig(context: { 11 | [key: string]: Context; 12 | }): Config { 13 | if (!context) return null; 14 | 15 | const keys = Object.keys(context); 16 | 17 | if (keys.length) { 18 | // TODO: can we use a better way to capture and return the Config context? 19 | const ctx: any = context[keys[0]]; 20 | return ctx.props.value; 21 | } 22 | 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /src/util/html.ts: -------------------------------------------------------------------------------- 1 | import { h, VNode } from 'preact'; 2 | import { HTMLElement } from '../view/htmlElement'; 3 | 4 | export function decode(content: string): string { 5 | const value = new DOMParser().parseFromString(content, 'text/html'); 6 | return value.documentElement.textContent; 7 | } 8 | 9 | export function html(content: string, parentElement?: string): VNode { 10 | return h(HTMLElement, { content: content, parentElement: parentElement }); 11 | } 12 | -------------------------------------------------------------------------------- /src/util/id.ts: -------------------------------------------------------------------------------- 1 | export type ID = string; 2 | 3 | export function generateUUID(): ID { 4 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 5 | const r = (Math.random() * 16) | 0, 6 | v = c == 'x' ? r : (r & 0x3) | 0x8; 7 | return v.toString(16); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/util/log.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized logging lib 3 | * 4 | * This class needs some improvements but so far it has been used to have a coherent way to log 5 | */ 6 | class Logger { 7 | private format(message: string, type: string): string { 8 | return `[Grid.js] [${type.toUpperCase()}]: ${message}`; 9 | } 10 | 11 | error(message: string, throwException = false): void { 12 | const msg = this.format(message, 'error'); 13 | 14 | if (throwException) { 15 | throw Error(msg); 16 | } else { 17 | console.error(msg); 18 | } 19 | } 20 | 21 | warn(message: string): void { 22 | console.warn(this.format(message, 'warn')); 23 | } 24 | 25 | info(message: string): void { 26 | console.info(this.format(message, 'info')); 27 | } 28 | } 29 | 30 | export default new Logger(); 31 | -------------------------------------------------------------------------------- /src/util/string.ts: -------------------------------------------------------------------------------- 1 | export function camelCase(str: string): string { 2 | if (!str) return ''; 3 | 4 | const words = str.split(' '); 5 | 6 | // do not convert strings that are already in camelCase format 7 | if (words.length === 1 && /([a-z][A-Z])+/g.test(str)) { 8 | return str; 9 | } 10 | 11 | return words 12 | .map(function (word, index) { 13 | // if it is the first word, lowercase all the chars 14 | if (index == 0) { 15 | return word.toLowerCase(); 16 | } 17 | 18 | // if it is not the first word only upper case the first char and lowercase the rest 19 | return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); 20 | }) 21 | .join(''); 22 | } 23 | -------------------------------------------------------------------------------- /src/util/table.ts: -------------------------------------------------------------------------------- 1 | import { TColumn } from '../types'; 2 | import Header from '../header'; 3 | 4 | export function calculateRowColSpans( 5 | column: TColumn, 6 | rowIndex: number, 7 | totalRows: number, 8 | ): { rowSpan: number; colSpan: number } { 9 | const depth = Header.maximumDepth(column); 10 | const remainingRows = totalRows - rowIndex; 11 | const rowSpan = Math.floor(remainingRows - depth - depth / remainingRows); 12 | const colSpan = (column.columns && column.columns.length) || 1; 13 | 14 | return { 15 | rowSpan: rowSpan, 16 | colSpan: colSpan, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/util/throttle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Throttle a given function 3 | * @param fn Function to be called 4 | * @param wait Throttle timeout in milliseconds 5 | * @returns Throttled function 6 | */ 7 | export const throttle = (fn: (...args) => void, wait = 100) => { 8 | let timeoutId: ReturnType; 9 | let lastTime = Date.now(); 10 | 11 | const execute = (...args) => { 12 | lastTime = Date.now(); 13 | fn(...args); 14 | }; 15 | 16 | return (...args) => { 17 | const currentTime = Date.now(); 18 | const elapsed = currentTime - lastTime; 19 | 20 | if (elapsed >= wait) { 21 | // If enough time has passed since the last call, execute the function immediately 22 | execute(...args); 23 | } else { 24 | // If not enough time has passed, schedule the function call after the remaining delay 25 | if (timeoutId) { 26 | clearTimeout(timeoutId); 27 | } 28 | 29 | timeoutId = setTimeout(() => { 30 | execute(...args); 31 | timeoutId = null; 32 | }, wait - elapsed); 33 | } 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/util/width.ts: -------------------------------------------------------------------------------- 1 | export function width(width: string | number, containerWidth?: number): number { 2 | if (typeof width == 'string') { 3 | if (width.indexOf('%') > -1) { 4 | return (containerWidth / 100) * parseInt(width, 10); 5 | } else { 6 | return parseInt(width, 10); 7 | } 8 | } 9 | 10 | return width; 11 | } 12 | 13 | export function px(width: number): string { 14 | if (!width) return ''; 15 | return `${Math.floor(width)}px`; 16 | } 17 | -------------------------------------------------------------------------------- /src/view/actions.ts: -------------------------------------------------------------------------------- 1 | import Header from 'src/header'; 2 | import Tabular from '../tabular'; 3 | import { Status } from '../types'; 4 | 5 | export const SetStatusToRendered = () => (state) => { 6 | if (state.status === Status.Loaded) { 7 | return { 8 | ...state, 9 | status: Status.Rendered, 10 | }; 11 | } 12 | 13 | return state; 14 | }; 15 | 16 | export const SetLoadingData = () => (state) => { 17 | return { 18 | ...state, 19 | status: Status.Loading, 20 | }; 21 | }; 22 | 23 | export const SetData = (data: Tabular) => (state) => { 24 | if (!data) return state; 25 | 26 | return { 27 | ...state, 28 | data: data, 29 | status: Status.Loaded, 30 | }; 31 | }; 32 | 33 | export const SetDataErrored = () => (state) => { 34 | return { 35 | ...state, 36 | data: null, 37 | status: Status.Error, 38 | }; 39 | }; 40 | 41 | export const SetHeader = (header: Header) => (state) => { 42 | return { 43 | ...state, 44 | header: header, 45 | }; 46 | }; 47 | 48 | export const SetTableRef = (tableRef) => (state) => { 49 | return { 50 | ...state, 51 | tableRef: tableRef, 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/view/container.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, h } from 'preact'; 2 | import { classJoin, className } from '../util/className'; 3 | import { Status } from '../types'; 4 | import { Table } from './table/table'; 5 | import { HeaderContainer } from './headerContainer'; 6 | import { FooterContainer } from './footerContainer'; 7 | import log from '../util/log'; 8 | import { useEffect } from 'preact/hooks'; 9 | import * as actions from './actions'; 10 | import { useStore } from '../hooks/useStore'; 11 | import useSelector from '../hooks/useSelector'; 12 | import { useConfig } from '../hooks/useConfig'; 13 | import { throttle } from '../util/throttle'; 14 | 15 | export function Container() { 16 | const config = useConfig(); 17 | const { dispatch } = useStore(); 18 | const status = useSelector((state) => state.status); 19 | const data = useSelector((state) => state.data); 20 | const tableRef = useSelector((state) => state.tableRef); 21 | const tempRef = createRef(); 22 | 23 | const processPipeline = throttle(async () => { 24 | dispatch(actions.SetLoadingData()); 25 | 26 | try { 27 | const data = await config.pipeline.process(); 28 | dispatch(actions.SetData(data)); 29 | 30 | // TODO: do we need this setTimemout? 31 | setTimeout(() => { 32 | dispatch(actions.SetStatusToRendered()); 33 | }, 0); 34 | } catch (e) { 35 | log.error(e); 36 | dispatch(actions.SetDataErrored()); 37 | } 38 | }, config.processingThrottleMs); 39 | 40 | useEffect(() => { 41 | // set the initial header object 42 | // we update the header width later when "data" 43 | // is available in the state 44 | dispatch(actions.SetHeader(config.header)); 45 | 46 | processPipeline(); 47 | config.pipeline.on('updated', processPipeline); 48 | 49 | return () => config.pipeline.off('updated', processPipeline); 50 | }, []); 51 | 52 | useEffect(() => { 53 | if (config.header && status === Status.Loaded && data?.length) { 54 | // now that we have the data, let's adjust columns width 55 | // NOTE: that we only calculate the columns width once 56 | dispatch( 57 | actions.SetHeader(config.header.adjustWidth(config, tableRef, tempRef)), 58 | ); 59 | } 60 | }, [data, config, tempRef]); 61 | 62 | return ( 63 |

78 | {status === Status.Loading && ( 79 |
80 | )} 81 | 82 | 83 | 84 |
85 | 86 | 87 | 88 | 89 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/view/events.ts: -------------------------------------------------------------------------------- 1 | import Tabular from '../tabular'; 2 | 3 | export interface ContainerEvents { 4 | beforeLoad: () => void; 5 | load: (data: Tabular) => void; 6 | ready: () => void; 7 | } 8 | -------------------------------------------------------------------------------- /src/view/footerContainer.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { classJoin, className } from '../util/className'; 3 | import { PluginPosition, PluginRenderer } from '../plugin'; 4 | import { useEffect, useRef, useState } from 'preact/hooks'; 5 | import { useConfig } from '../hooks/useConfig'; 6 | 7 | export function FooterContainer() { 8 | const footerRef = useRef(null); 9 | const [isActive, setIsActive] = useState(true); 10 | const config = useConfig(); 11 | 12 | useEffect(() => { 13 | if (footerRef.current.children.length === 0) { 14 | setIsActive(false); 15 | } 16 | }, [footerRef]); 17 | 18 | if (isActive) { 19 | return ( 20 |
25 | 26 |
27 | ); 28 | } 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /src/view/headerContainer.tsx: -------------------------------------------------------------------------------- 1 | import { classJoin, className } from '../util/className'; 2 | import { h } from 'preact'; 3 | import { PluginPosition, PluginRenderer } from '../plugin'; 4 | import { useEffect, useRef, useState } from 'preact/hooks'; 5 | import { useConfig } from '../hooks/useConfig'; 6 | 7 | export function HeaderContainer() { 8 | const [isActive, setIsActive] = useState(true); 9 | const headerRef = useRef(null); 10 | const config = useConfig(); 11 | 12 | useEffect(() => { 13 | if (headerRef.current.children.length === 0) { 14 | setIsActive(false); 15 | } 16 | }, [headerRef]); 17 | 18 | if (isActive) { 19 | return ( 20 |
25 | 26 |
27 | ); 28 | } 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /src/view/htmlElement.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | export interface HTMLContentProps { 4 | content: string; 5 | parentElement?: string; 6 | } 7 | 8 | export function HTMLElement(props: HTMLContentProps) { 9 | return h(props.parentElement || 'span', { 10 | dangerouslySetInnerHTML: { __html: props.content }, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/view/plugin/resize/resize.tsx: -------------------------------------------------------------------------------- 1 | import { h, RefObject } from 'preact'; 2 | import { classJoin, className } from '../../../util/className'; 3 | import { TColumn } from '../../../types'; 4 | import { throttle } from '../../../util/throttle'; 5 | 6 | export function Resize(props: { 7 | column: TColumn; 8 | thRef: RefObject; 9 | }) { 10 | let moveFn: (e) => void; 11 | 12 | const getPageX = (e: MouseEvent | TouchEvent) => { 13 | if (e instanceof MouseEvent) { 14 | return Math.floor(e.pageX); 15 | } else { 16 | return Math.floor(e.changedTouches[0].pageX); 17 | } 18 | }; 19 | 20 | const start = (e: MouseEvent | TouchEvent) => { 21 | e.stopPropagation(); 22 | 23 | const thElement = props.thRef.current; 24 | 25 | const offsetStart = parseInt(thElement.style.width, 10) - getPageX(e); 26 | 27 | moveFn = throttle((e) => move(e, offsetStart), 10); 28 | 29 | document.addEventListener('mouseup', end); 30 | document.addEventListener('touchend', end); 31 | document.addEventListener('mousemove', moveFn); 32 | document.addEventListener('touchmove', moveFn); 33 | }; 34 | 35 | const move = (e: MouseEvent | TouchEvent, offsetStart: number) => { 36 | e.stopPropagation(); 37 | 38 | const thElement = props.thRef.current; 39 | 40 | if (offsetStart + getPageX(e) >= parseInt(thElement.style.minWidth, 10)) { 41 | thElement.style.width = `${offsetStart + getPageX(e)}px`; 42 | } 43 | }; 44 | 45 | const end = (e: MouseEvent | TouchEvent) => { 46 | e.stopPropagation(); 47 | 48 | document.removeEventListener('mouseup', end); 49 | document.removeEventListener('mousemove', moveFn); 50 | document.removeEventListener('touchmove', moveFn); 51 | document.removeEventListener('touchend', end); 52 | }; 53 | 54 | return ( 55 |
e.stopPropagation()} 60 | /> 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/view/plugin/search/actions.ts: -------------------------------------------------------------------------------- 1 | export const SearchKeyword = (payload) => (state) => { 2 | return { 3 | ...state, 4 | search: { 5 | keyword: payload, 6 | }, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/view/plugin/search/search.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import GlobalSearchFilter from '../../../pipeline/filter/globalSearch'; 3 | import { classJoin, className } from '../../../util/className'; 4 | import ServerGlobalSearchFilter from '../../../pipeline/filter/serverGlobalSearch'; 5 | import { TCell } from '../../../types'; 6 | import { useConfig } from '../../../hooks/useConfig'; 7 | import { useCallback, useEffect, useState } from 'preact/hooks'; 8 | import { useTranslator } from '../../../i18n/language'; 9 | import * as actions from './actions'; 10 | import { useStore } from '../../../hooks/useStore'; 11 | import useSelector from '../../../hooks/useSelector'; 12 | import { debounce } from '../../../util/debounce'; 13 | 14 | export interface SearchConfig { 15 | keyword?: string; 16 | ignoreHiddenColumns?: boolean; 17 | debounceTimeout?: number; 18 | selector?: (cell: TCell, rowIndex: number, cellIndex: number) => string; 19 | server?: { 20 | url?: (prevUrl: string, keyword: string) => string; 21 | body?: (prevBody: BodyInit, keyword: string) => BodyInit; 22 | }; 23 | } 24 | 25 | export function Search() { 26 | const [processor, setProcessor] = useState< 27 | GlobalSearchFilter | ServerGlobalSearchFilter 28 | >(undefined); 29 | const config = useConfig(); 30 | const props = config.search as SearchConfig; 31 | const _ = useTranslator(); 32 | const { dispatch } = useStore(); 33 | const state = useSelector((state) => state.search); 34 | 35 | useEffect(() => { 36 | if (!processor) return; 37 | 38 | processor.setProps({ 39 | keyword: state?.keyword, 40 | }); 41 | }, [state, processor]); 42 | 43 | useEffect(() => { 44 | if (props.server) { 45 | setProcessor( 46 | new ServerGlobalSearchFilter({ 47 | keyword: props.keyword, 48 | url: props.server.url, 49 | body: props.server.body, 50 | }), 51 | ); 52 | } else { 53 | setProcessor( 54 | new GlobalSearchFilter({ 55 | keyword: props.keyword, 56 | columns: config.header && config.header.columns, 57 | ignoreHiddenColumns: 58 | props.ignoreHiddenColumns || 59 | props.ignoreHiddenColumns === undefined, 60 | selector: props.selector, 61 | }), 62 | ); 63 | } 64 | 65 | // initial search 66 | if (props.keyword) dispatch(actions.SearchKeyword(props.keyword)); 67 | }, [props]); 68 | 69 | useEffect(() => { 70 | if (!processor) return undefined; 71 | 72 | config.pipeline.register(processor); 73 | 74 | return () => config.pipeline.unregister(processor); 75 | }, [config, processor]); 76 | 77 | const debouncedOnInput = useCallback( 78 | debounce( 79 | (event: JSX.TargetedEvent) => { 80 | if (event.target instanceof HTMLInputElement) { 81 | dispatch(actions.SearchKeyword(event.target.value)); 82 | } 83 | }, 84 | processor instanceof ServerGlobalSearchFilter 85 | ? props.debounceTimeout || 250 86 | : 0, 87 | ), 88 | [props, processor], 89 | ); 90 | 91 | return ( 92 |
93 | 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/view/plugin/sort/actions.ts: -------------------------------------------------------------------------------- 1 | import { Comparator, TCell } from '../../../types'; 2 | 3 | export const SortColumn = 4 | ( 5 | index: number, 6 | direction: 1 | -1, 7 | multi?: boolean, 8 | compare?: Comparator, 9 | ) => 10 | (state) => { 11 | let columns = state.sort?.columns 12 | ? state.sort.columns.map((x) => { 13 | return { ...x }; 14 | }) 15 | : []; 16 | const count = columns.length; 17 | const column = columns.find((x) => x.index === index); 18 | const exists = column !== undefined; 19 | 20 | let add = false; 21 | let reset = false; 22 | let remove = false; 23 | let update = false; 24 | 25 | if (!exists) { 26 | // the column has not been sorted 27 | if (count === 0) { 28 | // the first column to be sorted 29 | add = true; 30 | } else if (count > 0 && !multi) { 31 | // remove the previously sorted column 32 | // and sort the current column 33 | add = true; 34 | reset = true; 35 | } else if (count > 0 && multi) { 36 | // multi-sorting 37 | // sort this column as well 38 | add = true; 39 | } 40 | } else { 41 | // the column has been sorted before 42 | if (!multi) { 43 | // single column sorting 44 | if (count === 1) { 45 | update = true; 46 | } else if (count > 1) { 47 | // this situation happens when we have already entered 48 | // multi-sorting mode but then user tries to sort a single column 49 | reset = true; 50 | add = true; 51 | } 52 | } else { 53 | // multi sorting 54 | if (column.direction === -1) { 55 | // remove the current column from the 56 | // sorted columns array 57 | remove = true; 58 | } else { 59 | update = true; 60 | } 61 | } 62 | } 63 | 64 | if (reset) { 65 | // resetting the sorted columns 66 | columns = []; 67 | } 68 | 69 | if (add) { 70 | columns.push({ 71 | index: index, 72 | direction: direction, 73 | compare: compare, 74 | }); 75 | } else if (update) { 76 | const index = columns.indexOf(column); 77 | columns[index].direction = direction; 78 | } else if (remove) { 79 | const index = columns.indexOf(column); 80 | columns.splice(index, 1); 81 | } 82 | 83 | return { 84 | ...state, 85 | sort: { 86 | columns: columns, 87 | }, 88 | }; 89 | }; 90 | 91 | export const SortToggle = 92 | (index: number, multi: boolean, compare?: Comparator) => (state) => { 93 | const columns = state.sort ? [...state.sort.columns] : []; 94 | const column = columns.find((x) => x.index === index); 95 | 96 | if (!column) { 97 | return { 98 | ...state, 99 | ...SortColumn(index, 1, multi, compare)(state), 100 | }; 101 | } 102 | 103 | return { 104 | ...state, 105 | ...SortColumn( 106 | index, 107 | column.direction === 1 ? -1 : 1, 108 | multi, 109 | compare, 110 | )(state), 111 | }; 112 | }; 113 | -------------------------------------------------------------------------------- /src/view/table/events.ts: -------------------------------------------------------------------------------- 1 | import { TCell, TColumn } from '../../types'; 2 | import { JSX } from 'preact'; 3 | import Row from '../../row'; 4 | 5 | export interface TableEvents { 6 | cellClick: ( 7 | e: JSX.TargetedMouseEvent, 8 | cell: TCell, 9 | column: TColumn, 10 | row: Row, 11 | ) => void; 12 | rowClick: (e: JSX.TargetedMouseEvent, row: Row) => void; 13 | } 14 | -------------------------------------------------------------------------------- /src/view/table/messageRow.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import Cell from '../../cell'; 3 | import { classJoin, className } from '../../util/className'; 4 | import { TR } from './tr'; 5 | import { TD } from './td'; 6 | 7 | export function MessageRow(props: { 8 | message: string; 9 | colSpan?: number; 10 | className?: string; 11 | }) { 12 | return ( 13 |
14 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/view/table/shadow.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { className } from '../../util/className'; 3 | 4 | /** 5 | * ShadowTable renders a hidden table and is used to calculate the column's width 6 | * when autoWidth option is enabled 7 | */ 8 | export function ShadowTable(props: { tableRef: HTMLTableElement }) { 9 | const shadowTable = props.tableRef.cloneNode(true) as HTMLTableElement; 10 | 11 | shadowTable.style.position = 'absolute'; 12 | shadowTable.style.width = '100%'; 13 | shadowTable.style.zIndex = '-2147483640'; 14 | shadowTable.style.visibility = 'hidden'; 15 | 16 | return ( 17 |
{ 19 | nodeElement && nodeElement.appendChild(shadowTable); 20 | }} 21 | /> 22 | ); 23 | } 24 | 25 | export function getShadowTableWidths(tempRef: HTMLDivElement): { 26 | [columnId: string]: { minWidth: number; width: number }; 27 | } { 28 | const tableElement: HTMLTableElement = tempRef.querySelector( 29 | 'table', 30 | ) as HTMLTableElement; 31 | 32 | if (!tableElement) { 33 | return {}; 34 | } 35 | 36 | const tableClassName = tableElement.className; 37 | const tableStyle = tableElement.style.cssText; 38 | tableElement.className = `${tableClassName} ${className('shadowTable')}`; 39 | 40 | tableElement.style.tableLayout = 'auto'; 41 | tableElement.style.width = 'auto'; 42 | tableElement.style.padding = '0'; 43 | tableElement.style.margin = '0'; 44 | tableElement.style.border = 'none'; 45 | tableElement.style.outline = 'none'; 46 | 47 | let obj = Array.from( 48 | tableElement.parentNode.querySelectorAll('thead th'), 49 | ).reduce((prev, current) => { 50 | current.style.width = `${current.clientWidth}px`; 51 | 52 | return { 53 | [current.getAttribute('data-column-id')]: { 54 | minWidth: current.clientWidth, 55 | }, 56 | ...prev, 57 | }; 58 | }, {}); 59 | 60 | tableElement.className = tableClassName; 61 | tableElement.style.cssText = tableStyle; 62 | tableElement.style.tableLayout = 'auto'; 63 | 64 | obj = Array.from( 65 | tableElement.parentNode.querySelectorAll('thead th'), 66 | ).reduce((prev, current) => { 67 | prev[current.getAttribute('data-column-id')]['width'] = current.clientWidth; 68 | 69 | return prev; 70 | }, obj); 71 | 72 | return obj; 73 | } 74 | -------------------------------------------------------------------------------- /src/view/table/table.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { TBody } from './tbody'; 3 | import { THead } from './thead'; 4 | import { classJoin, className } from '../../util/className'; 5 | import { useConfig } from '../../hooks/useConfig'; 6 | import { useEffect, useRef } from 'preact/hooks'; 7 | import * as actions from '../actions'; 8 | import { useStore } from '../../hooks/useStore'; 9 | 10 | export function Table() { 11 | const config = useConfig(); 12 | const tableRef = useRef(null); 13 | const { dispatch } = useStore(); 14 | 15 | useEffect(() => { 16 | if (tableRef) dispatch(actions.SetTableRef(tableRef)); 17 | }, [tableRef]); 18 | 19 | return ( 20 |
24 |
31 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/view/table/tbody.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import Row from '../../row'; 3 | import { TR } from './tr'; 4 | import { classJoin, className } from '../../util/className'; 5 | import { Status } from '../../types'; 6 | import { MessageRow } from './messageRow'; 7 | import { useConfig } from '../../hooks/useConfig'; 8 | import { useTranslator } from '../../i18n/language'; 9 | import useSelector from '../../hooks/useSelector'; 10 | 11 | export function TBody() { 12 | const config = useConfig(); 13 | const data = useSelector((state) => state.data); 14 | const status = useSelector((state) => state.status); 15 | const header = useSelector((state) => state.header); 16 | const _ = useTranslator(); 17 | 18 | const headerLength = () => { 19 | if (header) { 20 | return header.visibleColumns.length; 21 | } 22 | return 0; 23 | }; 24 | 25 | return ( 26 | 27 | {data && 28 | data.rows.map((row: Row) => { 29 | return ; 30 | })} 31 | 32 | {status === Status.Loading && (!data || data.length === 0) && ( 33 | 38 | )} 39 | 40 | {status === Status.Rendered && data && data.length === 0 && ( 41 | 49 | )} 50 | 51 | {status === Status.Error && ( 52 | 57 | )} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/view/table/td.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChild, JSX } from 'preact'; 2 | 3 | import Cell from '../../cell'; 4 | import { classJoin, className } from '../../util/className'; 5 | import { CSSDeclaration, TColumn } from '../../types'; 6 | import Row from '../../row'; 7 | import { JSXInternal } from 'preact/src/jsx'; 8 | import { PluginRenderer } from '../../plugin'; 9 | import { useConfig } from '../../hooks/useConfig'; 10 | 11 | export function TD( 12 | props: { 13 | cell: Cell; 14 | row?: Row; 15 | column?: TColumn; 16 | style?: CSSDeclaration; 17 | messageCell?: boolean; 18 | } & Omit, 'style'>, 19 | ) { 20 | const config = useConfig(); 21 | 22 | const content = (): ComponentChild => { 23 | if (props.column && typeof props.column.formatter === 'function') { 24 | return props.column.formatter(props.cell.data, props.row, props.column); 25 | } 26 | 27 | if (props.column && props.column.plugin) { 28 | return ( 29 | 37 | ); 38 | } 39 | 40 | return props.cell.data; 41 | }; 42 | 43 | const handleClick = ( 44 | e: JSX.TargetedMouseEvent, 45 | ): void => { 46 | if (props.messageCell) return; 47 | 48 | config.eventEmitter.emit( 49 | 'cellClick', 50 | e, 51 | props.cell, 52 | props.column, 53 | props.row, 54 | ); 55 | }; 56 | 57 | const getCustomAttributes = ( 58 | column: TColumn | null, 59 | ): JSXInternal.HTMLAttributes => { 60 | if (!column) return {}; 61 | 62 | if (typeof column.attributes === 'function') { 63 | return column.attributes(props.cell.data, props.row, props.column); 64 | } else { 65 | return column.attributes; 66 | } 67 | }; 68 | 69 | return ( 70 | 86 | {content()} 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/view/table/th.tsx: -------------------------------------------------------------------------------- 1 | import { h, ComponentChild, JSX } from 'preact'; 2 | 3 | import { classJoin, className } from '../../util/className'; 4 | import { CSSDeclaration, TColumn } from '../../types'; 5 | import { GenericSortConfig, Sort } from '../plugin/sort/sort'; 6 | import { PluginRenderer } from '../../plugin'; 7 | import { Resize } from '../plugin/resize/resize'; 8 | import { useEffect, useRef, useState } from 'preact/hooks'; 9 | import { useConfig } from '../../hooks/useConfig'; 10 | import * as SortActions from '../plugin/sort/actions'; 11 | import { useStore } from '../../hooks/useStore'; 12 | 13 | export function TH( 14 | props: { 15 | index: number; 16 | column: TColumn; 17 | style?: CSSDeclaration; 18 | } & Omit, 'style'>, 19 | ) { 20 | const config = useConfig(); 21 | const thRef = useRef(null); 22 | const [style, setStyle] = useState({}); 23 | const { dispatch } = useStore(); 24 | 25 | useEffect(() => { 26 | // sets the `top` style if the current TH is fixed 27 | if (config.fixedHeader && thRef.current) { 28 | const offsetTop = thRef.current.offsetTop; 29 | 30 | if (typeof offsetTop === 'number') { 31 | setStyle({ 32 | top: offsetTop, 33 | }); 34 | } 35 | } 36 | }, [thRef]); 37 | 38 | const isSortable = (): boolean => props.column.sort != undefined; 39 | const isResizable = (): boolean => props.column.resizable; 40 | const onClick = ( 41 | e: 42 | | JSX.TargetedMouseEvent 43 | | JSX.TargetedKeyboardEvent, 44 | ) => { 45 | e.stopPropagation(); 46 | 47 | if (isSortable()) { 48 | const sortConfig = config.sort as GenericSortConfig; 49 | 50 | dispatch( 51 | SortActions.SortToggle( 52 | props.index, 53 | e.shiftKey === true && sortConfig.multiColumn, 54 | props.column.sort.compare, 55 | ), 56 | ); 57 | } 58 | }; 59 | 60 | const keyDown = (e: JSX.TargetedKeyboardEvent) => { 61 | // Enter key 62 | if (isSortable() && e.which === 13) { 63 | onClick(e); 64 | } 65 | }; 66 | 67 | const content = (): ComponentChild => { 68 | if (props.column.name !== undefined) { 69 | return props.column.name; 70 | } 71 | 72 | if (props.column.plugin !== undefined) { 73 | return ( 74 | 80 | ); 81 | } 82 | 83 | return null; 84 | }; 85 | 86 | const getCustomAttributes = () => { 87 | const column = props.column; 88 | 89 | if (!column) return {}; 90 | 91 | if (typeof column.attributes === 'function') { 92 | return column.attributes(null, null, props.column); 93 | } else { 94 | return column.attributes; 95 | } 96 | }; 97 | 98 | return ( 99 | 1 ? props.rowSpan : undefined} 120 | colSpan={props.colSpan > 1 ? props.colSpan : undefined} 121 | {...getCustomAttributes()} 122 | {...(isSortable() ? { tabIndex: 0 } : {})} 123 | > 124 |
{content()}
125 | {isSortable() && } 126 | {isResizable() && 127 | props.index < config.header.visibleColumns.length - 1 && ( 128 | 129 | )} 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /src/view/table/thead.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { TR } from './tr'; 3 | import { TH } from './th'; 4 | import { classJoin, className } from '../../util/className'; 5 | import Header from '../../header'; 6 | import { TColumn } from '../../types'; 7 | import { calculateRowColSpans } from '../../util/table'; 8 | import { useConfig } from '../../hooks/useConfig'; 9 | import useSelector from '../../hooks/useSelector'; 10 | 11 | export function THead() { 12 | const config = useConfig(); 13 | const header = useSelector((state) => state.header); 14 | 15 | const renderColumn = ( 16 | column: TColumn, 17 | rowIndex: number, 18 | columnIndex: number, 19 | totalRows: number, 20 | ) => { 21 | const { rowSpan, colSpan } = calculateRowColSpans( 22 | column, 23 | rowIndex, 24 | totalRows, 25 | ); 26 | 27 | return ( 28 | 34 | ); 35 | }; 36 | 37 | const renderRow = (row: TColumn[], rowIndex: number, totalRows: number) => { 38 | // because the only sortable columns are leaf columns (not parents) 39 | const leafColumns = Header.leafColumns(header.columns); 40 | 41 | return ( 42 | 43 | {row.map((col) => { 44 | if (col.hidden) return null; 45 | 46 | return renderColumn( 47 | col, 48 | rowIndex, 49 | leafColumns.indexOf(col), 50 | totalRows, 51 | ); 52 | })} 53 | 54 | ); 55 | }; 56 | 57 | const renderRows = () => { 58 | const rows = Header.tabularFormat(header.columns); 59 | 60 | return rows.map((row, rowIndex) => renderRow(row, rowIndex, rows.length)); 61 | }; 62 | 63 | if (header) { 64 | return ( 65 | 69 | {renderRows()} 70 | 71 | ); 72 | } 73 | 74 | return null; 75 | } 76 | -------------------------------------------------------------------------------- /src/view/table/tr.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX, ComponentChildren } from 'preact'; 2 | 3 | import Row from '../../row'; 4 | import Cell from '../../cell'; 5 | import { classJoin, className } from '../../util/className'; 6 | import { TColumn } from '../../types'; 7 | import { TD } from './td'; 8 | import Header from '../../header'; 9 | import { useConfig } from '../../hooks/useConfig'; 10 | import useSelector from '../../hooks/useSelector'; 11 | 12 | export function TR(props: { 13 | row?: Row; 14 | messageRow?: boolean; 15 | children?: ComponentChildren; 16 | }) { 17 | const config = useConfig(); 18 | const header = useSelector((state) => state.header); 19 | 20 | const getColumn = (cellIndex: number): TColumn => { 21 | if (header) { 22 | const cols = Header.leafColumns(header.columns); 23 | 24 | if (cols) { 25 | return cols[cellIndex]; 26 | } 27 | } 28 | 29 | return null; 30 | }; 31 | 32 | const handleClick = ( 33 | e: JSX.TargetedMouseEvent, 34 | ): void => { 35 | if (props.messageRow) return; 36 | config.eventEmitter.emit('rowClick', e, props.row); 37 | }; 38 | 39 | const getChildren = (): ComponentChildren => { 40 | if (props.children) { 41 | return props.children; 42 | } 43 | 44 | return props.row.cells.map((cell: Cell, i) => { 45 | const column = getColumn(i); 46 | 47 | if (column && column.hidden) return null; 48 | 49 | return ; 50 | }); 51 | }; 52 | 53 | return ( 54 | 58 | {getChildren()} 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /tests/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "screenshotsFolder": "./cypress/snapshots/actual", 3 | "trashAssetsBeforeRuns": true, 4 | "env": { 5 | "failSilently": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/cypress/integration/table.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Table', () => { 4 | beforeEach(() => { 5 | cy.visit('http://localhost:8080'); 6 | }); 7 | 8 | it('should render a table', () => { 9 | cy.get('table').should('have.length', 1); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const getCompareSnapshotsPlugin = require('cypress-visual-regression/dist/plugin'); 3 | 4 | module.exports = (on, config) => { 5 | getCompareSnapshotsPlugin(on, config); 6 | }; 7 | -------------------------------------------------------------------------------- /tests/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | import compareSnapshotCommand = require('cypress-visual-regression/dist/command'); 2 | 3 | compareSnapshotCommand({ 4 | capture: 'fullPage', 5 | }); 6 | -------------------------------------------------------------------------------- /tests/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | -------------------------------------------------------------------------------- /tests/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["es5", "dom"], 6 | "types": ["cypress"] 7 | }, 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/dev-server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | /*.log -------------------------------------------------------------------------------- /tests/dev-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "gridjs-dev-server", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "preact build", 8 | "serve": "sirv build --port 8080 --cors --single --quiet", 9 | "dev": "preact watch", 10 | "start": "npm run build && npm run serve" 11 | }, 12 | "devDependencies": { 13 | "preact-cli": "^3.0.0", 14 | "sirv-cli": "^1.0.3" 15 | }, 16 | "dependencies": { 17 | "gridjs": "../..", 18 | "preact": "^10.1.0", 19 | "preact-render-to-string": "^5.1.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/dev-server/src/index.js: -------------------------------------------------------------------------------- 1 | import './style'; 2 | import { Grid } from 'gridjs'; 3 | import 'gridjs/dist/theme/mermaid.css'; 4 | import { useEffect, useRef } from 'preact/hooks'; 5 | 6 | export default function App() { 7 | const ref = useRef(); 8 | 9 | useEffect(() => { 10 | new Grid({ 11 | columns: ['a', 'b', 'c'], 12 | data: [ 13 | [1, 2, 3], 14 | [4, 5, 6], 15 | ], 16 | }).render(ref.current); 17 | }); 18 | 19 | return ( 20 |
21 |

Hello, World!

22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tests/dev-server/src/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font: 14px/1.21 'Helvetica Neue', arial, sans-serif; 4 | font-weight: 400; 5 | } 6 | 7 | h1 { 8 | text-align: center; 9 | } 10 | 11 | #wrapper { 12 | width: 900px; 13 | margin: 0 auto; 14 | } 15 | -------------------------------------------------------------------------------- /tests/dev-server/src/sw.js: -------------------------------------------------------------------------------- 1 | import { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/'; 2 | 3 | setupRouting(); 4 | setupPrecaching(getFiles()); 5 | -------------------------------------------------------------------------------- /tests/dev-server/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% preact.title %> 6 | 7 | 8 | 9 | <% preact.headEnd %> 10 | 11 | 12 | <% preact.bodyEnd %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/jest/__snapshots__/plugin.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Plugin should render the plugins 1`] = `"dummyplugindummyplugin"`; 4 | -------------------------------------------------------------------------------- /tests/jest/base.test.ts: -------------------------------------------------------------------------------- 1 | import Base from '../../src/base'; 2 | 3 | describe('Base class', () => { 4 | it('should generate unique IDs', () => { 5 | const b1 = new Base(); 6 | const b2 = new Base(); 7 | expect(b1.id).not.toBe(b2.id); 8 | }); 9 | 10 | it('should accept ID', () => { 11 | const b1 = new Base('1'); 12 | const b2 = new Base('1'); 13 | expect(b1.id).toBe(b2.id); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/jest/cell.test.ts: -------------------------------------------------------------------------------- 1 | import Cell from '../../src/cell'; 2 | 3 | describe('Cell class', () => { 4 | it('should init with value', () => { 5 | const cell = new Cell('boo'); 6 | expect(cell.data).toBe('boo'); 7 | }); 8 | 9 | it('set should update the data', () => { 10 | const cell = new Cell('boo'); 11 | cell.data = 'foo'; 12 | expect(cell.data).toBe('foo'); 13 | }); 14 | 15 | it('should accept int', () => { 16 | const cell = new Cell(1); 17 | expect(cell.data).toBe(1); 18 | }); 19 | 20 | it('should accept boolean', () => { 21 | const cell = new Cell(true); 22 | expect(cell.data).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/jest/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | -------------------------------------------------------------------------------- /tests/jest/grid.test.ts: -------------------------------------------------------------------------------- 1 | import Grid from '../../src/grid'; 2 | import * as Actions from '../../src/view/actions'; 3 | import MemoryStorage from '../../src/storage/memory'; 4 | import { mount } from 'enzyme'; 5 | import { flushPromises } from './testUtil'; 6 | 7 | describe('Grid class', () => { 8 | afterEach(() => { 9 | jest.restoreAllMocks(); 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | it('should trigger the events in the correct order', async () => { 14 | const grid = new Grid({ 15 | processingThrottleMs: 0, 16 | columns: ['a', 'b', 'c'], 17 | data: [[1, 2, 3]], 18 | }); 19 | 20 | const setLoadingDataMock = jest.spyOn(Actions, 'SetLoadingData'); 21 | const setDataMock = jest.spyOn(Actions, 'SetData'); 22 | const setStatusToRenderedMock = jest.spyOn(Actions, 'SetStatusToRendered'); 23 | 24 | mount(grid.createElement()); 25 | 26 | await flushPromises(); 27 | await flushPromises(); 28 | 29 | expect(setLoadingDataMock).toHaveBeenCalledBefore(setDataMock); 30 | expect(setDataMock).toHaveBeenCalledBefore(setStatusToRenderedMock); 31 | 32 | expect(setLoadingDataMock).toBeCalledTimes(1); 33 | expect(setDataMock).toBeCalledTimes(1); 34 | expect(setStatusToRenderedMock).toBeCalledTimes(1); 35 | }); 36 | 37 | it('should raise an exception with empty config', () => { 38 | expect(() => { 39 | new Grid({}).render(document.createElement('div')); 40 | }).toThrow('Could not determine the storage type'); 41 | }); 42 | 43 | it('should init a memory storage', () => { 44 | const grid = new Grid({ 45 | data: [[1, 2, 3]], 46 | style: { 47 | table: { 48 | border: '1px', 49 | }, 50 | }, 51 | }).render(document.createElement('div')); 52 | 53 | expect(grid.config.storage).toBeInstanceOf(MemoryStorage); 54 | }); 55 | 56 | it('should set the config correctly', () => { 57 | const config = { 58 | data: [[1, 2, 3]], 59 | }; 60 | 61 | const grid = new Grid(config).render(document.createElement('div')); 62 | 63 | expect(grid.config.data).toStrictEqual(config.data); 64 | }); 65 | 66 | it('should update the config correctly', () => { 67 | const config1 = { 68 | data: [[1, 2, 3]], 69 | }; 70 | 71 | const config2 = { 72 | width: '500px', 73 | }; 74 | 75 | const grid = new Grid(config1); 76 | 77 | grid.updateConfig(config2).render(document.createElement('div')); 78 | 79 | expect(grid.config.data).toStrictEqual(config1.data); 80 | expect(grid.config.width).toStrictEqual(config2.width); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/jest/operator/search.test.ts: -------------------------------------------------------------------------------- 1 | import Cell from '../../../src/cell'; 2 | import Tabular from '../../../src/tabular'; 3 | import Row from '../../../src/row'; 4 | import search from '../../../src/operator/search'; 5 | 6 | describe('search', () => { 7 | const column1 = { id: 'col1', name: 'col1' }; 8 | const column2 = { id: 'col2', name: 'col2' }; 9 | const column3 = { id: 'col3', name: 'col3' }; 10 | const column4 = { id: 'col4', name: 'col4', hidden: true }; 11 | const columns = [column1, column2, column3, column4]; 12 | const row1 = new Row([ 13 | new Cell('hello'), 14 | new Cell('world'), 15 | new Cell('!'), 16 | new Cell('hidden content'), 17 | ]); 18 | const row2 = new Row([ 19 | new Cell('foo'), 20 | new Cell('boo'), 21 | new Cell('bar'), 22 | new Cell('hidden content'), 23 | ]); 24 | const row3 = new Row([ 25 | new Cell('hello'), 26 | new Cell('test'), 27 | new Cell('!!!'), 28 | new Cell('hidden content'), 29 | ]); 30 | const row4 = new Row([ 31 | new Cell(null), 32 | new Cell('xkcd'), 33 | new Cell('???'), 34 | new Cell('hidden content'), 35 | ]); 36 | const row5 = new Row([ 37 | new Cell('foo'), 38 | new Cell('ping pong ping'), 39 | new Cell('bar'), 40 | ]); 41 | const tabular: Tabular = new Tabular([row1, row2, row3, row4, row5]); 42 | 43 | it('should work with exact match', () => { 44 | expect(search('hello', columns, true, tabular).rows).toStrictEqual( 45 | new Tabular([row1, row3]).rows, 46 | ); 47 | }); 48 | 49 | it('should return results with partial keyword', () => { 50 | expect(search('h', columns, true, tabular).rows).toStrictEqual( 51 | new Tabular([row1, row3]).rows, 52 | ); 53 | }); 54 | 55 | it('should return results with exact match', () => { 56 | expect(search('!!!', columns, true, tabular).rows).toStrictEqual( 57 | new Tabular([row3]).rows, 58 | ); 59 | }); 60 | 61 | it('should return results for a keyword with a space in', () => { 62 | expect(search('ping pong', columns, true, tabular).rows).toStrictEqual( 63 | new Tabular([row5]).rows, 64 | ); 65 | }); 66 | 67 | it('should return results for words with the letter s in', () => { 68 | expect(search('test', columns, true, tabular).rows).toStrictEqual( 69 | new Tabular([row3]).rows, 70 | ); 71 | }); 72 | 73 | it('should use the selector with hardcoded string', () => { 74 | expect( 75 | search('test', columns, true, tabular, () => 'custom keyword').rows, 76 | ).toStrictEqual(new Tabular([]).rows); 77 | }); 78 | 79 | it('should use the selector with dynamic string', () => { 80 | expect( 81 | search( 82 | '00', 83 | columns, 84 | true, 85 | tabular, 86 | (_, rowIndex, cellIndex) => `${rowIndex}${cellIndex}`, 87 | ).rows, 88 | ).toStrictEqual(new Tabular([row1]).rows); 89 | }); 90 | 91 | it('should ignore content of hidden columns', () => { 92 | expect(search('hidden', columns, true, tabular).rows).toStrictEqual( 93 | new Tabular([]).rows, 94 | ); 95 | }); 96 | 97 | it('should not ignore content of hidden columns if ignoreHiddenColumns option is set to false', () => { 98 | expect(search('hidden', columns, false, tabular).rows).toStrictEqual( 99 | new Tabular([row1, row2, row3, row4]).rows, 100 | ); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /tests/jest/pipeline/extractor/storage.test.ts: -------------------------------------------------------------------------------- 1 | import StorageExtractor from '../../../../src/pipeline/extractor/storage'; 2 | import MemoryStorage from '../../../../src/storage/memory'; 3 | 4 | describe('StorageExtractor', () => { 5 | it('should pull data from a Storage', async () => { 6 | const data = { 7 | data: [ 8 | [1, 2, 3], 9 | ['a', 'b', 'c'], 10 | ], 11 | total: 2, 12 | }; 13 | 14 | const storage = new MemoryStorage(data.data); 15 | const processor = new StorageExtractor({ storage: storage }); 16 | expect(await processor.process()).toStrictEqual(data); 17 | }); 18 | 19 | it('should have unique ID', async () => { 20 | const data = [[1, 2, 3]]; 21 | const storage = new MemoryStorage(data); 22 | const processor1 = new StorageExtractor({ storage: storage }); 23 | const processor2 = new StorageExtractor({ storage: storage }); 24 | expect(processor1.id).not.toBe(processor2.id); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/jest/pipeline/filter/globalSearch.test.ts: -------------------------------------------------------------------------------- 1 | import GlobalSearchFilter from '../../../../src/pipeline/filter/globalSearch'; 2 | import Tabular from '../../../../src/tabular'; 3 | 4 | describe('GlobalSearch', () => { 5 | let data: Tabular; 6 | 7 | beforeAll(() => { 8 | data = Tabular.fromArray([ 9 | ['a1', 'a2', 'a3'], 10 | ['b1', 'b2', 'b3'], 11 | ['c1', 'c2', 'c3'], 12 | ]); 13 | }); 14 | 15 | it('should process string', () => { 16 | expect( 17 | new GlobalSearchFilter().setProps({ keyword: 'a' }).process(data), 18 | ).toHaveLength(1); 19 | }); 20 | 21 | it('should process int', () => { 22 | expect( 23 | new GlobalSearchFilter().setProps({ keyword: '1' }).process(data), 24 | ).toHaveLength(3); 25 | }); 26 | 27 | it('should accept props constructor', () => { 28 | expect(new GlobalSearchFilter({ keyword: '1' }).process(data)).toHaveLength( 29 | 3, 30 | ); 31 | }); 32 | 33 | it('should call propsUpdated', () => { 34 | const callback = jest.fn(); 35 | const search = new GlobalSearchFilter(); 36 | search.on('propsUpdated', callback); 37 | search.setProps({ keyword: '1' }).setProps({ keyword: '2' }).process(data); 38 | expect(callback).toBeCalledTimes(2); 39 | }); 40 | 41 | it('should call beforeProcess and afterProcess', () => { 42 | const beforeProcess = jest.fn(); 43 | const afterProcess = jest.fn(); 44 | const search = new GlobalSearchFilter(); 45 | search.on('beforeProcess', beforeProcess); 46 | search.on('afterProcess', afterProcess); 47 | search.setProps({ keyword: '2' }).process(data); 48 | 49 | expect(beforeProcess).toBeCalledTimes(1); 50 | expect(afterProcess).toBeCalledTimes(1); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/jest/pipeline/limit/pagination.test.ts: -------------------------------------------------------------------------------- 1 | import Tabular from '../../../../src/tabular'; 2 | import PaginationLimit from '../../../../src/pipeline/limit/pagination'; 3 | 4 | describe('PaginationLimit', () => { 5 | let data: Tabular; 6 | 7 | beforeAll(() => { 8 | data = Tabular.fromArray([ 9 | ['a1', 'a2', 'a3'], 10 | ['b1', 'b2', 'b3'], 11 | ['c1', 'c2', 'c3'], 12 | ['d1', 'd2', 'd3'], 13 | ['e1', 'e2', 'e3'], 14 | ]); 15 | }); 16 | 17 | it('should trim an array', async () => { 18 | const pagination = new PaginationLimit().setProps({ 19 | limit: 2, 20 | page: 0, 21 | }); 22 | const newData = await pagination.process(data); 23 | 24 | expect(newData).toBeInstanceOf(Tabular); 25 | expect(newData).toHaveLength(2); 26 | 27 | expect(newData.rows[0].cells[0].data).toBe('a1'); 28 | expect(newData.rows[0].cells[1].data).toBe('a2'); 29 | expect(newData.rows[0].cells[2].data).toBe('a3'); 30 | expect(newData.rows[1].cells[0].data).toBe('b1'); 31 | expect(newData.rows[1].cells[1].data).toBe('b2'); 32 | expect(newData.rows[1].cells[2].data).toBe('b3'); 33 | }); 34 | 35 | it('should go to page 1', async () => { 36 | const pagination = new PaginationLimit().setProps({ 37 | limit: 2, 38 | page: 1, 39 | }); 40 | const newData = await pagination.process(data); 41 | 42 | expect(newData).toBeInstanceOf(Tabular); 43 | expect(newData).toHaveLength(2); 44 | 45 | expect(newData.rows[0].cells[0].data).toBe('c1'); 46 | expect(newData.rows[0].cells[1].data).toBe('c2'); 47 | expect(newData.rows[0].cells[2].data).toBe('c3'); 48 | expect(newData.rows[1].cells[0].data).toBe('d1'); 49 | expect(newData.rows[1].cells[1].data).toBe('d2'); 50 | expect(newData.rows[1].cells[2].data).toBe('d3'); 51 | }); 52 | 53 | it('should go to page 2', async () => { 54 | const pagination = new PaginationLimit().setProps({ 55 | limit: 2, 56 | page: 2, 57 | }); 58 | const newData = await pagination.process(data); 59 | 60 | expect(newData).toBeInstanceOf(Tabular); 61 | expect(newData).toHaveLength(1); 62 | expect(newData.rows[0].cells[0].data).toBe('e1'); 63 | expect(newData.rows[0].cells[1].data).toBe('e2'); 64 | expect(newData.rows[0].cells[2].data).toBe('e3'); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/jest/pipeline/transformer/arrayToTabular.test.ts: -------------------------------------------------------------------------------- 1 | import ArrayToTabularTransformer from '../../../../src/pipeline/transformer/arrayToTabular'; 2 | import Tabular from '../../../../src/tabular'; 3 | 4 | describe('ArrayToTabularTransformer', () => { 5 | it('should convert raw data to Tabular', async () => { 6 | const raw = { 7 | data: [ 8 | [1, 2, 3], 9 | ['a', 'b', 'c'], 10 | ], 11 | }; 12 | 13 | const transformer = new ArrayToTabularTransformer(); 14 | const data = await transformer.process(raw); 15 | 16 | expect(data).toBeInstanceOf(Tabular); 17 | expect(data).toHaveLength(2); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/jest/pipeline/transformer/storageResponseToArray.test.ts: -------------------------------------------------------------------------------- 1 | import StorageResponseToArrayTransformer from '../../../../src/pipeline/transformer/storageResponseToArray'; 2 | import Header from '../../../../src/header'; 3 | 4 | describe('StorageResponseToArray', () => { 5 | it('should convert array of arrays', async () => { 6 | const raw = { 7 | data: [ 8 | [1, 2, 3], 9 | ['a', 'b', 'c'], 10 | ], 11 | total: 2, 12 | }; 13 | 14 | const transformer = new StorageResponseToArrayTransformer(); 15 | const data = await transformer.process(raw); 16 | 17 | expect(data.total).toBe(2); 18 | expect(data.data).toHaveLength(2); 19 | expect(data.data).toStrictEqual([ 20 | [1, 2, 3], 21 | ['a', 'b', 'c'], 22 | ]); 23 | }); 24 | 25 | it('should convert array of objects', async () => { 26 | const raw = { 27 | data: [ 28 | { 29 | name: 'boo', 30 | age: 8, 31 | }, 32 | { 33 | name: 'foo', 34 | age: 10, 35 | }, 36 | ], 37 | total: 2, 38 | }; 39 | 40 | const transformer = new StorageResponseToArrayTransformer({ 41 | header: Header.createFromConfig({ 42 | columns: [ 43 | { 44 | name: 'name', 45 | }, 46 | { 47 | name: 'age', 48 | }, 49 | ], 50 | }), 51 | }); 52 | const data = await transformer.process(raw); 53 | 54 | expect(data.total).toBe(2); 55 | expect(data.data).toHaveLength(2); 56 | expect(data.data).toStrictEqual([ 57 | ['boo', 8], 58 | ['foo', 10], 59 | ]); 60 | }); 61 | 62 | it('should use static data field', async () => { 63 | const raw = { 64 | data: [ 65 | [1, 2, 3], 66 | ['a', 'b', 'c'], 67 | ], 68 | total: 2, 69 | }; 70 | 71 | const transformer = new StorageResponseToArrayTransformer({ 72 | header: Header.createFromConfig({ 73 | columns: [ 74 | { 75 | name: 'a', 76 | }, 77 | { 78 | name: 'b', 79 | }, 80 | { 81 | name: 'c', 82 | }, 83 | { 84 | name: 'def', 85 | data: 42, 86 | }, 87 | ], 88 | }), 89 | }); 90 | const data = await transformer.process(raw); 91 | 92 | expect(data.total).toBe(2); 93 | expect(data.data).toHaveLength(2); 94 | expect(data.data).toStrictEqual([ 95 | [1, 2, 3, 42], 96 | ['a', 'b', 'c', 42], 97 | ]); 98 | }); 99 | 100 | it('should convert array of objects when selector is a function', async () => { 101 | const raw = { 102 | data: [ 103 | { 104 | name: { 105 | first: 'boo', 106 | last: 'bar', 107 | }, 108 | _age: 8, 109 | }, 110 | { 111 | name: { 112 | first: 'foo', 113 | last: 'far', 114 | }, 115 | _age: 10, 116 | }, 117 | ], 118 | total: 2, 119 | }; 120 | 121 | const transformer = new StorageResponseToArrayTransformer({ 122 | header: Header.createFromConfig({ 123 | columns: [ 124 | { 125 | data: (row: any) => row.name.first, 126 | name: 'firstName', 127 | }, 128 | { 129 | data: (row: any) => row.name.last, 130 | name: 'lastname', 131 | }, 132 | { 133 | data: (row: any) => row.name.first + ' ' + row.name.last, 134 | name: 'firstlastname', 135 | }, 136 | { 137 | name: 'age', 138 | id: '_age', 139 | }, 140 | ], 141 | }), 142 | }); 143 | const data = await transformer.process(raw); 144 | 145 | expect(data.data).toStrictEqual([ 146 | ['boo', 'bar', 'boo bar', 8], 147 | ['foo', 'far', 'foo far', 10], 148 | ]); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /tests/jest/row.test.ts: -------------------------------------------------------------------------------- 1 | import Row from '../../src/row'; 2 | import Cell from '../../src/cell'; 3 | 4 | describe('Row class', () => { 5 | it('should init with value', () => { 6 | const cell1 = new Cell(1); 7 | const cell2 = new Cell(2); 8 | const row = new Row([cell1, cell2]); 9 | 10 | expect(row).toHaveLength(2); 11 | }); 12 | 13 | it('should accept empty constructor', () => { 14 | const cell1 = new Cell(1); 15 | const cell2 = new Cell(2); 16 | const row = new Row(); 17 | row.cells.push(cell1); 18 | row.cells.push(cell2); 19 | 20 | expect(row).toHaveLength(2); 21 | }); 22 | 23 | it('should return a single cell', () => { 24 | const cell1 = new Cell(24); 25 | const cell2 = new Cell(42); 26 | const row = new Row(); 27 | row.cells.push(cell1); 28 | row.cells.push(cell2); 29 | 30 | expect(row.cell(0).data).toBe(24); 31 | expect(row.cell(1).data).toBe(42); 32 | }); 33 | 34 | it('should return a list of cells', () => { 35 | const cell1 = new Cell(24); 36 | const cell2 = new Cell(42); 37 | const row = new Row(); 38 | row.cells.push(cell1); 39 | row.cells.push(cell2); 40 | 41 | expect(row.cells).toHaveLength(2); 42 | expect(row.cells.map((x) => x.data)).toContain(24); 43 | expect(row.cells.map((x) => x.data)).toContain(42); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tests/jest/setup.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | 4 | import { JSDOM } from 'jsdom'; 5 | import { configure } from 'enzyme'; 6 | import Adapter from 'enzyme-adapter-preact-pure'; 7 | 8 | const jsdom = new JSDOM('', { 9 | url: 'http://localhost/', 10 | // Enable `requestAnimationFrame` which Preact uses internally. 11 | pretendToBeVisual: true, 12 | }); 13 | const { window } = jsdom; 14 | 15 | function copyProps(src, target) { 16 | Object.defineProperties(target, { 17 | ...Object.getOwnPropertyDescriptors(src), 18 | ...Object.getOwnPropertyDescriptors(target), 19 | }); 20 | } 21 | 22 | global.window = window; 23 | global.document = window.document; 24 | global.Node = window.Node; 25 | global.Event = window.Event; 26 | global.Element = window.Element; 27 | global.HTMLElement = window.HTMLElement; 28 | 29 | global.requestAnimationFrame = function (callback) { 30 | return setTimeout(callback, 0); 31 | }; 32 | global.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | copyProps(window, global); 36 | 37 | // Setup Enzyme 38 | configure({ adapter: new Adapter() }); 39 | -------------------------------------------------------------------------------- /tests/jest/state/store.test.ts: -------------------------------------------------------------------------------- 1 | import { Store } from '../../../src/state/store'; 2 | 3 | describe('Store', () => { 4 | it('should set the initial state', () => { 5 | const stubState = { 6 | a: 45, 7 | }; 8 | const store = new Store(stubState); 9 | expect(store.getState()).toEqual(stubState); 10 | }); 11 | 12 | it('should update the state', () => { 13 | const store = new Store({ 14 | hello: 'world', 15 | }); 16 | store.dispatch((state) => { 17 | return { 18 | ...state, 19 | newKey: 42, 20 | }; 21 | }); 22 | expect(store.getState()).toEqual({ 23 | hello: 'world', 24 | newKey: 42, 25 | }); 26 | }); 27 | 28 | it('should override the state', () => { 29 | const store = new Store({ 30 | hello: 'world', 31 | }); 32 | store.dispatch((state) => { 33 | return { 34 | ...state, 35 | hello: 'updated', 36 | }; 37 | }); 38 | expect(store.getState()).toEqual({ 39 | hello: 'updated', 40 | }); 41 | }); 42 | 43 | it('should call the subscribers', () => { 44 | const store = new Store({ 45 | hello: 'world', 46 | }); 47 | 48 | const mockSubscriber = jest.fn(); 49 | 50 | store.subscribe(mockSubscriber); 51 | 52 | store.dispatch((state) => { 53 | return { 54 | ...state, 55 | hello: 'updated', 56 | newKey: 42, 57 | }; 58 | }); 59 | 60 | expect(mockSubscriber).toBeCalledTimes(1); 61 | expect(mockSubscriber).toBeCalledWith( 62 | { 63 | hello: 'updated', 64 | newKey: 42, 65 | }, 66 | { 67 | hello: 'world', 68 | }, 69 | ); 70 | }); 71 | 72 | it('should return a list of subscribers', () => { 73 | const store = new Store({ 74 | hello: 'world', 75 | }); 76 | 77 | const mockSubscriber1 = jest.fn(); 78 | const mockSubscriber2 = jest.fn(); 79 | 80 | store.subscribe(mockSubscriber1); 81 | store.subscribe(mockSubscriber2); 82 | 83 | expect(store.getListeners()).toHaveLength(2); 84 | expect(store.getListeners()).toEqual( 85 | expect.arrayContaining([mockSubscriber2, mockSubscriber1]), 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /tests/jest/storage/memory.test.ts: -------------------------------------------------------------------------------- 1 | import MemoryStorage from '../../../src/storage/memory'; 2 | 3 | describe('MemoryStorage class', () => { 4 | let data; 5 | 6 | beforeEach(() => { 7 | data = [ 8 | [1, 2, 3], 9 | [4, 5, 6], 10 | ]; 11 | }); 12 | 13 | it('should load from the config', async () => { 14 | const memoryStorage = new MemoryStorage(data); 15 | expect((await memoryStorage.get()).total).toBe(2); 16 | }); 17 | 18 | it('should return the correct length', async () => { 19 | const memoryStorage = new MemoryStorage(data); 20 | 21 | await memoryStorage.set([[1, 2, 3]]); 22 | 23 | expect((await memoryStorage.get()).total).toBe(1); 24 | }); 25 | 26 | it('should set and get rows', async () => { 27 | const memoryStorage = new MemoryStorage(data); 28 | 29 | await memoryStorage.set([['a', 'b', 'c']]); 30 | 31 | expect((await memoryStorage.get()).data).toStrictEqual([['a', 'b', 'c']]); 32 | }); 33 | 34 | it('should set rows from a function', async () => { 35 | const memoryStorage = new MemoryStorage(data); 36 | 37 | await memoryStorage.set(() => [['a', 'b', 'c']]); 38 | 39 | expect((await memoryStorage.get()).data).toStrictEqual([['a', 'b', 'c']]); 40 | }); 41 | 42 | it('should set rows from an async function', async () => { 43 | const memoryStorage = new MemoryStorage(data); 44 | 45 | await memoryStorage.set(async () => { 46 | return new Promise((resolve) => { 47 | setTimeout(() => resolve([['a', 'b', 'c']]), 500); 48 | }); 49 | }); 50 | 51 | expect((await memoryStorage.get()).data).toStrictEqual([['a', 'b', 'c']]); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/jest/storage/server.test.ts: -------------------------------------------------------------------------------- 1 | import ServerStorage from '../../../src/storage/server'; 2 | 3 | describe('ServerStorage class', () => { 4 | beforeEach(() => { 5 | const mockSuccessResponse = { 6 | rows: [ 7 | [6, 6, 6], 8 | [7, 7, 7], 9 | ], 10 | numRows: 10, 11 | }; 12 | const mockJsonPromise = Promise.resolve(mockSuccessResponse); 13 | const mockFetchPromise = Promise.resolve({ 14 | ok: true, 15 | json: () => mockJsonPromise, 16 | }); 17 | 18 | global.fetch = jest.fn().mockImplementation(() => mockFetchPromise); 19 | }); 20 | 21 | afterEach(() => { 22 | // eslint-disable-next-line 23 | // @ts-ignore 24 | global.fetch.mockClear(); 25 | }); 26 | 27 | it('should call fetch once get is called', async () => { 28 | const opts = { 29 | url: 'https://example.com', 30 | then: (res) => res, 31 | }; 32 | await new ServerStorage(opts).get(); 33 | 34 | expect(global.fetch).toHaveBeenCalledTimes(1); 35 | expect(global.fetch).toHaveBeenCalledWith('https://example.com', opts); 36 | }); 37 | 38 | it('should pass options to fetch', async () => { 39 | const opts = { 40 | url: 'https://example.com', 41 | method: 'POST', 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | 'X-Test': 'HelloWorld', 45 | }, 46 | then: (res) => res, 47 | }; 48 | await new ServerStorage(opts).get(); 49 | 50 | expect(global.fetch).toHaveBeenCalledTimes(1); 51 | expect(global.fetch).toHaveBeenCalledWith('https://example.com', opts); 52 | }); 53 | 54 | it('should format the response with then callback', async () => { 55 | const opts = { 56 | url: 'https://example.com', 57 | then: (_) => [ 58 | [1, 2, 3], 59 | [4, 5, 6], 60 | ], 61 | }; 62 | 63 | const resp = await new ServerStorage(opts).get(); 64 | expect(resp).toStrictEqual({ 65 | data: [ 66 | [1, 2, 3], 67 | [4, 5, 6], 68 | ], 69 | total: undefined, 70 | }); 71 | }); 72 | 73 | it('should set total', async () => { 74 | const opts = { 75 | url: 'https://example.com', 76 | then: (res) => res.rows, 77 | total: (res) => res.numRows + 2, 78 | }; 79 | 80 | const resp = await new ServerStorage(opts).get(); 81 | expect(resp).toStrictEqual({ 82 | data: [ 83 | [6, 6, 6], 84 | [7, 7, 7], 85 | ], 86 | total: 12, 87 | }); 88 | }); 89 | 90 | it('should call data', async () => { 91 | const opts = { 92 | url: 'https://example.com', 93 | data: async () => { 94 | return { 95 | data: [ 96 | [3, 3, 3], 97 | [9, 9, 9], 98 | ], 99 | total: 100, 100 | }; 101 | }, 102 | }; 103 | 104 | const resp = await new ServerStorage(opts).get(); 105 | expect(resp).toStrictEqual({ 106 | data: [ 107 | [3, 3, 3], 108 | [9, 9, 9], 109 | ], 110 | total: 100, 111 | }); 112 | 113 | expect(global.fetch).toHaveBeenCalledTimes(0); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /tests/jest/tabular.test.ts: -------------------------------------------------------------------------------- 1 | import Tabular from '../../src/tabular'; 2 | import Row from '../../src/row'; 3 | import Cell from '../../src/cell'; 4 | 5 | describe('Tabular class', () => { 6 | it('should init with rows', () => { 7 | const rows = [new Row([new Cell(1), new Cell(2), new Cell(3)])]; 8 | 9 | const tabular = new Tabular(rows); 10 | 11 | expect(tabular.rows).toStrictEqual(rows); 12 | }); 13 | 14 | it('should set and get rows', () => { 15 | const row1 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 16 | 17 | const row2 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 18 | 19 | const tabular = new Tabular([row1]); 20 | tabular.rows = [row2]; 21 | 22 | expect(tabular.rows).toStrictEqual([row2]); 23 | }); 24 | 25 | it('should push row', () => { 26 | const row1 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 27 | 28 | const row2 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 29 | 30 | const tabular = new Tabular([row1]); 31 | tabular.rows.push(row2); 32 | 33 | expect(tabular.rows).toStrictEqual([row1, row2]); 34 | }); 35 | 36 | it('should convert more than one row to array', () => { 37 | const row1 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 38 | const row2 = new Row([new Cell(4), new Cell(5), new Cell(6)]); 39 | const row3 = new Row([new Cell(6), new Cell(7), new Cell(8)]); 40 | 41 | const tabular = new Tabular([row1, row2, row3]); 42 | 43 | expect(tabular.toArray()).toStrictEqual([ 44 | [1, 2, 3], 45 | [4, 5, 6], 46 | [6, 7, 8], 47 | ]); 48 | }); 49 | 50 | it('should convert one row or empty to array', () => { 51 | const row1 = new Row([new Cell(1), new Cell(2), new Cell(3)]); 52 | const tabular = new Tabular([row1]); 53 | expect(tabular.toArray()).toStrictEqual([[1, 2, 3]]); 54 | 55 | expect(new Tabular([new Row([])]).toArray()).toStrictEqual([[]]); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/jest/testUtil.ts: -------------------------------------------------------------------------------- 1 | export const flushPromises = () => { 2 | return new Promise((resolve) => setImmediate(resolve)); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/jest/util/array.test.ts: -------------------------------------------------------------------------------- 1 | import { oneDtoTwoD } from '../../../src/util/array'; 2 | 3 | describe('cast module', () => { 4 | describe('oneDtwoTwoD', () => { 5 | it('should cast 1d arrays to 2d', () => { 6 | expect(oneDtoTwoD([1, 2, 3])).toStrictEqual([[1, 2, 3]]); 7 | }); 8 | 9 | it('should not change a 2d array', () => { 10 | expect(oneDtoTwoD([[1, 2, 3]])).toStrictEqual([[1, 2, 3]]); 11 | }); 12 | 13 | it('should work with empty arrays', () => { 14 | expect(oneDtoTwoD([])).toStrictEqual([]); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/jest/util/className.test.ts: -------------------------------------------------------------------------------- 1 | import { classJoin, className } from '../../../src/util/className'; 2 | 3 | describe('className', () => { 4 | describe('classJoin', () => { 5 | it('should join empty classes', () => { 6 | expect(classJoin(null, 'boo')).toBe('boo'); 7 | }); 8 | 9 | it('should join one class', () => { 10 | expect(classJoin('boo')).toBe('boo'); 11 | }); 12 | 13 | it('should join two or more class', () => { 14 | expect(classJoin('boo', 'foo', 'bar')).toBe('boo foo bar'); 15 | }); 16 | 17 | it('should return empty string when inputs are null and undefined', () => { 18 | expect(classJoin(null, undefined, null)).toBe(''); 19 | }); 20 | 21 | it('should return empty string when inputs are null', () => { 22 | expect(classJoin(null, null)).toBe(''); 23 | }); 24 | }); 25 | 26 | describe('className', () => { 27 | it('should accept two or more args', () => { 28 | expect(className('boo', 'foo', 'bar')).toBe('gridjs-boo-foo-bar'); 29 | }); 30 | 31 | it('should generate classNames', () => { 32 | expect(className('boo')).toBe('gridjs-boo'); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/jest/util/deepEqual.test.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from '../../../src/util/deepEqual'; 2 | import { html } from '../../../src/util/html'; 3 | 4 | describe('deepEqual', () => { 5 | it('should return true when objects are the same', () => { 6 | const result = deepEqual({ a: 42 }, { a: 42 }); 7 | expect(result).toBeTrue(); 8 | }); 9 | 10 | it('should return false when objects are not the same', () => { 11 | const result = deepEqual({ b: 42 }, { a: 42 }); 12 | expect(result).toBeFalse(); 13 | }); 14 | 15 | it('should return true when nested objects are the same', () => { 16 | const result = deepEqual({ a: 42, c: { a: 24 } }, { a: 42, c: { a: 24 } }); 17 | expect(result).toBeTrue(); 18 | }); 19 | 20 | it('should return false when nested objects not are the same', () => { 21 | const result = deepEqual({ a: 42, c: { x: 24 } }, { a: 42, c: { a: 24 } }); 22 | expect(result).toBeFalse(); 23 | }); 24 | 25 | it('should return false when objects have functions', () => { 26 | const result = deepEqual({ a: 42, c: jest.fn() }, { a: 42, c: jest.fn() }); 27 | expect(result).toBeFalse(); 28 | }); 29 | 30 | it('should return true when objects have same functions', () => { 31 | const fn = jest.fn(); 32 | const result = deepEqual({ a: 42, c: fn }, { a: 42, c: fn }); 33 | expect(result).toBeTrue(); 34 | }); 35 | 36 | it('should return true when objects are VNodes', () => { 37 | const result = deepEqual( 38 | html('Grid.js'), 39 | html('Grid.js'), 40 | ); 41 | expect(result).toBeTrue(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/jest/util/eventEmitter.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '../../../src/util/eventEmitter'; 2 | 3 | describe('EventEmitter class', () => { 4 | interface EventGeneric { 5 | boo: (x: string | number) => void; 6 | } 7 | 8 | it('should emit events', () => { 9 | const emitter = new EventEmitter(); 10 | 11 | emitter.on('boo', (n: number) => { 12 | expect(n).toBe(42); 13 | }); 14 | 15 | emitter.emit('boo', 42); 16 | }); 17 | 18 | it('emit and on should accept args', () => { 19 | interface Event { 20 | boo: (a: number, b: number, c: number, x: string, q: boolean) => void; 21 | } 22 | 23 | const emitter = new EventEmitter(); 24 | const input = [1, 2, 5, 'boo', false]; 25 | 26 | emitter.on('boo', (...args) => { 27 | expect(args).toStrictEqual(input); 28 | }); 29 | 30 | const result = emitter.emit('boo', 1, 2, 5, 'boo', false); 31 | expect(result).toBe(true); 32 | }); 33 | 34 | it('emit should return false', () => { 35 | const emitter = new EventEmitter(); 36 | 37 | const result = emitter.emit('boo', '??'); 38 | expect(result).toBe(false); 39 | }); 40 | 41 | it('should call all listeners', () => { 42 | const emitter = new EventEmitter(); 43 | 44 | const handler1 = jest.fn(); 45 | const handler2 = jest.fn(); 46 | 47 | emitter.on('boo', handler1); 48 | emitter.on('boo', handler2); 49 | emitter.emit('boo', 'foo'); 50 | 51 | expect(handler1).toBeCalled(); 52 | expect(handler2).toBeCalled(); 53 | }); 54 | 55 | it('off should remove listener', () => { 56 | const emitter = new EventEmitter(); 57 | 58 | const handler1 = jest.fn(); 59 | const handler2 = jest.fn(); 60 | 61 | emitter.on('boo', handler1); 62 | emitter.on('boo', handler2); 63 | emitter.off('boo', handler2); 64 | emitter.emit('boo', 'foo'); 65 | 66 | expect(handler1).toBeCalled(); 67 | expect(handler2).not.toBeCalled(); 68 | }); 69 | 70 | it('off should not remove an incorrect handler', () => { 71 | const emitter = new EventEmitter(); 72 | 73 | const handler1 = jest.fn(); 74 | const handler2 = jest.fn(); 75 | const handler3 = jest.fn(); 76 | 77 | emitter.on('boo', handler1); 78 | emitter.on('boo', handler2); 79 | emitter.off('boo', handler3); 80 | emitter.emit('boo', 'foo'); 81 | 82 | expect(handler1).toBeCalled(); 83 | expect(handler2).toBeCalled(); 84 | expect(handler3).not.toBeCalled(); 85 | }); 86 | 87 | it('off should work with null', () => { 88 | const emitter = new EventEmitter(); 89 | 90 | const handler1 = jest.fn(); 91 | const handler2 = jest.fn(); 92 | 93 | emitter.on('boo', handler1); 94 | emitter.on('boo', handler2); 95 | emitter.off('boo', null); 96 | emitter.emit('boo', 'foo'); 97 | 98 | expect(handler1).toBeCalled(); 99 | expect(handler2).toBeCalled(); 100 | }); 101 | 102 | it('should call handlers with correct args', () => { 103 | interface Event { 104 | boo: (a: number, b: number, x: string, q: boolean) => void; 105 | } 106 | 107 | const emitter = new EventEmitter(); 108 | 109 | const args = [1, 2, 'boo', false]; 110 | const handler1 = jest.fn(); 111 | 112 | emitter.on('boo', handler1); 113 | emitter.emit('boo', 1, 2, 'boo', false); 114 | emitter.emit('boo', 1, 2, 'boo', false); 115 | emitter.emit('boo', 1, 2, 'boo', false); 116 | emitter.emit('boo', 1, 2, 'boo', false); 117 | 118 | expect(handler1).toBeCalledTimes(4); 119 | expect(handler1).toBeCalledWith(...args); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /tests/jest/util/id.test.ts: -------------------------------------------------------------------------------- 1 | import { generateUUID } from '../../../src/util/id'; 2 | 3 | describe('generateUUID function', () => { 4 | it('should generate unique UUIDs', () => { 5 | expect(generateUUID()).not.toBe(generateUUID()); 6 | expect(generateUUID()).not.toBe(generateUUID()); 7 | expect(generateUUID()).not.toBe(generateUUID()); 8 | expect(generateUUID()).not.toBe(generateUUID()); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/jest/util/string.test.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from '../../../src/util/string'; 2 | 3 | describe('string module', () => { 4 | describe('camelCase', () => { 5 | it('should convert two words', () => { 6 | expect(camelCase('Hello World')).toBe('helloWorld'); 7 | expect(camelCase('hello world')).toBe('helloWorld'); 8 | expect(camelCase('hello World')).toBe('helloWorld'); 9 | expect(camelCase('Hello world')).toBe('helloWorld'); 10 | }); 11 | 12 | it('should convert two words and number', () => { 13 | expect(camelCase('Hello World 42')).toBe('helloWorld42'); 14 | }); 15 | 16 | it('should convert empty str', () => { 17 | expect(camelCase('')).toBe(''); 18 | expect(camelCase(' ')).toBe(''); 19 | expect(camelCase(null)).toBe(''); 20 | expect(camelCase(undefined)).toBe(''); 21 | }); 22 | 23 | it('should convert one number', () => { 24 | expect(camelCase('42')).toBe('42'); 25 | }); 26 | 27 | it('should convert one lowercase word', () => { 28 | expect(camelCase('Hello')).toBe('hello'); 29 | }); 30 | 31 | it('should convert uppercase words', () => { 32 | expect(camelCase('HELLO')).toBe('hello'); 33 | expect(camelCase('HELLO WORLD')).toBe('helloWorld'); 34 | }); 35 | 36 | it('should NOT convert camelCase strings', () => { 37 | expect(camelCase('phoneNumber')).toBe('phoneNumber'); 38 | expect(camelCase('myPhoneNumber')).toBe('myPhoneNumber'); 39 | }); 40 | 41 | it('should convert mixed strings', () => { 42 | expect(camelCase('my phoneNumber')).toBe('myPhonenumber'); 43 | expect(camelCase('THEIR PhoneNumber')).toBe('theirPhonenumber'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/jest/util/throttle.test.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from '../../../src/util/throttle'; 2 | 3 | const sleep = (wait: number) => new Promise((r) => setTimeout(r, wait)); 4 | 5 | describe('throttle', () => { 6 | it('should throttle calls', async () => { 7 | const wait = 100; 8 | const fn = jest.fn(); 9 | const throttled = throttle(fn, wait); 10 | 11 | throttled('a', 'b', 'c'); 12 | sleep(wait - 5); 13 | throttled('b', 'a', 'c'); 14 | sleep(wait - 10); 15 | throttled('c', 'b', 'a'); 16 | 17 | await sleep(wait); 18 | 19 | expect(fn).toBeCalledTimes(1); 20 | expect(fn).toBeCalledWith('c', 'b', 'a'); 21 | }); 22 | 23 | it('should execute the first call', async () => { 24 | const wait = 100; 25 | const fn = jest.fn(); 26 | const throttled = throttle(fn, wait); 27 | 28 | throttled(); 29 | 30 | await sleep(wait); 31 | 32 | expect(fn).toBeCalledTimes(1); 33 | }); 34 | 35 | it('should call at trailing edge of the timeout', async () => { 36 | const wait = 100; 37 | const fn = jest.fn(); 38 | const throttled = throttle(fn, wait); 39 | 40 | throttled(); 41 | 42 | expect(fn).toBeCalledTimes(0); 43 | 44 | await sleep(wait); 45 | 46 | expect(fn).toBeCalledTimes(1); 47 | }); 48 | 49 | it('should call after the timer', async () => { 50 | const wait = 100; 51 | const fn = jest.fn(); 52 | const throttled = throttle(fn, wait); 53 | 54 | throttled(); 55 | await sleep(wait); 56 | 57 | expect(fn).toBeCalledTimes(1); 58 | 59 | throttled(); 60 | await sleep(wait); 61 | 62 | expect(fn).toBeCalledTimes(2); 63 | 64 | throttled(); 65 | await sleep(wait); 66 | 67 | expect(fn).toBeCalledTimes(3); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/jest/view/plugin/__snapshots__/pagination.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Pagination plugin should render the pagination with no records 1`] = `"
"`; 4 | 5 | exports[`Pagination plugin should render the pagination with one page 1`] = `"
Showing 1 to 3 of 3 results
"`; 6 | 7 | exports[`Pagination plugin should render the pagination with three page 1`] = `"
Showing 1 to 1 of 3 results
"`; 8 | -------------------------------------------------------------------------------- /tests/jest/view/plugin/pagination.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { h } from 'preact'; 3 | import { Config, ConfigContext } from '../../../../src/config'; 4 | import { Pagination } from '../../../../src/view/plugin/pagination'; 5 | 6 | describe('Pagination plugin', () => { 7 | let config: Config; 8 | 9 | beforeEach(() => { 10 | config = new Config().update({ 11 | columns: ['a', 'b', 'c'], 12 | data: [ 13 | [1, 2, 3], 14 | [4, 5, 6], 15 | [7, 8, 9], 16 | ], 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | it('should render the pagination with no records', async () => { 25 | config.update({ 26 | data: [], 27 | pagination: true, 28 | }); 29 | 30 | const pagination = mount( 31 | 32 | 33 | , 34 | ); 35 | 36 | await config.pipeline.process(); 37 | expect(pagination.html()).toMatchSnapshot(); 38 | }); 39 | 40 | it('should render the pagination with one page', async () => { 41 | config.update({ 42 | pagination: { 43 | limit: 3, 44 | }, 45 | }); 46 | 47 | const pagination = mount( 48 | 49 | 50 | , 51 | ); 52 | await config.pipeline.process(); 53 | 54 | expect(pagination.html()).toMatchSnapshot(); 55 | }); 56 | 57 | it('should render the pagination with three page', async () => { 58 | config.update({ 59 | pagination: { 60 | limit: 1, 61 | }, 62 | }); 63 | 64 | const pagination = mount( 65 | 66 | 67 | , 68 | ); 69 | 70 | pagination.update(); 71 | await config.pipeline.process(); 72 | 73 | expect(pagination.html()).toMatchSnapshot(); 74 | }); 75 | 76 | it('should add config.className.pagination', async () => { 77 | config.update({ 78 | pagination: { 79 | limit: 1, 80 | }, 81 | className: { 82 | pagination: 'my-pagination-class', 83 | paginationButton: 'my-button', 84 | paginationButtonNext: 'my-next-button', 85 | paginationButtonPrev: 'my-prev-button', 86 | paginationSummary: 'my-page-summary', 87 | paginationButtonCurrent: 'my-current-button', 88 | }, 89 | }); 90 | 91 | const pagination = mount( 92 | 93 | 94 | , 95 | ); 96 | 97 | await config.pipeline.process(); 98 | pagination.update(); 99 | 100 | expect( 101 | pagination.find('.my-pagination-class').hasClass('gridjs-pagination'), 102 | ).toBe(true); 103 | expect(pagination.find('.my-pagination-class').name()).toBe('div'); 104 | 105 | expect(pagination.find('.my-button')).toHaveLength(5); 106 | expect(pagination.find('.my-next-button')).toHaveLength(1); 107 | expect(pagination.find('.my-next-button').prop('disabled')).toBe(false); 108 | expect(pagination.find('.my-prev-button').prop('disabled')).toBe(true); 109 | expect(pagination.find('.my-prev-button')).toHaveLength(1); 110 | expect(pagination.find('.my-current-button')).toHaveLength(1); 111 | expect(pagination.find('.my-current-button').text()).toBe('1'); 112 | 113 | expect(pagination.find('.my-page-summary')).toHaveLength(1); 114 | expect(pagination.find('.my-page-summary').text()).toBe( 115 | 'Showing 1 to 1 of 3 results', 116 | ); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/jest/view/plugin/search/__snapshots__/search.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Search plugin should render the search box 1`] = `""`; 4 | -------------------------------------------------------------------------------- /tests/jest/view/plugin/search/search.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { act } from 'preact/test-utils'; 3 | import { mount } from 'enzyme'; 4 | import { Config, ConfigContext } from '../../../../../src/config'; 5 | import { Search } from '../../../../../src/view/plugin/search/search'; 6 | import * as SearchActions from '../../../../../src/view/plugin/search/actions'; 7 | import { flushPromises } from '../../../testUtil'; 8 | 9 | describe('Search plugin', () => { 10 | let config: Config; 11 | 12 | beforeEach(() => { 13 | config = new Config().update({ 14 | data: [['a', 'b', 'c']], 15 | columns: ['Name', 'Phone Number'], 16 | }); 17 | }); 18 | 19 | afterEach(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | it('should render the search box', async () => { 24 | const mock = jest.spyOn(SearchActions, 'SearchKeyword'); 25 | 26 | config.update({ 27 | search: { 28 | keyword: 'boo', 29 | }, 30 | }); 31 | 32 | const search = mount( 33 | 34 | 35 | , 36 | ); 37 | 38 | expect(mock).toBeCalledWith('boo'); 39 | expect(search.html()).toMatchSnapshot(); 40 | }); 41 | 42 | it('should not call search if keyword is undefined', async () => { 43 | const mock = jest.spyOn(SearchActions, 'SearchKeyword'); 44 | 45 | config.update({ 46 | search: true, 47 | }); 48 | 49 | mount( 50 | 51 | 52 | , 53 | ); 54 | 55 | expect(mock).not.toBeCalled(); 56 | }); 57 | 58 | it('should call search action after input change', async () => { 59 | const mock = jest.spyOn(SearchActions, 'SearchKeyword'); 60 | 61 | config.update({ 62 | search: true, 63 | }); 64 | 65 | const wrapper = mount( 66 | 67 | 68 | , 69 | ); 70 | 71 | const input = wrapper.find('input'); 72 | const onInput = input.props().onInput; 73 | 74 | await act(() => { 75 | const htmlInputElement = document.createElement('input'); 76 | htmlInputElement.value = '123'; 77 | onInput({ target: htmlInputElement }); 78 | }); 79 | 80 | wrapper.update(); 81 | 82 | await flushPromises(); 83 | 84 | return new Promise((resolve) => { 85 | // TODO: can we fix this and remove the setTimeout? 86 | setTimeout(() => { 87 | expect(mock).toBeCalledWith('123'); 88 | resolve(); 89 | }, 100); 90 | }); 91 | }); 92 | 93 | it('should add config.className.search', async () => { 94 | config.update({ 95 | search: true, 96 | className: { 97 | search: 'test-search-class-name', 98 | }, 99 | }); 100 | 101 | const search = mount( 102 | 103 | 104 | , 105 | ); 106 | 107 | expect( 108 | search.find('.test-search-class-name').hasClass('gridjs-search'), 109 | ).toBeTrue(); 110 | expect(search.find('.test-search-class-name').name()).toBe('div'); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/jest/view/table/__snapshots__/message-row.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MessageRow component should match the snapshot 1`] = `"boo"`; 4 | -------------------------------------------------------------------------------- /tests/jest/view/table/__snapshots__/td.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TD component should match the snapshot 1`] = `"boo"`; 4 | -------------------------------------------------------------------------------- /tests/jest/view/table/__snapshots__/tr.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TR component should match the snapshot 1`] = `"boo"`; 4 | -------------------------------------------------------------------------------- /tests/jest/view/table/message-row.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { mount } from 'enzyme'; 3 | import { Config, ConfigContext } from '../../../../src/config'; 4 | import { EventEmitter } from '../../../../src/util/eventEmitter'; 5 | import { TableEvents } from '../../../../src/view/table/events'; 6 | import { MessageRow } from '../../../../src/view/table/messageRow'; 7 | 8 | describe('MessageRow component', () => { 9 | let config: Config; 10 | 11 | beforeEach(() => { 12 | config = new Config(); 13 | }); 14 | 15 | it('should match the snapshot', () => { 16 | const td = mount( 17 | 18 | 19 | , 20 | ); 21 | expect(td.html()).toMatchSnapshot(); 22 | }); 23 | 24 | it('should prevent emit rowClick', async () => { 25 | config.eventEmitter = new EventEmitter(); 26 | const onClick = jest.fn(); 27 | 28 | const rows = mount( 29 | 30 | 31 | , 32 | ).find('tr'); 33 | 34 | config.eventEmitter.on('rowClick', onClick); 35 | rows.map((tr) => tr.simulate('click')); 36 | 37 | expect(rows.length).toEqual(1); 38 | expect(onClick).toHaveBeenCalledTimes(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/jest/view/table/td.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { mount } from 'enzyme'; 3 | import { TD } from '../../../../src/view/table/td'; 4 | import Cell from '../../../../src/cell'; 5 | import { Config, ConfigContext } from '../../../../src/config'; 6 | import { EventEmitter } from '../../../../src/util/eventEmitter'; 7 | import { TableEvents } from '../../../../src/view/table/events'; 8 | 9 | describe('TD component', () => { 10 | let config: Config; 11 | 12 | beforeEach(() => { 13 | config = new Config(); 14 | }); 15 | 16 | it('should match the snapshot', () => { 17 | const td = mount( 18 | 19 | 20 | , 21 | ); 22 | expect(td.html()).toMatchSnapshot(); 23 | }); 24 | 25 | it('should emit cellClick', async () => { 26 | config.eventEmitter = new EventEmitter(); 27 | const onClick = jest.fn(); 28 | 29 | const cells = mount( 30 | 31 | 32 | , 33 | ).find('td'); 34 | 35 | config.eventEmitter.on('cellClick', onClick); 36 | cells.map((td) => td.simulate('click')); 37 | 38 | expect(cells.length).toEqual(1); 39 | expect(onClick).toHaveBeenCalledTimes(1); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/jest/view/table/tr.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { mount } from 'enzyme'; 3 | import { Config, ConfigContext } from '../../../../src/config'; 4 | import { EventEmitter } from '../../../../src/util/eventEmitter'; 5 | import { TableEvents } from '../../../../src/view/table/events'; 6 | import { TD } from '../../../../src/view/table/td'; 7 | import { TR } from '../../../../src/view/table/tr'; 8 | import Cell from '../../../../src/cell'; 9 | 10 | describe('TR component', () => { 11 | let config: Config; 12 | 13 | beforeEach(() => { 14 | config = new Config(); 15 | }); 16 | 17 | it('should match the snapshot', () => { 18 | const tr = mount( 19 | 20 | 21 | 22 | 23 | , 24 | ); 25 | expect(tr.html()).toMatchSnapshot(); 26 | }); 27 | 28 | it('should emit rowClick', async () => { 29 | config.eventEmitter = new EventEmitter(); 30 | const onClick = jest.fn(); 31 | 32 | const rows = mount( 33 | 34 | 35 | 36 | 37 | , 38 | ).find('tr'); 39 | 40 | config.eventEmitter.on('rowClick', onClick); 41 | rows.map((tr) => tr.simulate('click')); 42 | 43 | expect(rows.length).toEqual(1); 44 | expect(onClick).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('should attach the custom tr className', async () => { 48 | const tr = mount( 49 | 59 | 60 | 61 | 62 | , 63 | ); 64 | 65 | expect(tr.find('tr.custom-tr-classname')).toHaveLength(1); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "allowJs": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "jsxFactory": "h", 11 | "jsxFragmentFactory": "Fragment", 12 | "alwaysStrict": true, 13 | "sourceMap": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitAny": false, 20 | "noImplicitThis": false, 21 | "strictNullChecks": false, 22 | "declaration": true, 23 | "baseUrl": "./", 24 | "paths": { 25 | "react": ["./node_modules/preact/compat/"], 26 | "react-dom": ["./node_modules/preact/compat/"] 27 | }, 28 | }, 29 | 30 | "include": [ 31 | "index.ts", 32 | "src/**/*", 33 | "tests/jest/**/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "removeComments": true 6 | }, 7 | "include": [ 8 | "index.ts", 9 | "src/**/*" 10 | ], 11 | "exclude": [ 12 | "tests/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest"], 5 | "esModuleInterop": true 6 | } 7 | } 8 | --------------------------------------------------------------------------------