diff --git a/.eslintrc.json b/.eslintrc.json index 6e97e187d..4c1f544a6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,5 +5,13 @@ "rules": { "prettier/prettier": ["error"], "import/no-extraneous-dependencies": ["off"] - } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": "./tsconfig.eslint.json" + } + } + ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b3d685f0..911004c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,11 @@ name: Continuous Integration -on: push +on: + push: + branches: + - master + pull_request: + workflow_dispatch: jobs: tests-and-coverage: @@ -59,7 +64,7 @@ jobs: run: npm test - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false files: ./coverage/e2e/coverage-final.json,./coverage/unit/coverage-final.json,./coverage/integration/coverage-final.json diff --git a/.gitignore b/.gitignore index e4c083a95..82d386fde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ coverage -/dist +dist docs/assets pages/dist-demos/assets pages/dist-e2e diff --git a/README.md b/README.md index e15795e85..a3d2e2bab 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,8 @@ const draggableChart = new Chart(ctx, { showTooltip: true, // show the tooltip while dragging [default = true] // dragX: true // also enable dragging along the x-axis. // this solely works for continous, numerical x-axis scales (no categories or dates)! - onDragStart: function (e, element) { + onDragStart: function (event, datasetIndex, index, value) { /* - // e = event, element = datapoint that was dragged // you may use this callback to prohibit dragging certain datapoints // by returning false in this callback if (element.datasetIndex === 0 && element.index === 0) { @@ -138,7 +137,7 @@ const draggableChart = new Chart(ctx, { } */ }, - onDrag: function (e, datasetIndex, index, value) { + onDrag: function (event, datasetIndex, index, value) { /* // you may control the range in which datapoints are allowed to be // dragged by returning `false` in this callback @@ -146,7 +145,7 @@ const draggableChart = new Chart(ctx, { if (datasetIndex === 0 && index === 0 && value > 20) return false */ }, - onDragEnd: function (e, datasetIndex, index, value) { + onDragEnd: function (event, datasetIndex, index, value) { // you may use this callback to store the final datapoint value // (after dragging) in a database, or update other UI elements that // dependent on it diff --git a/jest.config.ts b/jest.config.ts index 1637cf496..5b9e19a9a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,7 +13,7 @@ const config: Config = { "/tests/unit/jest.unit.config.ts", "/tests/integration/jest.integration.config.ts", ], - collectCoverageFrom: ["src/*.{js,ts,jsx,tsx}"], + collectCoverageFrom: ["src/**/*.{js,ts,jsx,tsx}"], coverageReporters: ["lcov", "json"], coveragePathIgnorePatterns: testPathIgnorePatterns, verbose: true, diff --git a/package-lock.json b/package-lock.json index 441defb1b..509678182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,16 +24,18 @@ "@playwright/test": "^1.45.3", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/vue": "^8.1.0", "@types/config": "^3.3.4", + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", "@types/ejs": "^3.1.5", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.7", + "@types/react": "^18.3.3", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", "@vue/vue3-jest": "^29.2.6", @@ -55,11 +57,13 @@ "eslint-plugin-prettier": "^5.2.1", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-extended": "^4.0.2", "lefthook": "^1.7.9", "lint-staged": "^15.2.7", "lodash": "^4.17.21", "node-sass": "^9.0.0", "nyc": "^17.0.0", + "patch-package": "^8.0.0", "playwright": "^1.45.3", "playwright-test-coverage": "^1.2.12", "prettier": "^3.3.3", @@ -4587,28 +4591,6 @@ } } }, - "node_modules/@rollup/plugin-replace": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.7.tgz", - "integrity": "sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", @@ -5300,6 +5282,23 @@ "@types/node": "*" } }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", + "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", @@ -5482,6 +5481,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -5937,6 +5954,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6393,6 +6417,16 @@ "dev": true, "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -9841,6 +9875,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -11034,6 +11078,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11412,6 +11472,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -12680,6 +12753,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -14156,6 +14251,25 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz", + "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -14186,6 +14300,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -14249,6 +14373,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -16654,6 +16788,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -16672,6 +16823,16 @@ "node": ">= 0.8.0" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -16811,6 +16972,235 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/patch-package/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/patch-package/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/patch-package/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/patch-package/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -19168,6 +19558,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 371886a23..bd4d73580 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,15 @@ "name": "chartjs-plugin-dragdata", "version": "2.2.5", "description": "Draggable data points for Chart.js", + "exports": { + "import": "./dist/chartjs-plugin-dragdata.esm.js", + "require": "./dist/chartjs-plugin-dragdata.js", + "types": "./dist/typeAugmentations.d.ts" + }, "main": "dist/chartjs-plugin-dragdata.js", "module": "dist/chartjs-plugin-dragdata.esm.js", "browser": "dist/chartjs-plugin-dragdata.min.js", + "types": "dist/typeAugmentations.d.ts", "scripts": { "build": "npm run rollup:base", "build:no-coverage": "npx cross-env DISABLE_ISTANBUL_COVERAGE_AT_BUILD=true npm run rollup:base", @@ -17,10 +23,10 @@ "lint": "npx eslint .", "lint:fix": "npx eslint . --fix", "nyc:base": "npx cross-env NODE_ENV=test nyc", - "prepare": "npm run build && npm run build:pages", "pretest": "npm run build && npm run build:pages && npm run cleanCoverage", + "prepack": "npm run build:no-coverage", "posttest": "npm run collectCoverage", - "postinstall": "npx playwright install && npx lefthook install", + "postinstall": "npx patch-package && npx playwright install && npx lefthook install", "rollup:base": "rollup --config rollup.config.js", "test": "npm run test:unit && npm run test:integration && npm run test:e2e", "test:unit": "npx jest --coverage --coverageDirectory=coverage/unit --config jest.config.ts --selectProjects unit", @@ -62,16 +68,18 @@ "@playwright/test": "^1.45.3", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/vue": "^8.1.0", "@types/config": "^3.3.4", + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", "@types/ejs": "^3.1.5", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.7", + "@types/react": "^18.3.3", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", "@vue/vue3-jest": "^29.2.6", @@ -93,11 +101,13 @@ "eslint-plugin-prettier": "^5.2.1", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-extended": "^4.0.2", "lefthook": "^1.7.9", "lint-staged": "^15.2.7", "lodash": "^4.17.21", "node-sass": "^9.0.0", "nyc": "^17.0.0", + "patch-package": "^8.0.0", "playwright": "^1.45.3", "playwright-test-coverage": "^1.2.12", "prettier": "^3.3.3", @@ -114,5 +124,9 @@ "typescript": "^5.5.4", "vue": "^3.4.34", "vue-chartjs": "^5.3.1" - } + }, + "files": [ + "dist/**/*", + "package.json" + ] } diff --git a/pages/src/bundle.ts b/pages/src/bundle.ts index a38d504eb..652c5af8a 100644 --- a/pages/src/bundle.ts +++ b/pages/src/bundle.ts @@ -16,20 +16,39 @@ const LOG_TAG = "[Bundler]", e2eDistDirPath = path.join(pagesSrcDirPath, "..", "dist-e2e"), pagesFactoriesDirPath = path.join(pagesSrcDirPath, "pages"), demosAssetsDirPath = path.join(demosDistDirPath, "assets"), - e2eAssetsDirPath = path.join(e2eDistDirPath, "assets"); - -function copyAsset(sourcePath: string, destFileName: string) { - console.log(`${LOG_TAG} Copying asset ${sourcePath} -> ${destFileName}`); + e2eAssetsDirPath = path.join(e2eDistDirPath, "assets"), + projectRootAbsPath = path.resolve( + path.join(path.dirname(__filename), "..", ".."), + ); + +function copyAsset(assetSpec: AssetSpec): void { + console.log( + `${LOG_TAG} Copying asset for variants ${assetSpec.variants.join(", ")}: ${assetSpec.sourcePath.replace(projectRootAbsPath, "")} -> ${assetSpec.destFileName}`, + ); + + if (assetSpec.variants.includes("demo")) { + fs.copyFileSync( + assetSpec.sourcePath, + path.join(demosAssetsDirPath, assetSpec.destFileName), + ); + } - fs.copyFileSync(sourcePath, path.join(demosAssetsDirPath, destFileName)); - fs.copyFileSync(sourcePath, path.join(e2eAssetsDirPath, destFileName)); + if (assetSpec.variants.includes("e2e")) { + fs.copyFileSync( + assetSpec.sourcePath, + path.join(e2eAssetsDirPath, assetSpec.destFileName), + ); + } } export type AssetSpec = { sourcePath: string; destFileName: string; + variants: AssetSpecVariant[]; }; +export type AssetSpecVariant = "demo" | "e2e"; + export const assetSpecs: AssetSpec[] = [ { sourcePath: path.join( @@ -41,6 +60,7 @@ export const assetSpecs: AssetSpec[] = [ "lodash.min.js", ), destFileName: "lodash.min.js", + variants: ["demo", "e2e"], }, { sourcePath: path.join( @@ -53,6 +73,7 @@ export const assetSpecs: AssetSpec[] = [ "chartjs-plugin-datalabels.min.js", ), destFileName: "chartjs-plugin-datalabels.min.js", + variants: ["demo", "e2e"], }, { sourcePath: path.join( @@ -65,6 +86,7 @@ export const assetSpecs: AssetSpec[] = [ "chartjs-adapter-date-fns.bundle.min.js", ), destFileName: "chartjs-adapter-date-fns.bundle.min.js", + variants: ["demo", "e2e"], }, { sourcePath: path.join( @@ -77,7 +99,9 @@ export const assetSpecs: AssetSpec[] = [ "chart.umd.js", ), destFileName: "chart.min.js", + variants: ["demo", "e2e"], }, + // demo-only plugin bundle { sourcePath: path.join( path.dirname(__filename), @@ -87,16 +111,20 @@ export const assetSpecs: AssetSpec[] = [ "chartjs-plugin-dragdata.min.js", ), destFileName: "chartjs-plugin-dragdata.min.js", + variants: ["demo"], }, + // test-only plugin bundle { sourcePath: path.join( path.dirname(__filename), "..", "..", "dist", + "test", "chartjs-plugin-dragdata-test-browser.js", ), destFileName: "chartjs-plugin-dragdata-test-browser.js", + variants: ["e2e"], }, ]; @@ -117,7 +145,7 @@ export async function bundle(): Promise { // copy assets for (const assetSpec of assetSpecs) { - copyAsset(assetSpec.sourcePath, assetSpec.destFileName); + copyAsset(assetSpec); } // render EJS to HTML @@ -153,7 +181,7 @@ export async function bundle(): Promise { ); console.log( - `${LOG_TAG} Rendering ${isE2ETest ? "E2E" : "demo"} page ${demosPageFilename} -> ${htmlDestPath}`, + `${LOG_TAG} Rendering ${isE2ETest ? "E2E" : "demo"} page ${demosPageFilename} -> ${htmlDestPath.replace(projectRootAbsPath, "")}`, ); fs.writeFileSync( diff --git a/patches/chart.js+4.4.3.patch b/patches/chart.js+4.4.3.patch new file mode 100644 index 000000000..a24506ada --- /dev/null +++ b/patches/chart.js+4.4.3.patch @@ -0,0 +1,864 @@ +diff --git a/node_modules/chart.js/dist/core/core.scale.d.ts b/node_modules/chart.js/dist/core/core.scale.d.ts +index ca85bba..ea0fd1b 100644 +--- a/node_modules/chart.js/dist/core/core.scale.d.ts ++++ b/node_modules/chart.js/dist/core/core.scale.d.ts +@@ -333,7 +333,7 @@ export default class Scale extends Element void): void; +- add(chart: Chart, items: readonly Animation[]): void; +- has(chart: Chart): boolean; +- start(chart: Chart): void; +- running(chart: Chart): boolean; +- stop(chart: Chart): void; +- remove(chart: Chart): boolean; ++ listen(chart: TChart, event: 'complete' | 'progress', cb: (event: AnimationEvent) => void): void; ++ add(chart: TChart, items: readonly Animation[]): void; ++ has(chart: TChart): boolean; ++ start(chart: TChart): void; ++ running(chart: TChart): boolean; ++ stop(chart: TChart): void; ++ remove(chart: TChart): boolean; + } + + export declare class Animations { +- constructor(chart: Chart, animations: AnyObject); ++ constructor(chart: TChart, animations: AnyObject); + configure(animations: AnyObject): void; + update(target: AnyObject, values: AnyObject): undefined | boolean; + } +diff --git a/node_modules/chart.js/dist/types/index.d.ts b/node_modules/chart.js/dist/types/index.d.ts +index 98bdf09..a162304 100644 +--- a/node_modules/chart.js/dist/types/index.d.ts ++++ b/node_modules/chart.js/dist/types/index.d.ts +@@ -22,7 +22,7 @@ export {LayoutItem, LayoutPosition} from './layout.js'; + + export interface ScriptableContext { + active: boolean; +- chart: Chart; ++ chart: TChart; + dataIndex: number; + dataset: UnionToIntersection>; + datasetIndex: number; +@@ -156,7 +156,7 @@ export interface BarControllerChartOptions { + export type BarController = DatasetController + export declare const BarController: ChartComponent & { + prototype: BarController; +- new (chart: Chart, datasetIndex: number): BarController; ++ new (chart: TChart, datasetIndex: number): BarController; + }; + + export interface BubbleControllerDatasetOptions +@@ -183,7 +183,7 @@ export interface BubbleDataPoint extends Point { + export type BubbleController = DatasetController + export declare const BubbleController: ChartComponent & { + prototype: BubbleController; +- new (chart: Chart, datasetIndex: number): BubbleController; ++ new (chart: TChart, datasetIndex: number): BubbleController; + }; + + export interface LineControllerDatasetOptions +@@ -229,7 +229,7 @@ export interface LineControllerChartOptions { + export type LineController = DatasetController + export declare const LineController: ChartComponent & { + prototype: LineController; +- new (chart: Chart, datasetIndex: number): LineController; ++ new (chart: TChart, datasetIndex: number): LineController; + }; + + export type ScatterControllerDatasetOptions = LineControllerDatasetOptions; +@@ -241,7 +241,7 @@ export type ScatterControllerChartOptions = LineControllerChartOptions; + export type ScatterController = LineController + export declare const ScatterController: ChartComponent & { + prototype: ScatterController; +- new (chart: Chart, datasetIndex: number): ScatterController; ++ new (chart: TChart, datasetIndex: number): ScatterController; + }; + + export interface DoughnutControllerDatasetOptions +@@ -349,7 +349,7 @@ export interface DoughnutController extends DatasetController { + + export declare const DoughnutController: ChartComponent & { + prototype: DoughnutController; +- new (chart: Chart, datasetIndex: number): DoughnutController; ++ new (chart: TChart, datasetIndex: number): DoughnutController; + }; + + export interface DoughnutMetaExtensions { +@@ -366,7 +366,7 @@ export type PieMetaExtensions = DoughnutMetaExtensions; + export type PieController = DoughnutController + export declare const PieController: ChartComponent & { + prototype: PieController; +- new (chart: Chart, datasetIndex: number): PieController; ++ new (chart: TChart, datasetIndex: number): PieController; + }; + + export interface PolarAreaControllerDatasetOptions extends DoughnutControllerDatasetOptions { +@@ -394,7 +394,7 @@ export interface PolarAreaController extends DoughnutController { + } + export declare const PolarAreaController: ChartComponent & { + prototype: PolarAreaController; +- new (chart: Chart, datasetIndex: number): PolarAreaController; ++ new (chart: TChart, datasetIndex: number): PolarAreaController; + }; + + export interface RadarControllerDatasetOptions +@@ -427,7 +427,7 @@ export type RadarControllerChartOptions = LineControllerChartOptions; + export type RadarController = DatasetController + export declare const RadarController: ChartComponent & { + prototype: RadarController; +- new (chart: Chart, datasetIndex: number): RadarController; ++ new (chart: TChart, datasetIndex: number): RadarController; + }; + interface ChartMetaCommon { + type: string; +@@ -481,7 +481,7 @@ export interface ActiveElement extends ActiveDataPoint { + element: Element; + } + +-export declare class Chart< ++export declare class TChart< + TType extends ChartType = ChartType, + TData = DefaultDataPoint, + TLabel = unknown +@@ -546,14 +546,14 @@ export declare class Chart< + + isPluginEnabled(pluginId: string): boolean; + +- getContext(): { chart: Chart, type: string }; ++ getContext(): { chart: TChart, type: string }; + + static readonly defaults: Defaults; + static readonly overrides: Overrides; + static readonly version: string; +- static readonly instances: { [key: string]: Chart }; ++ static readonly instances: { [key: string]: TChart }; + static readonly registry: Registry; +- static getChart(key: string | CanvasRenderingContext2D | HTMLCanvasElement): Chart | undefined; ++ static getChart(key: string | CanvasRenderingContext2D | HTMLCanvasElement): TChart | undefined; + static register(...items: ChartComponentLike[]): void; + static unregister(...items: ChartComponentLike[]): void; + } +@@ -585,9 +585,9 @@ export declare class DatasetController< + TDatasetElement extends Element = Element, + TParsedData = ParsedDataType, + > { +- constructor(chart: Chart, datasetIndex: number); ++ constructor(chart: TChart, datasetIndex: number); + +- readonly chart: Chart; ++ readonly chart: TChart; + readonly index: number; + readonly _cachedMeta: ChartMeta; + enableOptionSharing: boolean; +@@ -721,7 +721,7 @@ export interface InteractionItem { + } + + export type InteractionModeFunction = ( +- chart: Chart, ++ chart: TChart, + e: ChartEvent, + options: InteractionOptions, + useFinalPosition?: boolean +@@ -767,7 +767,7 @@ export declare const Interaction: { + * Helper function to select candidate elements for interaction + */ + evaluateInteractionItems( +- chart: Chart, ++ chart: TChart, + axis: InteractionAxis, + position: Point, + handler: (element: Element & VisualElement, datasetIndex: number, index: number) => void, +@@ -779,26 +779,26 @@ export declare const layouts: { + /** + * Register a box to a chart. + * A box is simply a reference to an object that requires layout. eg. Scales, Legend, Title. +- * @param {Chart} chart - the chart to use ++ * @param {TChart} chart - the chart to use + * @param {LayoutItem} item - the item to add to be laid out + */ +- addBox(chart: Chart, item: LayoutItem): void; ++ addBox(chart: TChart, item: LayoutItem): void; + + /** + * Remove a layoutItem from a chart +- * @param {Chart} chart - the chart to remove the box from ++ * @param {TChart} chart - the chart to remove the box from + * @param {LayoutItem} layoutItem - the item to remove from the layout + */ +- removeBox(chart: Chart, layoutItem: LayoutItem): void; ++ removeBox(chart: TChart, layoutItem: LayoutItem): void; + + /** + * Sets (or updates) options on the given `item`. +- * @param {Chart} chart - the chart in which the item lives (or will be added to) ++ * @param {TChart} chart - the chart in which the item lives (or will be added to) + * @param {LayoutItem} item - the item to configure with the given options + * @param options - the new item options. + */ + configure( +- chart: Chart, ++ chart: TChart, + item: LayoutItem, + options: { fullSize?: number; position?: LayoutPosition; weight?: number } + ): void; +@@ -806,11 +806,11 @@ export declare const layouts: { + /** + * Fits boxes of the given chart into the given size by having each box measure itself + * then running a fitting algorithm +- * @param {Chart} chart - the chart ++ * @param {TChart} chart - the chart + * @param {number} width - the width to fit into + * @param {number} height - the height to fit into + */ +- update(chart: Chart, width: number, height: number): void; ++ update(chart: TChart, width: number, height: number): void; + }; + + export interface Plugin extends ExtendedPlugin { +@@ -824,102 +824,102 @@ export interface Plugin exte + + /** + * @desc Called when plugin is installed for this chart instance. This hook is also invoked for disabled plugins (options === false). +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @since 3.0.0 + */ +- install?(chart: Chart, args: EmptyObject, options: O): void; ++ install?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called when a plugin is starting. This happens when chart is created or plugin is enabled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @since 3.0.0 + */ +- start?(chart: Chart, args: EmptyObject, options: O): void; ++ start?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called when a plugin stopping. This happens when chart is destroyed or plugin is disabled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @since 3.0.0 + */ +- stop?(chart: Chart, args: EmptyObject, options: O): void; ++ stop?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before initializing `chart`. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- beforeInit?(chart: Chart, args: EmptyObject, options: O): void; ++ beforeInit?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called after `chart` has been initialized and before the first update. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterInit?(chart: Chart, args: EmptyObject, options: O): void; ++ afterInit?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before updating `chart`. If any plugin returns `false`, the update + * is cancelled (and thus subsequent render(s)) until another `update` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {UpdateMode} args.mode - The update mode + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart update. + */ +- beforeUpdate?(chart: Chart, args: { mode: UpdateMode, cancelable: true }, options: O): boolean | void; ++ beforeUpdate?(chart: TChart, args: { mode: UpdateMode, cancelable: true }, options: O): boolean | void; + /** + * @desc Called after `chart` has been updated and before rendering. Note that this + * hook will not be called if the chart update has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {UpdateMode} args.mode - The update mode + * @param {object} options - The plugin options. + */ +- afterUpdate?(chart: Chart, args: { mode: UpdateMode }, options: O): void; ++ afterUpdate?(chart: TChart, args: { mode: UpdateMode }, options: O): void; + /** + * @desc Called during the update process, before any chart elements have been created. + * This can be used for data decimation by changing the data array inside a dataset. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- beforeElementsUpdate?(chart: Chart, args: EmptyObject, options: O): void; ++ beforeElementsUpdate?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called during chart reset +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @since version 3.0.0 + */ +- reset?(chart: Chart, args: EmptyObject, options: O): void; ++ reset?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before updating the `chart` datasets. If any plugin returns `false`, + * the datasets update is cancelled until another `update` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {UpdateMode} args.mode - The update mode. + * @param {object} options - The plugin options. + * @returns {boolean} false to cancel the datasets update. + * @since version 2.1.5 + */ +- beforeDatasetsUpdate?(chart: Chart, args: { mode: UpdateMode }, options: O): boolean | void; ++ beforeDatasetsUpdate?(chart: TChart, args: { mode: UpdateMode }, options: O): boolean | void; + /** + * @desc Called after the `chart` datasets have been updated. Note that this hook + * will not be called if the datasets update has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {UpdateMode} args.mode - The update mode. + * @param {object} options - The plugin options. + * @since version 2.1.5 + */ +- afterDatasetsUpdate?(chart: Chart, args: { mode: UpdateMode, cancelable: true }, options: O): void; ++ afterDatasetsUpdate?(chart: TChart, args: { mode: UpdateMode, cancelable: true }, options: O): void; + /** + * @desc Called before updating the `chart` dataset at the given `args.index`. If any plugin + * returns `false`, the datasets update is cancelled until another `update` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {number} args.index - The dataset index. + * @param {object} args.meta - The dataset metadata. +@@ -927,156 +927,156 @@ export interface Plugin exte + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart datasets drawing. + */ +- beforeDatasetUpdate?(chart: Chart, args: { index: number; meta: ChartMeta, mode: UpdateMode, cancelable: true }, options: O): boolean | void; ++ beforeDatasetUpdate?(chart: TChart, args: { index: number; meta: ChartMeta, mode: UpdateMode, cancelable: true }, options: O): boolean | void; + /** + * @desc Called after the `chart` datasets at the given `args.index` has been updated. Note + * that this hook will not be called if the datasets update has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {number} args.index - The dataset index. + * @param {object} args.meta - The dataset metadata. + * @param {UpdateMode} args.mode - The update mode. + * @param {object} options - The plugin options. + */ +- afterDatasetUpdate?(chart: Chart, args: { index: number; meta: ChartMeta, mode: UpdateMode, cancelable: false }, options: O): void; ++ afterDatasetUpdate?(chart: TChart, args: { index: number; meta: ChartMeta, mode: UpdateMode, cancelable: false }, options: O): void; + /** + * @desc Called before laying out `chart`. If any plugin returns `false`, + * the layout update is cancelled until another `update` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart layout. + */ +- beforeLayout?(chart: Chart, args: { cancelable: true }, options: O): boolean | void; ++ beforeLayout?(chart: TChart, args: { cancelable: true }, options: O): boolean | void; + /** + * @desc Called before scale data limits are calculated. This hook is called separately for each scale in the chart. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Scale} args.scale - The scale. + * @param {object} options - The plugin options. + */ +- beforeDataLimits?(chart: Chart, args: { scale: Scale }, options: O): void; ++ beforeDataLimits?(chart: TChart, args: { scale: Scale }, options: O): void; + /** + * @desc Called after scale data limits are calculated. This hook is called separately for each scale in the chart. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Scale} args.scale - The scale. + * @param {object} options - The plugin options. + */ +- afterDataLimits?(chart: Chart, args: { scale: Scale }, options: O): void; ++ afterDataLimits?(chart: TChart, args: { scale: Scale }, options: O): void; + /** + * @desc Called before scale builds its ticks. This hook is called separately for each scale in the chart. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Scale} args.scale - The scale. + * @param {object} options - The plugin options. + */ +- beforeBuildTicks?(chart: Chart, args: { scale: Scale }, options: O): void; ++ beforeBuildTicks?(chart: TChart, args: { scale: Scale }, options: O): void; + /** + * @desc Called after scale has build its ticks. This hook is called separately for each scale in the chart. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Scale} args.scale - The scale. + * @param {object} options - The plugin options. + */ +- afterBuildTicks?(chart: Chart, args: { scale: Scale }, options: O): void; ++ afterBuildTicks?(chart: TChart, args: { scale: Scale }, options: O): void; + /** + * @desc Called after the `chart` has been laid out. Note that this hook will not + * be called if the layout update has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterLayout?(chart: Chart, args: EmptyObject, options: O): void; ++ afterLayout?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before rendering `chart`. If any plugin returns `false`, + * the rendering is cancelled until another `render` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart rendering. + */ +- beforeRender?(chart: Chart, args: { cancelable: true }, options: O): boolean | void; ++ beforeRender?(chart: TChart, args: { cancelable: true }, options: O): boolean | void; + /** + * @desc Called after the `chart` has been fully rendered (and animation completed). Note + * that this hook will not be called if the rendering has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterRender?(chart: Chart, args: EmptyObject, options: O): void; ++ afterRender?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before drawing `chart` at every animation frame. If any plugin returns `false`, + * the frame drawing is cancelled untilanother `render` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart drawing. + */ +- beforeDraw?(chart: Chart, args: { cancelable: true }, options: O): boolean | void; ++ beforeDraw?(chart: TChart, args: { cancelable: true }, options: O): boolean | void; + /** + * @desc Called after the `chart` has been drawn. Note that this hook will not be called + * if the drawing has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterDraw?(chart: Chart, args: EmptyObject, options: O): void; ++ afterDraw?(chart: TChart, args: EmptyObject, options: O): void; + /** + * @desc Called before drawing the `chart` datasets. If any plugin returns `false`, + * the datasets drawing is cancelled until another `render` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart datasets drawing. + */ +- beforeDatasetsDraw?(chart: Chart, args: { cancelable: true }, options: O): boolean | void; ++ beforeDatasetsDraw?(chart: TChart, args: { cancelable: true }, options: O): boolean | void; + /** + * @desc Called after the `chart` datasets have been drawn. Note that this hook + * will not be called if the datasets drawing has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterDatasetsDraw?(chart: Chart, args: EmptyObject, options: O, cancelable: false): void; ++ afterDatasetsDraw?(chart: TChart, args: EmptyObject, options: O, cancelable: false): void; + /** + * @desc Called before drawing the `chart` dataset at the given `args.index` (datasets + * are drawn in the reverse order). If any plugin returns `false`, the datasets drawing + * is cancelled until another `render` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {number} args.index - The dataset index. + * @param {object} args.meta - The dataset metadata. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart datasets drawing. + */ +- beforeDatasetDraw?(chart: Chart, args: { index: number; meta: ChartMeta }, options: O): boolean | void; ++ beforeDatasetDraw?(chart: TChart, args: { index: number; meta: ChartMeta }, options: O): boolean | void; + /** + * @desc Called after the `chart` datasets at the given `args.index` have been drawn + * (datasets are drawn in the reverse order). Note that this hook will not be called + * if the datasets drawing has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {number} args.index - The dataset index. + * @param {object} args.meta - The dataset metadata. + * @param {object} options - The plugin options. + */ +- afterDatasetDraw?(chart: Chart, args: { index: number; meta: ChartMeta }, options: O): void; ++ afterDatasetDraw?(chart: TChart, args: { index: number; meta: ChartMeta }, options: O): void; + /** + * @desc Called before processing the specified `event`. If any plugin returns `false`, + * the event will be discarded. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {ChartEvent} args.event - The event object. + * @param {boolean} args.replay - True if this event is replayed from `Chart.update` + * @param {boolean} args.inChartArea - The event position is inside chartArea + * @param {object} options - The plugin options. + */ +- beforeEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, cancelable: true, inChartArea: boolean }, options: O): boolean | void; ++ beforeEvent?(chart: TChart, args: { event: ChartEvent, replay: boolean, cancelable: true, inChartArea: boolean }, options: O): boolean | void; + /** + * @desc Called after the `event` has been consumed. Note that this hook + * will not be called if the `event` has been previously discarded. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {ChartEvent} args.event - The event object. + * @param {boolean} args.replay - True if this event is replayed from `Chart.update` +@@ -1084,37 +1084,37 @@ export interface Plugin exte + * @param {boolean} [args.changed] - Set to true if the plugin needs a render. Should only be changed to true, because this args object is passed through all plugins. + * @param {object} options - The plugin options. + */ +- afterEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, changed?: boolean, cancelable: false, inChartArea: boolean }, options: O): void; ++ afterEvent?(chart: TChart, args: { event: ChartEvent, replay: boolean, changed?: boolean, cancelable: false, inChartArea: boolean }, options: O): void; + /** + * @desc Called after the chart as been resized. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {number} args.size - The new canvas display size (eq. canvas.style width & height). + * @param {object} options - The plugin options. + */ +- resize?(chart: Chart, args: { size: { width: number, height: number } }, options: O): void; ++ resize?(chart: TChart, args: { size: { width: number, height: number } }, options: O): void; + /** + * Called before the chart is being destroyed. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- beforeDestroy?(chart: Chart, args: EmptyObject, options: O): void; ++ beforeDestroy?(chart: TChart, args: EmptyObject, options: O): void; + /** + * Called after the chart has been destroyed. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + */ +- afterDestroy?(chart: Chart, args: EmptyObject, options: O): void; ++ afterDestroy?(chart: TChart, args: EmptyObject, options: O): void; + /** + * Called after chart is destroyed on all plugins that were installed for that chart. This hook is also invoked for disabled plugins (options === false). +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {object} options - The plugin options. + * @since 3.0.0 + */ +- uninstall?(chart: Chart, args: EmptyObject, options: O): void; ++ uninstall?(chart: TChart, args: EmptyObject, options: O): void; + + /** + * Default options used in the plugin +@@ -1263,7 +1263,7 @@ export interface Scale extends El + readonly id: string; + readonly type: string; + readonly ctx: CanvasRenderingContext2D; +- readonly chart: Chart; ++ readonly chart: TChart; + + maxWidth: number; + maxHeight: number; +@@ -1372,18 +1372,18 @@ export interface Scale extends El + isFullSize(): boolean; + } + export declare class Scale { +- constructor(cfg: {id: string, type: string, ctx: CanvasRenderingContext2D, chart: Chart}); ++ constructor(cfg: {id: string, type: string, ctx: CanvasRenderingContext2D, chart: TChart}); + } + + export interface ScriptableScaleContext { +- chart: Chart; ++ chart: TChart; + scale: Scale; + index: number; + tick: Tick; + } + + export interface ScriptableScalePointLabelContext { +- chart: Chart; ++ chart: TChart; + scale: Scale; + index: number; + label: string; +@@ -1653,7 +1653,7 @@ export interface CoreChartOptions extends ParsingOption + /** + * Called when a resize occurs. Gets passed two arguments: the chart instance and the new size. + */ +- onResize(chart: Chart, size: { width: number; height: number }): void; ++ onResize(chart: TChart, size: { width: number; height: number }): void; + + /** + * Override the window's default devicePixelRatio. +@@ -1674,12 +1674,12 @@ export interface CoreChartOptions extends ParsingOption + /** + * Called when any of the events fire. Passed the event, an array of active elements (bars, points, etc), and the chart. + */ +- onHover(event: ChartEvent, elements: ActiveElement[], chart: Chart): void; ++ onHover(event: ChartEvent, elements: ActiveElement[], chart: TChart): void; + + /** + * Called if the event is of type 'mouseup' or 'click'. Passed the event, an array of active elements, and the chart. + */ +- onClick(event: ChartEvent, elements: ActiveElement[], chart: Chart): void; ++ onClick(event: ChartEvent, elements: ActiveElement[], chart: TChart): void; + + layout: Partial<{ + autoPadding: boolean; +@@ -1748,11 +1748,11 @@ export type AnimationOptions = { + /** + * Callback called on each step of an animation. + */ +- onProgress?: (this: Chart, event: AnimationEvent) => void; ++ onProgress?: (this: TChart, event: AnimationEvent) => void; + /** + * Callback called when all animations are completed. + */ +- onComplete?: (this: Chart, event: AnimationEvent) => void; ++ onComplete?: (this: TChart, event: AnimationEvent) => void; + }; + animations: AnimationsSpec; + transitions: TransitionsSpec; +@@ -2157,19 +2157,19 @@ export declare class BasePlatform { + releaseContext(context: CanvasRenderingContext2D): boolean; + /** + * Registers the specified listener on the given chart. +- * @param {Chart} chart - Chart from which to listen for event ++ * @param {TChart} chart - Chart from which to listen for event + * @param {string} type - The ({@link ChartEvent}) type to listen for + * @param listener - Receives a notification (an object that implements + * the {@link ChartEvent} interface) when an event of the specified type occurs. + */ +- addEventListener(chart: Chart, type: string, listener: (e: ChartEvent) => void): void; ++ addEventListener(chart: TChart, type: string, listener: (e: ChartEvent) => void): void; + /** + * Removes the specified listener previously registered with addEventListener. +- * @param {Chart} chart - Chart from which to remove the listener ++ * @param {TChart} chart - Chart from which to remove the listener + * @param {string} type - The ({@link ChartEvent}) type to remove + * @param listener - The listener function to remove from the event target. + */ +- removeEventListener(chart: Chart, type: string, listener: (e: ChartEvent) => void): void; ++ removeEventListener(chart: TChart, type: string, listener: (e: ChartEvent) => void): void; + /** + * @returns {number} the current devicePixelRatio of the device this platform is connected to. + */ +@@ -2340,7 +2340,7 @@ export interface LegendItem { + } + + export interface LegendElement extends Element>, LayoutItem { +- chart: Chart; ++ chart: TChart; + ctx: CanvasRenderingContext2D; + legendItems?: LegendItem[]; + options: LegendOptions; +@@ -2431,7 +2431,7 @@ export interface LegendOptions { + /** + * Generates legend items for each thing in the legend. Default implementation returns the text + styling for the color box. See Legend Item for details. + */ +- generateLabels(chart: Chart): LegendItem[]; ++ generateLabels(chart: TChart): LegendItem[]; + + /** + * Filters legend items out of the legend. Receives 2 parameters, a Legend Item and the chart data +@@ -2578,7 +2578,7 @@ export interface TooltipLabelStyle { + borderRadius?: number | BorderRadius; + } + export interface TooltipModel extends Element> { +- readonly chart: Chart; ++ readonly chart: TChart; + + // The items that we are rendering in the tooltip. See Tooltip Item Interface section + dataPoints: TooltipItem[]; +@@ -2687,26 +2687,26 @@ export interface ExtendedPlugin< + /** + * @desc Called before drawing the `tooltip`. If any plugin returns `false`, + * the tooltip drawing is cancelled until another `render` is triggered. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Tooltip} args.tooltip - The tooltip. + * @param {object} options - The plugin options. + * @returns {boolean} `false` to cancel the chart tooltip drawing. + */ +- beforeTooltipDraw?(chart: Chart, args: { tooltip: Model, cancelable: true }, options: O): boolean | void; ++ beforeTooltipDraw?(chart: TChart, args: { tooltip: Model, cancelable: true }, options: O): boolean | void; + /** + * @desc Called after drawing the `tooltip`. Note that this hook will not + * be called if the tooltip drawing has been previously cancelled. +- * @param {Chart} chart - The chart instance. ++ * @param {TChart} chart - The chart instance. + * @param {object} args - The call arguments. + * @param {Tooltip} args.tooltip - The tooltip. + * @param {object} options - The plugin options. + */ +- afterTooltipDraw?(chart: Chart, args: { tooltip: Model }, options: O): void; ++ afterTooltipDraw?(chart: TChart, args: { tooltip: Model }, options: O): void; + } + + export interface ScriptableTooltipContext { +- chart: UnionToIntersection>; ++ chart: UnionToIntersection>; + tooltip: UnionToIntersection>; + tooltipItems: TooltipItem[]; + } +@@ -2720,7 +2720,7 @@ export interface TooltipOptions extends Cor + /** + * See external tooltip section. + */ +- external(this: TooltipModel, args: { chart: Chart; tooltip: TooltipModel }): void; ++ external(this: TooltipModel, args: { chart: TChart; tooltip: TooltipModel }): void; + /** + * The mode for positioning the tooltip + */ +@@ -2894,7 +2894,7 @@ export interface TooltipItem { + /** + * The chart the tooltip is being shown on + */ +- chart: Chart; ++ chart: TChart; + + /** + * Label for the tooltip +@@ -3161,7 +3161,7 @@ export interface ScriptableCartesianScaleContext { + } + + export interface ScriptableChartContext { +- chart: Chart; ++ chart: TChart; + type: string; + } + +@@ -3581,6 +3581,8 @@ export type RadialLinearScaleOptions = CoreScaleOptions & { + }; + + export interface RadialLinearScale extends Scale { ++ xCenter: number; ++ yCenter: number; + setCenterPoint(leftMovement: number, rightMovement: number, topMovement: number, bottomMovement: number): void; + getIndexAngle(index: number): number; + getDistanceFromCenterForValue(value: number): number; +@@ -3744,14 +3746,14 @@ export type ScaleChartOptions = { + }; + }; + +-export type ChartOptions = DeepPartial< ++export type ChartOptions = Exclude & + ElementChartOptions & + PluginChartOptions & + DatasetChartOptions & + ScaleChartOptions & +-ChartTypeRegistry[TType]['chartOptions'] +->; ++(ChartTypeRegistry[TType]['chartOptions'] extends unknown ? {} : ChartTypeRegistry[TType]['chartOptions']) ++>, DeepPartial>; + + export type DefaultDataPoint = DistributiveArray; + diff --git a/rollup.config.js b/rollup.config.js index 032e22be1..24d74c7df 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,8 +1,10 @@ +const fs = require("fs"); +const path = require("path"); + const commonjs = require("@rollup/plugin-commonjs"); const resolve = require("@rollup/plugin-node-resolve"); const terser = require("@rollup/plugin-terser"); const istanbul = require("rollup-plugin-istanbul"); -const replace = require("@rollup/plugin-replace"); const typescript = require("@rollup/plugin-typescript"); const pkg = require("./package.json"); @@ -28,7 +30,7 @@ function bundleDragDataPlugin(options) { /** @type {import('rollup').RollupOptions} */ const customOptions = { - input: "src/index.js", + input: "src/index.ts", external: [ "chart.js", "chart.js/helpers", @@ -37,7 +39,7 @@ function bundleDragDataPlugin(options) { output: { exports: "named", banner, - name: "index", + name: "ChartJSDragDataPlugin", file, format, globals: { @@ -46,7 +48,7 @@ function bundleDragDataPlugin(options) { }, }, plugins: [ - commonjs(), + ...(format === "umd" ? [commonjs()] : []), resolve({ browser: true, }), @@ -59,20 +61,25 @@ function bundleDragDataPlugin(options) { }), ] : [] - : [ - // in a non-test build, strip the testing exports - replace({ - values: { - "export const exportsForTesting = mExportsForTesting;": "", - }, - delimiters: ["", ""], // no delimiters, we want to replace literally - preventAssignment: true, // prevent replacing near assignment - setting recommended by plugin docs - }), - ]), + : []), terse ? terser() : undefined, typescript({ tsconfig: "./tsconfig.build.json", }), + { + // copy index.d.ts to file matching the bundle filename for jest tests to pick up typings + closeBundle() { + if (bTestBuild) { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + + fs.copyFileSync( + path.join(dir, "index.d.ts"), + file.replace(".js", ".d.ts"), + ); + } + }, + }, ], }; @@ -97,14 +104,16 @@ const config = [ bundleDragDataPlugin({ file: pkg.module, - format: "es", + format: "esm", terse: true, bTestBuild: false, }), // bundle for E2E testing: istanbul + bundled D3 (for browser) bundleDragDataPlugin({ - file: pkg.main.replace(".js", "-test-browser.js"), + file: pkg.main + .replace(".js", "-test-browser.js") + .replace("dist/", "dist/test/"), format: "umd", terse: false, bTestBuild: true, @@ -112,7 +121,7 @@ const config = [ // bundle for unit/integration testing: istanbul + external D3 bundleDragDataPlugin({ - file: pkg.main.replace(".js", "-test.js"), + file: pkg.main.replace(".js", "-test.js").replace("dist/", "dist/test/"), format: "es", terse: false, bTestBuild: true, diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 978507ec8..000000000 --- a/src/index.js +++ /dev/null @@ -1,465 +0,0 @@ -import { Chart } from "chart.js"; -import { getRelativePosition } from "chart.js/helpers"; -import { drag } from "d3-drag"; -import { select } from "d3-selection"; - -let element, - yAxisID, - xAxisID, - rAxisID, - type, - stacked, - floatingBar, - initValue, - curDatasetIndex, - curIndex, - eventSettings; -let isDragging = false; - -function getSafe(func) { - try { - return func(); - } catch (e) { - return ""; - } -} - -function checkDraggingConfiguration( - chartInstance, - datasetIndex, - dataPointIndex, -) { - const dataset = chartInstance.data.datasets[datasetIndex]; - - /** per-chart option */ - const chartDraggingDisabled = - chartInstance.config.options.plugins.dragData === false; - - /** per-dataset option */ - const datasetDraggingDisabled = - chartDraggingDisabled || dataset.dragData === false; - - /** x-axis option (per-axis); dragging on the x-axis is disabled by default */ - const _xAxisDraggingPerAxisOptionValue = - chartInstance.config.options.scales[xAxisID]?.dragData; - - /** x-axis option (per-axis); dragging on the x-axis is disabled by default */ - let xAxisDraggingDisabled = true; - - if ( - !datasetDraggingDisabled && - (_xAxisDraggingPerAxisOptionValue === true || // finally, dragging can be enabled on the x-axis by the plugin options, - // unless it's explicitly disabled in x-axis options - (chartInstance.config.options.plugins?.dragData?.dragX === true && - _xAxisDraggingPerAxisOptionValue !== false)) - ) { - xAxisDraggingDisabled = false; - } - - /** y-axis option (per-axis); dragging on the y-axis is enabled by default */ - const yAxisDraggingDisabled = - datasetDraggingDisabled || - chartInstance.config.options.plugins?.dragData?.dragY === false || - chartInstance.config.options.scales[yAxisID]?.dragData === false; - - /** per-data-point option */ - const dataPointDraggingDisabled = - datasetDraggingDisabled || dataset.data[dataPointIndex].dragData === false; - - return { - chartDraggingDisabled, - datasetDraggingDisabled, - xAxisDraggingDisabled, - yAxisDraggingDisabled, - dataPointDraggingDisabled, - }; -} - -const getElement = (e, chartInstance, callback) => { - const searchMode = - chartInstance.config.options.interaction?.mode ?? "nearest", - searchOptions = chartInstance.config.options.interaction ?? { - intersect: true, - }; - - element = chartInstance.getElementsAtEventForMode( - e, - searchMode, - searchOptions, - false, - )[0]; - type = chartInstance.config.type; - - if (element) { - let datasetIndex = element.datasetIndex; - let index = element.index; - - // save element settings - eventSettings = getSafe( - () => chartInstance.config.options.plugins.tooltip.animation, - ); - - const dataset = chartInstance.data.datasets[datasetIndex]; - const datasetMeta = chartInstance.getDatasetMeta(datasetIndex); - let curValue = dataset.data[index]; - // get the id of the datasets scale - xAxisID = datasetMeta.xAxisID; - yAxisID = datasetMeta.yAxisID; - rAxisID = datasetMeta.rAxisID; - - const draggingConfiguration = checkDraggingConfiguration( - chartInstance, - datasetIndex, - index, - ), - { - datasetDraggingDisabled, - xAxisDraggingDisabled, - yAxisDraggingDisabled, - dataPointDraggingDisabled, - } = draggingConfiguration; - - // check if dragging the dataset or datapoint is prohibited - if ( - datasetDraggingDisabled || - // dragging disabled on all scales - (xAxisDraggingDisabled && yAxisDraggingDisabled) || - dataPointDraggingDisabled - ) { - element = null; - return; - } - - if (type === "bar") { - stacked = chartInstance.config.options.scales[xAxisID].stacked; - - // if a bar has a data point that is an array of length 2, it's a floating bar - const samplePoint = chartInstance.data.datasets[0].data[0]; - floatingBar = - samplePoint !== null && - Array.isArray(samplePoint) && - samplePoint.length >= 2; - - let dataPoint = chartInstance.data.datasets[datasetIndex].data[index]; - let newPos = calcPosition( - e, - chartInstance, - dataPoint, - draggingConfiguration, - ); - initValue = newPos - curValue; - } - - // disable the tooltip animation - if ( - chartInstance.config.options.plugins.dragData.showTooltip === undefined || - chartInstance.config.options.plugins.dragData.showTooltip - ) { - if (!chartInstance.config.options.plugins.tooltip) - chartInstance.config.options.plugins.tooltip = {}; - chartInstance.config.options.plugins.tooltip.animation = false; - } - - if (typeof callback === "function" && element) { - if (callback(e, datasetIndex, index, curValue) === false) { - element = null; - } - } - } -}; - -function roundValue(value, pos) { - if (!isNaN(pos) && pos >= 0) { - return Math.round(value * Math.pow(10, pos)) / Math.pow(10, pos); - } - return value; -} - -function calcRadar(e, chartInstance, curIndex, rAxisID) { - let { x: cursorX, y: cursorY } = getRelativePosition(e, chartInstance); - const rScale = chartInstance.scales[rAxisID]; - let { angle: axisAngleRad } = rScale.getPointPositionForValue( - // the radar chart has points draggable along primary axes that are aligned with - // scales' lines; the polarArea chart, however, is draggable along lines placed in the center - // between major lines, thus the +0.5 of index is added for the helper to calculate the angle - // of this center guide line (the helper accept a continuous argument, in spite of the name "index") - curIndex + (chartInstance.config.type === "polarArea" ? 0.5 : 0), - chartInstance.scales[rAxisID].max, - ); - const { xCenter, yCenter } = rScale; - - // we calculate the dot product of the vector from center to cursor & the axis direction vector - // center-to-cursor vector v - let vx = cursorX - xCenter; - let vy = cursorY - yCenter; - // axis direction vector d - let dx = Math.cos(axisAngleRad); - let dy = Math.sin(axisAngleRad); - // dot product of v & d - let dotProduct = vx * dx + vy * dy; - let d = - // if dot product <= 0, then the point is on the opposite side of the center than the direction of the axis - dotProduct > 0 - ? // Euclidean distance between cursor & center - Math.sqrt( - Math.pow(cursorX - xCenter, 2) + Math.pow(cursorY - yCenter, 2), - ) - : 0; - - // calculate the value from distance - let v = rScale.getValueForDistanceFromCenter(d); - - // apply rounding - v = roundValue(v, chartInstance.config.options.plugins.dragData.round); - - v = - v > chartInstance.scales[rAxisID].max - ? chartInstance.scales[rAxisID].max - : v; - v = - v < chartInstance.scales[rAxisID].min - ? chartInstance.scales[rAxisID].min - : v; - - return v; -} - -function calcPosition( - e, - chartInstance, - data, - { xAxisDraggingDisabled, yAxisDraggingDisabled }, -) { - let x, y; - const dataPoint = cloneDataPoint(data); - - if (e.touches) { - x = chartInstance.scales[xAxisID].getValueForPixel( - e.touches[0].clientX - chartInstance.canvas.getBoundingClientRect().left, - ); - y = chartInstance.scales[yAxisID].getValueForPixel( - e.touches[0].clientY - chartInstance.canvas.getBoundingClientRect().top, - ); - } else { - x = chartInstance.scales[xAxisID].getValueForPixel( - e.clientX - chartInstance.canvas.getBoundingClientRect().left, - ); - y = chartInstance.scales[yAxisID].getValueForPixel( - e.clientY - chartInstance.canvas.getBoundingClientRect().top, - ); - } - - x = roundValue(x, chartInstance.config.options.plugins.dragData.round); - y = roundValue(y, chartInstance.config.options.plugins.dragData.round); - - x = - x > chartInstance.scales[xAxisID].max - ? chartInstance.scales[xAxisID].max - : x; - x = - x < chartInstance.scales[xAxisID].min - ? chartInstance.scales[xAxisID].min - : x; - - y = - y > chartInstance.scales[yAxisID].max - ? chartInstance.scales[yAxisID].max - : y; - y = - y < chartInstance.scales[yAxisID].min - ? chartInstance.scales[yAxisID].min - : y; - - if (floatingBar) { - // x contains the new value for one end of the floating bar - // dataPoint contains the old interval [left, right] of the floating bar - // calculate difference between the new value and both sides - // the side with the smallest difference from the new value was the one that was dragged - // return an interval with new value on the dragged side and old value on the other side - let newVal; - // choose the right variable based on the orientation of the graph (vertical, horizontal) - if (chartInstance.config.options.indexAxis === "y") { - newVal = x; - } else { - newVal = y; - } - const diffFromLeft = Math.abs(newVal - dataPoint[0]); - const diffFromRight = Math.abs(newVal - dataPoint[1]); - - if (diffFromLeft <= diffFromRight) { - dataPoint[0] = newVal; - } else { - dataPoint[1] = newVal; - } - - return dataPoint; - } - - if (dataPoint.x !== undefined && !xAxisDraggingDisabled) { - dataPoint.x = x; - } - - if (dataPoint.y !== undefined) { - if (!yAxisDraggingDisabled) { - dataPoint.y = y; - } - return dataPoint; - } else { - if (chartInstance.config.options.indexAxis === "y") { - if (!xAxisDraggingDisabled) { - return x; - } else { - return dataPoint; - } - } else { - if (!yAxisDraggingDisabled) { - return y; - } else { - return dataPoint; - } - } - } -} - -const updateData = (e, chartInstance, pluginOptions, callback) => { - if (element) { - curDatasetIndex = element.datasetIndex; - curIndex = element.index; - - isDragging = true; - - let dataPoint = chartInstance.data.datasets[curDatasetIndex].data[curIndex]; - - const draggingConfiguration = checkDraggingConfiguration( - chartInstance, - curDatasetIndex, - curIndex, - ); - - if (type === "radar" || type === "polarArea") { - dataPoint = calcRadar(e, chartInstance, curIndex, rAxisID); - } else if (stacked) { - let cursorPos = calcPosition( - e, - chartInstance, - dataPoint, - draggingConfiguration, - ); - dataPoint = roundValue(cursorPos - initValue, pluginOptions.round); - } else { - dataPoint = calcPosition( - e, - chartInstance, - dataPoint, - draggingConfiguration, - ); - } - - if ( - !callback || - (typeof callback === "function" && - callback(e, curDatasetIndex, curIndex, dataPoint) !== false) - ) { - chartInstance.data.datasets[curDatasetIndex].data[curIndex] = dataPoint; - chartInstance.update("none"); - } - } -}; - -// Update values to the nearest values -function applyMagnet(chartInstance, i, j) { - const pluginOptions = chartInstance.config.options.plugins.dragData; - if (pluginOptions.magnet) { - const magnet = pluginOptions.magnet; - if (magnet.to && typeof magnet.to === "function") { - let data = chartInstance.data.datasets[i].data[j]; - data = magnet.to(data); - chartInstance.data.datasets[i].data[j] = data; - chartInstance.update("none"); - return data; - } - } else { - return chartInstance.data.datasets[i].data[j]; - } -} - -const dragEndCallback = (e, chartInstance, callback) => { - curDatasetIndex, (curIndex = undefined); - isDragging = false; - // re-enable the tooltip animation - if (chartInstance.config.options.plugins.tooltip) { - chartInstance.config.options.plugins.tooltip.animation = eventSettings; - chartInstance.update("none"); - } - - // chartInstance.update('none') - if (typeof callback === "function" && element) { - const datasetIndex = element.datasetIndex; - const index = element.index; - let value = applyMagnet(chartInstance, datasetIndex, index); - return callback(e, datasetIndex, index, value); - } -}; - -const cloneDataPoint = (source) => { - if (Array.isArray(source)) return [...source]; - else if (typeof source === "number") return source; - else if (typeof source === "object") return { ...source }; -}; - -const ChartJSdragDataPlugin = { - id: "dragdata", - afterInit: function (chartInstance) { - const pluginOptions = chartInstance.config.options.plugins.dragData; - - select(chartInstance.canvas).call( - drag() - .container(chartInstance.canvas) - .on("start", (e) => - getElement(e.sourceEvent, chartInstance, pluginOptions.onDragStart), - ) - .on("drag", (e) => - updateData( - e.sourceEvent, - chartInstance, - pluginOptions, - pluginOptions.onDrag, - ), - ) - .on("end", (e) => - dragEndCallback( - e.sourceEvent, - chartInstance, - pluginOptions.onDragEnd, - ), - ), - ); - }, - beforeEvent: function (chart) { - if (isDragging) { - if (chart.tooltip) chart.tooltip.update(); - return false; - } - }, -}; - -Chart.register(ChartJSdragDataPlugin); - -// this export will be stripped by @rollup/plugin-replace in non-test builds -const mExportsForTesting = { - dragEndCallback, - updateData, - getElement, - applyMagnet, - calcPosition, - calcRadar, - getSafe, - roundValue, - checkDraggingConfiguration, - getStateVarElement: () => element, -}; - -// IMPORTANT: do not alter the below line or rollup will not pick it up for removal -export const exportsForTesting = mExportsForTesting; - -export default ChartJSdragDataPlugin; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..5370f4b79 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +// ensure Chart.js type augmentations are bundled +export type * from "./typeAugmentations"; + +// export all types +export type * from "./types"; + +// export all utility functions +export * from "./util"; + +import ChartJSDragDataPlugin from "./plugin"; + +export default ChartJSDragDataPlugin; diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 000000000..e7b21027e --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,60 @@ +import { Chart, ChartType } from "chart.js"; +import { drag } from "d3-drag"; +import { select } from "d3-selection"; + +import { DragDataState } from "./types"; +import * as util from "./util"; + +const ChartJSDragDataPlugin = { + id: "dragdata", + statesStore: new Map(), + afterInit: function dragDataAfterInit( + chartInstance: Chart, + ) { + const state: DragDataState = { + curIndex: undefined, + curDatasetIndex: undefined, + element: null, + eventSettings: false, + floatingBar: false, + initValue: 0, + xAxisID: "", + yAxisID: "", + rAxisID: "", + stacked: false, + type: undefined, + isDragging: false, + }; + + ChartJSDragDataPlugin.statesStore.set(chartInstance.id, state); + + select(chartInstance.canvas).call( + drag() + .container(chartInstance.canvas) + .on("start", (e) => + util.getElement(e.sourceEvent, chartInstance, state), + ) + .on("drag", (e) => util.updateData(e.sourceEvent, chartInstance, state)) + .on("end", (e) => + util.dragEndCallback(e.sourceEvent, chartInstance, state), + ), + ); + }, + beforeEvent: function dragDataBeforeEvent( + chartInstance: Chart, + ) { + let state = ChartJSDragDataPlugin.statesStore.get(chartInstance.id); + + if (state?.isDragging) { + (chartInstance.tooltip as any | undefined)?.update(); + + return false; + } + }, +}; + +// TODO: in a future major release, stop auto-registering the plugin and require users to manually register it +// see https://chartjs-plugin-datalabels.netlify.app/guide/getting-started.html#registration +Chart.register(ChartJSDragDataPlugin); + +export default ChartJSDragDataPlugin; diff --git a/src/typeAugmentations.ts b/src/typeAugmentations.ts new file mode 100644 index 000000000..d7c017c71 --- /dev/null +++ b/src/typeAugmentations.ts @@ -0,0 +1,75 @@ +// this file is not a .d.ts in src/ file so as for rollup to bundle it along with other ts files + +import { ChartType, Plugin } from "chart.js"; + +import { + DataPointDraggingConfiguration, + DatasetDraggingConfiguration, + PluginConfiguration, + ScaleDraggingConfiguration, +} from "./types/Configuration"; + +declare module "chart.js" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface ChartDatasetProperties { + /** + * Configuration controlling the dragdata plugin behaviour for a given dataset. + * + * For information regarding the mechanics, please consult `PluginConfiguration`. + * + * @see PluginConfiguration + */ + dragData?: DatasetDraggingConfiguration | boolean; + } + + export interface PluginOptionsByType { + /** + * Configuration controlling the dragdata plugin behaviour. + * The scope depends on where the configuration is applied. The possible locations are: + * - per-chart (inside `plugins` section in chart configuration) + * - per-scale (inside) + * - per-dataset + * - per-data-point + * + * Each next level from the listing above overrides any preceding configuration by entirely ignoring it. + * + * Example 1: applying a per-chart `round: 2` property and for one of the points `round: 3` will cause this point + * to round to `3` decimal places and all other points of this chart to `2` decimal places. + * + * Example 2: applying a per-chart `round: 2` property and for one of the points `false` (disabling the plugin) + * will disable the plugin for that poi + * nt. + * + * To entirely disable the plugin, pass `false`. By default, the plugin is enabled for every data point but only + * on the y-axis, unless a lower-level configuration specifies otherwise. + */ + dragData?: PluginConfiguration | boolean; + } + + export interface CoreScaleOptions { + /** + * Configuration controlling the dragdata plugin behaviour for a given scale. + * If `true`, the plugin is enabled for this scale, otherwise it is disabled. + * + * The default value depends on the scale: for the y-axis, it is `true` by default, + * while it is `false` by default otherwise (in which case `true` needs to be + * explicitly specified to enable dragging on the axis). + */ + dragData?: ScaleDraggingConfiguration | boolean; + } + + export interface Point { + /** + * Configuration controlling the dragdata plugin behaviour for a given data point. + * + * For information regarding the mechanics, please consult `PluginConfiguration`. + * + * @see PluginConfiguration + */ + dragData?: DataPointDraggingConfiguration | false; + } +} + +declare const ChartJSDragDataPlugin: Plugin; + +export default ChartJSDragDataPlugin; diff --git a/src/types/ChartJSTypes.ts b/src/types/ChartJSTypes.ts new file mode 100644 index 000000000..ff30fba00 --- /dev/null +++ b/src/types/ChartJSTypes.ts @@ -0,0 +1,4 @@ +import type { ChartType, DefaultDataPoint } from "chart.js"; + +export type ChartDataItemType = + DefaultDataPoint[0]; diff --git a/src/types/Configuration.ts b/src/types/Configuration.ts new file mode 100644 index 000000000..ea40eb039 --- /dev/null +++ b/src/types/Configuration.ts @@ -0,0 +1,119 @@ +import type { ChartType } from "chart.js"; + +import { ChartDataItemType } from "./ChartJSTypes"; +import { DragEventCallback } from "./EventTypes"; + +export type OptionalPluginConfiguration = + | PluginConfiguration + | undefined; + +/** + * Core configuration, used as a common base for other specialized configuration types. + */ +type CoreConfiguration = { + /** + * Rounds the values to `round` decimal places. + * + * Example: `1`, would cause `0.1234` to be rounded to `0.1`. + */ + round: number; + + /** + * Whether to show the tooltip while dragging. + * + * @default true + */ + showTooltip: boolean; +}; + +// per-scale configuration; docstring located in types.d.ts to be visible to the end users +export type ScaleDraggingConfiguration = boolean; + +// per-dataset configuration; docstring located in types.d.ts to be visible to the end users +export type DatasetDraggingConfiguration = CoreConfiguration; + +// per-data-point configuration; docstring located in types.d.ts to be visible to the end users +export type DataPointDraggingConfiguration = boolean; + +// plugin (per-chart) configuration; docstring located in types.d.ts to be visible to the end users +export type PluginConfiguration = + CoreConfiguration & { + /** + * Whether to allow for dragging on the x-axis. + * + * **Disabled by default.** + * + * @default `false` + */ + dragX: boolean; + + /** + * Whether to allow for dragging on the y-axis. + * + * @default `true` + */ + dragY: boolean; + + /** + * Callback fired during the drag numerous times. + * + * If the callback returns `false`, the drag is prevented and the previous + * value of the data point is still effective while the new one is discarded. + * + * May be used e.g. to process the data point value or to prevent the drag. + * + * **Note: this solely works for continous, numerical x-axis scales (no categories or dates)** + * + * @example + * { + * dragData: { + * onDragStart: () => { + * if (element.datasetIndex === 0 && element.index === 0) { + * // this would prohibit dragging the first datapoint in the first + * // dataset entirely + * return false + * } + * } + * } + * } + */ + onDragStart: DragEventCallback; + + /** + * Callback fired during the drag numerous times. + * If the callback returns `false`, the drag is prevented and the previous + * value of the data point is still effective while the new one is discarded. + * + * May be used e.g. to process the data point value or to prevent the drag. + * + * @example + * { + * dragData: { + * onDragStart: () => { + * if (element.datasetIndex === 0 && element.index === 0) { + * // you may control the range in which data points are allowed to be + * // dragged by returning `false` in this callback + * if (value < 0) return false // this only allows positive values + * if (datasetIndex === 0 && index === 0 && value > 20) return false + * } + * } + * } + * } + */ + onDrag: DragEventCallback; + + /** + * Callback fired after a drag completes. + * + * May be used e.g. to store the final data point value (after dragging) + * or perform any other desired side effects. + */ + onDragEnd: DragEventCallback; + + /** + * + */ + magnet: { + to: (value: ChartDataItemType) => ChartDataItemType; + }; + }; diff --git a/src/types/DragDataState.ts b/src/types/DragDataState.ts new file mode 100644 index 000000000..a556a29ac --- /dev/null +++ b/src/types/DragDataState.ts @@ -0,0 +1,21 @@ +import type { + AnimationSpec, + CartesianScaleOptions, + ChartType, + InteractionItem, +} from "chart.js"; + +export type DragDataState = { + element: InteractionItem | null; + yAxisID: string; + xAxisID: string; + rAxisID: string; + type: ChartType | undefined; + stacked: CartesianScaleOptions["stacked"]; + floatingBar: boolean; + initValue: number; + curDatasetIndex: number | undefined; + curIndex: number | undefined; + eventSettings: AnimationSpec | false | undefined; + isDragging: boolean; +}; diff --git a/src/types/DraggingConfiguration.ts b/src/types/DraggingConfiguration.ts new file mode 100644 index 000000000..1c2c83b9b --- /dev/null +++ b/src/types/DraggingConfiguration.ts @@ -0,0 +1,13 @@ +export type DraggingConfiguration = Record< + | "chartDraggingDisabled" + | "datasetDraggingDisabled" + | "xAxisDraggingDisabled" + | "yAxisDraggingDisabled" + | "dataPointDraggingDisabled", + boolean +>; + +export type AxisDraggingConfiguration = Pick< + DraggingConfiguration, + "xAxisDraggingDisabled" | "yAxisDraggingDisabled" +>; diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts new file mode 100644 index 000000000..363a99d26 --- /dev/null +++ b/src/types/EventTypes.ts @@ -0,0 +1,17 @@ +import type { ChartType } from "chart.js"; + +import type { ChartDataItemType } from "./ChartJSTypes"; + +export type DragDataEvent = MouseEvent | TouchEvent; + +// note: docstring located in Configuration.ts to be visible to the end users +export type DragEventCallback = ( + /** the interaction event */ + event: DragDataEvent, + /** the index of the dataset containing the dragged point */ + datasetIndex: number, + /** the index of the dragged point in its parent dataset */ + index: number, + /** the current value of the data point */ + value: ChartDataItemType, +) => boolean; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 000000000..e4e8a7cfe --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +export * from "./ChartJSTypes"; +export * from "./Configuration"; +export * from "./EventTypes"; +export * from "./DragDataState"; +export * from "./DraggingConfiguration"; diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 000000000..5f4b65fdf --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1 @@ +import "./typeAugmentations"; diff --git a/src/util/applyMagnet.ts b/src/util/applyMagnet.ts new file mode 100644 index 000000000..215234800 --- /dev/null +++ b/src/util/applyMagnet.ts @@ -0,0 +1,38 @@ +import { Chart, ChartType } from "chart.js"; + +import { ChartDataItemType, OptionalPluginConfiguration } from "../types"; + +/** + * Updates values to the nearest values + * @param chartInstance the chart instance + * @param datasetIndex the dataset index + * @param index the data point index + * @returns value after applying magnet or unchanged if not magnet is configured + */ +export function applyMagnet( + chartInstance: Chart, + datasetIndex: number, + index: number, +): ChartDataItemType { + const pluginOptions = chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration; + + if (pluginOptions?.magnet) { + const magnet = pluginOptions?.magnet; + + if (typeof magnet.to === "function") { + let data = chartInstance.data.datasets[datasetIndex].data[index]; + data = magnet.to(data); + + chartInstance.data.datasets[datasetIndex].data[index] = data; + + chartInstance.update("none"); + + return data; + } + + return null; + } else { + return chartInstance.data.datasets[datasetIndex].data[index]; + } +} diff --git a/src/util/calc/cartesian.ts b/src/util/calc/cartesian.ts new file mode 100644 index 000000000..3ebd0e959 --- /dev/null +++ b/src/util/calc/cartesian.ts @@ -0,0 +1,105 @@ +import type { Chart, ChartType, Point } from "chart.js"; +import { getRelativePosition } from "chart.js/helpers"; + +import ChartJSDragDataPlugin from "../../plugin"; +import { + ChartDataItemType, + DragDataEvent, + DragDataState, + OptionalPluginConfiguration, +} from "../../types"; +import { AxisDraggingConfiguration } from "../../types/DraggingConfiguration"; +import { cloneDataPoint } from "../cloneDataPoint"; +import { roundValue } from "../roundValue"; +import { clipValue } from "./clipValue"; + +export function calcCartesian( + event: DragDataEvent, + chartInstance: Chart, + data: NonNullable>, + { xAxisDraggingDisabled, yAxisDraggingDisabled }: AxisDraggingConfiguration, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +): NonNullable> { + if (!state) return data; + + const dataPoint = cloneDataPoint(data)!; + + let { x: cursorX, y: cursorY } = getRelativePosition( + event, + chartInstance as any, + ); + + let x = chartInstance.scales[state.xAxisID].getValueForPixel(cursorX); + let y = chartInstance.scales[state.yAxisID].getValueForPixel(cursorY); + + const rounding = ( + chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.round; + + x = roundValue(x!, rounding); + y = roundValue(y!, rounding); + + x = clipValue( + x, + chartInstance.scales[state.xAxisID].min, + chartInstance.scales[state.xAxisID].max, + ); + y = clipValue( + y, + chartInstance.scales[state.yAxisID].min, + chartInstance.scales[state.yAxisID].max, + ); + + if (state.floatingBar) { + // x contains the new value for one end of the floating bar + // dataPoint contains the old interval [left, right] of the floating bar + // calculate difference between the new value and both sides + // the side with the smallest difference from the new value was the one that was dragged + // return an interval with new value on the dragged side and old value on the other side + let newVal; + // choose the right variable based on the orientation of the graph (vertical, horizontal) + if (chartInstance.config.options?.indexAxis === "y") { + newVal = x; + } else { + newVal = y; + } + const diffFromLeft = Math.abs(newVal - (dataPoint as [number, number])[0]); + const diffFromRight = Math.abs(newVal - (dataPoint as [number, number])[1]); + + if (diffFromLeft <= diffFromRight) { + (dataPoint as [number, number])[0] = newVal; + } else { + (dataPoint as [number, number])[1] = newVal; + } + + return dataPoint; + } + + if ((dataPoint as Point).x !== undefined && !xAxisDraggingDisabled) { + (dataPoint as Point).x = x; + } + + if ((dataPoint as Point).y !== undefined) { + if (!yAxisDraggingDisabled) { + (dataPoint as Point).y = y; + } + return dataPoint; + } else { + if (chartInstance.config.options?.indexAxis === "y") { + if (!xAxisDraggingDisabled) { + return x; + } else { + return dataPoint; + } + } else { + if (!yAxisDraggingDisabled) { + return y; + } else { + return dataPoint; + } + } + } +} diff --git a/src/util/calc/clipValue.ts b/src/util/calc/clipValue.ts new file mode 100644 index 000000000..1a45be770 --- /dev/null +++ b/src/util/calc/clipValue.ts @@ -0,0 +1,10 @@ +/** + * Clips a value between a minimum and maximum value. + * @param value the value to be clipped + * @param min the minimum value + * @param max the maximum value + * @returns value in range [min, max] + */ +export function clipValue(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/util/calc/index.ts b/src/util/calc/index.ts new file mode 100644 index 000000000..a1fdc27ce --- /dev/null +++ b/src/util/calc/index.ts @@ -0,0 +1,3 @@ +export * from "./radialLinear"; +export * from "./cartesian"; +export * from "./clipValue"; diff --git a/src/util/calc/radialLinear.ts b/src/util/calc/radialLinear.ts new file mode 100644 index 000000000..7d67f2e2a --- /dev/null +++ b/src/util/calc/radialLinear.ts @@ -0,0 +1,74 @@ +import type { Chart, ChartType, RadialLinearScale } from "chart.js"; +import { getRelativePosition } from "chart.js/helpers"; + +import ChartJSDragDataPlugin from "../../plugin"; +import { + DragDataEvent, + DragDataState, + OptionalPluginConfiguration, +} from "../../types"; +import { roundValue } from "../roundValue"; +import { clipValue } from "./clipValue"; + +export function calcRadialLinear( + event: DragDataEvent, + chartInstance: Chart, + curIndex: number, + rAxisID: string, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +) { + let { x: cursorX, y: cursorY } = getRelativePosition( + event, + chartInstance as any, + ); + const rScale = chartInstance.scales[rAxisID] as RadialLinearScale; + let { angle: axisAngleRad } = rScale.getPointPositionForValue( + // the radar chart has points draggable along primary axes that are aligned with + // scales' lines; the polarArea chart, however, is draggable along lines placed in the center + // between major lines, thus the +0.5 of index is added for the helper to calculate the angle + // of this center guide line (the helper accept a continuous argument, in spite of the name "index") + curIndex + (state?.type === "polarArea" ? 0.5 : 0), + chartInstance.scales[rAxisID].max, + ); + const { xCenter, yCenter } = rScale; + + // we calculate the dot product of the vector from center to cursor & the axis direction vector + // center-to-cursor vector v + let vx = cursorX - xCenter; + let vy = cursorY - yCenter; + // axis direction vector d + let dx = Math.cos(axisAngleRad); + let dy = Math.sin(axisAngleRad); + // dot product of v & d + let dotProduct = vx * dx + vy * dy; + let d = + // if dot product <= 0, then the point is on the opposite side of the center than the direction of the axis + dotProduct > 0 + ? // Euclidean distance between cursor & center + Math.sqrt( + Math.pow(cursorX - xCenter, 2) + Math.pow(cursorY - yCenter, 2), + ) + : 0; + + // calculate the value from distance + let v = rScale.getValueForDistanceFromCenter(d); + + // apply rounding + v = roundValue( + v, + ( + chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.round, + ); + + v = clipValue( + v, + chartInstance.scales[rAxisID].min, + chartInstance.scales[rAxisID].max, + ); + + return v; +} diff --git a/src/util/checkDraggingConfiguration.ts b/src/util/checkDraggingConfiguration.ts new file mode 100644 index 000000000..e45597585 --- /dev/null +++ b/src/util/checkDraggingConfiguration.ts @@ -0,0 +1,78 @@ +import type { BubbleDataPoint, ChartType, Point } from "chart.js"; +import { Chart } from "chart.js"; + +import ChartJSDragDataPlugin from "../plugin"; +import { DragDataState } from "../types"; +import { OptionalPluginConfiguration } from "../types/Configuration"; +import { DraggingConfiguration } from "../types/DraggingConfiguration"; + +export function checkDraggingConfiguration( + chartInstance: Chart, + datasetIndex: number, + dataPointIndex: number, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +): DraggingConfiguration { + if (!state) + return { + chartDraggingDisabled: true, + datasetDraggingDisabled: true, + xAxisDraggingDisabled: true, + yAxisDraggingDisabled: true, + dataPointDraggingDisabled: true, + }; + + const dataset = chartInstance.data.datasets[datasetIndex]; + + /** per-chart option */ + const chartDraggingDisabled = + chartInstance.config.options?.plugins?.dragData === false; + + /** per-dataset option */ + const datasetDraggingDisabled = + chartDraggingDisabled || dataset.dragData === false; + + /** x-axis option (per-axis); dragging on the x-axis is disabled by default */ + const _xAxisDraggingPerAxisOptionValue = + chartInstance.config.options?.scales?.[state.xAxisID]?.dragData; + + /** x-axis option (per-axis); dragging on the x-axis is disabled by default */ + let xAxisDraggingDisabled = true; + + if ( + !datasetDraggingDisabled && + (_xAxisDraggingPerAxisOptionValue === true || // finally, dragging can be enabled on the x-axis by the plugin options, + // unless it's explicitly disabled in x-axis options + (( + chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.dragX === true && + _xAxisDraggingPerAxisOptionValue !== false)) + ) { + xAxisDraggingDisabled = false; + } + + /** y-axis option (per-axis); dragging on the y-axis is enabled by default */ + const yAxisDraggingDisabled = + datasetDraggingDisabled || + ( + chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.dragY === false || + chartInstance.config.options?.scales?.[state.yAxisID]?.dragData === false; + + /** per-data-point option */ + const dataPointDraggingDisabled = + datasetDraggingDisabled || + (dataset.data[dataPointIndex] as Point | BubbleDataPoint)?.dragData === + false; + + return { + chartDraggingDisabled, + datasetDraggingDisabled, + xAxisDraggingDisabled, + yAxisDraggingDisabled, + dataPointDraggingDisabled, + }; +} diff --git a/src/util/cloneDataPoint.ts b/src/util/cloneDataPoint.ts new file mode 100644 index 000000000..c47194830 --- /dev/null +++ b/src/util/cloneDataPoint.ts @@ -0,0 +1,14 @@ +import type { ChartType } from "chart.js"; + +import { ChartDataItemType } from "../types"; + +export function cloneDataPoint< + TType extends ChartType, + T = ChartDataItemType, +>(source: T): T { + if (Array.isArray(source)) return [...source] as T; + else if (typeof source === "object") return { ...source }; + + // below: typeof source === "number" + return source; +} diff --git a/src/util/dragEndCallback.ts b/src/util/dragEndCallback.ts new file mode 100644 index 000000000..11cde13c4 --- /dev/null +++ b/src/util/dragEndCallback.ts @@ -0,0 +1,44 @@ +import type { ChartType } from "chart.js"; +import { Chart } from "chart.js"; + +import ChartJSDragDataPlugin from "../plugin"; +import { + DragDataEvent, + DragDataState, + OptionalPluginConfiguration, +} from "../types"; +import { applyMagnet } from "./applyMagnet"; + +export function dragEndCallback( + event: DragDataEvent, + chartInstance: Chart, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +) { + if (!state) return; + + const callback = ( + chartInstance.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.onDragEnd; + + state.curIndex = undefined; + state.isDragging = false; + + // re-enable the tooltip animation + if (chartInstance.config.options?.plugins?.tooltip) { + chartInstance.config.options.plugins.tooltip.animation = + state.eventSettings; + chartInstance.update("none"); + } + + if (typeof callback === "function" && state.element) { + const datasetIndex = state.element.datasetIndex; + const index = state.element.index; + + let value = applyMagnet(chartInstance, datasetIndex, index); + + return callback(event, datasetIndex, index, value); + } +} diff --git a/src/util/getElement.ts b/src/util/getElement.ts new file mode 100644 index 000000000..4710a9b9e --- /dev/null +++ b/src/util/getElement.ts @@ -0,0 +1,138 @@ +import type { + CartesianScaleOptions, + ChartConfiguration, + ChartType, +} from "chart.js"; +import { Chart } from "chart.js"; + +import ChartJSDragDataPlugin from "../plugin"; +import { + DragDataEvent, + DragDataState, + OptionalPluginConfiguration, +} from "../types"; +import { checkDraggingConfiguration } from "../util/checkDraggingConfiguration"; +import { calcCartesian } from "./calc"; + +export function getElement( + event: DragDataEvent, + chartInstance: Chart, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +) { + const callback = ( + chartInstance.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.onDragStart; + + if (!state) return; + + const searchMode = + chartInstance.config.options?.interaction?.mode ?? "nearest", + searchOptions = chartInstance.config.options?.interaction ?? { + intersect: true, + }; + + state.element = chartInstance.getElementsAtEventForMode( + event, + searchMode, + searchOptions, + false, + )[0]; + + if (state.element) { + let datasetIndex = state.element.datasetIndex; + let index = state.element.index; + + // note: type may be absent if config is ChartConfigurationCustomTypesPerDataset, in which case we pull this value from the dataset + state.type = + (chartInstance.config as ChartConfiguration).type ?? + chartInstance.data.datasets[datasetIndex].type ?? + undefined; + + // save element settings + state.eventSettings = + chartInstance.config.options?.plugins?.tooltip?.animation; + + const dataset = chartInstance.data.datasets[datasetIndex]; + const datasetMeta = chartInstance.getDatasetMeta(datasetIndex); + let curValue = dataset.data[index]; + // get the id of the datasets scale + state.xAxisID = datasetMeta.xAxisID!; + state.yAxisID = datasetMeta.yAxisID!; + state.rAxisID = datasetMeta.rAxisID!; + + const draggingConfiguration = checkDraggingConfiguration( + chartInstance, + datasetIndex, + index, + ), + { + datasetDraggingDisabled, + xAxisDraggingDisabled, + yAxisDraggingDisabled, + dataPointDraggingDisabled, + } = draggingConfiguration; + + // check if dragging the dataset or datapoint is prohibited + if ( + datasetDraggingDisabled || + // dragging disabled on all scales + (xAxisDraggingDisabled && yAxisDraggingDisabled) || + dataPointDraggingDisabled + ) { + state.element = null; + return; + } + + if (state.type === "bar") { + // note: stacked may be missing in RadialLinearScaleOptions + state.stacked = + ( + chartInstance.config.options?.scales?.[ + state.xAxisID + ] as CartesianScaleOptions + )?.stacked ?? undefined; + + // if a bar has a data point that is an array of length 2, it's a floating bar + const samplePoint = chartInstance.data.datasets[0].data[0]; + state.floatingBar = + samplePoint !== null && + Array.isArray(samplePoint) && + samplePoint.length >= 2; + + let dataPoint = chartInstance.data.datasets[datasetIndex].data[index]!; + let newPos = calcCartesian( + event, + chartInstance, + dataPoint, + draggingConfiguration, + state, + ); + state.initValue = (newPos as number) - (curValue as number); + } + + // disable the tooltip animation + const showTooltipOptionValue = ( + chartInstance.config.options?.plugins + ?.dragData as OptionalPluginConfiguration + )?.showTooltip; + if ( + showTooltipOptionValue === undefined || + showTooltipOptionValue === true + ) { + chartInstance.config.options ??= {} as any; + chartInstance.config.options!.plugins ??= {} as any; + chartInstance.config.options!.plugins!.tooltip ??= {} as any; + + chartInstance.config.options!.plugins!.tooltip!.animation = false; + } + + if (typeof callback === "function" && state.element) { + if (callback(event, datasetIndex, index, curValue) === false) { + state.element = null; + } + } + } +} diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 000000000..954f42923 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,8 @@ +export * from "./calc"; +export * from "./applyMagnet"; +export * from "./checkDraggingConfiguration"; +export * from "./cloneDataPoint"; +export * from "./dragEndCallback"; +export * from "./getElement"; +export * from "./roundValue"; +export * from "./updateData"; diff --git a/src/util/roundValue.ts b/src/util/roundValue.ts new file mode 100644 index 000000000..66c4cde0e --- /dev/null +++ b/src/util/roundValue.ts @@ -0,0 +1,5 @@ +export function roundValue(value: number, pos: number | undefined) { + if (pos === undefined || isNaN(pos) || pos < 0) return value; + + return Math.round(value * Math.pow(10, pos)) / Math.pow(10, pos); +} diff --git a/src/util/typings.d.ts b/src/util/typings.d.ts new file mode 100644 index 000000000..4817b5094 --- /dev/null +++ b/src/util/typings.d.ts @@ -0,0 +1 @@ +import "../typings.d.ts"; diff --git a/src/util/updateData.ts b/src/util/updateData.ts new file mode 100644 index 000000000..0926fb572 --- /dev/null +++ b/src/util/updateData.ts @@ -0,0 +1,84 @@ +import type { ChartType } from "chart.js"; +import { Chart } from "chart.js"; + +import ChartJSDragDataPlugin from "../plugin"; +import { + DragDataEvent, + DragDataState, + OptionalPluginConfiguration, +} from "../types"; +import { checkDraggingConfiguration } from "../util/checkDraggingConfiguration"; +import { calcCartesian, calcRadialLinear } from "./calc"; +import { roundValue } from "./roundValue"; + +export function updateData( + event: DragDataEvent, + chartInstance: Chart, + state: DragDataState | undefined = ChartJSDragDataPlugin.statesStore.get( + chartInstance.id, + ), +) { + if (!state) return; + + const pluginOptions = chartInstance.options?.plugins + ?.dragData as OptionalPluginConfiguration; + + const callback = pluginOptions?.onDrag; + + if (state.element) { + state.curDatasetIndex = state.element.datasetIndex; + state.curIndex = state.element.index; + + state.isDragging = true; + + let dataPoint = + chartInstance.data.datasets[state.curDatasetIndex].data[state.curIndex]!; + + const draggingConfiguration = checkDraggingConfiguration( + chartInstance, + state.curDatasetIndex, + state.curIndex, + ); + + if (state.type === "radar" || state.type === "polarArea") { + dataPoint = calcRadialLinear( + event, + chartInstance, + state.curIndex, + state.rAxisID, + state, + ); + } else if (state.stacked) { + let cursorPos = calcCartesian( + event, + chartInstance, + dataPoint, + draggingConfiguration, + state, + ); + dataPoint = roundValue( + (cursorPos as number) - state.initValue, + (pluginOptions as OptionalPluginConfiguration)?.round, + ); + } else { + dataPoint = calcCartesian( + event, + chartInstance, + dataPoint, + draggingConfiguration, + state, + ); + } + + if ( + typeof callback === "function" + ? callback(event, state.curDatasetIndex, state.curIndex, dataPoint) !== + false + : true + ) { + chartInstance.data.datasets[state.curDatasetIndex].data[state.curIndex] = + dataPoint; + chartInstance.update("none"); + } + } +} diff --git a/tests/__data__/data.ts b/tests/__data__/data.ts index 2360760ae..e18c021de 100644 --- a/tests/__data__/data.ts +++ b/tests/__data__/data.ts @@ -17,10 +17,10 @@ import { DeepPartial } from "../__utils__/types"; import { ganttChartScenario } from "./gantt"; import { scatterChartScenario } from "./scatter"; -export const TestChartOptions: ChartOptions = { +export const JestTestChartOptions: ChartOptions = { plugins: { dragData: true, - } as any, // TODO: fix this later with proper TS typings + }, animation: false, }; @@ -438,8 +438,6 @@ export const bubbleXOnlyChartScenario = mergeScenarioPartialConfigurations( configuration: { options: { plugins: { - // TODO: fix this later with proper TS typings - // @ts-ignore dragData: { dragY: false, // only allow X axis to be draggable }, diff --git a/tests/__data__/gantt.ts b/tests/__data__/gantt.ts index 99c6012c1..3104a7eb4 100644 --- a/tests/__data__/gantt.ts +++ b/tests/__data__/gantt.ts @@ -91,7 +91,6 @@ export const ganttChartScenario = { onDrag: (_e, _datasetIndex, index, _value) => { // const duration = dateFns.differenceInDays(value[1], value[0]); window.testedChart.data.datasets.forEach((segment, i) => { - // console.log(i); const curDuration = dateFns.differenceInDays( // @ts-ignore: this is valid since chartjs-adapter-date-fns is used segment.data[index][1], @@ -108,15 +107,12 @@ export const ganttChartScenario = { // @ts-ignore: this is valid since chartjs-adapter-date-fns is used window.testedChart.data.datasets[i + 1].data[index][0], ); - // console.log(nextStart); if (nextStart !== thisEnd) { // @ts-ignore: this is valid since chartjs-adapter-date-fns is used segment.data[index] = [thisStart, nextStart]; } } if (i > 0) { - // console.log(i); - // console.log(window.testedChart.data.datasets[i - 1]); const prevEnd = new Date( // @ts-ignore: this is valid since chartjs-adapter-date-fns is used window.testedChart.data.datasets[i - 1].data[index][1], @@ -124,7 +120,6 @@ export const ganttChartScenario = { if (thisStart !== prevEnd) { // retain current segment length const newEnd = dateFns.addDays(prevEnd, curDuration); - // console.log(prevEnd, newEnd); // @ts-ignore: this is valid since chartjs-adapter-date-fns is used segment.data[index] = [prevEnd, newEnd]; } diff --git a/tests/__setup__/jestSetup.ts b/tests/__setup__/jestSetup.ts index a8d029b0d..ab2732a28 100644 --- a/tests/__setup__/jestSetup.ts +++ b/tests/__setup__/jestSetup.ts @@ -3,8 +3,11 @@ import "@testing-library/jest-dom/jest-globals"; import "./commonSetup"; import Chart from "chart.js/auto"; +import * as matchers from "jest-extended"; import ResizeObserver from "resize-observer-polyfill"; +expect.extend(matchers); + global.Chart = Chart; global.ResizeObserver = ResizeObserver; diff --git a/tests/__utils__/testsConfig.ts b/tests/__utils__/testsConfig.ts index 05640d031..d18f885f9 100644 --- a/tests/__utils__/testsConfig.ts +++ b/tests/__utils__/testsConfig.ts @@ -105,8 +105,15 @@ export type UnitTestCategory = | "roundValue" | "pluginRegistration" | "dragListenersRegistration" - | "calcPosition" - | "checkDraggingConfiguration"; + | "calcCartesian" + | "calcRadialLinear" + | "checkDraggingConfiguration" + | "clipValue" + | "applyMagnet" + | "getElement" + | "plugin" + | "dragEndCallback" + | "updateData"; export type UnitConfig = { whitelistedTestCategories: Whitelist | undefined; diff --git a/tests/e2e/__utils__/constants.ts b/tests/e2e/__utils__/constants.ts index 11d6e43cf..40a0712fe 100644 --- a/tests/e2e/__utils__/constants.ts +++ b/tests/e2e/__utils__/constants.ts @@ -1,5 +1,5 @@ /** threshold for desktop projects above which percentage difference of pixels from baseline in the screenshot causes a failure; normalized range (0-1) */ -export const SCREENSHOT_TESTING_MAX_PIXEL_DIFF_PERCENT_DESKTOP = 0.021; +export const SCREENSHOT_TESTING_MAX_PIXEL_DIFF_PERCENT_DESKTOP = 0.041; /** threshold for mobile projects above which percentage difference of pixels from baseline in the screenshot causes a failure; normalized range (0-1) */ export const SCREENSHOT_TESTING_MAX_PIXEL_DIFF_PERCENT_MOBILE = 0.051; diff --git a/tests/e2e/configurationChanges.spec.ts b/tests/e2e/configurationChanges.spec.ts index 0e6bce603..7663bbe50 100644 --- a/tests/e2e/configurationChanges.spec.ts +++ b/tests/e2e/configurationChanges.spec.ts @@ -1,3 +1,4 @@ +import { Point } from "chart.js"; import { test } from "playwright-test-coverage"; import Offset2D from "../__utils__/structures/Offset2D"; @@ -125,14 +126,10 @@ describeEachChartType(function testGenerator(fileName, scenario) { ? { x: dataSample[0], y: dataSample[1], - // TODO: fix this later with proper TS typings - // @ts-ignore dragData: bEnabled, } : { - ...dataSample, - // TODO: fix this later with proper TS typings - // @ts-ignore + ...(dataSample as Point), dragData: bEnabled, }; } @@ -142,8 +139,6 @@ describeEachChartType(function testGenerator(fileName, scenario) { case "y-scale": window.testedChart.config.options!.scales![ enablerLocationSpec === "x-scale" ? "x" : "y" - // TODO: fix this later with proper TS typings - // @ts-ignore ]!.dragData = bEnabled; break; } diff --git a/tests/e2e/interaction.spec.ts b/tests/e2e/interaction.spec.ts index c170397a1..129bcbbd8 100644 --- a/tests/e2e/interaction.spec.ts +++ b/tests/e2e/interaction.spec.ts @@ -37,7 +37,8 @@ for (const disablePlugin of [false, true]) { () => { test.describe.configure({ mode: "parallel" }); - for (const magnet of disablePlugin + // only test magnet enabled in different variants on both-axes-draggable scenarios to reduce the number of test cases + for (const magnet of disablePlugin || draggableAxis !== "both" ? (["none"] satisfies MagnetVariant[]) : ALL_TESTED_MAGNET_VARIANTS) { (isTestsConfigWhitelistItemAllowed( diff --git a/tests/integration/__utils__/utils.ts b/tests/integration/__utils__/index.ts similarity index 100% rename from tests/integration/__utils__/utils.ts rename to tests/integration/__utils__/index.ts diff --git a/tests/integration/react/react-chartjs-2.spec.tsx b/tests/integration/react/react-chartjs-2.spec.tsx index f891c4e3a..863c55e1a 100644 --- a/tests/integration/react/react-chartjs-2.spec.tsx +++ b/tests/integration/react/react-chartjs-2.spec.tsx @@ -4,12 +4,12 @@ import { Chart } from "react-chartjs-2"; import { cleanup, render } from "@testing-library/react"; -import ChartJSdragDataPlugin from "../../../dist/chartjs-plugin-dragdata-test-browser"; +import ChartJSDragDataPlugin from "../../../dist/test/chartjs-plugin-dragdata-test"; import { - TestChartOptions, + JestTestChartOptions, genericChartScenarioBase, } from "../../__data__/data"; -import { integrationAllowed } from "../__utils__/utils"; +import { integrationAllowed } from "../__utils__"; let chartInstance: ChartJS | null = null; @@ -32,9 +32,9 @@ function ChartComponent() { type="line" data={genericChartScenarioBase.configuration.data} options={{ - ...TestChartOptions, + ...JestTestChartOptions, }} - plugins={[ChartJSdragDataPlugin]} + plugins={[ChartJSDragDataPlugin]} /> ); } diff --git a/tests/integration/vue/vue-chartjs.spec.ts b/tests/integration/vue/vue-chartjs.spec.ts index 0323f5ab2..5adbbe6b4 100644 --- a/tests/integration/vue/vue-chartjs.spec.ts +++ b/tests/integration/vue/vue-chartjs.spec.ts @@ -1,14 +1,15 @@ import "chart.js/auto"; +import { ChartOptions } from "chart.js/auto"; import { Line } from "vue-chartjs"; import { cleanup, render } from "@testing-library/vue"; import { - TestChartOptions, + JestTestChartOptions, genericChartScenarioBase, } from "../../__data__/data"; -import { integrationAllowed } from "../__utils__/utils"; +import { integrationAllowed } from "../__utils__"; afterEach(() => { cleanup(); @@ -19,7 +20,7 @@ afterEach(() => { const wrapper = render(Line, { props: { data: genericChartScenarioBase.configuration.data, - options: TestChartOptions, + options: JestTestChartOptions as ChartOptions<"line">, }, }); diff --git a/tests/unit/__fixtures__/mockedEventUtils.ts b/tests/unit/__fixtures__/mockedEventUtils.ts new file mode 100644 index 000000000..0033e09d6 --- /dev/null +++ b/tests/unit/__fixtures__/mockedEventUtils.ts @@ -0,0 +1,39 @@ +import { DragDataEvent } from "../../../src"; + +export type TestEventType = "MouseEvent" | "TouchEvent"; + +export function getEventX(event: DragDataEvent, eventType: TestEventType) { + return eventType === "MouseEvent" + ? (event as MouseEvent).clientX + : (event as TouchEvent).touches[0].clientX; +} + +export function getEventY(event: DragDataEvent, eventType: TestEventType) { + return eventType === "MouseEvent" + ? (event as MouseEvent).clientY + : (event as TouchEvent).touches[0].clientY; +} + +export function setEventX( + event: DragDataEvent, + eventType: TestEventType, + x: number, +) { + if (eventType === "MouseEvent") { + ((event as MouseEvent).clientX as number) = x; + } else { + ((event as TouchEvent).touches[0].clientX as number) = x; + } +} + +export function setEventY( + event: DragDataEvent, + eventType: TestEventType, + y: number, +) { + if (eventType === "MouseEvent") { + ((event as MouseEvent).clientY as number) = y; + } else { + ((event as TouchEvent).touches[0].clientY as number) = y; + } +} diff --git a/tests/unit/__utils__/constants.ts b/tests/unit/__utils__/constants.ts index 1945d32bc..ec1dbd087 100644 --- a/tests/unit/__utils__/constants.ts +++ b/tests/unit/__utils__/constants.ts @@ -1,4 +1,4 @@ -import { ChartTypeRegistry } from "chart.js"; +import { ChartType } from "chart.js"; import { ArrayItemType } from "../../__utils__/types"; export const UNIT_TEST_CHART_TYPES = [ @@ -8,6 +8,6 @@ export const UNIT_TEST_CHART_TYPES = [ "bubble", "polarArea", "radar", -] satisfies Array; +] satisfies Array; export type TestChartTypes = ArrayItemType; diff --git a/tests/unit/__utils__/utils.ts b/tests/unit/__utils__/utils.ts index 02c9eef64..695ffdbb0 100644 --- a/tests/unit/__utils__/utils.ts +++ b/tests/unit/__utils__/utils.ts @@ -1,4 +1,4 @@ -import { +import type { ChartConfiguration, ChartData, ChartOptions, @@ -9,8 +9,9 @@ import { } from "chart.js"; import _ from "lodash"; +import { ChartDataItemType } from "../../../src"; import { - TestChartOptions, + JestTestChartOptions, genericChartScenarioBase, } from "../../__data__/data"; import { Point2DObject } from "../../__utils__/testTypes"; @@ -45,22 +46,29 @@ export function setupChartInstance( }: SetupChartInstanceOptions = {}, ): TChart { const canvas = document.createElement("canvas"); - canvas.width = canvasWidth; - canvas.height = canvasHeight; canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`; var ctx = canvas.getContext("2d")!; - return new Chart(ctx, { + const chart = new Chart(ctx, { type: chartType, data: _.cloneDeep(chartData), options: _.merge( // deep cloning is needed to avoid former tests mutating next tests' data - _.cloneDeep(TestChartOptions), + _.cloneDeep(JestTestChartOptions), _.cloneDeep(customOptions ?? {}), ) as any, }); + + // ensure these properties are initialized for chart.js helpers to be able to work properly + chart.width = canvasWidth; + chart.height = canvasHeight; + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + return chart; } /** @@ -91,9 +99,8 @@ export function assertPointsEqual({ }: { /** The baseline expected point coordinates as a 2D point; only the applicable coordinate will be used for actual assertion based on the shape of `actual` */ expected: Point2DObject; - // TODO: fix the below typing to use imported type from plugin sources later with proper TS typings /** The actual point to be checked for validity */ - actual: Point2DObject | [number, number] | number; + actual: Point; /** Specifies the configured chart index axis; this parameter picks the proper coordinate to be compared from the baseline (which is 2D) when the actual point is 1D */ indexAxis: "x" | "y"; }) { @@ -125,8 +132,7 @@ export function assertPointsEqual({ export function dataPointCompatFromPoint2D( point2D: Point2DObject, type: ChartType, -): // TODO: fix the below typing to use imported type from plugin sources later with proper TS typings -Point2DObject | [number, number] | number { +): Point2DObject | [number, number] { switch (type) { case "bar": return [point2D.x, point2D.y]; @@ -144,8 +150,7 @@ Point2DObject | [number, number] | number { * @param point the point to be converted to a `Point2DObject` */ export function dataPointCompatToPoint2D( - // TODO: fix the below typing to use imported type from plugin sources later with proper TS typings - point: Point2DObject | [number, number] | number, + point: ChartDataItemType, ): Point2DObject { const { isDataPointShape2DObject, isDataPointShapePair } = getChartDataPointShape({ datasets: [{ data: [point] }] }); diff --git a/tests/unit/applyMagnet.spec.ts b/tests/unit/applyMagnet.spec.ts new file mode 100644 index 000000000..1dc1f80c6 --- /dev/null +++ b/tests/unit/applyMagnet.spec.ts @@ -0,0 +1,61 @@ +import { Chart, ChartType } from "chart.js"; + +import { applyMagnet } from "../../dist/test/chartjs-plugin-dragdata-test"; +import { OptionalPluginConfiguration } from "../../src"; +import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; +import { UNIT_TEST_CHART_TYPES } from "./__utils__/constants"; +import { setupChartInstance } from "./__utils__/utils"; + +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "applyMagnet", +) + ? describe + : describe.skip)("applyMagnet", () => { + for (const chartType of UNIT_TEST_CHART_TYPES) { + let chartInstance: Chart; + + (isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestedChartTypes", + chartType, + ) + ? describe + : describe.skip)(`${chartType} chart`, () => { + beforeEach(() => { + chartInstance = setupChartInstance(chartType as ChartType, { + plugins: { dragData: {} }, + }); + }); + + it("should return the original data point if magnet is not configured", () => { + const result = applyMagnet(chartInstance, 0, 1); + expect(result).toBe(chartInstance.data.datasets[0].data[1]); + }); + + it("should update the data point using the magnet function", () => { + const magnetFunction = jest.fn().mockReturnValue(99.234); + (chartInstance.config.options!.plugins! + .dragData as OptionalPluginConfiguration)!.magnet = + { + to: magnetFunction, + }; + + const chartUpdateSpy = jest.spyOn(chartInstance, "update"); + + // since applyMagnet mutates the data point, we need to store the original value + const dataPointBeforeMutation = chartInstance.data.datasets[0].data[1]; + + const result = applyMagnet(chartInstance, 0, 1); + expect(magnetFunction).toHaveBeenCalledExactlyOnceWith( + dataPointBeforeMutation, + ); + + expect(result).toBe(magnetFunction()); + + expect(chartUpdateSpy).toHaveBeenCalledExactlyOnceWith("none"); + }); + }); + } +}); diff --git a/tests/unit/calc/calcCartesian.spec.ts b/tests/unit/calc/calcCartesian.spec.ts new file mode 100644 index 000000000..91eb1983a --- /dev/null +++ b/tests/unit/calc/calcCartesian.spec.ts @@ -0,0 +1,449 @@ +import { Chart, ChartConfiguration, ChartType, Point } from "chart.js"; +import _ from "lodash"; + +import { + type DragDataEvent, + type PluginConfiguration, + type AxisDraggingConfiguration, + getElement, + calcCartesian, +} from "../../../dist/test/chartjs-plugin-dragdata-test"; +import { genericChartScenarioBase } from "../../__data__/data"; +import { Point2DObject } from "../../__utils__/testTypes"; +import { isTestsConfigWhitelistItemAllowed } from "../../__utils__/testsConfig"; +import { + DEFAULT_TEST_CHART_INSTANCE_HEIGHT, + DEFAULT_TEST_CHART_INSTANCE_WIDTH, + SetupChartInstanceOptions, + assertPointsEqual, + dataPointCompatFromPoint2D, + dataPointCompatToPoint2D, + setupChartInstance, +} from "../__utils__/utils"; +import { + getEventX, + getEventY, + setEventX, + setEventY, + TestEventType, +} from "../__fixtures__/mockedEventUtils"; + +const xAxisID = "x", + yAxisID = "y", + PX_TO_VALUE_X_DIVISOR = 10, + PX_TO_VALUE_Y_DIVISOR = 20; + +const DEFAULT_DRAGGING_CONFIGURATION: AxisDraggingConfiguration = { + xAxisDraggingDisabled: false, + yAxisDraggingDisabled: false, +}; + +const FLOATING_BAR_DATA_POINTS: [number, number][] = [ + [5, 12], + [0, 19], + [1, 3], + [3, 5], + ], + TEST_SCENARIOS = [ + // line scenario with data of shape number[] + { + chartType: "line", + description: "data of shape number[]", + }, + // line scenario with data of shape Point2DObject[] + { + chartType: "line", + chartSetupOptions: { + chartData: { + ...genericChartScenarioBase.configuration.data, + datasets: genericChartScenarioBase.configuration.data.datasets.map( + (dataset) => ({ + ...dataset, + data: dataset.data.map((v) => ({ + x: 0.5 * v, + y: v, + })), + }), + ) as any, + }, + }, + description: "data of shape Point2DObject[]", + }, + // bar scenario with data of shape number[] + { + chartType: "bar", + description: "data of shape number[]", + }, + // floating bar scenario (data of shape [number, number][]) + { + chartType: "bar", + chartSetupOptions: { + chartData: { + labels: ["Red", "Blue", "Yellow", "Green"], + datasets: [ + { + label: "test bar data", + data: FLOATING_BAR_DATA_POINTS, + fill: true, + tension: 0.4, + borderWidth: 1, + pointHitRadius: 25, + }, + ], + }, + }, + isFloatingBar: true, + }, + ] as { + chartType: ChartType; + chartSetupOptions?: SetupChartInstanceOptions; + isFloatingBar?: boolean; + description?: string; + }[]; + +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "calcCartesian", +) + ? describe + : describe.skip)("calcCartesian", () => { + for (const { + chartType, + chartSetupOptions, + isFloatingBar = false, + description, + } of TEST_SCENARIOS) { + for (const eventType of ["MouseEvent", "TouchEvent"] as TestEventType[]) { + describe(`${isFloatingBar ? "floating " : ""}${chartType} chart${description ? `, ${description}` : ""} with ${eventType}`, () => { + let chartInstance: Chart; + let testDataPoint: Point2DObject; + let event: DragDataEvent; + + beforeEach(() => { + // mock required chart methods for unit tests to work + chartInstance = setupChartInstance( + chartType, + { + plugins: { + dragData: { + round: 2, + dragX: true, + dragY: true, + }, + }, + indexAxis: "x", + }, + _.cloneDeep(chartSetupOptions), // so that the options are not mutated in-between tests + ); + + chartInstance.getElementsAtEventForMode = () => [ + { + datasetIndex: 0, + index: 0, + element: { + x: testDataPoint.x, + y: testDataPoint.y, + active: true, + $animations: {}, + options: {}, + tooltipPosition: () => ({ + x: testDataPoint.x, + y: testDataPoint.y, + }), + getProps() { + return {}; + }, + hasValue() { + return true; + }, + }, + }, + ]; + + const origGetDatasetMeta = + chartInstance.getDatasetMeta.bind(chartInstance); + chartInstance.getDatasetMeta = (datasetIndex) => ({ + ...origGetDatasetMeta(datasetIndex), + // mock the IDs for axes + xAxisID, + yAxisID, + }); + + // virtual pixel-to-value calculations + chartInstance.scales[xAxisID] = { + getValueForPixel: jest.fn((pixel) => pixel / PX_TO_VALUE_X_DIVISOR), + min: 0, + max: 100, + } as any; + chartInstance.scales[yAxisID] = { + getValueForPixel: jest.fn((pixel) => pixel / PX_TO_VALUE_Y_DIVISOR), + min: 0, + max: 50, + } as any; + + // default test scenario variables, they may altered by tests + testDataPoint = { x: 5, y: 10 }; + + if (eventType === "MouseEvent") { + event = { + clientX: 50, + clientY: 200, + type: "click", + } as any; + } else { + event = { + touches: [{ clientX: 50, clientY: 200 }], + type: "touchstart", + } as any; + } + + // final preparation: initializes all proper variables in the dragdata plugin + getElement(event, chartInstance); + }); + + // test to prevent regression of https://github.com/artus9033/chartjs-plugin-dragdata/issues/96 + it("should not mutate chart data nor the passed in data point", () => { + // freeze the datasets to ensure they cannot be mutated + for (const dataset of chartInstance.data.datasets) { + for (const dataPoint of dataset.data) { + Object.freeze(dataPoint); + } + } + + // freeze the test data point argument to ensure it cannot be mutated + const frozenDataPoint = Object.freeze( + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + ); + + function calcCartesianWrapper() { + calcCartesian( + event, + chartInstance, + frozenDataPoint as [number, number], + { + xAxisDraggingDisabled: false, + yAxisDraggingDisabled: false, + }, + ); + } + + expect(calcCartesianWrapper).not.toThrow(); + }); + + it("should calculate position properly upon interaction", () => { + // slightly different scenario for floating bar chart: to simulate dragging the vertical bar by the top edge + if (isFloatingBar) { + // simulate that the new value is higher + testDataPoint.y = FLOATING_BAR_DATA_POINTS[0][1] * 1.4; + // simulate the mouse event happening somewhere above the current top edge + setEventY( + event, + eventType, + FLOATING_BAR_DATA_POINTS[0][1] * PX_TO_VALUE_Y_DIVISOR * 1.4, + ); + } + + const result = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + DEFAULT_DRAGGING_CONFIGURATION, + ); + + expect( + chartInstance.scales[xAxisID].getValueForPixel, + ).toHaveBeenCalledWith(getEventX(event, eventType)); + + if (isFloatingBar) { + // floating bar scenario behaves differently: only the edge of the bar that + // is closer to the drag point is moved; here we drag the top edge + + // eslint-disable-next-line jest/no-conditional-expect + expect(result).toStrictEqual([ + FLOATING_BAR_DATA_POINTS[0][0], // this edge should not be moved + getEventY(event, eventType) / PX_TO_VALUE_Y_DIVISOR, // this edge should be moved + ]); + } else { + assertPointsEqual({ + actual: result as Point, + expected: { + x: getEventX(event, eventType) / PX_TO_VALUE_X_DIVISOR, + y: getEventY(event, eventType) / PX_TO_VALUE_Y_DIVISOR, + }, + indexAxis: chartInstance.config.options!.indexAxis!, + }); + } + }); + + // a vertical bar only moves on the y-axis and calcCartesian always returns the y-axis + // value for a vertical bar, thus this test is inapplicable to these cases + (chartType === "bar" ? it.skip : it)( + "should clamp x-axis to x-scale bounds", + () => { + // above max + setEventX( + event, + eventType, + DEFAULT_TEST_CHART_INSTANCE_WIDTH + 600, + ); + + const resultMax = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + { + xAxisDraggingDisabled: false, + yAxisDraggingDisabled: false, + }, + ); + + // the below would be a false-positive by eslint, as the block is actually in it or it.skip + // eslint-disable-next-line jest/no-standalone-expect + expect(dataPointCompatToPoint2D(resultMax).x).toStrictEqual( + chartType === "bar" + ? testDataPoint.x + : chartInstance.scales[xAxisID].max, + ); + + // below min + setEventX(event, eventType, -600); + const resultMin = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + { + xAxisDraggingDisabled: false, + yAxisDraggingDisabled: false, + }, + ); + + // the below would be a false-positive by eslint, as the block is actually in it or it.skip + // eslint-disable-next-line jest/no-standalone-expect + expect(dataPointCompatToPoint2D(resultMin).x).toStrictEqual( + // a vertical floating bar only moves on the y-axis, thus in this case, the x-axis value should remain unchanged + chartType === "bar" + ? testDataPoint.x + : chartInstance.scales[xAxisID].min, + ); + }, + ); + + it("should clamp y-axis to y-scale bounds", () => { + // above max + setEventY(event, eventType, DEFAULT_TEST_CHART_INSTANCE_HEIGHT + 600); + + const resultMax = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + DEFAULT_DRAGGING_CONFIGURATION, + ); + + expect(dataPointCompatToPoint2D(resultMax).y).toStrictEqual( + chartInstance.scales[yAxisID].max, + ); + + // below min + setEventY(event, eventType, -600); + const resultMin = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + DEFAULT_DRAGGING_CONFIGURATION, + ); + + expect( + dataPointCompatToPoint2D(resultMin)[ + isFloatingBar + ? // in case of the floating bar, it is the bottom edge that has been dragged, + // thus we have to compare the first coordinate; dataPointCompatToPoint2D maps + // the first coordinate to x and the second y + "x" + : "y" + ], + ).toStrictEqual(chartInstance.scales[yAxisID].min); + }); + + // below it block is a false-positive for eslint, as it uses assertPointsEqual, which in fact asserts with expect() + // eslint-disable-next-line jest/expect-expect + it("should not drag x-axis if dragX is false", () => { + ( + chartInstance.config.options!.plugins! + .dragData as PluginConfiguration + ).dragX = false; + + setEventY(event, eventType, DEFAULT_TEST_CHART_INSTANCE_WIDTH); + + const result = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + DEFAULT_DRAGGING_CONFIGURATION, + ); + + assertPointsEqual({ + expected: { + x: testDataPoint.x, // x should be unchanged + y: getEventY(event, eventType) / PX_TO_VALUE_Y_DIVISOR, // y should be changed + }, + actual: result as Point, + indexAxis: chartInstance.config.options!.indexAxis!, + }); + }); + + // below it block is a false-positive for eslint, as it uses assertPointsEqual, which in fact asserts with expect() + // eslint-disable-next-line jest/expect-expect + it("should not drag y-axis if dragY is false", () => { + ( + chartInstance.config.options!.plugins! + .dragData as PluginConfiguration + ).dragY = false; + + const result = calcCartesian( + event, + chartInstance, + dataPointCompatFromPoint2D( + testDataPoint, + (chartInstance.config as ChartConfiguration).type, + ), + { + xAxisDraggingDisabled: false, + yAxisDraggingDisabled: true, + }, + ); + + assertPointsEqual({ + expected: { + x: getEventX(event, eventType) / PX_TO_VALUE_X_DIVISOR, // x should be changed + y: testDataPoint.y, // y should be unchanged + }, + actual: result as Point, + indexAxis: chartInstance.config.options!.indexAxis!, + }); + }); + }); + } + } +}); diff --git a/tests/unit/calcRadar.spec.ts b/tests/unit/calc/calcRadialLinear.spec.ts similarity index 76% rename from tests/unit/calcRadar.spec.ts rename to tests/unit/calc/calcRadialLinear.spec.ts index c39428156..ef3c3d0d9 100644 --- a/tests/unit/calcRadar.spec.ts +++ b/tests/unit/calc/calcRadialLinear.spec.ts @@ -3,9 +3,13 @@ import { getRelativePosition } from "chart.js/helpers"; import type { Mock } from "jest-mock"; import _ from "lodash"; -import { exportsForTesting } from "../../dist/chartjs-plugin-dragdata-test"; -import { genericChartScenarioBase } from "../__data__/data"; -import { setupChartInstance } from "./__utils__/utils"; +import { + type DragDataEvent, + calcRadialLinear, +} from "../../../dist/test/chartjs-plugin-dragdata-test"; +import { genericChartScenarioBase } from "../../__data__/data"; +import { setupChartInstance } from "../__utils__/utils"; +import { isTestsConfigWhitelistItemAllowed } from "../../__utils__/testsConfig"; const CHART_CENTER_POS_X = 75; const CHART_CENTER_POS_Y = 80; @@ -18,13 +22,18 @@ jest.mock("chart.js/helpers", () => ({ })), })); -const { calcRadar } = exportsForTesting; - const rAxisID = "y"; -describe("calcRadar", () => { +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "calcRadialLinear", +) + ? describe + : describe.skip)("calcRadialLinear", () => { for (const chartType of ["radar", "polarArea"] as ChartType[]) { let chartInstance: Chart; + let mouseEvent: DragDataEvent; describe(`${chartType} chart`, () => { beforeEach(() => { @@ -33,7 +42,7 @@ describe("calcRadar", () => { dragData: { round: 4, }, - } as any, // TODO: fix this later with proper TS typings + }, scales: { [rAxisID]: { max: 100, @@ -94,6 +103,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y, })), ); + + mouseEvent = { + clientX: 50, + clientY: 200, + type: "click", + } as any; }); test.each( @@ -101,7 +116,12 @@ describe("calcRadar", () => { )( "should calculate 0 when drag event occurs in the center of the chart", (curIndex) => { - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toEqual(0); }, @@ -115,7 +135,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y * 4.74, // to the bottom })); - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBeCloseTo(14.96, 2); }); @@ -130,7 +155,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y * 4.74, // to the bottom })); - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBeCloseTo(chartInstance.scales[rAxisID].max - 14.96, 2); }); @@ -143,7 +173,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y - 300, // to the top })); - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toEqual(0); }); @@ -156,7 +191,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y, })); - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toEqual(15); }); @@ -171,7 +211,12 @@ describe("calcRadar", () => { y: CHART_CENTER_POS_Y * 4.74, // to the top })); - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toEqual(0); }); @@ -184,7 +229,12 @@ describe("calcRadar", () => { const curIndex = 0; - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBe(0); }); @@ -197,7 +247,12 @@ describe("calcRadar", () => { const curIndex = 0; - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBe(chartInstance.scales[rAxisID].max); }); @@ -210,7 +265,12 @@ describe("calcRadar", () => { const curIndex = 0; - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBe(chartInstance.scales[rAxisID].min); }); @@ -223,7 +283,12 @@ describe("calcRadar", () => { const curIndex = 1; - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBe(chartInstance.scales[rAxisID].max); }); @@ -236,7 +301,12 @@ describe("calcRadar", () => { const curIndex = 1; - const value = calcRadar({}, chartInstance, curIndex, rAxisID); + const value = calcRadialLinear( + mouseEvent, + chartInstance, + curIndex, + rAxisID, + ); expect(value).toBe(chartInstance.scales[rAxisID].min); }); diff --git a/tests/unit/calc/clipValue.spec.ts b/tests/unit/calc/clipValue.spec.ts new file mode 100644 index 000000000..cc5daf023 --- /dev/null +++ b/tests/unit/calc/clipValue.spec.ts @@ -0,0 +1,26 @@ +import { clipValue } from "../../../dist/test/chartjs-plugin-dragdata-test"; +import { isTestsConfigWhitelistItemAllowed } from "../../__utils__/testsConfig"; + +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "clipValue", +) + ? describe + : describe.skip)("clipValue", () => { + it("should return the value if it is within [min, max] range", () => { + expect(clipValue(5.24, 1, 10)).toBe(5.24); + expect(clipValue(1, 1, 10)).toBe(1); + expect(clipValue(10, 1, 10)).toBe(10); + }); + + it("should return the minimum value if value < min", () => { + expect(clipValue(0.9999, 1, 10)).toBe(1); + expect(clipValue(-5.2, 1, 10)).toBe(1); + }); + + it("should return the maximum value if value > max", () => { + expect(clipValue(10.0001, 1, 10)).toBe(10); + expect(clipValue(17.74, 1, 10)).toBe(10); + }); +}); diff --git a/tests/unit/calcPosition.spec.ts b/tests/unit/calcPosition.spec.ts deleted file mode 100644 index 159b1096c..000000000 --- a/tests/unit/calcPosition.spec.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { Chart, ChartConfiguration, ChartType } from "chart.js"; -import _ from "lodash"; - -import { exportsForTesting } from "../../dist/chartjs-plugin-dragdata-test-browser"; -import { genericChartScenarioBase } from "../__data__/data"; -import { Point2DObject } from "../__utils__/testTypes"; -import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; -import { - DEFAULT_TEST_CHART_INSTANCE_HEIGHT, - DEFAULT_TEST_CHART_INSTANCE_WIDTH, - SetupChartInstanceOptions, - assertPointsEqual, - dataPointCompatFromPoint2D, - dataPointCompatToPoint2D, - setupChartInstance, -} from "./__utils__/utils"; - -const { calcPosition, getElement } = exportsForTesting; - -const xAxisID = "x", - yAxisID = "y", - PX_TO_VALUE_X_DIVISOR = 10, - PX_TO_VALUE_Y_DIVISOR = 20; - -const DEFAULT_DRAGGING_CONFIGURATION = { - xAxisDraggingDisabled: false, - yAxisDraggingDisabled: false, -}; - -const FLOATING_BAR_DATA_POINTS: [number, number][] = [ - [5, 12], - [0, 19], - [1, 3], - [3, 5], - ], - TEST_SCENARIOS = [ - // line scenario with data of shape number[] - { - chartType: "line", - description: "data of shape number[]", - }, - // line scenario with data of shape Point2DObject[] - { - chartType: "line", - chartSetupOptions: { - chartData: { - ...genericChartScenarioBase.configuration.data, - datasets: genericChartScenarioBase.configuration.data.datasets.map( - (dataset) => ({ - ...dataset, - data: dataset.data.map((v) => ({ - x: 0.5 * v, - y: v, - })), - }), - ) as any, - }, - }, - description: "data of shape Point2DObject[]", - }, - // bar scenario with data of shape number[] - { - chartType: "bar", - description: "data of shape number[]", - }, - // floating bar scenario (data of shape [number, number][]) - { - chartType: "bar", - chartSetupOptions: { - chartData: { - labels: ["Red", "Blue", "Yellow", "Green"], - datasets: [ - { - label: "test bar data", - data: FLOATING_BAR_DATA_POINTS, - fill: true, - tension: 0.4, - borderWidth: 1, - pointHitRadius: 25, - }, - ], - }, - }, - isFloatingBar: true, - }, - ] as { - chartType: ChartType; - chartSetupOptions?: SetupChartInstanceOptions; - isFloatingBar?: boolean; - description?: string; - }[]; - -(isTestsConfigWhitelistItemAllowed( - "unit", - "whitelistedTestCategories", - "calcPosition", -) - ? describe - : describe.skip)("calcPosition", () => { - for (const { - chartType, - chartSetupOptions, - isFloatingBar = false, - description, - } of TEST_SCENARIOS) - describe(`${isFloatingBar ? "floating " : ""}${chartType} chart${description ? `, ${description}` : ""}`, () => { - let chartInstance: Chart; - let testDataPoint: Point2DObject; - - // make clientX & clientY mutable (they are readonly in MouseEvent) - let mouseEvent: Partial< - MouseEvent & { clientX: number; clientY: number } - >; - - beforeEach(() => { - // mock required chart methods for unit tests to work - chartInstance = setupChartInstance( - chartType, - { - plugins: { - // TODO: fix this later with proper TS typings - // @ts-ignore next line - dragData: { - round: 2, - dragX: true, - dragY: true, - }, - }, - indexAxis: "x", - }, - _.cloneDeep(chartSetupOptions), // so that the options are not mutated in-between tests - ); - - chartInstance.getElementsAtEventForMode = () => [ - { - datasetIndex: 0, - index: 0, - element: { - x: testDataPoint.x, - y: testDataPoint.y, - active: true, - $animations: {}, - options: {}, - tooltipPosition: () => ({ - x: testDataPoint.x, - y: testDataPoint.y, - }), - getProps() { - return {}; - }, - hasValue() { - return true; - }, - }, - }, - ]; - - const origGetDatasetMeta = - chartInstance.getDatasetMeta.bind(chartInstance); - chartInstance.getDatasetMeta = (datasetIndex) => ({ - ...origGetDatasetMeta(datasetIndex), - // mock the IDs for axes - xAxisID, - yAxisID, - }); - - // virtual pixel-to-value calculations - chartInstance.scales[xAxisID] = { - getValueForPixel: jest.fn((pixel) => pixel / PX_TO_VALUE_X_DIVISOR), - min: 0, - max: 100, - } as any; - chartInstance.scales[yAxisID] = { - getValueForPixel: jest.fn((pixel) => pixel / PX_TO_VALUE_Y_DIVISOR), - min: 0, - max: 50, - } as any; - - // default test scenario variables, they may altered by tests - testDataPoint = { x: 5, y: 10 }; - - mouseEvent = { - clientX: 50, - clientY: 200, - type: "click", - }; - - // final preparation: initializes all proper variables in the dragdata plugin - getElement(mouseEvent, chartInstance, () => {}); - }); - - // test to prevent regression of https://github.com/artus9033/chartjs-plugin-dragdata/issues/96 - it("should not mutate chart data nor the passed in data point", () => { - // freeze the datasets to ensure they cannot be mutated - for (const dataset of chartInstance.data.datasets) { - for (const dataPoint of dataset.data) { - Object.freeze(dataPoint); - } - } - - // freeze the test data point argument to ensure it cannot be mutated - const frozenDataPoint = Object.freeze( - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - ); - - function calcPositionWrapper() { - calcPosition(mouseEvent, chartInstance, frozenDataPoint, { - xAxisDraggingDisabled: false, - yAxisDraggingDisabled: false, - }); - } - - expect(calcPositionWrapper).not.toThrow(); - }); - - it("should calculate position properly on mouse event", () => { - // slightly different scenario for floating bar chart: to simulate dragging the vertical bar by the top edge - if (isFloatingBar) { - testDataPoint.y = FLOATING_BAR_DATA_POINTS[0][1] * 1.4; // simulate that the new value is higher - mouseEvent.clientY = - FLOATING_BAR_DATA_POINTS[0][1] * PX_TO_VALUE_Y_DIVISOR * 1.4; // simulate the mouse event happening somewhere above the current top edge - } - - const result = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - DEFAULT_DRAGGING_CONFIGURATION, - ); - expect( - chartInstance.scales[xAxisID].getValueForPixel, - ).toHaveBeenCalledWith(mouseEvent.clientX); - - if (isFloatingBar) { - // floating bar scenario behaves differently: only the edge of the bar that - // is closer to the drag point is moved; here we drag the top edge - - // eslint-disable-next-line jest/no-conditional-expect - expect(result).toStrictEqual([ - FLOATING_BAR_DATA_POINTS[0][0], // this edge should not be moved - mouseEvent.clientY! / PX_TO_VALUE_Y_DIVISOR, // this edge should be moved - ]); - } else { - assertPointsEqual({ - actual: result, - expected: { - x: mouseEvent.clientX! / PX_TO_VALUE_X_DIVISOR, - y: mouseEvent.clientY! / PX_TO_VALUE_Y_DIVISOR, - }, - indexAxis: chartInstance.config.options!.indexAxis!, - }); - } - }); - - // a vertical bar only moves on the y-axis and calcPosition always returns the y-axis - // value for a vertical bar, thus this test is inapplicable to these cases - (chartType === "bar" ? it.skip : it)( - "should clamp x-axis to x-scale bounds", - () => { - mouseEvent.clientX = DEFAULT_TEST_CHART_INSTANCE_WIDTH + 600; // above max - const resultMax = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - { - xAxisDraggingDisabled: false, - yAxisDraggingDisabled: false, - }, - ); - - // the below would be a false-positive by eslint, as the block is actually in it or it.skip - // eslint-disable-next-line jest/no-standalone-expect - expect(dataPointCompatToPoint2D(resultMax).x).toStrictEqual( - chartType === "bar" - ? testDataPoint.x - : chartInstance.scales[xAxisID].max, - ); - - mouseEvent.clientX = -600; // below min - const resultMin = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - { - xAxisDraggingDisabled: false, - yAxisDraggingDisabled: false, - }, - ); - - // the below would be a false-positive by eslint, as the block is actually in it or it.skip - // eslint-disable-next-line jest/no-standalone-expect - expect(dataPointCompatToPoint2D(resultMin).x).toStrictEqual( - // a vertical floating bar only moves on the y-axis, thus in this case, the x-axis value should remain unchanged - chartType === "bar" - ? testDataPoint.x - : chartInstance.scales[xAxisID].min, - ); - }, - ); - - it("should clamp y-axis to y-scale bounds", () => { - mouseEvent.clientY = DEFAULT_TEST_CHART_INSTANCE_HEIGHT + 600; // above max - const resultMax = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - DEFAULT_DRAGGING_CONFIGURATION, - ); - - expect(dataPointCompatToPoint2D(resultMax).y).toStrictEqual( - chartInstance.scales[yAxisID].max, - ); - - mouseEvent.clientY = -600; // below min - const resultMin = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - DEFAULT_DRAGGING_CONFIGURATION, - ); - - expect( - dataPointCompatToPoint2D(resultMin)[ - isFloatingBar - ? // in case of the floating bar, it is the bottom edge that has been dragged, - // thus we have to compare the first coordinate; dataPointCompatToPoint2D maps - // the first coordinate to x and the second y - "x" - : "y" - ], - ).toStrictEqual(chartInstance.scales[yAxisID].min); - }); - - // below it block is a false-positive for eslint, as it uses assertPointsEqual, which in fact asserts with expect() - // eslint-disable-next-line jest/expect-expect - it("should not drag x-axis if dragX is false", () => { - // TODO: fix this later with proper TS typings - (chartInstance.config.options!.plugins as any).dragData!.dragX = false; - - mouseEvent.clientY = DEFAULT_TEST_CHART_INSTANCE_WIDTH; - const result = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - DEFAULT_DRAGGING_CONFIGURATION, - ); - - assertPointsEqual({ - expected: { - x: testDataPoint.x, // x should be unchanged - y: mouseEvent.clientY / PX_TO_VALUE_Y_DIVISOR, // y should be changed - }, - actual: result, - indexAxis: chartInstance.config.options!.indexAxis!, - }); - }); - - // below it block is a false-positive for eslint, as it uses assertPointsEqual, which in fact asserts with expect() - // eslint-disable-next-line jest/expect-expect - it("should not drag y-axis if dragY is false", () => { - // TODO: fix this later with proper TS typings - (chartInstance.config.options!.plugins as any).dragData.dragY = false; - - const result = calcPosition( - mouseEvent, - chartInstance, - dataPointCompatFromPoint2D( - testDataPoint, - (chartInstance.config as ChartConfiguration).type, - ), - { - xAxisDraggingDisabled: false, - yAxisDraggingDisabled: true, - }, - ); - - assertPointsEqual({ - expected: { - x: mouseEvent.clientX! / PX_TO_VALUE_X_DIVISOR, // x should be changed - y: testDataPoint.y, // y should be unchanged - }, - actual: result, - indexAxis: chartInstance.config.options!.indexAxis!, - }); - }); - }); -}); diff --git a/tests/unit/checkDraggingConfiguration.spec.ts b/tests/unit/checkDraggingConfiguration.spec.ts index 9e7e21d1a..20fd706a7 100644 --- a/tests/unit/checkDraggingConfiguration.spec.ts +++ b/tests/unit/checkDraggingConfiguration.spec.ts @@ -1,16 +1,19 @@ -import { Chart } from "chart.js"; - -import { exportsForTesting } from "../../dist/chartjs-plugin-dragdata-test-browser"; +import type { Point, Chart as TChart } from "chart.js"; + +import { + type PluginConfiguration, + type DraggingConfiguration, + checkDraggingConfiguration, + getElement, +} from "../../dist/test/chartjs-plugin-dragdata-test"; import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; import { setupChartInstance } from "./__utils__/utils"; -const { checkDraggingConfiguration, getElement } = exportsForTesting; - /** * The default configuration of dragging that should be calculated when * no dragging options (or default values) are passed to chart */ -const VANILLA_DEFAULT_CONFIG = { +const VANILLA_DEFAULT_CONFIG: DraggingConfiguration = { chartDraggingDisabled: false, datasetDraggingDisabled: false, xAxisDraggingDisabled: true, @@ -20,7 +23,7 @@ const VANILLA_DEFAULT_CONFIG = { /** * The default configuration of dragging that is set up in beforeAll() call */ - TEST_SETUP_DEFAULT_CONFIG = { + TEST_SETUP_DEFAULT_CONFIG: DraggingConfiguration = { ...VANILLA_DEFAULT_CONFIG, xAxisDraggingDisabled: false, }; @@ -35,7 +38,7 @@ const xAxisID = "x", ) ? describe : describe.skip)("checkDraggingConfiguration", () => { - let chartInstance: Chart; + let chartInstance: TChart<"line">; beforeEach(() => { // mock required chart methods for unit tests to work @@ -43,8 +46,6 @@ const xAxisID = "x", "line", { plugins: { - // TODO: fix this later with proper TS typings - // @ts-ignore next line dragData: { round: 2, dragX: true, @@ -117,17 +118,14 @@ const xAxisID = "x", clientX: 50, clientY: 200, type: "click", - }, + } as any, chartInstance, - () => {}, ); }); it("has valid default dragging configuration", () => { // reset the options to the default state (beforeAll activates dragging on both axes via plugin options) - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.options.plugins.dragData = undefined; + chartInstance.options.plugins!.dragData = undefined; expect(checkDraggingConfiguration(chartInstance, 0, 0)).toEqual( VANILLA_DEFAULT_CONFIG, @@ -135,9 +133,7 @@ const xAxisID = "x", }); it("disables all drag interactions when chart-level dragging is disabled", () => { - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.plugins.dragData = false; + chartInstance.config.options!.plugins!.dragData = false; const allDisabled = { chartDraggingDisabled: true, @@ -162,8 +158,6 @@ const xAxisID = "x", }); it("disables dataset-level-and-below drag interactions when dataset dragging is disabled", () => { - // TODO: fix this later with proper TS typings - // @ts-ignore chartInstance.data.datasets[0].dragData = false; const datasetDisabledConfig = { @@ -191,9 +185,7 @@ const xAxisID = "x", }); it("disables only x-axis dragging when per-axis option for x-axis is disabled", () => { - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.scales[xAxisID].dragData = false; + chartInstance.config.options!.scales![xAxisID]!.dragData = false; const expectedConfig = { ...TEST_SETUP_DEFAULT_CONFIG, @@ -209,9 +201,7 @@ const xAxisID = "x", }); it("does not enable x-axis dragging when per-axis option is set to false and overrides enabled plugin option for x-axis", () => { - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.scales[xAxisID].dragData = false; + chartInstance.config.options!.scales![xAxisID]!.dragData = false; const expectedConfig = { ...TEST_SETUP_DEFAULT_CONFIG, @@ -227,9 +217,7 @@ const xAxisID = "x", }); it("disables only y-axis dragging when per-axis option for y-axis is disabled when x-axis dragging is disabled by default", () => { - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.scales[yAxisID].dragData = false; + chartInstance.config.options!.scales![yAxisID]!.dragData = false; const expectedConfig = { ...TEST_SETUP_DEFAULT_CONFIG, @@ -245,13 +233,11 @@ const xAxisID = "x", }); it("disables only y-axis dragging when per-axis option for y-axis is disabled when x-axis dragging is enabled", () => { - // enable dragging the x-axis in scales' options - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.scales[xAxisID].dragData = true; - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.config.options.scales[yAxisID].dragData = false; + // enable dragging the x-axis in scale's options + chartInstance.config.options!.scales![xAxisID]!.dragData = true; + + // disable dragging the y-axis in scale's options + chartInstance.config.options!.scales![yAxisID]!.dragData = false; const expectedConfig = { ...TEST_SETUP_DEFAULT_CONFIG, @@ -269,12 +255,11 @@ const xAxisID = "x", it("produces proper config for all axes enabled & per-data-point dragging disabled just for one point", () => { // enable dragging on all axes in plugin options - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.options.plugins.dragData.dragX = true; - // TODO: fix this later with proper TS typings - // @ts-ignore - chartInstance.data.datasets[0].data[0].dragData = false; + (chartInstance.options!.plugins!.dragData as PluginConfiguration)!.dragX = + true; + + // disable dragging for the first point in the first dataset + (chartInstance.data.datasets[0].data[0] as Point)!.dragData = false; const expectedConfigBase = { chartDraggingDisabled: false, @@ -304,4 +289,31 @@ const xAxisID = "x", expectedConfigOtherPoints, ); }); + + it("produces config with all axes disabled for an inexistent chart object", () => { + const expectedConfig: DraggingConfiguration = { + chartDraggingDisabled: true, + datasetDraggingDisabled: true, + xAxisDraggingDisabled: true, + yAxisDraggingDisabled: true, + dataPointDraggingDisabled: true, + }; + + // create a fake chart-like-object that creation of does not call afterInit + // thus one that does not exist in the state map + const unregisteredChartInstance = { id: 98732 } as any; + + expect(checkDraggingConfiguration(unregisteredChartInstance, 0, 0)).toEqual( + expectedConfig, + ); + expect(checkDraggingConfiguration(unregisteredChartInstance, 0, 1)).toEqual( + expectedConfig, + ); + expect(checkDraggingConfiguration(unregisteredChartInstance, 1, 0)).toEqual( + expectedConfig, + ); + expect(checkDraggingConfiguration(unregisteredChartInstance, 1, 1)).toEqual( + expectedConfig, + ); + }); }); diff --git a/tests/unit/getElement.spec.ts b/tests/unit/getElement.spec.ts index 2e406209f..ccc6d5914 100644 --- a/tests/unit/getElement.spec.ts +++ b/tests/unit/getElement.spec.ts @@ -5,19 +5,24 @@ import { Chart as TChart, } from "chart.js"; -import ChartJSdragDataPlugin, { - exportsForTesting, -} from "../../dist/chartjs-plugin-dragdata-test"; +import ChartJSDragDataPlugin, { + getElement, +} from "../../dist/test/chartjs-plugin-dragdata-test"; +import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; import { maxValueCustomMode, setupChartInstance } from "./__utils__/utils"; -const { getElement, getStateVarElement } = exportsForTesting; - const DEFAULT_GET_ELEMENTS_AT_EVENT_MOCK_RETURN_VALUE = [ { index: 0, datasetIndex: 0, element: new PointElement({}) }, { index: 0, datasetIndex: 1, element: new PointElement({}) }, ]; -describe("getElement", () => { +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "getElement", +) + ? describe + : describe.skip)("getElement", () => { describe("line chart with custom interaction mode", () => { let chartInstance: TChart<"line">; let interactionMode: InteractionMode; @@ -38,7 +43,7 @@ describe("getElement", () => { DEFAULT_GET_ELEMENTS_AT_EVENT_MOCK_RETURN_VALUE, ); - Chart.register(ChartJSdragDataPlugin); + Chart.register(ChartJSDragDataPlugin); interactionMode = chartInstance.config.options?.interaction?.mode ?? "nearest"; @@ -53,35 +58,40 @@ describe("getElement", () => { }); it("getElement should properly call getElementsAtEventForMode & select first returned point", () => { - const evtMock = {}; + const evtMock = {} as any; getElement(evtMock, chartInstance); - expect(chartInstance.getElementsAtEventForMode).toHaveBeenCalledTimes(1); - expect(chartInstance.getElementsAtEventForMode).toHaveBeenCalledWith( + expect( + chartInstance.getElementsAtEventForMode, + ).toHaveBeenCalledExactlyOnceWith( evtMock, interactionMode, interactionOptions, false, ); - expect(getStateVarElement()).toBe( - DEFAULT_GET_ELEMENTS_AT_EVENT_MOCK_RETURN_VALUE[0], - ); + + expect( + ChartJSDragDataPlugin.statesStore.get(chartInstance.id)?.element, + ).toBe(DEFAULT_GET_ELEMENTS_AT_EVENT_MOCK_RETURN_VALUE[0]); }); it("getElement should result in selecting null if callback returns false", () => { - const evtMock = {}; + const evtMock = {} as any; getElement(evtMock, chartInstance, () => false); - expect(chartInstance.getElementsAtEventForMode).toHaveBeenCalledTimes(1); - expect(chartInstance.getElementsAtEventForMode).toHaveBeenCalledWith( + expect( + chartInstance.getElementsAtEventForMode, + ).toHaveBeenCalledExactlyOnceWith( evtMock, interactionMode, interactionOptions, false, ); - expect(getStateVarElement()).toBe(null); + expect( + ChartJSDragDataPlugin.statesStore.get(chartInstance.id)?.element, + ).toBe(null); }); }); }); diff --git a/tests/unit/plugin.spec.ts b/tests/unit/plugin.spec.ts index cf834bc0e..9ad5dd62d 100644 --- a/tests/unit/plugin.spec.ts +++ b/tests/unit/plugin.spec.ts @@ -1,16 +1,26 @@ /* eslint-disable jest/no-standalone-expect */ // above: mitigate ESLint false-positive due to wrapping inside conditional test / it.skip -import { Chart } from "chart.js"; -import d3Drag from "d3-drag"; +import { Chart, InteractionItem, Plugin } from "chart.js"; +import d3Drag, { D3DragEvent } from "d3-drag"; import d3Selection from "d3-selection"; -import ChartJSdragDataPlugin from "../../dist/chartjs-plugin-dragdata-test"; +import ChartJSDragDataPlugin from "../../dist/test/chartjs-plugin-dragdata-test"; import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; import { UNIT_TEST_CHART_TYPES } from "./__utils__/constants"; import { setupChartInstance, unitTestCategoryAllowed } from "./__utils__/utils"; -describe("plugin", () => { +const xAxisID = "x", + yAxisID = "y", + rAxisID = "r"; + +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "plugin", +) + ? describe + : describe.skip)("plugin", () => { for (const chartType of UNIT_TEST_CHART_TYPES) { (isTestsConfigWhitelistItemAllowed( "unit", @@ -24,7 +34,7 @@ describe("plugin", () => { beforeEach(() => { chartInstance = setupChartInstance(chartType); - Chart.register(ChartJSdragDataPlugin); + Chart.register(ChartJSDragDataPlugin); }); afterEach(() => { @@ -36,19 +46,62 @@ describe("plugin", () => { `plugin should be accepted by chart.js register() method`, () => { expect( - Chart.registry.getPlugin(ChartJSdragDataPlugin.id), - ).toStrictEqual(ChartJSdragDataPlugin); + Chart.registry.getPlugin(ChartJSDragDataPlugin.id), + ).toStrictEqual(ChartJSDragDataPlugin); }, ); (unitTestCategoryAllowed("pluginRegistration") ? test : it.skip)( "should register canvas via d3's select & pass in drag() handler instance", () => { - expect(d3Selection.select).toHaveBeenCalledWith(chartInstance.canvas); + expect(d3Selection.select).toHaveBeenCalledExactlyOnceWith( + chartInstance.canvas, + ); - expect(d3Selection.call).toHaveBeenCalledTimes(1); - expect(d3Selection.call).toHaveBeenCalledWith( - d3Drag.drag.mock.results[0].value, + expect( + (d3Selection as any as jest.Mock).call, + ).toHaveBeenCalledExactlyOnceWith( + (d3Drag.drag as jest.Mock).mock.results[0].value, + ); + }, + ); + + (unitTestCategoryAllowed("pluginRegistration") ? test : it.skip)( + `plugin should register proper logic to be executed on beforeEvent`, + () => { + const mockedEvent = { + cancelable: true, + event: { + native: null, + type: "mousedown", + x: 10, + y: 15, + }, + inChartArea: true, + replay: false, + } as Parameters>[1], + plugin = Chart.registry.getPlugin(ChartJSDragDataPlugin.id); + + let state = ChartJSDragDataPlugin.statesStore.get(chartInstance.id)!; + + (chartInstance.tooltip as any).update = jest.fn(); + + // first, test when isDragging is false - chartInstance.tooltip.update() should not be called + state.isDragging = false; + + expect( + plugin!.beforeEvent!(chartInstance as Chart, mockedEvent, {}), + ).toStrictEqual(undefined); // nothing should be returned (void) + expect((chartInstance.tooltip as any).update).not.toHaveBeenCalled(); + + // then, test when isDragging is true - chartInstance.tooltip.update() should be called + state.isDragging = true; + + expect( + plugin!.beforeEvent!(chartInstance as Chart, mockedEvent, {}), + ).toStrictEqual(false); // false should be returned + expect((chartInstance.tooltip as any).update).toHaveBeenCalledTimes( + 1, ); }, ); @@ -58,12 +111,13 @@ describe("plugin", () => { () => { expect(d3Drag.drag).toHaveBeenCalledTimes(1); - const d3DragContainerFun = - d3Drag.drag.mock.results[0].value.container; + const d3DragContainerFun = (d3Drag.drag as any as jest.Mock).mock + .results[0].value.container; // test if drag()'s return instance's container() method had been called with the canvas instance - expect(d3DragContainerFun).toHaveBeenCalledTimes(1); - expect(d3DragContainerFun).toHaveBeenCalledWith(chartInstance.canvas); + expect(d3DragContainerFun).toHaveBeenCalledExactlyOnceWith( + chartInstance.canvas, + ); }, ); @@ -71,7 +125,19 @@ describe("plugin", () => { "should register drag event listeners", () => { expect(d3Drag.drag).toHaveBeenCalledTimes(1); - const d3DragOnFun = d3Drag.drag.mock.results[0].value.on; + + const d3DragOnFun = (d3Drag.drag as any as jest.Mock).mock.results[0] + .value.on; + + const onDragStartCbMock = jest.fn(), + onDragCbMock = jest.fn(), + onDragEndCbMock = jest.fn(); + + chartInstance.options.plugins!.dragData = { + onDragStart: onDragStartCbMock, + onDrag: onDragCbMock, + onDragEnd: onDragEndCbMock, + }; // test if drag()'s return instance's on() method had been called with the correct event types & handlers expect(d3DragOnFun).toHaveBeenCalledTimes(3); @@ -84,6 +150,110 @@ describe("plugin", () => { expect.any(Function), ); expect(d3DragOnFun).toHaveBeenCalledWith("end", expect.any(Function)); + + const calls = (d3DragOnFun as jest.Mock).mock.calls as [ + "start" | "drag" | "end", + Function, + ][]; + + const startCallCallbackArg = calls.find( + ([eventType]) => eventType === "start", + )![1], + dragCallCallbackArg = calls.find( + ([eventType]) => eventType === "drag", + )![1], + endCallCallbackArg = calls.find( + ([eventType]) => eventType === "end", + )![1]; + + const eventMock = { + target: {} as any, + active: 0, + dx: 0, + dy: 0, + x: 10, + y: 15, + subject: {}, + type: "mousedown", + identifier: "mouse", + sourceEvent: { + test: 2, + }, + on() { + return {} as any; + }, + } as D3DragEvent; + + const element: InteractionItem = { + datasetIndex: 0, + index: 1, + element: {} as any, + }; + + chartInstance.getElementsAtEventForMode = jest.fn( + (_e, _mode, _options) => [element], + ); + + const origGetDatasetMeta = + chartInstance.getDatasetMeta.bind(chartInstance); + chartInstance.getDatasetMeta = (datasetIndex) => ({ + ...origGetDatasetMeta(datasetIndex), + // mock the IDs for axes + xAxisID, + yAxisID, + rAxisID, + }); + + // make update() work without errors + chartInstance.update = jest.fn(); + + // virtual pixel-to-value calculations + chartInstance.scales[xAxisID] = { + getValueForPixel: jest.fn((pixel) => pixel), + } as any; + chartInstance.scales[yAxisID] = { + getValueForPixel: jest.fn((pixel) => pixel), + } as any; + chartInstance.scales[rAxisID] = { + getPointPositionForValue: jest.fn((value) => value), + getValueForDistanceFromCenter: jest.fn((value) => value), + } as any; + + // TODO: since it is hard to spy on getElement, for now only toString() is checked; this should be reorganized in the future but probably requires more effort + expect(startCallCallbackArg.toString()).toContain("getElement"); + expect(dragCallCallbackArg.toString()).toContain("updateData"); + expect(endCallCallbackArg.toString()).toContain("dragEndCallback"); + + // verify if callbacks were succesfully fired instead; TODO: make this use spies instead + startCallCallbackArg(eventMock); + expect(onDragStartCbMock).toHaveBeenCalledExactlyOnceWith( + eventMock.sourceEvent, + element.datasetIndex, + element.index, + chartInstance.data.datasets[element.datasetIndex].data[ + element.index + ], + ); + + dragCallCallbackArg(eventMock); + expect(onDragCbMock).toHaveBeenCalledExactlyOnceWith( + eventMock.sourceEvent, + element.datasetIndex, + element.index, + chartInstance.data.datasets[element.datasetIndex].data[ + element.index + ], + ); + + endCallCallbackArg(eventMock); + expect(onDragEndCbMock).toHaveBeenCalledExactlyOnceWith( + eventMock.sourceEvent, + element.datasetIndex, + element.index, + chartInstance.data.datasets[element.datasetIndex].data[ + element.index + ], + ); }, ); }); diff --git a/tests/unit/roundValue.spec.ts b/tests/unit/roundValue.spec.ts index 415a2b9a4..0991887e0 100644 --- a/tests/unit/roundValue.spec.ts +++ b/tests/unit/roundValue.spec.ts @@ -1,8 +1,6 @@ -import { exportsForTesting } from "../../dist/chartjs-plugin-dragdata-test-browser"; +import { roundValue } from "../../dist/test/chartjs-plugin-dragdata-test"; import { isTestsConfigWhitelistItemAllowed } from "../__utils__/testsConfig"; -const { roundValue } = exportsForTesting; - (isTestsConfigWhitelistItemAllowed( "unit", "whitelistedTestCategories", diff --git a/tests/unit/types.d.ts b/tests/unit/types.d.ts deleted file mode 100644 index d163c47aa..000000000 --- a/tests/unit/types.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare module "d3-selection"; -declare module "d3-drag"; diff --git a/tests/unit/util/dragEndCallback.spec.ts b/tests/unit/util/dragEndCallback.spec.ts new file mode 100644 index 000000000..8c5cc0950 --- /dev/null +++ b/tests/unit/util/dragEndCallback.spec.ts @@ -0,0 +1,119 @@ +import { Chart, InteractionItem } from "chart.js"; +import "jest-extended"; // somehow, types for jest-extended matchers in this subdirectory don't work without an explicit import + +import ChartJSDragDataPlugin, { + PluginConfiguration, + dragEndCallback, + type DragDataEvent, + type DragEventCallback, +} from "../../../dist/test/chartjs-plugin-dragdata-test"; +import { isTestsConfigWhitelistItemAllowed } from "../../__utils__/testsConfig"; +import { setupChartInstance } from "../__utils__/utils"; + +const rAxisID = "y"; + +(isTestsConfigWhitelistItemAllowed( + "unit", + "whitelistedTestCategories", + "dragEndCallback", +) + ? describe + : describe.skip)("dragEndCallback", () => { + let chartInstance: Chart<"line">; + let callback: jest.Mock< + ReturnType>, + Parameters> + >; + + beforeEach(() => { + callback = jest.fn(); + + chartInstance = setupChartInstance("line", { + plugins: { + dragData: { + round: 4, + onDragEnd: callback, + }, + }, + scales: { + [rAxisID]: { + max: 100, + min: 0, + reverse: false, + }, + }, + }); + }); + + it("should return early if state is undefined", () => { + dragEndCallback<"line">({} as DragDataEvent, { id: 99, config: {} } as any); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("should re-enable the tooltip animation", () => { + chartInstance.config.options = { + plugins: { + tooltip: { animation: {} }, + }, + }; + + const chartUpdateFunctionSpy = jest.spyOn(chartInstance, "update"); + + dragEndCallback<"line">({} as DragDataEvent, chartInstance); + expect(chartInstance.config.options.plugins?.tooltip?.animation).toBe( + ChartJSDragDataPlugin.statesStore.get(chartInstance.id)?.eventSettings, + ); + + expect(chartUpdateFunctionSpy).toHaveBeenCalledWith("none"); + }); + + for (const withMagnet of [true, false]) { + // eslint-disable-next-line jest/valid-title + describe(withMagnet ? "with magnet" : "without magnet", () => { + it("should invoke callback with correct arguments and update isDragging to false", () => { + const event = {} as DragDataEvent; + const mockedMagnetReturnValue = 42; + + if (withMagnet) { + (chartInstance.options.plugins! + .dragData as PluginConfiguration<"line">)!.magnet ??= { + to: () => mockedMagnetReturnValue, + }; + } + + let element: InteractionItem = { + datasetIndex: 0, + index: 1, + element: {} as any, + }; + + // since no previous interaction had happened, we need to set the element in state manually + // note that the state before the below was a valid object, but element was null + ChartJSDragDataPlugin.statesStore.set(chartInstance.id, { + ...ChartJSDragDataPlugin.statesStore.get(chartInstance.id)!, + element, + isDragging: true, // we want to set this to true to test if it was properly set to false by dragEndCallback + }); + + const expectedValue = withMagnet + ? mockedMagnetReturnValue + : chartInstance.data.datasets[element.datasetIndex].data[ + element.index + ]; + + dragEndCallback<"line">(event, chartInstance); + + expect(callback).toHaveBeenCalledWith( + event, + element.datasetIndex, + element.index, + expectedValue, + ); + expect( + ChartJSDragDataPlugin.statesStore.get(chartInstance.id)?.isDragging, + ).toBe(false); + }); + }); + } +}); diff --git a/tsconfig.build.base.json b/tsconfig.build.base.json index cb0d8e889..5cd4cd467 100644 --- a/tsconfig.build.base.json +++ b/tsconfig.build.base.json @@ -15,8 +15,10 @@ "noEmit": true, "jsx": "react", "experimentalDecorators": true, - "emitDecoratorMetadata": true + "emitDecoratorMetadata": true, + "declaration": true, + "outDir": "dist" }, - "include": ["src"], + "include": ["src", "src/typings.d.ts"], "exclude": ["node_modules", "dist", "scripts"] } diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 000000000..1bf19b44d --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.js", + "tests/**/*.ts", + "tests/**/*.tsx", + "tests/**/*.js", + "scripts/**/*.ts", + "scripts/**/*.tsx", + "scripts/**/*.js", + "pages/**/*.ts", + "pages/**/*.tsx", + "pages/**/*.js", + "types.d.ts", + "*.config.ts", + "*.config.mjs", + "*.config.js" + ] +} diff --git a/tsconfig.json b/tsconfig.json index a0f62fef5..fa22830e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,11 @@ { "extends": "./tsconfig.build.json", - "include": ["tests", "*.config.ts", "scripts", "types.d.ts", "pages/src"], + "include": [ + "tests", + "*.config.ts", + "scripts", + "types.d.ts", + "pages/src", + "src/typings.d.ts" + ] }