diff --git a/.babelrc b/.babelrc index d5e14244ad2..24fd1912fa9 100644 --- a/.babelrc +++ b/.babelrc @@ -2,6 +2,7 @@ "presets": ["env", "react"], "plugins": [ "transform-class-properties", + "transform-export-extensions", "transform-object-rest-spread", "transform-object-assign" ], diff --git a/package-lock.json b/package-lock.json index a8906727747..80ce89724c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "@babel/types": "7.0.0-beta.36", "babylon": "7.0.0-beta.36", "debug": "3.1.0", - "globals": "11.1.0", + "globals": "11.2.0", "invariant": "2.2.2", "lodash": "4.17.4" }, @@ -87,9 +87,9 @@ } }, "globals": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.1.0.tgz", - "integrity": "sha512-uEuWt9mqTlPDwSqi+sHjD4nWU/1N+q0fiWI9T1mZpD2UENqX20CFD5T/ziLZvztPaBKl7ZylUi1q6Qfm7E2CiQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.2.0.tgz", + "integrity": "sha512-RDC7Tj17I/56wpVvCVLSXtnn2Fo6CQZ9vaj+ARn+qlzm/ozbKQZe+j9fvHZCbSq+4JSGjTpKEt7p/AA1IKXRFA==", "dev": true } } @@ -255,7 +255,7 @@ "npm-conf": "1.1.3", "npm-registry-client": "8.5.0", "read-pkg": "3.0.0", - "registry-auth-token": "3.3.1" + "registry-auth-token": "3.3.2" }, "dependencies": { "debug": { @@ -384,9 +384,9 @@ } }, "@semantic-release/release-notes-generator": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-6.0.3.tgz", - "integrity": "sha512-qvO8B6kUm5cVHdBx0uAla/clAitUR3Pyl+/6a7wSp6RqExgWfa1twq1ekVRBgCHmxX1LFFmLtL3knnSXjpXj1Q==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-6.0.4.tgz", + "integrity": "sha512-/TlcuMFh6hHpbaTiD6aAm2OegYhnCqCf6JowkFB4uHkTaHi2Zc7l/Dej7TYEd9N+auxKA6l518IYvF8K1crxfA==", "dev": true, "requires": { "conventional-changelog-angular": "1.6.0", @@ -394,7 +394,7 @@ "conventional-commits-parser": "2.1.0", "debug": "3.1.0", "get-stream": "3.0.0", - "git-url-parse": "7.0.2", + "git-url-parse": "8.0.0", "import-from": "2.1.0", "into-stream": "3.1.0", "lodash": "4.17.4" @@ -410,9 +410,9 @@ } }, "git-url-parse": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-7.0.2.tgz", - "integrity": "sha512-OQVonLdJfnGUz7Umyh9NmkZ4j9QmTB+r8ARqqSeZWlMgepn6oTrrHn7wSM5ptJQ1AcXj04xHFacpZVcXvKQAqg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-8.0.0.tgz", + "integrity": "sha512-7BqaSBLGji3qnJXDPqZZHHvzaIKZWVLFjtCyu2IItX9qDlEpB4LrbZqUVSBE63upZbmzj3EuHemdSvyx2ODvyA==", "dev": true, "requires": { "git-up": "2.0.10" @@ -543,7 +543,7 @@ "@storybook/addons": "3.3.10", "@storybook/channel-postmessage": "3.3.10", "@storybook/ui": "3.3.10", - "airbnb-js-shims": "1.4.0", + "airbnb-js-shims": "1.4.1", "autoprefixer": "7.2.5", "babel-core": "6.26.0", "babel-loader": "7.1.2", @@ -812,9 +812,9 @@ } }, "airbnb-js-shims": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-1.4.0.tgz", - "integrity": "sha512-KIlW3epMtB1x3PtJr2L7ltkoYvuKzjqrgZPq6mzJUL6Gz+3Y2Oc9FS9ZRzRWym2/jk1r+JsDOOyS2Vavc0E3Pw==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-1.4.1.tgz", + "integrity": "sha512-b7S3d+DPRMwaDAs0cgKQTMLO/JG/iSehIlzEGvt2FpxIztRDDABEjWI73AfTxkSiK3/OsraPRYxVNAX3yhSNLw==", "dev": true, "requires": { "array-includes": "3.0.3", @@ -2988,7 +2988,7 @@ "dev": true, "requires": { "browserslist": "1.7.7", - "caniuse-db": "1.0.30000794", + "caniuse-db": "1.0.30000795", "lodash.memoize": "4.1.2", "lodash.uniq": "4.5.0" }, @@ -2999,16 +2999,16 @@ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=", "dev": true, "requires": { - "caniuse-db": "1.0.30000794", + "caniuse-db": "1.0.30000795", "electron-to-chromium": "1.3.31" } } } }, "caniuse-db": { - "version": "1.0.30000794", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000794.tgz", - "integrity": "sha1-u+cRBPonfOSzYjh9VJBei4jlLzU=", + "version": "1.0.30000795", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000795.tgz", + "integrity": "sha1-ZE8D+rAN2L0Wk+Xh5w2Gsxxc/s4=", "dev": true }, "caniuse-lite": { @@ -3986,7 +3986,7 @@ "dev": true, "requires": { "browserslist": "1.7.7", - "caniuse-db": "1.0.30000794", + "caniuse-db": "1.0.30000795", "normalize-range": "0.1.2", "num2fraction": "1.2.2", "postcss": "5.2.18", @@ -3999,7 +3999,7 @@ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=", "dev": true, "requires": { - "caniuse-db": "1.0.30000794", + "caniuse-db": "1.0.30000795", "electron-to-chromium": "1.3.31" } }, @@ -4391,9 +4391,9 @@ "dev": true }, "domain-browser": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz", - "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", "dev": true }, "domexception": { @@ -4743,7 +4743,7 @@ "file-entry-cache": "2.0.0", "functional-red-black-tree": "1.0.1", "glob": "7.1.2", - "globals": "11.1.0", + "globals": "11.2.0", "ignore": "3.3.7", "imurmurhash": "0.1.4", "inquirer": "3.3.0", @@ -4818,9 +4818,9 @@ } }, "globals": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.1.0.tgz", - "integrity": "sha512-uEuWt9mqTlPDwSqi+sHjD4nWU/1N+q0fiWI9T1mZpD2UENqX20CFD5T/ziLZvztPaBKl7ZylUi1q6Qfm7E2CiQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.2.0.tgz", + "integrity": "sha512-RDC7Tj17I/56wpVvCVLSXtnn2Fo6CQZ9vaj+ARn+qlzm/ozbKQZe+j9fvHZCbSq+4JSGjTpKEt7p/AA1IKXRFA==", "dev": true }, "inquirer": { @@ -5053,9 +5053,9 @@ "dev": true }, "eslint-plugin-react": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.5.1.tgz", - "integrity": "sha512-YGSjB9Qu6QbVTroUZi66pYky3DfoIPLdHQ/wmrBGyBRnwxQsBXAov9j2rpXt/55i8nyMv6IRWJv2s4d4YnduzQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.6.0.tgz", + "integrity": "sha512-5rTLxuZg8nJnjAVjd6aySU4NrThUNf7spX+eA179B1UJHzcIAvdqLv8Hnv/3OhtfQbtvjvE2DntPrxkSaSLPug==", "dev": true, "requires": { "doctrine": "2.1.0", @@ -8066,7 +8066,7 @@ "requires": { "jest-mock": "22.1.0", "jest-util": "22.1.4", - "jsdom": "11.6.0" + "jsdom": "11.6.1" } }, "jest-environment-node": { @@ -8126,7 +8126,7 @@ "jest-matcher-utils": "22.1.0", "jest-message-util": "22.1.0", "jest-snapshot": "22.1.2", - "source-map-support": "0.5.2" + "source-map-support": "0.5.3" }, "dependencies": { "callsites": { @@ -8136,9 +8136,9 @@ "dev": true }, "source-map-support": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.2.tgz", - "integrity": "sha512-9zHceZbQwERaMK1MiFguvx1dL9GQPLXInr2D/wUxAsuV6ZKc9F0DHYWeloMcalkYRbtanwqUakoDjvj55cL/4A==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.3.tgz", + "integrity": "sha512-eKkTgWYeBOQqFGXRfKabMFdnWepo51vWqEdoeikaEPFiJC7MCU5j2h4+6Q8npkZTeLGbSyecZvRxiSoWl3rh+w==", "dev": true, "requires": { "source-map": "0.6.1" @@ -8425,9 +8425,9 @@ "optional": true }, "jsdom": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.6.0.tgz", - "integrity": "sha512-4lMxDCiQYK7qfVi9fKhDf2PpvXXeH/KAmcH6o0Ga7fApi8+lTBxRqGHWZ9B11SsK/pxQKOtsw413utw0M+hUrg==", + "version": "11.6.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.6.1.tgz", + "integrity": "sha512-x1vDo5CQuwsuP0w3kuU04vQdem9Q8apRV2PXp8GeSFQpgtYvSwbcypIbNgRrXu82O4TMroGYSAbu9wyVZHcehw==", "dev": true, "requires": { "abab": "1.0.4", @@ -8795,6 +8795,12 @@ "integrity": "sha1-FQzwoWeR9ZA7iJHqsVRgknS96lU=", "dev": true }, + "lodash.orderby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.6.0.tgz", + "integrity": "sha1-5pfwTOXXhSL1TZM4syuBozk+TrM=", + "optional": true + }, "lodash.pick": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", @@ -9404,7 +9410,7 @@ "console-browserify": "1.1.0", "constants-browserify": "1.0.0", "crypto-browserify": "3.12.0", - "domain-browser": "1.1.7", + "domain-browser": "1.2.0", "events": "1.1.1", "https-browserify": "1.0.0", "os-browserify": "0.3.0", @@ -9416,7 +9422,7 @@ "stream-browserify": "2.0.1", "stream-http": "2.8.0", "string_decoder": "1.0.3", - "timers-browserify": "2.0.5", + "timers-browserify": "2.0.6", "tty-browserify": "0.0.0", "url": "0.11.0", "util": "0.10.3", @@ -10024,7 +10030,7 @@ "dev": true, "requires": { "got": "6.7.1", - "registry-auth-token": "3.3.1", + "registry-auth-token": "3.3.2", "registry-url": "3.1.0", "semver": "5.5.0" }, @@ -10202,9 +10208,9 @@ } }, "patternfly": { - "version": "3.37.7", - "resolved": "https://registry.npmjs.org/patternfly/-/patternfly-3.37.7.tgz", - "integrity": "sha1-5DHyjcfVmKqo0KLQYHXVMu/SXjc=", + "version": "3.37.8", + "resolved": "https://registry.npmjs.org/patternfly/-/patternfly-3.37.8.tgz", + "integrity": "sha1-i+A14cKFNuyuk6e1C2qlIeNTIQ4=", "requires": { "bootstrap": "3.3.7", "bootstrap-datepicker": "1.7.1", @@ -11374,7 +11380,7 @@ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=", "dev": true, "requires": { - "caniuse-db": "1.0.30000794", + "caniuse-db": "1.0.30000795", "electron-to-chromium": "1.3.31" } }, @@ -13041,6 +13047,14 @@ "velocity-react": "1.3.3" } }, + "reactabular-table": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/reactabular-table/-/reactabular-table-8.12.0.tgz", + "integrity": "sha512-Y+EuCPYLHV1OXlp3PwcyHWJrzRtlJ9VmKZv0Ok/wUWHcs34cYxj9QGTbotQL8th5RYpvDf/UmZ5jJNWQdIXBMQ==", + "requires": { + "classnames": "2.2.5" + } + }, "reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -13137,7 +13151,7 @@ "change-emitter": "0.1.6", "fbjs": "0.8.16", "hoist-non-react-statics": "2.3.1", - "symbol-observable": "1.1.0" + "symbol-observable": "1.2.0" } }, "redent": { @@ -13212,7 +13226,7 @@ "lodash": "4.17.4", "lodash-es": "4.17.4", "loose-envify": "1.3.1", - "symbol-observable": "1.1.0" + "symbol-observable": "1.2.0" } }, "regenerate": { @@ -13258,9 +13272,9 @@ } }, "registry-auth-token": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.1.tgz", - "integrity": "sha1-+w0yie4Nmtosu1KvXf5mywcNMAY=", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", "dev": true, "requires": { "rc": "1.2.4", @@ -13781,16 +13795,16 @@ } }, "semantic-release": { - "version": "12.2.4", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-12.2.4.tgz", - "integrity": "sha512-+CAi6HN74cY2bY9Rkwb3Buti876NJ9e393RpIPb6spvXeW/Zc0mwHqHFPZBYmkuXPmr70g9B/NSka4D3w6OR4Q==", + "version": "12.2.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-12.2.5.tgz", + "integrity": "sha512-Wd6MShvE0qScw3zb1ZNOekKBuXue3eTBKhZf6FPUd5RwL8dh9wa7C9m+wrF56SEEX5cM1c5+m8z/3z7WWxHR2A==", "dev": true, "requires": { "@semantic-release/commit-analyzer": "5.0.1", "@semantic-release/error": "2.1.0", "@semantic-release/github": "3.0.3", "@semantic-release/npm": "2.6.4", - "@semantic-release/release-notes-generator": "6.0.3", + "@semantic-release/release-notes-generator": "6.0.4", "chalk": "2.3.0", "commander": "2.13.0", "cosmiconfig": "4.0.0", @@ -14137,6 +14151,12 @@ "is-plain-obj": "1.1.0" } }, + "sortabular": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sortabular/-/sortabular-1.5.1.tgz", + "integrity": "sha512-heKAIwXf0VAC8LIfYH+8DiJ4HTRqzfPgOOZwpxFFYQXy+qwAey5CUirCKtue2ocgKnaJkertj+3ZL5DDKifSQg==", + "optional": true + }, "source-list-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", @@ -14500,9 +14520,9 @@ } }, "symbol-observable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.1.0.tgz", - "integrity": "sha512-dQoid9tqQ+uotGhuTKEY11X4xhyYePVnqGSoSm3OGKh2E8LZ6RPULp1uXTctk33IeERlrRJYoVSBglsL05F5Uw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, "symbol-tree": { "version": "3.2.2", @@ -14524,6 +14544,12 @@ "string-width": "2.1.1" } }, + "table-resolver": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/table-resolver/-/table-resolver-3.2.0.tgz", + "integrity": "sha512-DQrDHFdJPnvIhyjAcTqF4vhu/Uhp5eNRst9Url9KmBNqxYSMrPXOJoxhU7HPCd3efi1Hua7lMIDnBAphsdhPQw==", + "optional": true + }, "tapable": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz", @@ -14691,9 +14717,9 @@ "dev": true }, "timers-browserify": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.5.tgz", - "integrity": "sha512-BMeI1W6E2/mSaPVLUnH9rjEY1Ys2FEC/GKmE/101wusU3byZO5g68BJ5hpJEP8iD1qAJ6SzYAShGA+urHMxOzQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.6.tgz", + "integrity": "sha512-HQ3nbYRAowdVd0ckGFvmJPPCOH/CHleFN/Y0YQCX1DVaB7t+KFvisuyN09fuP8Jtp1CpfSh8O8bMkHbdbPe6Pw==", "dev": true, "requires": { "setimmediate": "1.0.5" diff --git a/package.json b/package.json index 4421450f951..3eb331a029c 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,14 @@ "react-bootstrap-switch": "^15.5.3", "react-c3js": "^0.1.20", "react-fontawesome": "^1.6.1", + "reactabular-table": "^8.12.0", "recompose": "^0.26.0" }, + "optionalDependencies": { + "lodash.orderby": "^4.6.0", + "sortabular": "^1.5.1", + "table-resolver": "^3.2.0" + }, "devDependencies": { "@storybook/addon-actions": "^3.2.12", "@storybook/addon-info": "3.2.12", @@ -38,6 +44,7 @@ "babel-eslint": "^8.1.2", "babel-jest": "^22.0.4", "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-export-extensions": "^6.22.0", "babel-plugin-transform-object-assign": "^6.22.0", "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-preset-env": "^1.6.1", diff --git a/sass/patternfly-react/_patternfly-react.scss b/sass/patternfly-react/_patternfly-react.scss index 91f53ee3a07..c8735a3a46f 100644 --- a/sass/patternfly-react/_patternfly-react.scss +++ b/sass/patternfly-react/_patternfly-react.scss @@ -1,4 +1,3 @@ - /** Patternfly React Partials */ diff --git a/src/components/DropdownKebab/DropdownKebab.js b/src/components/DropdownKebab/DropdownKebab.js index 562f1fb96f9..3c2cd23639d 100644 --- a/src/components/DropdownKebab/DropdownKebab.js +++ b/src/components/DropdownKebab/DropdownKebab.js @@ -3,15 +3,28 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Icon } from '../Icon'; import { Dropdown } from '../Dropdown'; +import { ButtonGroup } from '../Button'; /** * DropdownKebab Component for Patternfly React */ -const DropdownKebab = ({ className, children, id, pullRight }) => { +const DropdownKebab = ({ + className, + children, + id, + pullRight, + componentClass, + toggleStyle +}) => { const kebabClass = ClassNames('dropdown-kebab-pf', className); return ( - - + + {children} @@ -26,6 +39,14 @@ DropdownKebab.propTypes = { /** kebab dropdown id */ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, /** menu right aligned */ - pullRight: PropTypes.bool + pullRight: PropTypes.bool, + /** dropdown component class */ + componentClass: PropTypes.func, + /** toggle style */ + toggleStyle: PropTypes.string +}; +DropdownKebab.defaultProps = { + componentClass: ButtonGroup, + toggleStyle: 'link' }; export default DropdownKebab; diff --git a/src/components/Pagination/Paginate.test.js b/src/components/Pagination/Paginate.test.js new file mode 100644 index 00000000000..033649f91e7 --- /dev/null +++ b/src/components/Pagination/Paginate.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { Paginator, PaginationRow, PAGINATION_VIEW_TYPES } from './index'; + +const testPaginationRowSnapshot = viewType => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}; + +PAGINATION_VIEW_TYPES.forEach(viewType => { + test(`PaginationRow ${viewType} renders properly`, () => { + testPaginationRowSnapshot(viewType); + }); +}); + +test('PaginationRow.Items renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('PaginationRow.Back renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('PaginationRow.ButtonGroup renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('PaginationRow.Forward renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('PaginationRow.AmountOfPages renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Paginator renders properly the first page', () => { + const component = renderer.create( + + ); + + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Paginator renders properly a middle page', () => { + const component = renderer.create( + + ); + + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Paginator renders properly the last page', () => { + const component = renderer.create( + + ); + + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/Pagination/Pagination.stories.js b/src/components/Pagination/Pagination.stories.js new file mode 100644 index 00000000000..b2f73c9ef10 --- /dev/null +++ b/src/components/Pagination/Pagination.stories.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withInfo } from '@storybook/addon-info'; +import { withKnobs, text, number, select } from '@storybook/addon-knobs'; +import { inlineTemplate } from '../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../storybook/constants'; + +import { PaginationRow, Paginator, PAGINATION_VIEW_TYPES } from './index'; +import { + MockPaginationRow, + mockPaginationSource +} from './__mocks__/mockPaginationRow'; + +const stories = storiesOf('Pagination', module); +stories.addDecorator(withKnobs); + +stories.add( + 'Pagination row', + withInfo({ + source: false, + propTables: [ + PaginationRow, + PaginationRow.AmountOfPages, + PaginationRow.Back, + PaginationRow.ButtonGroup, + PaginationRow.Forward, + PaginationRow.Items + ], + propTablesExclude: [MockPaginationRow], + text: ( +
+

Story Source

+
{mockPaginationSource}
+
+ ) + })(() => { + let story = ( + + ); + return inlineTemplate({ + title: 'Pagination Row', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_NAVIGATION + 'pagination/', + story: story, + description: ( +
+ Pagination Row is a stateless functional component which exposes all + pagination callbacks (i.e.:{' '} + onFirstPage, onPreviousPage, onNextPage, onLastPage). See + Action Logger for details. +
+ ) + }); + }) +); + +stories.addWithInfo('Pagination row w/ state manager', '', () => { + const page = select('Page', ['1', '3', '8'], '1'); + const totalCount = select('Total items', ['75', '80', '81'], '75'); + var messages = {}; + for (let key of Object.keys(PaginationRow.defaultProps.messages)) { + messages[key] = text(key, PaginationRow.defaultProps.messages[key]); + } + + let story = ( + + ); + return inlineTemplate({ + title: 'Pagination Row with State Manager, a.k.a. Paginator', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_NAVIGATION + 'pagination/', + story: story, + description: ( +
+ Paginator is a stateful component which manages pagination state for you + and exposes a single onPageSet callback. See Action Logger for + details. +
+ ) + }); +}); diff --git a/src/components/Pagination/PaginationRow.js b/src/components/Pagination/PaginationRow.js new file mode 100644 index 00000000000..96f23441a1b --- /dev/null +++ b/src/components/Pagination/PaginationRow.js @@ -0,0 +1,185 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import PaginationRowAmountOfPages from './PaginationRowAmountOfPages'; +import PaginationRowButtonGroup from './PaginationRowButtonGroup'; +import PaginationRowItems from './PaginationRowItems'; +import PaginationRowBack from './PaginationRowBack'; +import PaginationRowForward from './PaginationRowForward'; +import { noop } from '../../common/helpers'; +import { PAGINATION_VIEW_TYPES, PAGINATION_VIEW } from './constants'; +import { Form, FormControl, FormGroup, ControlLabel } from '../Form'; +import { DropdownButton } from '../Button'; +import { MenuItem } from '../MenuItem'; + +/** + * PaginationRow component for Patternfly React + */ +const PaginationRow = ({ + baseClassName, + className, + viewType, + pagination, + pageInputValue, + amountOfPages, + itemCount, + itemsStart, + itemsEnd, + messages, + dropdownButtonId, + onSubmit, + onPerPageSelect, + onFirstPage, + onPreviousPage, + onPageInput, + onNextPage, + onLastPage +}) => { + const { page, perPage, perPageOptions = [] } = pagination; + const classes = cx(baseClassName, className, { + 'list-view-pf-pagination': viewType === PAGINATION_VIEW.LIST, + 'card-view-pf-pagination': viewType === PAGINATION_VIEW.CARD, + 'table-view-pf-pagination': viewType === PAGINATION_VIEW.TABLE, + clearfix: true + }); + const pageValue = pageInputValue !== undefined ? pageInputValue : page; + return ( +
{ + e.preventDefault(); + onSubmit(e); + }} + > + + + {perPageOptions.map((option, i) => { + return ( + + {option} + + ); + })} + + {messages.perPage} + + + + + + + {messages.currentPage} + + + + + + +
+ ); +}; +PaginationRow.propTypes = { + /** Base css class */ + baseClassName: PropTypes.string, + /** Additional css classes */ + className: PropTypes.string, + /** pagination row view type */ + viewType: PropTypes.oneOf(PAGINATION_VIEW_TYPES), + /** user pagination settings */ + pagination: PropTypes.shape({ + /** the current page */ + page: PropTypes.number.isRequired, + /** the current per page setting */ + perPage: PropTypes.number.isRequired, + /** per page options */ + perPageOptions: PropTypes.array + }), + /** page input (optional override for page input) */ + pageInputValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** calculated amount of pages */ + amountOfPages: PropTypes.number, + /** calculated number of rows */ + itemCount: PropTypes.number, + /** calculated items start */ + itemsStart: PropTypes.number, + /** calculated items end */ + itemsEnd: PropTypes.number, + /** message text inputs for i18n */ + messages: PropTypes.shape({ + firstPage: PropTypes.string, + previousPage: PropTypes.string, + nextPage: PropTypes.string, + lastPage: PropTypes.string, + perPage: PropTypes.string, + of: PropTypes.string + }), + /** dropdown button id */ + dropdownButtonId: PropTypes.string, + /** onSubmit callback */ + onSubmit: PropTypes.func, + /** per page selection callback */ + onPerPageSelect: PropTypes.func, + /** first page callback */ + onFirstPage: PropTypes.func, + /** previous page selection callback */ + onPreviousPage: PropTypes.func, + /** user page input callback */ + onPageInput: PropTypes.func, + /** next page callback */ + onNextPage: PropTypes.func, + /** last page callback */ + onLastPage: PropTypes.func +}; +PaginationRow.defaultProps = { + baseClassName: 'content-view-pf-pagination', + messages: { + firstPage: 'First Page', + previousPage: 'Previous Page', + currentPage: 'Current Page', + nextPage: 'Next Page', + lastPage: 'Last Page', + perPage: 'per page', + of: 'of' + }, + onSubmit: noop, + onPerPageSelect: noop, + onFirstPage: noop, + onPreviousPage: noop, + onPageInput: noop, + onNextPage: noop, + onLastPage: noop, + dropdownButtonId: 'pagination-row-dropdown' +}; +export default PaginationRow; diff --git a/src/components/Pagination/PaginationRowAmountOfPages.js b/src/components/Pagination/PaginationRowAmountOfPages.js new file mode 100644 index 00000000000..23c4c857cd2 --- /dev/null +++ b/src/components/Pagination/PaginationRowAmountOfPages.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * PaginationRowAmountOfPages component for Patternfly React + */ +const PaginationRowAmountOfPages = ({ + messagesOf, + amountOfPages, + ...props +}) => { + return ( + +  {messagesOf}  + {amountOfPages} + + ); +}; +PaginationRowAmountOfPages.propTypes = { + /** messages of */ + messagesOf: PropTypes.string, + /** calculated amount of pages */ + amountOfPages: PropTypes.number +}; +export default PaginationRowAmountOfPages; diff --git a/src/components/Pagination/PaginationRowArrowIcon.js b/src/components/Pagination/PaginationRowArrowIcon.js new file mode 100644 index 00000000000..7862d8fcd7c --- /dev/null +++ b/src/components/Pagination/PaginationRowArrowIcon.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '../Icon'; + +/** + * PaginationRowArrowIcon component for Patternfly React + */ +const PaginationRowArrowIcon = ({ name, ...props }) => { + const iconName = `angle-${name}`; + return ; +}; +PaginationRowArrowIcon.propTypes = { + /** icon name */ + name: PropTypes.oneOf(['left', 'double-left', 'right', 'double-right']) +}; +export default PaginationRowArrowIcon; diff --git a/src/components/Pagination/PaginationRowBack.js b/src/components/Pagination/PaginationRowBack.js new file mode 100644 index 00000000000..fe811f53010 --- /dev/null +++ b/src/components/Pagination/PaginationRowBack.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import PaginationRowArrowIcon from './PaginationRowArrowIcon'; + +/** + * PaginationRowBack component for Patternfly React + */ +const PaginationRowBack = ({ + className, + page, + messagesFirstPage, + messagesPreviousPage, + onFirstPage, + onPreviousPage, + ...props +}) => { + const classes = cx('pagination', 'pagination-pf-back', className); + return ( + + ); +}; +PaginationRowBack.propTypes = { + /** additional class names */ + className: PropTypes.string, + /** pagination page */ + page: PropTypes.number, + /** messages firstPage */ + messagesFirstPage: PropTypes.string, + /** messages previousPage */ + messagesPreviousPage: PropTypes.string, + /** first page callback */ + onFirstPage: PropTypes.func, + /** previous page selection callback */ + onPreviousPage: PropTypes.func +}; +export default PaginationRowBack; diff --git a/src/components/Pagination/PaginationRowButtonGroup.js b/src/components/Pagination/PaginationRowButtonGroup.js new file mode 100644 index 00000000000..7f860bd18d9 --- /dev/null +++ b/src/components/Pagination/PaginationRowButtonGroup.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { ButtonGroup } from '../Button'; + +const PaginationRowButtonGroup = ({ className, ...props }) => { + return ( + + ); +}; +PaginationRowButtonGroup.propTypes = { + /** additional classes */ + className: PropTypes.string +}; + +export default PaginationRowButtonGroup; diff --git a/src/components/Pagination/PaginationRowForward.js b/src/components/Pagination/PaginationRowForward.js new file mode 100644 index 00000000000..72099b2792b --- /dev/null +++ b/src/components/Pagination/PaginationRowForward.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import PaginationRowArrowIcon from './PaginationRowArrowIcon'; + +/** + * PaginationRowForward component for Patternfly React + */ +const PaginationRowForward = ({ + className, + page, + amountOfPages, + messagesNextPage, + messagesLastPage, + onNextPage, + onLastPage, + ...props +}) => { + const classes = cx('pagination', 'pagination-pf-forward', className); + return ( + + ); +}; +PaginationRowForward.propTypes = { + /** additional class names */ + className: PropTypes.string, + /** pagination page */ + page: PropTypes.number, + /** calculated amount of pages */ + amountOfPages: PropTypes.number, + /** messages next page */ + messagesNextPage: PropTypes.string, + /** messages last page */ + messagesLastPage: PropTypes.string, + /** next page callback */ + onNextPage: PropTypes.func, + /** last page callback */ + onLastPage: PropTypes.func +}; +export default PaginationRowForward; diff --git a/src/components/Pagination/PaginationRowItems.js b/src/components/Pagination/PaginationRowItems.js new file mode 100644 index 00000000000..3c6bb8a6d1c --- /dev/null +++ b/src/components/Pagination/PaginationRowItems.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * PaginationRowItems component for Patternfly React + */ +const PaginationRowItems = ({ + itemCount, + itemsStart, + itemsEnd, + messagesOf, + ...props +}) => { + return ( + + + {itemsStart}-{itemsEnd} + +  {messagesOf}  + {itemCount} + + ); +}; +PaginationRowItems.propTypes = { + /** calculated number of rows */ + itemCount: PropTypes.number, + /** calculated items start */ + itemsStart: PropTypes.number, + /** calculated items end */ + itemsEnd: PropTypes.number, + /** messages Of */ + messagesOf: PropTypes.string +}; +export default PaginationRowItems; diff --git a/src/components/Pagination/Paginator.js b/src/components/Pagination/Paginator.js new file mode 100644 index 00000000000..77f6f5a4023 --- /dev/null +++ b/src/components/Pagination/Paginator.js @@ -0,0 +1,145 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import PaginationRow from './PaginationRow'; +import { bindMethods, noop } from '../../common/helpers'; +import { PAGINATION_VIEW_TYPES } from './constants'; + +class Paginator extends React.Component { + constructor(props) { + super(props); + + bindMethods(this, ['handleFormSubmit']); + + this.initPagination(props); + + this.state = { + pageChangeValue: props.pagination.page + }; + } + + componentWillReceiveProps(nextProps) { + const { pagination } = nextProps; + if (this.state.pageChangeValue !== pagination.page) { + this.setState({ + pageChangeValue: Number(pagination.page) + }); + } + + this.initPagination(nextProps); + } + + initPagination(props) { + const { pagination } = props; + this.perPage = Number(pagination.perPage); + this.currentPage = Number(pagination.page); + this.itemCount = Number(props.itemCount); + } + + totalPages() { + return Math.ceil(this.props.itemCount / this.perPage); + } + + setPageRelative(diff) { + const { pagination } = this.props; + const page = Number(pagination.page) + diff; + this.setPage(page); + } + + setPage(value) { + const page = Number(value); + if ( + !isNaN(value) && + value !== '' && + page > 0 && + page <= this.totalPages() + ) { + this.props.onPageSet(page); + } + } + + handlePageChange(e) { + this.setState({ pageChangeValue: e.target.value }); + } + + handleFormSubmit(e) { + this.setPage(this.state.pageChangeValue); + } + + render() { + const { pageChangeValue } = this.state; + + const { + className, + viewType, + itemCount, + messages, + dropdownButtonId, + onPerPageSelect, + pagination + } = this.props; + + const itemsStart = (this.currentPage - 1) * this.perPage + 1; + const itemsEnd = Math.min(itemsStart + this.perPage - 1, this.itemCount); + const totalPages = this.totalPages(); + + return ( + this.setPage(1)} + onPreviousPage={() => this.setPageRelative(-1)} + onPageInput={e => this.handlePageChange(e)} + onNextPage={() => this.setPageRelative(1)} + onLastPage={() => this.setPage(totalPages)} + /> + ); + } +} + +Paginator.propTypes = { + /** Additional css classes */ + className: PropTypes.string, + /** pagination row view type */ + viewType: PropTypes.oneOf(PAGINATION_VIEW_TYPES), + /** user pagination settings */ + pagination: PropTypes.shape({ + /** the current page */ + page: PropTypes.number.isRequired, + /** the current per page setting */ + perPage: PropTypes.number.isRequired, + /** per page options */ + perPageOptions: PropTypes.array + }), + /** calculated number of rows */ + itemCount: PropTypes.number.isRequired, + /** message text inputs for i18n */ + messages: PropTypes.shape({ + firstPage: PropTypes.string, + previousPage: PropTypes.string, + nextPage: PropTypes.string, + lastPage: PropTypes.string, + perPage: PropTypes.string, + of: PropTypes.string + }), + /** dropdown button id */ + dropdownButtonId: PropTypes.string, + /** A callback triggered when a page is switched */ + onPageSet: PropTypes.func, + /** per page selection callback */ + onPerPageSelect: PropTypes.func +}; +Paginator.defaultProps = { + onPageSet: noop +}; + +export default Paginator; diff --git a/src/components/Pagination/__mocks__/mockPaginationRow.js b/src/components/Pagination/__mocks__/mockPaginationRow.js new file mode 100644 index 00000000000..6490ac71ba7 --- /dev/null +++ b/src/components/Pagination/__mocks__/mockPaginationRow.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { bindMethods } from '../../../common/helpers'; +import { PaginationRow, PAGINATION_VIEW_TYPES } from '../index'; + +export class MockPaginationRow extends React.Component { + constructor(props) { + super(props); + this.state = { + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15, 25, 50] + } + }; + bindMethods(this, ['onPageInput', 'onPerPageSelect']); + } + onPageInput(e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = e.target.value; + this.setState({ pagination: newPaginationState }); + } + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + this.setState({ pagination: newPaginationState }); + } + render() { + const { + viewType, + amountOfPages, + itemCount, + itemsStart, + itemsEnd, + onFirstPage, + onPreviousPage, + onNextPage, + onLastPage + } = this.props; + + return ( + + ); + } +} +MockPaginationRow.propTypes = { + viewType: PropTypes.oneOf(PAGINATION_VIEW_TYPES), + amountOfPages: PropTypes.number, + itemCount: PropTypes.number, + itemsStart: PropTypes.number, + itemsEnd: PropTypes.number, + onFirstPage: PropTypes.func, + onPreviousPage: PropTypes.func, + onNextPage: PropTypes.func, + onLastPage: PropTypes.func +}; + +export const mockPaginationSource = ` +import React from 'react'; +import PropTypes from 'prop-types'; +import { bindMethods } from '../../../common/helpers'; +import { PaginationRow, PAGINATION_VIEW_TYPES } from '../index'; + +export class MockPaginationRow extends React.Component { + constructor(props) { + super(props); + this.state = { + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15, 25, 50] + } + }; + bindMethods(this, ['onPageInput', 'onPerPageSelect']); + } + onPageInput(e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = e.target.value; + this.setState({ pagination: newPaginationState }); + } + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + this.setState({ pagination: newPaginationState }); + } + render() { + const { + viewType, + amountOfPages, + itemCount, + itemsStart, + itemsEnd, + onFirstPage, + onPreviousPage, + onNextPage, + onLastPage + } = this.props; + + return ( + + ); + } +} +MockPaginationRow.propTypes = { + viewType: PropTypes.oneOf(PAGINATION_VIEW_TYPES), + amountOfPages: PropTypes.number, + itemCount: PropTypes.number, + itemsStart: PropTypes.number, + itemsEnd: PropTypes.number, + onFirstPage: PropTypes.func, + onPreviousPage: PropTypes.func, + onNextPage: PropTypes.func, + onLastPage: PropTypes.func +}; +`; diff --git a/src/components/Pagination/__snapshots__/Paginate.test.js.snap b/src/components/Pagination/__snapshots__/Paginate.test.js.snap new file mode 100644 index 00000000000..e1e332d3f21 --- /dev/null +++ b/src/components/Pagination/__snapshots__/Paginate.test.js.snap @@ -0,0 +1,1364 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaginationRow card renders properly 1`] = ` +
+
+
+ + +
+ + לדף + +
+
+ + + 1 + - + 15 + +   + שֶׁל +   + + 75 + + + +
+
+`; + +exports[`PaginationRow list renders properly 1`] = ` +
+
+
+ + +
+ + לדף + +
+
+ + + 1 + - + 15 + +   + שֶׁל +   + + 75 + + + +
+
+`; + +exports[`PaginationRow table renders properly 1`] = ` +
+
+
+ + +
+ + לדף + +
+
+ + + 1 + - + 15 + +   + שֶׁל +   + + 75 + + + +
+
+`; + +exports[`PaginationRow.AmountOfPages renders 1`] = ` + +   + of +   + + 4 + + +`; + +exports[`PaginationRow.Back renders 1`] = ` + +`; + +exports[`PaginationRow.ButtonGroup renders 1`] = ` +
+`; + +exports[`PaginationRow.Forward renders 1`] = ` + +`; + +exports[`PaginationRow.Items renders 1`] = ` + + + 0 + - + 10 + +   + of +   + + 55 + + +`; + +exports[`Paginator renders properly a middle page 1`] = ` +
+
+
+ + +
+ + per page + +
+
+ + + 31 + - + 40 + +   + of +   + + 75 + + + + + + +   + of +   + + 8 + + + +
+
+`; + +exports[`Paginator renders properly the first page 1`] = ` +
+
+
+ + +
+ + per page + +
+
+ + + 1 + - + 10 + +   + of +   + + 75 + + + + + + +   + of +   + + 8 + + + +
+
+`; + +exports[`Paginator renders properly the last page 1`] = ` +
+
+
+ + +
+ + per page + +
+
+ + + 71 + - + 75 + +   + of +   + + 75 + + + + + + +   + of +   + + 8 + + + +
+
+`; diff --git a/src/components/Pagination/constants.js b/src/components/Pagination/constants.js new file mode 100644 index 00000000000..7b344fef3b1 --- /dev/null +++ b/src/components/Pagination/constants.js @@ -0,0 +1,11 @@ +export const PAGINATION_VIEW = { + LIST: 'list', + CARD: 'card', + TABLE: 'table' +}; + +export const PAGINATION_VIEW_TYPES = [ + PAGINATION_VIEW.LIST, + PAGINATION_VIEW.CARD, + PAGINATION_VIEW.TABLE +]; diff --git a/src/components/Pagination/index.js b/src/components/Pagination/index.js new file mode 100644 index 00000000000..a01045aa944 --- /dev/null +++ b/src/components/Pagination/index.js @@ -0,0 +1,33 @@ +import paginate from './paginate'; +import { PAGINATION_VIEW, PAGINATION_VIEW_TYPES } from './constants'; +import Paginator from './Paginator'; +import PaginationRow from './PaginationRow'; +import PaginationRowAmountOfPages from './PaginationRowAmountOfPages'; +import PaginationRowArrowIcon from './PaginationRowArrowIcon'; +import PaginationRowBack from './PaginationRowBack'; +import PaginationRowButtonGroup from './PaginationRowButtonGroup'; +import PaginationRowForward from './PaginationRowForward'; +import PaginationRowItems from './PaginationRowItems'; + +PaginationRow.AmountOfPages = PaginationRowAmountOfPages; +PaginationRow.ArrowIcon = PaginationRowArrowIcon; +PaginationRow.Back = PaginationRowBack; +PaginationRow.ButtonGroup = PaginationRowButtonGroup; +PaginationRow.Forward = PaginationRowForward; +PaginationRow.Items = PaginationRowItems; +PaginationRow.PAGINATION_VIEW = PAGINATION_VIEW; +PaginationRow.PAGINATION_VIEW_TYPES = PAGINATION_VIEW_TYPES; + +export { + paginate, + Paginator, + PAGINATION_VIEW, + PAGINATION_VIEW_TYPES, + PaginationRow, + PaginationRowAmountOfPages, + PaginationRowArrowIcon, + PaginationRowBack, + PaginationRowButtonGroup, + PaginationRowForward, + PaginationRowItems +}; diff --git a/src/components/Pagination/paginate.js b/src/components/Pagination/paginate.js new file mode 100644 index 00000000000..6ba10f41cfd --- /dev/null +++ b/src/components/Pagination/paginate.js @@ -0,0 +1,20 @@ +/** + * Client Side Pagination helper which returns amountOfPages, itemCount, + * itemsStart, itemsEnd, and paginated rows + */ +export default function paginate({ page, perPage }) { + return (rows = []) => { + // adapt to zero indexed logic + const p = page - 1 || 0; + const amountOfPages = Math.ceil(rows.length / perPage); + const startPage = p < amountOfPages ? p : 0; + const endOfPage = startPage * perPage + perPage; + return { + amountOfPages: amountOfPages, + itemCount: rows.length, + itemsStart: startPage * perPage + 1, + itemsEnd: endOfPage > rows.length ? rows.length : endOfPage, + rows: rows.slice(startPage * perPage, endOfPage) + }; + }; +} diff --git a/src/components/Table/Formatters/actionHeaderCellFormatter.js b/src/components/Table/Formatters/actionHeaderCellFormatter.js new file mode 100644 index 00000000000..b38027d50c9 --- /dev/null +++ b/src/components/Table/Formatters/actionHeaderCellFormatter.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from '../index'; + +const actionHeaderCellFormatter = (value, { column }) => { + return ( + + {column.header.label} + + ); +}; +actionHeaderCellFormatter.propTypes = { + /** cell value */ + value: PropTypes.node, + /** column definition */ + column: PropTypes.object +}; +export default actionHeaderCellFormatter; diff --git a/src/components/Table/Formatters/customHeaderFormattersDefinition.js b/src/components/Table/Formatters/customHeaderFormattersDefinition.js new file mode 100644 index 00000000000..cd706f975cb --- /dev/null +++ b/src/components/Table/Formatters/customHeaderFormattersDefinition.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; + +// wraps the default header definitions and adds support for `customFormatters` +const customHeaderFormattersDefinition = ({ + cellProps, + columns, + sortingColumns, + rows, + onSelectAllRows, + onSort +}) => { + const { index } = cellProps; + const column = columns[index]; + const customFormatters = column.header.customFormatters; + + if (customFormatters) { + return customFormatters.reduce( + (params, formatter) => ({ + value: formatter(params) + }), + { cellProps, column, sortingColumns, rows, onSelectAllRows, onSort } + ).value; + } else { + return cellProps.children; + } +}; +customHeaderFormattersDefinition.propTypes = { + /** column header cell props */ + cellProps: PropTypes.object, + /** column definitions */ + columns: PropTypes.array, + /** sorting object definition */ + sortingColumns: PropTypes.object, + /** current table rows */ + rows: PropTypes.array, + /** on select all rows callback */ + onSelectAllRows: PropTypes.func, + /** onSort callback */ + onSort: PropTypes.func +}; +export default customHeaderFormattersDefinition; diff --git a/src/components/Table/Formatters/selectionCellFormatter.js b/src/components/Table/Formatters/selectionCellFormatter.js new file mode 100644 index 00000000000..21a93b88700 --- /dev/null +++ b/src/components/Table/Formatters/selectionCellFormatter.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { noop } from '../../../common/helpers'; +import { Table } from '../index'; + +const selectionCellFormatter = ( + { rowData, rowIndex }, + onSelectRow = noop, + id, + label +) => { + const checkboxId = id || `select${rowIndex}`; + const checkboxLabel = label || `Select row ${rowIndex}`; + return ( + + { + onSelectRow(e, rowData); + }} + /> + + ); +}; +selectionCellFormatter.propTypes = { + /** rowData for this row */ + rowData: PropTypes.object, // eslint-disable-line react/no-unused-prop-types + /** rowIndex for this row */ + rowIndex: PropTypes.number, // eslint-disable-line react/no-unused-prop-types + /** row selected callback */ + onSelectRow: PropTypes.func, // eslint-disable-line react/no-unused-prop-types + /** checkbox id override */ + id: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + /** checkbox label override */ + label: PropTypes.string // eslint-disable-line react/no-unused-prop-types +}; + +export default selectionCellFormatter; diff --git a/src/components/Table/Formatters/selectionHeaderCellFormatter.js b/src/components/Table/Formatters/selectionHeaderCellFormatter.js new file mode 100644 index 00000000000..7af494163d7 --- /dev/null +++ b/src/components/Table/Formatters/selectionHeaderCellFormatter.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from '../index'; + +const selectionHeaderCellFormatter = ({ + cellProps, + column, + rows, + onSelectAllRows +}) => { + const unselectedRows = rows.filter(r => !r.selected).length > 0; + const id = cellProps.id || 'selectAll'; + return ( + + + + ); +}; +selectionHeaderCellFormatter.propTypes = { + /** column header cell props */ + cellProps: PropTypes.object, + /** column definition */ + column: PropTypes.object, + /** current table rows */ + rows: PropTypes.array, + /** on select all rows callback */ + onSelectAllRows: PropTypes.func +}; +export default selectionHeaderCellFormatter; diff --git a/src/components/Table/Formatters/sortableHeaderCellFormatter.js b/src/components/Table/Formatters/sortableHeaderCellFormatter.js new file mode 100644 index 00000000000..95b0bb7d81e --- /dev/null +++ b/src/components/Table/Formatters/sortableHeaderCellFormatter.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { noop } from '../../../common/helpers'; +import { Table } from '../index'; + +const sortableHeaderCellFormatter = ({ + cellProps, + column, + sortingColumns, + onSort +}) => { + const sortDirection = + sortingColumns[column.property] && + sortingColumns[column.property].direction; + return ( + { + onSort(e, column, sortDirection); + }} + sort + sortDirection={sortDirection} + aria-label={column.header.label} + {...cellProps} + > + {column.header.label} + + ); +}; +sortableHeaderCellFormatter.propTypes = { + /** column header cell props */ + cellProps: PropTypes.object, + /** column definition */ + column: PropTypes.object, + /** sorting object definition */ + sortingColumns: PropTypes.object, + /** onSort callback */ + onSort: PropTypes.func +}; +sortableHeaderCellFormatter.defaultProps = { + onSort: noop +}; + +export default sortableHeaderCellFormatter; diff --git a/src/components/Table/Formatters/tableCellFormatter.js b/src/components/Table/Formatters/tableCellFormatter.js new file mode 100644 index 00000000000..f869af507cf --- /dev/null +++ b/src/components/Table/Formatters/tableCellFormatter.js @@ -0,0 +1,12 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from '../index'; + +const tableCellFormatter = value => { + return {value}; +}; +tableCellFormatter.propTypes = { + /** cell value */ + value: PropTypes.node // eslint-disable-line react/no-unused-prop-types +}; +export default tableCellFormatter; diff --git a/src/components/Table/Stories/BootstrapTableStory.js b/src/components/Table/Stories/BootstrapTableStory.js new file mode 100644 index 00000000000..70d087899d6 --- /dev/null +++ b/src/components/Table/Stories/BootstrapTableStory.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { Table } from '../index'; +import { mockBootstrapRows } from '../__mocks__/mockBootstrapRows'; +import { mockBootstrapColumns } from '../__mocks__/mockBootstrapColumns'; + +import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../../storybook/constants'; +import { reactabularDescription } from './tableStoryDescriptions'; + +/** + * Bootstrap Table stories + */ + +const bootstrapTableAddWithInfo = stories => { + stories.addWithInfo('Bootstrap Table Styles', '', () => { + let story = ( +
+

Basic example

+ + Optional table caption. + + + + +

Striped Rows

+ + + + + +

Bordered Table

+ + + + + +

Hover Rows

+ + + + + +

Condensed table

+ + + + + +

Contextual Classes

+ + + { + switch (rowIndex) { + case 0: + return { className: 'active' }; + case 2: + return { className: 'success' }; + case 4: + return { className: 'warning' }; + case 6: + return { className: 'danger' }; + } + }} + /> + + +

Responsive Tables

+
+ + + + +
+
+ ); + + return inlineTemplate({ + title: 'Bootstrap Table Styles', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_CONTENT_VIEWS + 'table-view/', + story: story, + description: reactabularDescription + }); + }); +}; + +export default bootstrapTableAddWithInfo; diff --git a/src/components/Table/Stories/ClientPaginationTableStory.js b/src/components/Table/Stories/ClientPaginationTableStory.js new file mode 100644 index 00000000000..8c7bb5e9e0b --- /dev/null +++ b/src/components/Table/Stories/ClientPaginationTableStory.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { withInfo } from '@storybook/addon-info'; +import { decorateAction } from '@storybook/addon-actions'; +import { + MockClientPaginationTable, + mockClientPaginationTableSource +} from '../__mocks__/mockClientPaginationTable'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table +} from '../index'; +import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../../storybook/constants'; +import { reactabularDescription } from './tableStoryDescriptions'; + +/** + * Client Pagination Table stories + */ + +const clientPaginationTableAddWithInfo = stories => { + stories.add( + 'Client Paginated Table', + withInfo({ + source: false, + propTables: [ + Table.Actions, + Table.Button, + Table.Cell, + Table.Checkbox, + Table.DropdownKebab, + Table.Heading, + Table.PfProvider, + Table.SelectionCell, + Table.SelectionHeading, + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter + ], + propTablesExclude: [MockClientPaginationTable], + text: ( +
+

Story Source

+
{mockClientPaginationTableSource}
+
+ ) + })(() => { + const logAction = decorateAction([args => args]); + let story = ( + + ); + return inlineTemplate({ + title: 'Client Paginated Table', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_CONTENT_VIEWS + 'table-view/', + story: story, + description: reactabularDescription + }); + }) + ); +}; + +export default clientPaginationTableAddWithInfo; diff --git a/src/components/Table/Stories/ClientSortableTableStory.js b/src/components/Table/Stories/ClientSortableTableStory.js new file mode 100644 index 00000000000..a94cdd52d27 --- /dev/null +++ b/src/components/Table/Stories/ClientSortableTableStory.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { withInfo } from '@storybook/addon-info'; +import { + MockClientSortableTable, + mockClientSortableTableSource +} from '../__mocks__/mockClientSortableTable'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + sortableHeaderCellFormatter, + tableCellFormatter, + Table +} from '../index'; +import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../../storybook/constants'; +import { reactabularDescription } from './tableStoryDescriptions'; + +/** + * Client Sortable Table stories + */ + +const clientSortableTableAddWithInfo = stories => { + stories.add( + 'Client Sortable Table', + withInfo({ + source: false, + propTablesExclude: [MockClientSortableTable], + propTables: [ + Table.Actions, + Table.Button, + Table.Cell, + Table.DropdownKebab, + Table.Heading, + Table.PfProvider, + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + sortableHeaderCellFormatter, + tableCellFormatter + ], + text: ( +
+

Story Source

+
{mockClientSortableTableSource}
+
+ ) + })(() => { + let story = ; + return inlineTemplate({ + title: 'Client Sortable Table', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_CONTENT_VIEWS + 'table-view/', + story: story, + description: reactabularDescription + }); + }) + ); +}; + +export default clientSortableTableAddWithInfo; diff --git a/src/components/Table/Stories/PatternflyTableStory.js b/src/components/Table/Stories/PatternflyTableStory.js new file mode 100644 index 00000000000..73f3791a55a --- /dev/null +++ b/src/components/Table/Stories/PatternflyTableStory.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { Table } from '../index'; +import { mockBootstrapRows } from '../__mocks__/mockBootstrapRows'; +import { mockPatternflyColumns } from '../__mocks__/mockBootstrapColumns'; + +import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../../storybook/constants'; +import { reactabularDescription } from './tableStoryDescriptions'; + +/** + * Patternfly Table stories + */ + +const patternflyTableAddWithInfo = stories => { + stories.addWithInfo('PatternFly Table Styles', '', () => { + let story = ( +
+

+ PatternFly recommendation: Bootstrap striped, bordered, hover, and + responsive +

+ + + + +
+ ); + return inlineTemplate({ + title: 'PatternFly Table Styles', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_CONTENT_VIEWS + 'table-view/', + story: story, + description: reactabularDescription + }); + }); +}; + +export default patternflyTableAddWithInfo; diff --git a/src/components/Table/Stories/ServerPaginationTableStory.js b/src/components/Table/Stories/ServerPaginationTableStory.js new file mode 100644 index 00000000000..cc20efb0d54 --- /dev/null +++ b/src/components/Table/Stories/ServerPaginationTableStory.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { withInfo } from '@storybook/addon-info'; +import { decorateAction } from '@storybook/addon-actions'; +import { + MockServerPaginationTable, + mockServerPaginationTableSource +} from '../__mocks__/mockServerPaginationTable'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table +} from '../index'; +import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; +import { DOCUMENTATION_URL } from '../../../../storybook/constants'; +import { reactabularDescription } from './tableStoryDescriptions'; + +/** + * Server Pagination Table stories + */ + +const serverPaginationTableAddWithInfo = stories => { + stories.add( + 'Server Paginated Table', + withInfo({ + source: false, + propTablesExclude: [MockServerPaginationTable], + propTables: [ + Table.Actions, + Table.Button, + Table.Cell, + Table.Checkbox, + Table.DropdownKebab, + Table.Heading, + Table.PfProvider, + Table.SelectionCell, + Table.SelectionHeading, + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter + ], + text: ( +
+

Story Source

+
{mockServerPaginationTableSource}
+
+ ) + })(() => { + const logAction = decorateAction([args => args]); + let story = ( + + ); + return inlineTemplate({ + title: 'Server Paginated Table', + documentationLink: + DOCUMENTATION_URL.PATTERNFLY_ORG_CONTENT_VIEWS + 'table-view/', + story: story, + description: reactabularDescription + }); + }) + ); +}; + +export default serverPaginationTableAddWithInfo; diff --git a/src/components/Table/Stories/index.js b/src/components/Table/Stories/index.js new file mode 100644 index 00000000000..224d16049ef --- /dev/null +++ b/src/components/Table/Stories/index.js @@ -0,0 +1,11 @@ +export { default as bootstrapTableAddWithInfo } from './BootstrapTableStory'; +export { default as patternflyTableAddWithInfo } from './PatternflyTableStory'; +export { + default as clientSortableTableAddWithInfo +} from './ClientSortableTableStory'; +export { + default as clientPaginationTableAddWithInfo +} from './ClientPaginationTableStory'; +export { + default as serverPaginationTableAddWithInfo +} from './ServerPaginationTableStory.js'; diff --git a/src/components/Table/Stories/tableStoryDescriptions.js b/src/components/Table/Stories/tableStoryDescriptions.js new file mode 100644 index 00000000000..b038b670538 --- /dev/null +++ b/src/components/Table/Stories/tableStoryDescriptions.js @@ -0,0 +1,9 @@ +import React from 'react'; + +export const reactabularDescription = ( +
+ PatternFly React Tables are based on Reactabular. See{' '} + Reactabular Docs for complete + Reactabular documentation. +
+); diff --git a/src/components/Table/Table.js b/src/components/Table/Table.js new file mode 100644 index 00000000000..f6f810e67f4 --- /dev/null +++ b/src/components/Table/Table.js @@ -0,0 +1 @@ +export * as Table from 'reactabular-table'; diff --git a/src/components/Table/Table.stories.js b/src/components/Table/Table.stories.js new file mode 100644 index 00000000000..69b8f2946d2 --- /dev/null +++ b/src/components/Table/Table.stories.js @@ -0,0 +1,22 @@ +import { storiesOf } from '@storybook/react'; +import { withKnobs } from '@storybook/addon-knobs'; +import { + bootstrapTableAddWithInfo, + clientSortableTableAddWithInfo, + clientPaginationTableAddWithInfo, + patternflyTableAddWithInfo, + serverPaginationTableAddWithInfo +} from './Stories'; + +const stories = storiesOf('Table', module); +stories.addDecorator(withKnobs); + +/** + * Table stories + */ + +bootstrapTableAddWithInfo(stories); +patternflyTableAddWithInfo(stories); +clientSortableTableAddWithInfo(stories); +clientPaginationTableAddWithInfo(stories); +serverPaginationTableAddWithInfo(stories); diff --git a/src/components/Table/Table.test.js b/src/components/Table/Table.test.js new file mode 100644 index 00000000000..49202c6a253 --- /dev/null +++ b/src/components/Table/Table.test.js @@ -0,0 +1,142 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { Table } from './index'; + +import { mockBootstrapRows } from './__mocks__/mockBootstrapRows'; +import { + mockBootstrapColumns, + mockPatternflyColumns +} from './__mocks__/mockBootstrapColumns'; + +import { MockClientPaginationTable } from './__mocks__/mockClientPaginationTable'; +import { MockServerPaginationTable } from './__mocks__/mockServerPaginationTable'; + +test('Mock Client Pagination table renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Mock Server Pagination table renders', () => { + const component = renderer.create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap basic table renders properly', () => { + const component = renderer.create( + + Optional table caption. + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap striped table renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap bordered table renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap hover table renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap condensed table renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap contextual classes table renders properly', () => { + const component = renderer.create( + + + { + switch (rowIndex) { + case 0: + return { className: 'active' }; + case 2: + return { className: 'success' }; + case 4: + return { className: 'warning' }; + case 6: + return { className: 'danger' }; + } + }} + /> + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Bootstrap responsive table renders properly', () => { + const component = renderer.create( +
+ + + + +
+ ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Patternfly table renders properly', () => { + const component = renderer.create( + + + + + ); + + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/Table/TableActions.js b/src/components/Table/TableActions.js new file mode 100644 index 00000000000..ab963cd69c1 --- /dev/null +++ b/src/components/Table/TableActions.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +/** + * TableActions component for Patternfly React + */ +const TableActions = ({ children, className, ...props }) => { + const classes = cx('table-view-pf-actions', className); + return ( + + {children} + + ); +}; +TableActions.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string +}; +export default TableActions; diff --git a/src/components/Table/TableButton.js b/src/components/Table/TableButton.js new file mode 100644 index 00000000000..dfaedd45b7b --- /dev/null +++ b/src/components/Table/TableButton.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { Button } from '../Button'; + +/** + * TableButton component for Patternfly React + */ +const TableButton = ({ children, className, onClick, ...props }) => { + const classes = cx('table-view-pf-btn', className); + return ( +
+ +
+ ); +}; +TableButton.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string, + /** onClick callback for button */ + onClick: PropTypes.func +}; +export default TableButton; diff --git a/src/components/Table/TableCell.js b/src/components/Table/TableCell.js new file mode 100644 index 00000000000..25f5b059083 --- /dev/null +++ b/src/components/Table/TableCell.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { TABLE_ALIGN, TABLE_ALIGNMENT_TYPES } from './constants'; +/** + * TableCell component for Patternfly React + */ +const TableCell = ({ children, className, align, ...props }) => { + const classes = cx( + { + 'text-right': align === TABLE_ALIGN.RIGHT, + 'text-center': align === TABLE_ALIGN.CENTER + }, + className + ); + return ( + + {children} + + ); +}; +TableCell.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string, + /** Cell alignment */ + align: PropTypes.oneOf(TABLE_ALIGNMENT_TYPES) +}; +export default TableCell; diff --git a/src/components/Table/TableCheckbox.js b/src/components/Table/TableCheckbox.js new file mode 100644 index 00000000000..3589563bb9d --- /dev/null +++ b/src/components/Table/TableCheckbox.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ControlLabel } from '../Form'; + +/** + * TableCheckbox component for Patternfly React + */ +const TableCheckbox = ({ id, label, checked, onChange, ...props }) => { + return ( + + + {label} + + + + ); +}; +TableCheckbox.propTypes = { + /** checkbox id */ + id: PropTypes.string, + /** checkbox label */ + label: PropTypes.string, + /** checkbox is checked */ + checked: PropTypes.bool, + /** onChange callback */ + onChange: PropTypes.func +}; +export default TableCheckbox; diff --git a/src/components/Table/TableDropdownKebab.js b/src/components/Table/TableDropdownKebab.js new file mode 100644 index 00000000000..2586990cfee --- /dev/null +++ b/src/components/Table/TableDropdownKebab.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ButtonGroup } from '../Button'; +import { DropdownKebab } from '../DropdownKebab'; + +/** + * TableDropdownKebab component for Patternfly React + */ +const TableDropdownKebab = ({ children, ...props }) => { + const CustomButtonGroup = props => { + return ; + }; + + return ( + + {children} + + ); +}; +TableDropdownKebab.propTypes = { + /** children nodes */ + children: PropTypes.node +}; + +export default TableDropdownKebab; diff --git a/src/components/Table/TableHeading.js b/src/components/Table/TableHeading.js new file mode 100644 index 00000000000..332e815b2f5 --- /dev/null +++ b/src/components/Table/TableHeading.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { + TABLE_ALIGN, + TABLE_ALIGNMENT_TYPES, + TABLE_SORT_DIRECTION, + TABLE_SORT_DIRECTIONS +} from './constants'; + +/** + * TableHeading component for Patternfly React + */ +const TableHeading = ({ + children, + className, + align, + sort, + sortDirection, + ...props +}) => { + const sortingClass = cx({ + sorting_asc: sortDirection === TABLE_SORT_DIRECTION.ASC, + sorting_desc: sortDirection === TABLE_SORT_DIRECTION.DESC + }); + const classes = cx( + { + 'text-right': align === TABLE_ALIGN.RIGHT, + 'text-center': align === TABLE_ALIGN.CENTER + }, + sort ? sortingClass || 'sorting' : '', + className + ); + return ( + + {children} + + ); +}; +TableHeading.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string, + /** Heading alignment */ + align: PropTypes.oneOf(TABLE_ALIGNMENT_TYPES), + /** sortable heading */ + sort: PropTypes.bool, + /** sort direction */ + sortDirection: PropTypes.oneOf(TABLE_SORT_DIRECTIONS) +}; +export default TableHeading; diff --git a/src/components/Table/TablePfProvider.js b/src/components/Table/TablePfProvider.js new file mode 100644 index 00000000000..95ce6fd3e02 --- /dev/null +++ b/src/components/Table/TablePfProvider.js @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { Table } from './index'; +/** + * TablePfProvider component for Patternfly React + */ +const TablePfProvider = ({ + children, + className, + dataTable, + striped, + bordered, + hover, + condensed, + components, + ...props +}) => { + const headerCell = cellProps => { + return cellProps.children; + }; + const tableCell = cellProps => { + return cellProps.children; + }; + let mergedComponents = Object.assign( + { header: { cell: headerCell }, body: { cell: tableCell } }, + components + ); + const classes = cx( + { + table: true, + dataTable: dataTable, + 'table-striped': striped, + 'table-bordered': bordered, + 'table-hover': hover, + 'table-condensed': condensed + }, + className + ); + let attributes = {}; + if (dataTable) { + attributes.role = 'grid'; + } + + return ( + + {children} + + ); +}; +TablePfProvider.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string, + /** apply dataTable class */ + dataTable: PropTypes.bool, + /** apply Striped class */ + striped: PropTypes.bool, + /** apply Bordered class */ + bordered: PropTypes.bool, + /** apply Hover class */ + hover: PropTypes.bool, + /** apply Condensed class */ + condensed: PropTypes.bool, + /** reactabular components override */ + components: PropTypes.object +}; +export default TablePfProvider; diff --git a/src/components/Table/TableSelectionCell.js b/src/components/Table/TableSelectionCell.js new file mode 100644 index 00000000000..5056fe49042 --- /dev/null +++ b/src/components/Table/TableSelectionCell.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +/** + * TableSelectionCell component for Patternfly React + */ +const TableSelectionCell = ({ children, className, ...props }) => { + const classes = cx('table-view-pf-select', className); + return ( + + {children} + + ); +}; +TableSelectionCell.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string +}; +export default TableSelectionCell; diff --git a/src/components/Table/TableSelectionHeading.js b/src/components/Table/TableSelectionHeading.js new file mode 100644 index 00000000000..f5e294175e2 --- /dev/null +++ b/src/components/Table/TableSelectionHeading.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +/** + * TableSelectionHeading component for Patternfly React + */ +const TableSelectionHeading = ({ children, className, ...props }) => { + const classes = cx('table-view-pf-select', className); + return ( + + {children} + + ); +}; +TableSelectionHeading.propTypes = { + /** Children nodes */ + children: PropTypes.node, + /** Additional css classes */ + className: PropTypes.string +}; +export default TableSelectionHeading; diff --git a/src/components/Table/__mocks__/mockBootstrapColumns.js b/src/components/Table/__mocks__/mockBootstrapColumns.js new file mode 100644 index 00000000000..72f9d457000 --- /dev/null +++ b/src/components/Table/__mocks__/mockBootstrapColumns.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { Table } from '../index'; + +const headerFormat = value => { + return {value}; +}; +const cellFormat = value => { + return {value}; +}; + +export const mockBootstrapColumns = [ + { + header: { + label: '#', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'id' + }, + { + header: { + label: 'First Name', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'first_name' + }, + { + header: { + label: 'Last Name', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'last_name' + }, + { + header: { + label: 'Username', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'username' + } +]; + +const headerFormatRightAlign = value => { + return {value}; +}; +const cellFormatRightAlign = value => { + return {value}; +}; + +export const mockPatternflyColumns = [ + { + header: { + label: 'First Name', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'first_name' + }, + { + header: { + label: 'Last Name', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'last_name' + }, + { + header: { + label: 'Username', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'username' + }, + { + header: { + label: 'Commits', + formatters: [headerFormatRightAlign] + }, + cell: { + formatters: [cellFormatRightAlign] + }, + property: 'commits' + }, + { + header: { + label: 'Additions', + formatters: [headerFormatRightAlign] + }, + cell: { + formatters: [cellFormatRightAlign] + }, + property: 'additions' + }, + { + header: { + label: 'Location', + formatters: [ + value => { + return {value}; + } + ] + }, + cell: { + formatters: [ + value => { + return {value}; + } + ] + }, + property: 'location' + }, + { + header: { + label: 'Gender', + formatters: [headerFormat] + }, + cell: { + formatters: [cellFormat] + }, + property: 'gender' + } +]; diff --git a/src/components/Table/__mocks__/mockBootstrapRows.js b/src/components/Table/__mocks__/mockBootstrapRows.js new file mode 100644 index 00000000000..c345d073d09 --- /dev/null +++ b/src/components/Table/__mocks__/mockBootstrapRows.js @@ -0,0 +1,72 @@ +export const mockBootstrapRows = [ + { + id: 0, + first_name: 'Dan', + last_name: 'Abramov', + username: 'gaearon', + commits: 711, + additions: 272635, + location: 'London, UK', + gender: 'male' + }, + { + id: 1, + first_name: 'Sebastian', + last_name: 'Markbåge', + username: 'sebmarkbage', + commits: 476, + additions: 203610, + location: 'San Francisco, CA', + gender: 'male' + }, + { + id: 2, + first_name: 'Sophie', + last_name: 'Alpert', + username: 'sophiebits', + commits: 828, + additions: 114467, + location: 'California', + gender: 'female' + }, + { + id: 3, + first_name: 'Paul', + last_name: 'O’Shannessy', + username: 'zpao', + commits: 820, + additions: 87324, + location: 'Seattle, WA', + gender: 'male' + }, + { + id: 4, + first_name: 'Pete', + last_name: 'Hunt', + username: 'petehunt', + commits: 205, + additions: 86685, + location: 'San Francisco, CA', + gender: 'male' + }, + { + id: 5, + first_name: 'Andrew', + last_name: 'Clark', + username: 'acdlite', + commits: 320, + additions: 74162, + location: 'Redwood City, CA', + gender: 'male' + }, + { + id: 6, + first_name: 'Nathan', + last_name: 'Hunzaker', + username: 'nhunzaker', + commits: 77, + additions: 34504, + location: 'Durham, NC', + gender: 'male' + } +]; diff --git a/src/components/Table/__mocks__/mockClientPaginationTable.js b/src/components/Table/__mocks__/mockClientPaginationTable.js new file mode 100644 index 00000000000..6e173bda2b6 --- /dev/null +++ b/src/components/Table/__mocks__/mockClientPaginationTable.js @@ -0,0 +1,918 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import orderBy from 'lodash.orderby'; +import cx from 'classnames'; +import * as sort from 'sortabular'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + defaultSortingOrder, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { Grid } from '../../Grid'; +import { PaginationRow, paginate, PAGINATION_VIEW } from '../../Pagination'; +import { compose } from 'recompose'; +import { mockRows } from './mockRows'; + +/** + * Reactabular client side paging based on the following api docs: + * https://reactabular.js.org/#/data/pagination + */ + +export class MockClientPaginationTable extends React.Component { + constructor(props) { + super(props); + + const getSortingColumns = () => this.state.sortingColumns || {}; + + const sortableTransform = sort.sort({ + getSortingColumns, + onSort: selectedColumn => { + this.setState({ + sortingColumns: sort.byColumn({ + sortingColumns: this.state.sortingColumns, + sortingOrder: defaultSortingOrder, + selectedColumn + }) + }); + }, + // Use property or index dependening on the sortingColumns structure specified + strategy: sort.strategies.byProperty + }); + + const sortingFormatter = sort.header({ + sortableTransform, + getSortingColumns, + strategy: sort.strategies.byProperty + }); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + + bindMethods(this, [ + 'customHeaderFormatters', + 'onPageInput', + 'onSubmit', + 'onPerPageSelect', + 'onFirstPage', + 'onPreviousPage', + 'onNextPage', + 'onLastPage', + 'onRow', + 'onSelectAllRows', + 'onSelectRow', + 'setPage', + 'totalPages' + ]); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + + // column definitions + columns: [ + { + property: 'select', + header: { + label: 'Vyberte všechny řádky', + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + id: 'vybrat vše' + }, + customFormatters: [selectionHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [ + (value, { rowData, rowIndex }) => { + return selectionCellFormatter( + { rowData, rowIndex }, + this.onSelectRow, + `vybrat${rowIndex}`, + `vyberte řádek ${rowIndex}` + ); + } + ] + } + }, + { + property: 'name', + header: { + label: 'Name', + props: { + index: 1, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 2, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 4, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 5, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 6, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 6 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + + // rows and row selection state + rows: mockRows, + selectedRows: [], + + // pagination default states + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15] + }, + + // page input value + pageChangeValue: 1 + }; + } + totalPages() { + const { perPage } = this.state.pagination; + return Math.ceil(mockRows.length / perPage); + } + onPageInput(e) { + this.setState({ pageChangeValue: e.target.value }); + } + onSubmit() { + this.setPage(this.state.pageChangeValue); + } + setPage(value) { + const page = Number(value); + if ( + !isNaN(value) && + value !== '' && + page > 0 && + page <= this.totalPages() + ) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = page; + this.setState({ pagination: newPaginationState, pageChangeValue: page }); + } + } + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + newPaginationState.page = 1; + this.setState({ pagination: newPaginationState }); + } + onFirstPage() { + this.setPage(1); + } + onPreviousPage() { + if (this.state.pagination.page > 1) { + this.setPage(this.state.pagination.page - 1); + } + } + onNextPage() { + const { page } = this.state.pagination; + if (page < this.totalPages()) { + this.setPage(this.state.pagination.page + 1); + } + } + onLastPage() { + const { page } = this.state.pagination; + const totalPages = this.totalPages(); + if (page < totalPages) { + this.setPage(totalPages); + } + } + onSelectRow(event, row) { + const { onRowsLogger } = this.props; + const { rows, selectedRows } = this.state; + const selectedRowIndex = rows.findIndex(r => r.id === row.id); + if (selectedRowIndex > -1) { + let updatedSelectedRows, updatedRow; + if (row.selected) { + updatedSelectedRows = selectedRows.filter(r => !(r === row.id)); + updatedRow = this.deselectRow(row); + } else { + selectedRows.push(row.id); + updatedSelectedRows = selectedRows; + updatedRow = this.selectRow(row); + } + rows[selectedRowIndex] = updatedRow; + this.setState({ + rows: rows, + selectedRows: updatedSelectedRows + }); + onRowsLogger(rows.filter(r => r.selected)); + } + } + onSelectAllRows(event) { + const { onRowsLogger } = this.props; + const { rows, selectedRows } = this.state; + const checked = event.target.checked; + const currentRows = this.currentRows().rows; + + if (checked) { + const updatedSelections = [ + ...new Set([...currentRows.map(r => r.id), ...selectedRows]) + ]; + const updatedRows = rows.map(r => { + return updatedSelections.indexOf(r.id) > -1 ? this.selectRow(r) : r; + }); + this.setState({ + // important: you must update rows to force a re-render and trigger onRow hook + rows: updatedRows, + selectedRows: updatedSelections + }); + onRowsLogger(updatedRows.filter(r => r.selected)); + } else { + const ids = currentRows.map(r => r.id); + const updatedSelections = selectedRows.filter(r => { + return !(ids.indexOf(r) > -1); + }); + const updatedRows = rows.map(r => { + return updatedSelections.indexOf(r.id) > -1 ? r : this.deselectRow(r); + }); + this.setState({ + rows: updatedRows, + selectedRows: updatedSelections + }); + onRowsLogger(updatedRows.filter(r => r.selected)); + } + } + selectRow(row) { + return Object.assign({}, row, { selected: true }); + } + deselectRow(row) { + return Object.assign({}, row, { selected: false }); + } + currentRows() { + const { rows, sortingColumns, columns, pagination } = this.state; + return compose( + paginate(pagination), + sort.sorter({ + columns: columns, + sortingColumns, + sort: orderBy, + strategy: sort.strategies.byProperty + }) + )(rows); + } + onRow(row, { rowIndex }) { + const { selectedRows } = this.state; + const selected = selectedRows.indexOf(row.id) > -1; + return { + className: cx({ selected: selected }), + role: 'row' + }; + } + render() { + const { columns, pagination, sortingColumns, pageChangeValue } = this.state; + const sortedPaginatedRows = this.currentRows(); + + return ( + + { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns, + rows: sortedPaginatedRows.rows, + onSelectAllRows: this.onSelectAllRows + }); + } + } + }} + > + + + + + + ); + } +} +MockClientPaginationTable.propTypes = { + onRowsLogger: PropTypes.func +}; + +export const mockClientPaginationTableSource = ` +import React from 'react'; +import PropTypes from 'prop-types'; +import orderBy from 'lodash.orderby'; +import cx from 'classnames'; +import * as sort from 'sortabular'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + defaultSortingOrder, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { Grid } from '../../Grid'; +import { PaginationRow, paginate, PAGINATION_VIEW } from '../../Pagination'; +import { compose } from 'recompose'; +import { mockRows } from './mockRows'; + +/** + * Reactabular client side paging based on the following api docs: + * https://reactabular.js.org/#/data/pagination + */ + +export class MockClientPaginationTable extends React.Component { + constructor(props) { + super(props); + + const getSortingColumns = () => this.state.sortingColumns || {}; + + const sortableTransform = sort.sort({ + getSortingColumns, + onSort: selectedColumn => { + this.setState({ + sortingColumns: sort.byColumn({ + sortingColumns: this.state.sortingColumns, + sortingOrder: defaultSortingOrder, + selectedColumn + }) + }); + }, + // Use property or index dependening on the sortingColumns structure specified + strategy: sort.strategies.byProperty + }); + + const sortingFormatter = sort.header({ + sortableTransform, + getSortingColumns, + strategy: sort.strategies.byProperty + }); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + + bindMethods(this, [ + 'customHeaderFormatters', + 'onPageInput', + 'onSubmit', + 'onPerPageSelect', + 'onFirstPage', + 'onPreviousPage', + 'onNextPage', + 'onLastPage', + 'onRow', + 'onSelectAllRows', + 'onSelectRow', + 'setPage', + 'totalPages' + ]); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + + // column definitions + columns: [ + { + property: 'select', + header: { + label: 'Vyberte všechny řádky', + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + id: 'vybrat vše' + }, + customFormatters: [selectionHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [ + (value, { rowData, rowIndex }) => { + return selectionCellFormatter( + { rowData, rowIndex }, + this.onSelectRow, + \`vybrat \${rowIndex}\`, \`vyberte řádek \${rowIndex}\` + ); + } + ] + } + }, + { + property: 'name', + header: { + label: 'Name', + props: { + index: 1, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 2, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 4, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 5, + rowSpan: 1, + colSpan: 1 + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 6, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 6 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + + // rows and row selection state + rows: mockRows, + selectedRows: [], + + // pagination default states + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15] + }, + + // page input value + pageChangeValue: 1 + }; + } + totalPages() { + const { perPage } = this.state.pagination; + return Math.ceil(mockRows.length / perPage); + } + onPageInput(e) { + this.setState({ pageChangeValue: e.target.value }); + } + onSubmit() { + this.setPage(this.state.pageChangeValue); + } + setPage(value) { + const page = Number(value); + if ( + !isNaN(value) && + value !== '' && + page > 0 && + page <= this.totalPages() + ) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = page; + this.setState({ pagination: newPaginationState, pageChangeValue: page }); + } + } + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + newPaginationState.page = 1; + this.setState({ pagination: newPaginationState }); + } + onFirstPage() { + this.setPage(1); + } + onPreviousPage() { + if (this.state.pagination.page > 1) { + this.setPage(this.state.pagination.page - 1); + } + } + onNextPage() { + const { page } = this.state.pagination; + if (page < this.totalPages()) { + this.setPage(this.state.pagination.page + 1); + } + } + onLastPage() { + const { page } = this.state.pagination; + const totalPages = this.totalPages(); + if (page < totalPages) { + this.setPage(totalPages); + } + } + onSelectRow(event, row) { + const { onRowsLogger } = this.props; + const { rows, selectedRows } = this.state; + const selectedRowIndex = rows.findIndex(r => r.id === row.id); + if (selectedRowIndex > -1) { + let updatedSelectedRows, updatedRow; + if (row.selected) { + updatedSelectedRows = selectedRows.filter(r => !(r === row.id)); + updatedRow = this.deselectRow(row); + } else { + selectedRows.push(row.id); + updatedSelectedRows = selectedRows; + updatedRow = this.selectRow(row); + } + rows[selectedRowIndex] = updatedRow; + this.setState({ + rows: rows, + selectedRows: updatedSelectedRows + }); + onRowsLogger(rows.filter(r => r.selected)); + } + } + onSelectAllRows(event) { + const { onRowsLogger } = this.props; + const { rows, selectedRows } = this.state; + const checked = event.target.checked; + const currentRows = this.currentRows().rows; + + if (checked) { + const updatedSelections = [ + ...new Set([...currentRows.map(r => r.id), ...selectedRows]) + ]; + const updatedRows = rows.map(r => { + return updatedSelections.indexOf(r.id) > -1 ? this.selectRow(r) : r; + }); + this.setState({ + // important: you must update rows to force a re-render and trigger onRow hook + rows: updatedRows, + selectedRows: updatedSelections + }); + onRowsLogger(updatedRows.filter(r => r.selected)); + } else { + const ids = currentRows.map(r => r.id); + const updatedSelections = selectedRows.filter(r => { + return !(ids.indexOf(r) > -1); + }); + const updatedRows = rows.map(r => { + return updatedSelections.indexOf(r.id) > -1 ? r : this.deselectRow(r); + }); + this.setState({ + rows: updatedRows, + selectedRows: updatedSelections + }); + onRowsLogger(updatedRows.filter(r => r.selected)); + } + } + selectRow(row) { + return Object.assign({}, row, { selected: true }); + } + deselectRow(row) { + return Object.assign({}, row, { selected: false }); + } + currentRows() { + const { rows, sortingColumns, columns, pagination } = this.state; + return compose( + paginate(pagination), + sort.sorter({ + columns: columns, + sortingColumns, + sort: orderBy, + strategy: sort.strategies.byProperty + }) + )(rows); + } + onRow(row, { rowIndex }) { + const { selectedRows } = this.state; + const selected = selectedRows.indexOf(row.id) > -1; + return { + className: cx({ selected: selected }), + role: 'row' + }; + } + render() { + const { columns, pagination, sortingColumns, pageChangeValue } = this.state; + const sortedPaginatedRows = this.currentRows(); + + return ( + + { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns, + rows: sortedPaginatedRows.rows, + onSelectAllRows: this.onSelectAllRows + }); + } + } + }} + > + + + + + + ); + } +} +MockClientPaginationTable.propTypes = { + onRowsLogger: PropTypes.func +}; +`; diff --git a/src/components/Table/__mocks__/mockClientSortableTable.js b/src/components/Table/__mocks__/mockClientSortableTable.js new file mode 100644 index 00000000000..ec1afbe10d0 --- /dev/null +++ b/src/components/Table/__mocks__/mockClientSortableTable.js @@ -0,0 +1,522 @@ +import React from 'react'; +import orderBy from 'lodash.orderby'; +import * as sort from 'sortabular'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + defaultSortingOrder, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { compose } from 'recompose'; +import { mockRows } from './mockRows'; + +/** + * Reactabular client side data sorting based on the following api docs: + * https://reactabular.js.org/#/data/sorting + */ + +export class MockClientSortableTable extends React.Component { + constructor(props) { + super(props); + + // Point the transform to your sortingColumns. React state can work for this purpose + // but you can use a state manager as well. + const getSortingColumns = () => this.state.sortingColumns || {}; + + const sortableTransform = sort.sort({ + getSortingColumns, + onSort: selectedColumn => { + this.setState({ + sortingColumns: sort.byColumn({ + sortingColumns: this.state.sortingColumns, + sortingOrder: defaultSortingOrder, + selectedColumn + }) + }); + }, + // Use property or index dependening on the sortingColumns structure specified + strategy: sort.strategies.byProperty + }); + + const sortingFormatter = sort.header({ + sortableTransform, + getSortingColumns, + strategy: sort.strategies.byProperty + }); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + bindMethods(this, ['customHeaderFormatters']); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + columns: [ + { + property: 'name', + header: { + label: 'Name', + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 1, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 2, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 3, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 4, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 5, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + rows: mockRows.slice(0, 6) + }; + } + render() { + const { rows, sortingColumns, columns } = this.state; + + const sortedRows = compose( + sort.sorter({ + columns: columns, + sortingColumns, + sort: orderBy, + strategy: sort.strategies.byProperty + }) + )(rows); + + return ( +
+ { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns + }); + } + } + }} + > + + { + return { + role: 'row' + }; + }} + /> + +
+ ); + } +} + +export const mockClientSortableTableSource = ` +import React from 'react'; +import orderBy from 'lodash.orderby'; +import * as sort from 'sortabular'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + defaultSortingOrder, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { compose } from 'recompose'; +import { mockRows } from './mockRows'; + +/** + * Reactabular client side data sorting based on the following api docs: + * https://reactabular.js.org/#/data/sorting + */ + +export class MockClientSortableTable extends React.Component { + constructor(props) { + super(props); + + // Point the transform to your sortingColumns. React state can work for this purpose + // but you can use a state manager as well. + const getSortingColumns = () => this.state.sortingColumns || {}; + + const sortableTransform = sort.sort({ + getSortingColumns, + onSort: selectedColumn => { + this.setState({ + sortingColumns: sort.byColumn({ + sortingColumns: this.state.sortingColumns, + sortingOrder: defaultSortingOrder, + selectedColumn + }) + }); + }, + // Use property or index dependening on the sortingColumns structure specified + strategy: sort.strategies.byProperty + }); + + const sortingFormatter = sort.header({ + sortableTransform, + getSortingColumns, + strategy: sort.strategies.byProperty + }); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + bindMethods(this, ['customHeaderFormatters']); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + columns: [ + { + property: 'name', + header: { + label: 'Name', + props: { + index: 0, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 1, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 2, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 3, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 4, + rowSpan: 1, + colSpan: 1, + sort: true + }, + transforms: [sortableTransform], + formatters: [sortingFormatter], + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 5, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + rows: mockRows.slice(0, 6) + }; + } + render() { + const { rows, sortingColumns, columns } = this.state; + + const sortedRows = compose( + sort.sorter({ + columns: columns, + sortingColumns, + sort: orderBy, + strategy: sort.strategies.byProperty + }) + )(rows); + + return ( +
+ { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns + }); + } + } + }} + > + + { + return { + role: 'row' + }; + }} + /> + +
+ ); + } +}`; diff --git a/src/components/Table/__mocks__/mockRows.js b/src/components/Table/__mocks__/mockRows.js new file mode 100644 index 00000000000..e9f7af8c324 --- /dev/null +++ b/src/components/Table/__mocks__/mockRows.js @@ -0,0 +1,225 @@ +/** + * mockRows made courtesy of https://swapi.co/ + */ +export const mockRows = [ + { + id: 0, + name: 'Luke Skywalker', + height: 172, + mass: 77, + hair_color: 'blond', + skin_color: 'fair', + eye_color: 'blue', + birth_year: '19BBY', + gender: 'male' + }, + { + id: 1, + name: 'C-3PO', + height: 167, + mass: 75, + hair_color: 'n/a', + skin_color: 'gold', + eye_color: 'yellow', + birth_year: '112BBY', + gender: 'n/a' + }, + { + id: 2, + name: 'R2-D2', + height: 96, + mass: 32, + hair_color: 'n/a', + skin_color: 'white, blue', + eye_color: 'red', + birth_year: '33BBY', + gender: 'n/a' + }, + { + id: 3, + name: 'Darth Vader', + height: 202, + mass: 136, + hair_color: 'none', + skin_color: 'white', + eye_color: 'yellow', + birth_year: '41.9BBY', + gender: 'male' + }, + { + id: 4, + name: 'Leia Organa', + height: 150, + mass: 49, + hair_color: 'brown', + skin_color: 'light', + eye_color: 'brown', + birth_year: '19BBY', + gender: 'female' + }, + { + id: 5, + name: 'Owen Lars', + height: 178, + mass: 120, + hair_color: 'brown, grey', + skin_color: 'light', + eye_color: 'blue', + birth_year: '52BBY', + gender: 'male' + }, + { + id: 6, + name: 'Beru Whitesun lars', + height: 165, + mass: 75, + hair_color: 'brown', + skin_color: 'light', + eye_color: 'blue', + birth_year: '47BBY', + gender: 'female' + }, + { + id: 7, + name: 'R5-D4', + height: 97, + mass: 32, + hair_color: 'n/a', + skin_color: 'white, red', + eye_color: 'red', + birth_year: 'unknown', + gender: 'n/a' + }, + { + id: 8, + name: 'Biggs Darklighter', + height: 183, + mass: 84, + hair_color: 'black', + skin_color: 'light', + eye_color: 'brown', + birth_year: '24BBY', + gender: 'male' + }, + { + id: 9, + name: 'Obi-Wan Kenobi', + height: 182, + mass: 77, + hair_color: 'auburn, white', + skin_color: 'fair', + eye_color: 'blue-gray', + birth_year: '57BBY', + gender: 'male' + }, + { + id: 10, + name: 'Anakin Skywalker', + height: 188, + mass: 84, + hair_color: 'blond', + skin_color: 'fair', + eye_color: 'blue', + birth_year: '41.9BBY', + gender: 'male' + }, + { + id: 11, + name: 'Wilhuff Tarkin', + height: 180, + mass: 'unknown', + hair_color: 'auburn, grey', + skin_color: 'fair', + eye_color: 'blue', + birth_year: '64BBY', + gender: 'male' + }, + { + id: 12, + name: 'Chewbacca', + height: 228, + mass: 112, + hair_color: 'brown', + skin_color: 'unknown', + eye_color: 'blue', + birth_year: '200BBY', + gender: 'male' + }, + { + id: 13, + name: 'Han Solo', + height: 180, + mass: 80, + hair_color: 'brown', + skin_color: 'fair', + eye_color: 'brown', + birth_year: '29BBY', + gender: 'male' + }, + { + id: 14, + name: 'Greedo', + height: 173, + mass: 74, + hair_color: 'n/a', + skin_color: 'green', + eye_color: 'black', + birth_year: '44BBY', + gender: 'male' + }, + { + id: 15, + name: 'Jabba Desilijic Tiure', + height: 175, + mass: 1358, + hair_color: 'n/a', + skin_color: 'green-tan, brown', + eye_color: 'orange', + birth_year: '600BBY', + gender: 'hermaphrodite' + }, + { + id: 16, + name: 'Wedge Antilles', + height: 170, + mass: 77, + hair_color: 'brown', + skin_color: 'fair', + eye_color: 'hazel', + birth_year: '21BBY', + gender: 'male' + }, + { + id: 17, + name: 'Jek Tono Porkins', + height: 180, + mass: 110, + hair_color: 'brown', + skin_color: 'fair', + eye_color: 'blue', + birth_year: 'unknown', + gender: 'male' + }, + { + id: 18, + name: 'Yoda', + height: 66, + mass: 17, + hair_color: 'white', + skin_color: 'green', + eye_color: 'brown', + birth_year: '896BBY', + gender: 'male' + }, + { + id: 19, + name: 'Palpatine', + height: 170, + mass: 75, + hair_color: 'grey', + skin_color: 'pale', + eye_color: 'yellow', + birth_year: '82BBY', + gender: 'male' + } +]; diff --git a/src/components/Table/__mocks__/mockServerApi.js b/src/components/Table/__mocks__/mockServerApi.js new file mode 100644 index 00000000000..cf33f500202 --- /dev/null +++ b/src/components/Table/__mocks__/mockServerApi.js @@ -0,0 +1,80 @@ +import { mockRows } from './mockRows'; + +/** + * simple singleton mockServer API for server paginated table story + * + * typically one would connect this api to redux actions/redux store + * and pass data via a redux connected component. + */ +class MockServerApi { + constructor() { + this.mockRows = mockRows; + } + + getPage({ sortingColumns, page, perPage }) { + // server api accepts sort parameters, the current page, and the number perPage + // callServerApi(sortingColumns, page, perPage) + + // mock server logic to update the paginated rows + const p = page - 1 || 0; + const amountOfPages = Math.ceil(this.mockRows.length / perPage); + const startPage = p < amountOfPages ? p : 0; + const endOfPage = startPage * perPage + perPage; + + return new Promise(resolve => { + // server api returns sorted/paged rows, total of amount of pages, total items, + // items start, items end + resolve({ + rows: this.mockRows.slice(startPage * perPage, endOfPage), + amountOfPages: amountOfPages, + itemCount: this.mockRows.length + }); + }); + } + + selectRow({ row }) { + // call server api to update row + // callServerApi(row) + + // mock server logic to update `mockRows` + const index = this.mockRows.findIndex(r => r.id === row.id); + if (index > -1) { + this.mockRows[index] = this.toggleRow(this.mockRows[index]); + } + + return new Promise(resolve => { + // server api returns updated row + resolve({ + row: row + }); + }); + } + + selectAllRows({ checked, rows }) { + // call server api to update all current rows + // callServerApi(rows) + + // mock server logic to update `mockRows` + rows.map(row => { + const index = this.mockRows.findIndex(r => r.id === row.id); + if (index > -1) { + const updated = Object.assign({}, this.mockRows[index], { + selected: checked + }); + this.mockRows[index] = updated; + } + }); + + return new Promise(resolve => { + // server api returns updated rows + resolve({ + rows: rows + }); + }); + } + + toggleRow(row) { + return Object.assign({}, row, { selected: !row.selected }); + } +} +export default new MockServerApi(); diff --git a/src/components/Table/__mocks__/mockServerPaginationTable.js b/src/components/Table/__mocks__/mockServerPaginationTable.js new file mode 100644 index 00000000000..b13787e4e7b --- /dev/null +++ b/src/components/Table/__mocks__/mockServerPaginationTable.js @@ -0,0 +1,699 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { Grid } from '../../Grid'; +import { Paginator, PAGINATION_VIEW } from '../../Pagination'; +import MockServerApi from './mockServerApi'; + +export class MockServerPaginationTable extends React.Component { + constructor(props) { + super(props); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + + bindMethods(this, [ + 'customHeaderFormatters', + 'onPerPageSelect', + 'onPageSet', + 'onRow', + 'onSelectAllRows', + 'onSelectRow', + 'onSort' + ]); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + + // column definitions + columns: [ + { + property: 'select', + header: { + label: 'Select all rows', + props: { + index: 0, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [selectionHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [ + (value, { rowData, rowIndex }) => { + return selectionCellFormatter( + { rowData, rowIndex }, + this.onSelectRow + ); + } + ] + } + }, + { + property: 'name', + header: { + label: 'Name', + props: { + index: 1, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 2, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 4, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 5, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 6, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 6 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + + // rows and row selection state + rows: [], + selectedRows: [], + + // pagination default states + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15] + }, + + // server side pagination values + itemCount: 0 + }; + } + + componentWillMount() { + const { sortingColumns, pagination } = this.state; + this.getPage(sortingColumns, pagination); + } + + getPage(sortingColumns, pagination) { + const { onServerPageLogger } = this.props; + + // call our mock server with next sorting/paging arguments + const getPageArgs = { + sortingColumns, + page: pagination.page, + perPage: pagination.perPage + }; + + onServerPageLogger(getPageArgs); + + MockServerApi.getPage(getPageArgs).then(response => { + this.setState({ + sortingColumns: sortingColumns, + pagination: pagination, + rows: response.rows, + itemCount: response.itemCount + }); + }); + } + + onSort(e, column, sortDirection) { + // Clearing existing sortingColumns does simple single column sort. To do multisort, + // set each column based on existing sorts specified and set sort position. + const updatedSortingColumns = { + [column.property]: { + direction: + sortDirection === TABLE_SORT_DIRECTION.ASC + ? TABLE_SORT_DIRECTION.DESC + : TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }; + + alert( + 'Server API called with: sort by ' + + column.property + + ' ' + + updatedSortingColumns[column.property].direction + ); + + this.getPage(updatedSortingColumns, this.state.pagination); + } + + onSelectRow(event, row) { + const { sortingColumns, pagination } = this.state; + MockServerApi.selectRow({ row }).then(response => { + // refresh rows after row is selected + this.getPage(sortingColumns, pagination); + }); + } + onSelectAllRows(event) { + const { sortingColumns, pagination, rows } = this.state; + const checked = event.target.checked; + MockServerApi.selectAllRows({ rows, checked }).then(response => { + // refresh rows after all rows selected + this.getPage(sortingColumns, pagination); + }); + } + + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + newPaginationState.page = 1; + this.getPage(this.state.sortingColumns, newPaginationState); + } + onPageSet(page) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = page; + this.getPage(this.state.sortingColumns, newPaginationState); + } + + onRow(row, { rowIndex }) { + return { + className: cx({ selected: row.selected }), + role: 'row' + }; + } + + render() { + const { columns, pagination, sortingColumns, rows, itemCount } = this.state; + + return ( + + { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns, + rows: rows, + onSelectAllRows: this.onSelectAllRows, + onSort: this.onSort + }); + } + } + }} + > + + + + + + ); + } +} +MockServerPaginationTable.propTypes = { + onServerPageLogger: PropTypes.func +}; + +export const mockServerPaginationTableSource = ` +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import * as resolve from 'table-resolver'; +import { bindMethods } from '../../../common/helpers'; +import { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TABLE_SORT_DIRECTION +} from '../index'; +import { MenuItem } from '../../MenuItem'; +import { Grid } from '../../Grid'; +import { Paginator, PAGINATION_VIEW } from '../../Pagination'; +import MockServerApi from './mockServerApi'; + +export class MockServerPaginationTable extends React.Component { + constructor(props) { + super(props); + + // enables our custom header formatters extensions to reactabular + this.customHeaderFormatters = customHeaderFormattersDefinition; + + bindMethods(this, [ + 'customHeaderFormatters', + 'onPerPageSelect', + 'onPageSet', + 'onRow', + 'onSelectAllRows', + 'onSelectRow', + 'onSort' + ]); + + this.state = { + // Sort the first column in an ascending way by default. + sortingColumns: { + name: { + direction: TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }, + + // column definitions + columns: [ + { + property: 'select', + header: { + label: 'Select all rows', + props: { + index: 0, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [selectionHeaderCellFormatter] + }, + cell: { + props: { + index: 0 + }, + formatters: [ + (value, { rowData, rowIndex }) => { + return selectionCellFormatter( + { rowData, rowIndex }, + this.onSelectRow + ); + } + ] + } + }, + { + property: 'name', + header: { + label: 'Name', + props: { + index: 1, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 1 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'height', + header: { + label: 'Height', + props: { + index: 2, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 2 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'eye_color', + header: { + label: 'Eye Color', + props: { + index: 3, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 3 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'gender', + header: { + label: 'Gender', + props: { + index: 4, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 4 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'birth_year', + header: { + label: 'Birth Year', + props: { + index: 5, + rowSpan: 1, + colSpan: 1 + }, + customFormatters: [sortableHeaderCellFormatter] + }, + cell: { + props: { + index: 5 + }, + formatters: [tableCellFormatter] + } + }, + { + property: 'actions', + header: { + label: 'Actions', + props: { + index: 6, + rowSpan: 1, + colSpan: 2 + }, + formatters: [actionHeaderCellFormatter] + }, + cell: { + props: { + index: 6 + }, + formatters: [ + (value, { rowData }) => { + return [ + + alert('clicked ' + rowData.name)} + > + Actions + + , + + + Action + Another Action + Something else here + + Separated link + + + ]; + } + ] + } + } + ], + + // rows and row selection state + rows: [], + selectedRows: [], + + // pagination default states + pagination: { + page: 1, + perPage: 6, + perPageOptions: [6, 10, 15] + }, + + // server side pagination values + itemCount: 0 + }; + } + + componentWillMount() { + const { sortingColumns, pagination } = this.state; + this.getPage(sortingColumns, pagination); + } + + getPage(sortingColumns, pagination) { + const { onServerPageLogger } = this.props; + + // call our mock server with next sorting/paging arguments + const getPageArgs = { + sortingColumns, + page: pagination.page, + perPage: pagination.perPage + }; + + onServerPageLogger(getPageArgs); + + MockServerApi.getPage(getPageArgs).then(response => { + this.setState({ + sortingColumns: sortingColumns, + pagination: pagination, + rows: response.rows, + itemCount: response.itemCount + }); + }); + } + + onSort(e, column, sortDirection) { + // Clearing existing sortingColumns does simple single column sort. To do multisort, + // set each column based on existing sorts specified and set sort position. + const updatedSortingColumns = { + [column.property]: { + direction: + sortDirection === TABLE_SORT_DIRECTION.ASC + ? TABLE_SORT_DIRECTION.DESC + : TABLE_SORT_DIRECTION.ASC, + position: 0 + } + }; + + alert( + 'Server API called with: sort by ' + + column.property + + ' ' + + updatedSortingColumns[column.property].direction + ); + + this.getPage(updatedSortingColumns, this.state.pagination); + } + + onSelectRow(event, row) { + const { sortingColumns, pagination } = this.state; + MockServerApi.selectRow({ row }).then(response => { + // refresh rows after row is selected + this.getPage(sortingColumns, pagination); + }); + } + onSelectAllRows(event) { + const { sortingColumns, pagination, rows } = this.state; + const checked = event.target.checked; + MockServerApi.selectAllRows({ rows, checked }).then(response => { + // refresh rows after all rows selected + this.getPage(sortingColumns, pagination); + }); + } + + onPerPageSelect(eventKey, e) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.perPage = eventKey; + newPaginationState.page = 1; + this.getPage(this.state.sortingColumns, newPaginationState); + } + onPageSet(page) { + let newPaginationState = Object.assign({}, this.state.pagination); + newPaginationState.page = page; + this.getPage(this.state.sortingColumns, newPaginationState); + } + + onRow(row, { rowIndex }) { + return { + className: cx({ selected: row.selected }), + role: 'row' + }; + } + + render() { + const { columns, pagination, sortingColumns, rows, itemCount } = this.state; + + return ( + + { + return this.customHeaderFormatters({ + cellProps, + columns, + sortingColumns, + rows: rows, + onSelectAllRows: this.onSelectAllRows, + onSort: this.onSort + }); + } + } + }} + > + + + + + + ); + } +} +MockServerPaginationTable.propTypes = { + onServerPageLogger: PropTypes.func +}; +`; diff --git a/src/components/Table/__snapshots__/Table.test.js.snap b/src/components/Table/__snapshots__/Table.test.js.snap new file mode 100644 index 00000000000..5e1a6f7031a --- /dev/null +++ b/src/components/Table/__snapshots__/Table.test.js.snap @@ -0,0 +1,2630 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bootstrap basic table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Optional table caption. +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+`; + +exports[`Bootstrap bordered table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+`; + +exports[`Bootstrap condensed table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+`; + +exports[`Bootstrap contextual classes table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+ 3 + + Paul + + O’Shannessy + + zpao +
+ 4 + + Pete + + Hunt + + petehunt +
+ 5 + + Andrew + + Clark + + acdlite +
+ 6 + + Nathan + + Hunzaker + + nhunzaker +
+`; + +exports[`Bootstrap hover table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+`; + +exports[`Bootstrap responsive table renders properly 1`] = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+
+`; + +exports[`Bootstrap striped table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ # + + First Name + + Last Name + + Username +
+ 0 + + Dan + + Abramov + + gaearon +
+ 1 + + Sebastian + + Markbåge + + sebmarkbage +
+ 2 + + Sophie + + Alpert + + sophiebits +
+`; + +exports[`Mock Client Pagination table renders 1`] = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + Height + + Eye Color + + Gender + + Birth Year + + Actions +
+ + + + Anakin Skywalker + + 188 + + blue + + male + + 41.9BBY + +
+ +
+
+ +
+ + + + Beru Whitesun lars + + 165 + + blue + + female + + 47BBY + +
+ +
+
+ +
+ + + + Biggs Darklighter + + 183 + + brown + + male + + 24BBY + +
+ +
+
+ +
+ + + + C-3PO + + 167 + + yellow + + n/a + + 112BBY + +
+ +
+
+ +
+ + + + Chewbacca + + 228 + + blue + + male + + 200BBY + +
+ +
+
+ +
+ + + + Darth Vader + + 202 + + yellow + + male + + 41.9BBY + +
+ +
+
+ +
+
+
+
+ + +
+ + per page + +
+
+ + + 1 + - + 6 + +   + of +   + + 20 + + + + + + +   + of +   + + 4 + + + +
+
+
+`; + +exports[`Mock Server Pagination table renders 1`] = ` +
+ + + + + + + + + + + + + +
+ + + + Name + + Height + + Eye Color + + Gender + + Birth Year + + Actions +
+
+
+
+ + +
+ + per page + +
+
+ + + 1 + - + 0 + +   + of +   + + 0 + + + + + + +   + of +   + + 0 + + + +
+
+
+`; + +exports[`Patternfly table renders properly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ First Name + + Last Name + + Username + + Commits + + Additions + + Location + + Gender +
+ Dan + + Abramov + + gaearon + + 711 + + 272635 + + London, UK + + male +
+ Sebastian + + Markbåge + + sebmarkbage + + 476 + + 203610 + + San Francisco, CA + + male +
+ Sophie + + Alpert + + sophiebits + + 828 + + 114467 + + California + + female +
+ Paul + + O’Shannessy + + zpao + + 820 + + 87324 + + Seattle, WA + + male +
+ Pete + + Hunt + + petehunt + + 205 + + 86685 + + San Francisco, CA + + male +
+ Andrew + + Clark + + acdlite + + 320 + + 74162 + + Redwood City, CA + + male +
+ Nathan + + Hunzaker + + nhunzaker + + 77 + + 34504 + + Durham, NC + + male +
+`; diff --git a/src/components/Table/constants.js b/src/components/Table/constants.js new file mode 100644 index 00000000000..bb3ffbb0879 --- /dev/null +++ b/src/components/Table/constants.js @@ -0,0 +1,27 @@ +export const TABLE_ALIGN = { + CENTER: 'center', + RIGHT: 'right' +}; + +export const TABLE_ALIGNMENT_TYPES = [TABLE_ALIGN.CENTER, TABLE_ALIGN.RIGHT]; + +export const TABLE_SORT_DIRECTION = { + ASC: 'asc', + DESC: 'desc' +}; + +export const TABLE_SORT_DIRECTIONS = [ + TABLE_SORT_DIRECTION.ASC, + TABLE_SORT_DIRECTION.DESC +]; + +// Reactabular sorting order allows you to specifiy sort asc/desc only and removes +// the unsorted state. This is consistent with current PF Data Table but should +// be better spelled out in our design docs. +// https://github.com/patternfly/patternfly-design/issues/516 +// https://reactabular.js.org/#/data/sorting?a=customizing-sorting-order +export const defaultSortingOrder = { + FIRST: 'asc', + asc: 'desc', + desc: 'asc' +}; diff --git a/src/components/Table/index.js b/src/components/Table/index.js new file mode 100644 index 00000000000..38bba568ec1 --- /dev/null +++ b/src/components/Table/index.js @@ -0,0 +1,70 @@ +import actionHeaderCellFormatter from './Formatters/actionHeaderCellFormatter'; +import customHeaderFormattersDefinition from './Formatters/customHeaderFormattersDefinition'; +import selectionCellFormatter from './Formatters/selectionCellFormatter'; +import selectionHeaderCellFormatter from './Formatters/selectionHeaderCellFormatter'; +import sortableHeaderCellFormatter from './Formatters/sortableHeaderCellFormatter'; +import tableCellFormatter from './Formatters/tableCellFormatter'; + +import { Table } from './Table'; +import { + defaultSortingOrder, + TABLE_ALIGN, + TABLE_ALIGNMENT_TYPES, + TABLE_SORT_DIRECTION, + TABLE_SORT_DIRECTIONS +} from './constants'; +import TableActions from './TableActions'; +import TableButton from './TableButton'; +import TableCell from './TableCell'; +import TableCheckbox from './TableCheckbox'; +import TableDropdownKebab from './TableDropdownKebab'; +import TableHeading from './TableHeading'; +import TablePfProvider from './TablePfProvider'; +import TableSelectionCell from './TableSelectionCell'; +import TableSelectionHeading from './TableSelectionHeading'; + +Table.actionHeaderCellFormatter = actionHeaderCellFormatter; +Table.customHeaderFormattersDefinition = customHeaderFormattersDefinition; +Table.defaultSortingOrder = defaultSortingOrder; +Table.selectionCellFormatter = selectionCellFormatter; +Table.selectionHeaderCellFormatter = selectionHeaderCellFormatter; +Table.sortableHeaderCellFormatter = sortableHeaderCellFormatter; +Table.tableCellFormatter = tableCellFormatter; + +Table.Actions = TableActions; +Table.Button = TableButton; +Table.Cell = TableCell; +Table.Checkbox = TableCheckbox; +Table.DropdownKebab = TableDropdownKebab; +Table.Heading = TableHeading; +Table.PfProvider = TablePfProvider; +Table.SelectionCell = TableSelectionCell; +Table.SelectionHeading = TableSelectionHeading; +Table.TABLE_ALIGN = TABLE_ALIGN; +Table.TABLE_ALIGNMENT_TYPES = TABLE_ALIGNMENT_TYPES; +Table.TABLE_SORT_DIRECTION = TABLE_SORT_DIRECTION; +Table.TABLE_SORT_DIRECTIONS = TABLE_SORT_DIRECTIONS; + +export { + actionHeaderCellFormatter, + customHeaderFormattersDefinition, + defaultSortingOrder, + selectionCellFormatter, + selectionHeaderCellFormatter, + sortableHeaderCellFormatter, + tableCellFormatter, + Table, + TableActions, + TableButton, + TableCell, + TableCheckbox, + TableDropdownKebab, + TableHeading, + TablePfProvider, + TableSelectionCell, + TableSelectionHeading, + TABLE_ALIGN, + TABLE_ALIGNMENT_TYPES, + TABLE_SORT_DIRECTION, + TABLE_SORT_DIRECTIONS +}; diff --git a/src/index.js b/src/index.js index 7bba9ebdff9..26f7a2e9c98 100644 --- a/src/index.js +++ b/src/index.js @@ -18,11 +18,13 @@ export * from './components/MenuItem'; export * from './components/Modal'; export * from './components/Nav'; export * from './components/OverlayTrigger'; +export * from './components/Pagination'; export * from './components/Popover'; export * from './components/Sort'; export * from './components/Spinner'; export * from './components/Switch'; export * from './components/Tabs'; +export * from './components/Table'; export * from './components/ToastNotification'; export * from './components/Toolbar'; export * from './components/Tooltip'; diff --git a/storybook/.babelrc b/storybook/.babelrc index 4646081cfc2..331759834e9 100644 --- a/storybook/.babelrc +++ b/storybook/.babelrc @@ -2,6 +2,7 @@ "presets": ["env", "react"], "plugins": [ "transform-class-properties", + "transform-export-extensions", "transform-object-rest-spread", "transform-object-assign" ]