diff --git a/.github/workflows/vale.yml b/.github/workflows/vale.yml index fc11f81..2a1e66c 100644 --- a/.github/workflows/vale.yml +++ b/.github/workflows/vale.yml @@ -21,10 +21,31 @@ jobs: ssh-key: ${{ secrets.VALE_STYLES_ACCESS_KEY }} - name: copy vale rules to the root repo run: shopt -s dotglob && cp -r ./vale-styles/vale/* . + + - name: detect README file + id: detect + shell: bash + run: | + set -euo pipefail + + found="$(find . -maxdepth 1 -type f -iname 'readme.md' -print | head -n 1 || true)" + + if [[ -z "$found" ]]; then + echo "No README.md found in repo root. Nothing to lint." + echo "files=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + found="${found#./}" + + echo "Found README: $found" + echo "files=[\"$found\"]" >> "$GITHUB_OUTPUT" + - name: vale linter check + if: steps.detect.outputs.files != '[]' uses: DevExpress/vale-action@reviewdog with: - files: '["README.md", "readme.md", "Readme.md"]' + files: ${{ steps.detect.outputs.files }} fail_on_error: true filter_mode: nofilter - reporter: github-check + reporter: github-check \ No newline at end of file diff --git a/README.md b/README.md index e1d0156..4ce4dde 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -![](https://img.shields.io/endpoint?url=https://codecentral.devexpress.com/api/v1/VersionRange/128583254/25.1.3%2B) [![](https://img.shields.io/badge/Open_in_DevExpress_Support_Center-FF7200?style=flat-square&logo=DevExpress&logoColor=white)](https://supportcenter.devexpress.com/ticket/details/T444368) [![](https://img.shields.io/badge/📖_How_to_use_DevExpress_Examples-e9f6fc?style=flat-square)](https://docs.devexpress.com/GeneralInformation/403183) [![](https://img.shields.io/badge/💬_Leave_Feedback-feecdd?style=flat-square)](#does-this-example-address-your-development-requirementsobjectives) @@ -49,7 +48,7 @@ The GroupSelectionBehavior class uses the [customizeColumns](https://js.devexpre - [DataGrid Multiple Record Selection API Demo](https://js.devexpress.com/Demos/WidgetsGallery/Demo/DataGrid/MultipleRecordSelectionAPI) -## Does this example address your development requirements/objectives? +## Does This Example Address Your Development Requirements/Objectives? [](https://www.devexpress.com/support/examples/survey.xml?utm_source=github&utm_campaign=devextreme-datagrid-select-all-checkboxes&~~~was_helpful=yes) [](https://www.devexpress.com/support/examples/survey.xml?utm_source=github&utm_campaign=devextreme-datagrid-select-all-checkboxes&~~~was_helpful=no) diff --git a/React/package-lock.json b/React/package-lock.json index 926f545..4517682 100644 --- a/React/package-lock.json +++ b/React/package-lock.json @@ -30,7 +30,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-perf": "^3.3.1", "globals": "^16.0.0", - "jsdom": "^24.0.0", + "happy-dom": "^20.0.10", "npm-run-all": "^4.1.5", "stylelint": "^15.6.1", "stylelint-config-standard": "^33.0.0", @@ -64,6 +64,8 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -88,6 +90,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -112,6 +116,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" @@ -140,6 +146,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -163,6 +171,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -172,7 +182,9 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -684,6 +696,8 @@ } ], "license": "MIT-0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -1947,6 +1961,13 @@ "@types/jest": "*" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "dev": true, @@ -2560,6 +2581,8 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -2768,7 +2791,9 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -3071,6 +3096,8 @@ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3176,6 +3203,8 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -3189,7 +3218,9 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/csstype": { "version": "3.1.3", @@ -3207,6 +3238,8 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -3324,7 +3357,9 @@ "node_modules/decimal.js": { "version": "10.4.3", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/deep-eql": { "version": "4.1.4", @@ -3422,6 +3457,8 @@ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.4.0" } @@ -4565,6 +4602,8 @@ "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4842,6 +4881,48 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.0.10", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz", + "integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/hard-rejection": { "version": "2.1.0", "dev": true, @@ -4937,6 +5018,8 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -4966,6 +5049,8 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4980,6 +5065,8 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -5001,6 +5088,8 @@ "version": "0.6.3", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -5385,7 +5474,9 @@ "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/is-regex": { "version": "1.1.4", @@ -5632,6 +5723,8 @@ "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -6138,6 +6231,8 @@ "version": "1.52.0", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6146,6 +6241,8 @@ "version": "2.1.35", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6442,7 +6539,9 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -6687,6 +6786,8 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -6700,6 +6801,8 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -6943,7 +7046,9 @@ "node_modules/psl": { "version": "1.9.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/punycode": { "version": "2.3.1", @@ -6956,7 +7061,9 @@ "node_modules/querystringify": { "version": "2.2.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -7224,7 +7331,9 @@ "node_modules/requires-port": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/resolve": { "version": "1.22.8", @@ -7289,7 +7398,9 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -7358,7 +7469,9 @@ "node_modules/safer-buffer": { "version": "2.1.2", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/saxes": { "version": "6.0.0", @@ -7366,6 +7479,8 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -8006,7 +8121,9 @@ "node_modules/symbol-tree": { "version": "3.2.4", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/table": { "version": "6.8.2", @@ -8148,6 +8265,8 @@ "version": "4.1.4", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -8162,6 +8281,8 @@ "version": "0.2.0", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8172,6 +8293,8 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -8753,6 +8876,8 @@ "version": "1.5.10", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -10230,6 +10355,8 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -10243,6 +10370,8 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -10253,6 +10382,8 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -10266,6 +10397,8 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -10276,6 +10409,8 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -10416,6 +10551,8 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -10438,6 +10575,8 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -10447,7 +10586,9 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/yallist": { "version": "3.1.1", diff --git a/React/package.json b/React/package.json index f2cd04c..1a02be3 100644 --- a/React/package.json +++ b/React/package.json @@ -36,7 +36,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-perf": "^3.3.1", "globals": "^16.0.0", - "jsdom": "^24.0.0", + "happy-dom": "^20.0.10", "npm-run-all": "^4.1.5", "stylelint": "^15.6.1", "stylelint-config-standard": "^33.0.0", diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index 1f03afe..9f6d76c 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -1,8 +1,160 @@ -import { render, screen } from '@testing-library/react'; +// GroupRowComponent.test.tsx +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DataGridTypes } from 'devextreme-react/data-grid'; +import { vi } from 'vitest'; +import GroupRowComponent from './GroupRowSelection/GroupRowComponent'; import App from './App'; +import { GroupRowSelectionProvider } from './GroupRowSelection/selection-context/row-selection-context'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +describe('GroupRowComponent', () => { + const mockSelect = vi.fn(() => Promise.resolve()); + const mockDeselect = vi.fn(() => Promise.resolve()); + + const mockGroupData: DataGridTypes.ColumnGroupCellTemplateData = { + column: { caption: 'ShipCountry' }, + displayValue: 'USA', + row: { key: ['USA'] }, + component: { + selectRows: mockSelect, + deselectRows: mockDeselect, + }, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render group text', () => { + render( + + + , + ); + expect(screen.getByText('ShipCountry: USA')).toBeInTheDocument(); + }); + + // eslint-disable-next-line @typescript-eslint/space-before-function-paren + test('calls onInitialized and shows checkboxes', async() => { + render( + + + , + ); + + await waitFor( + () => { + const allCheckboxes = screen.getAllByRole('checkbox'); + expect(allCheckboxes.length).toBeGreaterThan(0); + allCheckboxes.forEach((checkbox) => { + expect(checkbox).toBeVisible(); + }); + }, + { timeout: 5000 }, + ); + }); + + // eslint-disable-next-line @typescript-eslint/space-before-function-paren + test('selects and deselects rows when checkbox clicked', async() => { + render( + + + , + ); + + const allCheckboxes = await waitFor(() => screen.getAllByRole('checkbox'), { + timeout: 5000, + }); + const checkbox = allCheckboxes[0]; + + await userEvent.click(checkbox); + + await waitFor(() => { + allCheckboxes.forEach((cb) => { + expect(cb).toHaveAttribute('aria-checked', 'true'); + }); + }); + + await userEvent.click(checkbox); + + await waitFor(() => { + allCheckboxes.forEach((cb) => { + expect(cb).toHaveAttribute('aria-checked', 'false'); + }); + }); + }); + + // eslint-disable-next-line @typescript-eslint/space-before-function-paren + test('selecting checkbox at index 1 selects 2–4 and leaves others unselected', async() => { + const user = userEvent.setup(); + + const { container } = render( + + + , + ); + + const allCheckboxes = await waitFor( + () => { + const checkboxes = screen.getAllByRole('checkbox'); + if (checkboxes.length < 10) throw new Error('Grid not loaded'); + return checkboxes; + }, + { timeout: 15000 }, + ); + + await user.click(allCheckboxes[1]); + + await waitFor(() => { + const freshCheckboxes = screen.getAllByRole('checkbox'); + const state = freshCheckboxes[1].getAttribute('aria-checked'); + if (state !== 'true' && state !== 'mixed') { + throw new Error('Group checkbox not updated yet'); + } + }); + + const expandButton = container.querySelector('.dx-datagrid-group-closed'); + + if (!expandButton) { + throw new Error('Expand button not found - cannot proceed with test'); + } + + await user.click(expandButton); + + await waitFor( + () => { + const checkboxes = screen.getAllByRole('checkbox'); + + if (checkboxes.length <= allCheckboxes.length) { + throw new Error('Rows did not expand yet'); + } + + checkboxes.forEach((cb, index) => { + const state = cb.getAttribute('aria-checked'); + + if (index === 0) { + expect(['true', 'mixed']).toContain(state); + return; + } + + if (index === 1) { + expect(['true', 'mixed']).toContain(state); + return; + } + + if (index > 1 && index <= 4) { + if (state !== 'true') { + throw new Error( + `Row ${index} should be selected but was ${state}`, + ); + } + return; + } + + expect(state).toBe('false'); + }); + }, + { timeout: 10000 }, + ); + }, 30000); }); diff --git a/React/src/App.tsx b/React/src/App.tsx index b972c2a..4623eb1 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,17 +1,22 @@ -import { - useEffect, useRef, useState, -} from 'react'; -import './App.css'; -import 'devextreme/dist/css/dx.material.blue.light.compact.css'; -import * as AspNetData from 'devextreme-aspnet-data-nojquery'; +import { useCallback } from 'react'; import DataGrid, { - Column, type DataGridTypes, GroupPanel, Grouping, type DataGridRef, Lookup, Paging, Selection, + Column, + type DataGridTypes, + GroupPanel, + Grouping, + Lookup, + Paging, + Selection, } from 'devextreme-react/data-grid'; -import GroupSelectionHelper from './GroupRowSelection/GroupRowSelectionHelper'; -import GroupRowComponent, { type IGroupRowReadyParameter } from './GroupRowSelection/GroupRowComponent'; +import * as AspNetData from 'devextreme-aspnet-data-nojquery'; import { useEventCallback } from './hooks'; +import { useGroupRowSelection } from './GroupRowSelection/selection-context/row-selection-context'; +import GroupRowComponent from './GroupRowSelection/GroupRowComponent'; + +import './App.css'; +import 'devextreme/dist/css/dx.material.blue.light.compact.css'; -const url = 'https://js.devexpress.com/Demos/Mvc/api/DataGridWebApi'; +const url = 'https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi'; const dataSource = AspNetData.createStore({ key: 'OrderID', loadUrl: `${url}/Orders`, @@ -35,74 +40,72 @@ const shippersData = AspNetData.createStore({ }); function App(): JSX.Element { - const dataGrid = useRef(null); - const [helper, setHelper] = useState(); + const { registerGrid } = useGroupRowSelection(); - useEffect(() => { - if (dataGrid?.current) { - setHelper(new GroupSelectionHelper(dataGrid.current.instance())); - } - }, [dataGrid, setHelper]); + const handleInitialized = useCallback( + (e: DataGridTypes.InitializedEvent) => { + if (!e.component) return; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - const groupRowInit = useEventCallback((arg: IGroupRowReadyParameter) => helper?.groupRowInit(arg)); + registerGrid(e.component); + }, + [registerGrid], + ); - const groupCellRender = useEventCallback((group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( - - )); + const groupCellRender = useEventCallback( + (group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( + + ), + ); return (
+ showCheckBoxesMode="always" + > - + - + + displayExpr="Text" + > + + - - + displayExpr="Text" + > diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index 98865f1..bb27fac 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -1,87 +1,156 @@ +import React, { + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from 'react'; +import CheckBox, { type CheckBoxTypes } from 'devextreme-react/check-box'; import { LoadIndicator } from 'devextreme-react'; -import './GroupRowComponent.css'; import { type DataGridTypes } from 'devextreme-react/data-grid'; -import { - useEffect, useMemo, useState, -} from 'react'; -import CheckBox from 'devextreme-react/check-box'; -import type { CheckBoxTypes } from 'devextreme-react/check-box'; -import { useEventCallback } from '../hooks'; +import { useGroupRowSelection } from './selection-context/row-selection-context'; +import './GroupRowComponent.css'; interface GroupRowProps { groupCellData: DataGridTypes.ColumnGroupCellTemplateData; - childRowKeys?: any[]; - // eslint-disable-next-line no-unused-vars - onInitialized: (param: IGroupRowReadyParameter) => Promise | undefined; +} + +export interface IGroupRowReadyParameter { + key: any[]; } const iconSize = 18; -// eslint-disable-next-line func-style -const GroupRowComponent: React.FC = ({ - groupCellData, - onInitialized, -}) => { - const [isLoading, setIsLoading] = useState(true); - const [checked, setChecked] = useState(false); - const [childKeys, setChildKeys] = useState([]); +const groupRowFlexStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', +}; + +const groupSelectionFrontStyle: React.CSSProperties = { + marginRight: '10px', + width: iconSize, + height: iconSize, +}; + +function GroupRowComponent({ groupCellData }: GroupRowProps): JSX.Element { + const [childKeys, setChildKeys] = useState([]); + const [isInitializing, setIsInitializing] = useState(true); + const actionInProgressRef = useRef(false); + + const { + selectedRows, + handleGroupSelection, + isGroupLoading, + setGroupLoading, + initializeGroupRow, + } = useGroupRowSelection(); + + const { component: gridInstance, row } = groupCellData; + + const isLoading = isGroupLoading(row.key); + const [blocked, setBlocked] = useState(false); + + const checkedValue = useMemo(() => { + if (!childKeys.length) return false; + + const allSelected = childKeys.every((key) => selectedRows.has(key)); + const noneSelected = childKeys.every((key) => !selectedRows.has(key)); + + if (allSelected) return true; + if (noneSelected) return false; + return undefined; + }, [selectedRows, childKeys]); + + const onValueChanged = useCallback( + (e: CheckBoxTypes.ValueChangedEvent) => { + if (!e.event) return; + if (actionInProgressRef.current) return; + actionInProgressRef.current = true; + + setBlocked(true); + const action = e.value ? 'select' : 'deselect'; + handleGroupSelection(row.key, childKeys, action, gridInstance).finally( + () => { + actionInProgressRef.current = false; + + setTimeout(() => { + setBlocked(false); + }, 200); + }, + ); + }, + [childKeys, gridInstance, handleGroupSelection, isLoading, row.key], + ); + + const onRowInitialized = useCallback( + (e: IGroupRowReadyParameter) => { + setIsInitializing(true); + + const promise = initializeGroupRow(e); + + promise + ?.then((keys: any[]) => { + setChildKeys(keys); + }) + .finally(() => { + setIsInitializing(false); + }); + }, + [initializeGroupRow], + ); + + useEffect((): (() => void) => { + onRowInitialized({ key: row.key }); + + return (): void => { + setGroupLoading(row.key, false); + }; + }, [row.key, initializeGroupRow, setGroupLoading]); + + const stopPropagation = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); - // Memoize the group text to avoid recalculating on every render const groupText = useMemo((): string => { - let text = `${groupCellData.column.caption}: ${groupCellData.displayValue}`; - if (groupCellData.groupContinuedMessage) text += ` (${groupCellData.groupContinuedMessage})`; - if (groupCellData.groupContinuesMessage) text += ` (${groupCellData.groupContinuesMessage})`; + const { + column, + displayValue, + groupContinuedMessage, + groupContinuesMessage, + } = groupCellData; + let text = `${column.caption}: ${displayValue}`; + if (groupContinuedMessage) text += ` (${groupContinuedMessage})`; + if (groupContinuesMessage) text += ` (${groupContinuesMessage})`; return text; - }, [groupCellData.column.caption, groupCellData.displayValue, groupCellData.groupContinuedMessage, groupCellData.groupContinuesMessage]); - - const onValueChange = useEventCallback((value: CheckBoxTypes.ValueChangedEvent) => { - if (value) { - // eslint-disable-next-line no-console - groupCellData.component.selectRows(childKeys ?? [], true).catch(console.error); - } else { - // eslint-disable-next-line no-console - groupCellData.component.deselectRows(childKeys ?? []).catch(console.error); - } - }); - - const setCheckedState = useEventCallback((value: boolean | undefined) => { - setChecked(value); - setIsLoading(false); - }); - - const groupRowKey = useMemo(() => JSON.stringify(groupCellData.row.key), [groupCellData.row.key]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-invalid-this - const action = onInitialized?.({ key: groupCellData.row.key, setCheckedState: setCheckedState.bind(this) }); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - action?.then((children: any) => { - setChildKeys(children); - }); - }, [groupRowKey]); + }, [groupCellData]); + + const showLoading = isInitializing || isLoading; + + const groupTextStyleDynamic: React.CSSProperties = { + opacity: blocked ? 0.5 : 1, + }; return ( -
-
- - +
+
+ {showLoading ? ( + + ) : ( + + )}
- {groupText} + {groupText}
); -}; - -export default GroupRowComponent; - -export interface IGroupRowReadyParameter { - key: string[]; - setCheckedState: Function; } + +export default React.memo(GroupRowComponent); diff --git a/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx b/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx deleted file mode 100644 index 364dcb6..0000000 --- a/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import dxDataGrid from 'devextreme/ui/data_grid'; -import { type DataGridTypes } from 'devextreme-react/data-grid'; -import { type LoadOptions } from 'devextreme/common/data'; -import { isItemsArray } from 'devextreme-react/common/data'; -import { type IGroupRowReadyParameter } from './GroupRowComponent'; - -export default class GroupSelectionHelper { - groupedColumns: DataGridTypes.Column[]; - - grid: dxDataGrid; - - getSelectedKeysPromise: Promise | null; - - selectedKeys: any[] = []; - - groupChildKeys: Record = {}; - - constructor(grid: dxDataGrid) { - this.grid = grid; - this.groupedColumns = this.collectGroupedColumns(grid); - this.getSelectedKeysPromise = this.getSelectedKeys(grid); - this.getSelectedKeysPromise.then((keys: any[]) => { - this.selectedKeys = keys; - }).catch(() => { }); - const defaultSelectionHandler: Function | undefined = grid.option('onSelectionChanged'); - grid.option('onSelectionChanged', (e: DataGridTypes.SelectionChangedEvent) => { - this.selectionChanged(e); - if (defaultSelectionHandler) { defaultSelectionHandler(e); } - }); - const defaultOptionChangedHandler: Function | undefined = grid.option('onOptionChanged'); - grid.option('onOptionChanged', (e: DataGridTypes.OptionChangedEvent) => { - if (e.fullName.includes('groupIndex')) { - this.groupedColumns = this.collectGroupedColumns(grid); - } - if (defaultOptionChangedHandler) { defaultOptionChangedHandler(e); } - }); - } - - groupRowInit(arg: IGroupRowReadyParameter): Promise { - const checkBoxId = this.calcCheckBoxId(this.grid, arg.key); - const promise = new Promise((resolve) => { - if (!this.groupChildKeys[checkBoxId]) { - const filter: any[] = []; - arg.key.forEach((key, i) => { - filter.push([this.groupedColumns[i].dataField, '=', key]); - }); - const loadOptions: LoadOptions = { - filter, - }; - const store = this.grid.getDataSource().store(); - - store.load(loadOptions).then((data) => { - if (isItemsArray(data)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - this.groupChildKeys[checkBoxId] = data.map((d) => this.grid.keyOf(d)); - this.getSelectedKeys(this.grid).then((selectedKeys) => { - const checkedState: boolean | undefined = this.areKeysSelected(this.groupChildKeys[checkBoxId], selectedKeys); - arg.setCheckedState(checkedState); - }).catch(() => { }); - resolve(this.groupChildKeys[checkBoxId]); - } - }).catch(() => { }); - } else { - this.getSelectedKeys(this.grid).then((selectedKeys) => { - const checkedState: boolean | undefined = this.areKeysSelected(this.groupChildKeys[checkBoxId], selectedKeys); - arg.setCheckedState(checkedState); - resolve(this.groupChildKeys[checkBoxId]); - }).catch(() => { }); - } - }); - - return promise; - } - - selectionChanged(e: DataGridTypes.SelectionChangedEvent): void { - const groupRows: DataGridTypes.Row[] = e.component.getVisibleRows().filter((r) => r.rowType === 'group'); - this.getSelectedKeysPromise = null; - if (e.component.option('selection.deferred')) { - const selectionFilter = e.component.option('selectionFilter'); - if (selectionFilter && selectionFilter.length >= 0) { - this.repaintGroupRowTree(e.component, groupRows); - } else { - e.component.repaintRows(groupRows.map((g) => g.rowIndex)); - } - } else if (e.selectedRowKeys.length >= e.component.totalCount() || e.currentDeselectedRowKeys.length >= e.component.totalCount()) { - e.component.repaintRows(groupRows.map((g) => g.rowIndex)); - } else { - this.repaintGroupRowTree(e.component, groupRows); - } - } - - getSelectedKeys(grid: dxDataGrid): Promise { - if (grid.option('selection.deferred')) { - if (!this.getSelectedKeysPromise) { - this.getSelectedKeysPromise = grid.getSelectedRowKeys(); - } - return this.getSelectedKeysPromise; - } - return grid.getSelectedRowKeys(); - } - - repaintGroupRowTree(grid: dxDataGrid, groupRows: DataGridTypes.Row[]): void { - const topGroupRow: DataGridTypes.Row | null = groupRows - .filter((r) => r.isExpanded) - .reduce((acc: DataGridTypes.Row | null, curr) => (!acc || acc.key.length > curr.key.length ? curr : acc), null); - if (topGroupRow) { - const affectedGroupRows = groupRows.filter((g) => g.key[0] == topGroupRow.key[0]); - grid.repaintRows(affectedGroupRows.map((g) => g.rowIndex)); - } - } - - areKeysSelected(keysToCheck: any[], selectedKeys: any[]): boolean | undefined { - if (selectedKeys.length == 0) { return false; } - const intersectionCount = keysToCheck.filter((k) => selectedKeys.includes(k)).length; - if (intersectionCount === 0) { return false; } - if (intersectionCount === keysToCheck.length) { return true; } - return undefined; - } - - getChildRowKeys(grid: dxDataGrid, groupRowKey: string[]): any[] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.groupChildKeys[this.calcCheckBoxId(grid, groupRowKey)]; - } - - calcCheckBoxId(grid: dxDataGrid, groupRowKey: string[]): string { - const gridId: string = grid.element().id; - return `${gridId}groupCheckBox${groupRowKey.join('')}`; - } - - collectGroupedColumns(grid: dxDataGrid): DataGridTypes.Column[] { - const allColumns: DataGridTypes.Column[] = grid.getVisibleColumns(); - return allColumns.filter((c: DataGridTypes.Column) => c.groupIndex != undefined && c.groupIndex >= 0) - .sort((a, b) => { - if (!a.groupIndex || !b.groupIndex) return 0; - return a.groupIndex > b.groupIndex ? 1 : -1; - }); - } -} diff --git a/React/src/GroupRowSelection/selection-context/helpers.ts b/React/src/GroupRowSelection/selection-context/helpers.ts new file mode 100644 index 0000000..00c3b27 --- /dev/null +++ b/React/src/GroupRowSelection/selection-context/helpers.ts @@ -0,0 +1,6 @@ +export function serializeKey(key: any): string { + if (typeof key === 'string' || typeof key === 'number') { + return String(key); + } + return JSON.stringify(key, Object.keys(key).sort()); +} diff --git a/React/src/GroupRowSelection/selection-context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts new file mode 100644 index 0000000..e524885 --- /dev/null +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -0,0 +1,256 @@ +import { + useState, useCallback, useMemo, useRef, +} from 'react'; +import type dxDataGrid from 'devextreme/ui/data_grid'; +import { + isItemsArray, + type LoadOptions, +} from 'devextreme-react/cjs/common/data'; +import { serializeKey } from './helpers'; +import type { + UseGridInstanceReturnType, + UseGroupLoadingReturnType, + UseGroupRowHandlerReturnType, + UseGroupSelectionHandlerReturnType, + UseSelectedRowsReturnType, +} from './types'; + +export function useSelectedRows(): UseSelectedRowsReturnType { + const [selectedRows, setSelectedRows] = useState>( + new Set(), + ); + + const syncSelection = useCallback( + (arg) => { + if (typeof arg === 'function') { + setSelectedRows((prev) => { + const newRowIds = arg(Array.from(prev)); + return new Set(newRowIds); + }); + return; + } + setSelectedRows(new Set(arg)); + }, + [], + ); + + return { selectedRows, syncSelection }; +} + +export function useGroupLoading(): UseGroupLoadingReturnType { + const [loadingGroupKeys, setLoadingGroupKeys] = useState>( + () => new Map(), + ); + + const setGroupLoading = useCallback((groupKey: any, isLoading: boolean) => { + const sKey = serializeKey(groupKey); + setLoadingGroupKeys((prev) => { + const next = new Map(prev); + const current = next.get(sKey) ?? 0; + + if (isLoading) { + next.set(sKey, current + 1); + } else { + const nextCount = current - 1; + if (nextCount <= 0) next.delete(sKey); + else next.set(sKey, nextCount); + } + return next; + }); + }, []); + + const isGroupLoading = useCallback( + (groupKey: any) => { + if (loadingGroupKeys.has(serializeKey(groupKey))) return true; + + if (Array.isArray(groupKey)) { + const parentPath = [...groupKey]; + + while (parentPath.length > 1) { + parentPath.pop(); + + if (loadingGroupKeys.has(serializeKey(parentPath))) { + return true; + } + } + } + + return false; + }, + [loadingGroupKeys], + ); + + const hasAnyLoading = useMemo( + () => loadingGroupKeys.size > 0, + [loadingGroupKeys], + ); + + return { setGroupLoading, isGroupLoading, hasAnyLoading }; +} + +export function useGridInstance( + syncSelection: UseSelectedRowsReturnType['syncSelection'], + hasAnyLoading?: boolean, +): UseGridInstanceReturnType { + const gridInstanceRef = useRef(null); + const groupedColumnsRef = useRef[]>([]); + + const latestRequestIdRef = useRef(0); + + const prevSelectedRowsRef = useRef>(new Set()); + + const collectGroupedColumns = useCallback( + (grid: dxDataGrid) => grid + .getVisibleColumns() + .filter((c) => c.groupIndex != null && c.groupIndex >= 0) + .sort((a, b) => ((a.groupIndex ?? 0) > (b.groupIndex ?? 0) ? 1 : -1)), + [], + ); + + const getSelectedKeys = useCallback( + (grid: dxDataGrid) => grid.getSelectedRowKeys(), + [], + ); + + const triggerFullSync = useCallback( + (grid: dxDataGrid) => { + const currentId = ++latestRequestIdRef.current; + + getSelectedKeys(grid) + .then((keys) => { + if (latestRequestIdRef.current === currentId) { + syncSelection(keys); + } + }) + .catch(() => {}); + }, + [getSelectedKeys, syncSelection], + ); + + const registerGrid = useCallback( + (grid: dxDataGrid) => { + gridInstanceRef.current = grid; + groupedColumnsRef.current = collectGroupedColumns(grid); + + getSelectedKeys(grid) + .then((keys: (string | number)[]) => syncSelection(keys)) + .catch(() => {}); + + const defaultOptionChanged = grid.option('onOptionChanged'); + + grid.option('onOptionChanged', (e) => { + if (!prevSelectedRowsRef?.current) return; + + if (e.fullName === 'selectionFilter') { + const selectAllAction = e.value === null || e.value.length === 0; + const isDeselectAction = (prevSelectedRowsRef?.current.size ?? 0) + > e.value?.filter((v: any) => Array.isArray(v))?.length + && e.value !== null; + + if (isDeselectAction) syncSelection((prev) => Array.from(prev).filter((key) => e.value.some((v: any) => (Array.isArray(v) ? v.includes(key) : v === key)))); + + if (selectAllAction) { + triggerFullSync(grid); + } else if (!hasAnyLoading && !isDeselectAction) { + grid.getSelectedRowKeys().then((selectedKeys) => { + syncSelection(selectedKeys); + }).catch(() => {}); + } + prevSelectedRowsRef.current = new Set(e.value); + } + if (e.fullName.includes('groupIndex')) { + groupedColumnsRef.current = collectGroupedColumns(grid); + } + defaultOptionChanged?.(e); + }); + }, + [collectGroupedColumns, getSelectedKeys, syncSelection], + ); + + return { gridInstanceRef, groupedColumnsRef, registerGrid }; +} + +export function useGroupSelectionHandler( + setGroupLoading: UseGroupLoadingReturnType['setGroupLoading'], +): UseGroupSelectionHandlerReturnType { + return useCallback( + // eslint-disable-next-line @typescript-eslint/space-before-function-paren + async( + groupKey, + childKeys, + action, + gridInstance, + ) => { + if (!gridInstance) return; + setGroupLoading(groupKey, true); + + try { + if (action === 'select') { + await gridInstance.selectRows(childKeys, true); + } else { + await gridInstance.deselectRows(childKeys); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Group selection failed', error); + } finally { + setGroupLoading(groupKey, false); + } + }, + [setGroupLoading], + ); +} + +export function useGroupRowHandler( + gridRef: React.RefObject, + groupedColumnsRef: React.MutableRefObject[]>, +): UseGroupRowHandlerReturnType { + const groupChildKeysRef = useRef>({}); + + const calcCheckBoxId = useCallback( + (grid: dxDataGrid, groupRowKey: string[]) => `${grid.element().id}groupCheckBox${groupRowKey.join('')}`, + [], + ); + + const groupRowInit = useCallback( + (arg) => { + const grid = gridRef.current?.instance(); + if (!grid) return Promise.resolve([]); + + const checkBoxId = calcCheckBoxId(grid, arg.key); + + return new Promise((resolve) => { + if (groupChildKeysRef.current[checkBoxId]) { + resolve(groupChildKeysRef.current[checkBoxId]); + return; + } + + const filter: string[][] = []; + arg.key.forEach((key, i) => { + filter.push([groupedColumnsRef.current[i].dataField, '=', key]); + }); + + const loadOptions: LoadOptions = { filter }; + const store = grid.getDataSource().store(); + + store + .load(loadOptions) + .then((data) => { + if (isItemsArray(data)) { + const keys: number[] = data.map((d) => grid.keyOf(d) as number); + groupChildKeysRef.current[checkBoxId] = keys; + resolve(keys); + } else { + resolve([]); + } + }) + .catch(() => resolve([])); + }); + }, + [calcCheckBoxId], + ); + + return { + groupRowInit, + }; +} diff --git a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx new file mode 100644 index 0000000..542d61b --- /dev/null +++ b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx @@ -0,0 +1,77 @@ +import { + createContext, + useContext, + useMemo, + type ReactNode, +} from 'react'; +import { + useGridInstance, + useGroupLoading, + useGroupRowHandler, + useGroupSelectionHandler, + useSelectedRows, +} from './hooks'; +import type { GroupRowSelectionContextType } from './types'; + +const GroupRowSelectionContext = createContext< +GroupRowSelectionContextType | undefined +>(undefined); + +export function GroupRowSelectionProvider({ children }: { children: ReactNode }): JSX.Element { + const { setGroupLoading, isGroupLoading, hasAnyLoading } = useGroupLoading(); + const { selectedRows, syncSelection } = useSelectedRows(); + const { gridInstanceRef, groupedColumnsRef, registerGrid } = useGridInstance( + syncSelection, + hasAnyLoading, + ); + const handleGroupSelection = useGroupSelectionHandler(setGroupLoading); + const { groupRowInit } = useGroupRowHandler( + gridInstanceRef, + groupedColumnsRef, + ); + + const contextValue = useMemo( + () => ({ + selectedRows, + gridInstanceRef, + groupedColumnsRef, + hasAnyLoading, + syncSelection, + isGroupLoading, + setGroupLoading, + handleGroupSelection, + registerGrid, + initializeGroupRow: groupRowInit, + }), + [ + selectedRows, + gridInstanceRef, + groupedColumnsRef, + hasAnyLoading, + syncSelection, + isGroupLoading, + setGroupLoading, + handleGroupSelection, + registerGrid, + groupRowInit, + ], + ); + + return ( + + {children} + + ); +} + +export function useGroupRowSelection(): GroupRowSelectionContextType { + const context = useContext(GroupRowSelectionContext); + if (!context) { + throw new Error( + 'useGroupRowSelection must be used within GroupRowSelectionProvider', + ); + } + return context; +} diff --git a/React/src/GroupRowSelection/selection-context/types.ts b/React/src/GroupRowSelection/selection-context/types.ts new file mode 100644 index 0000000..64ac23f --- /dev/null +++ b/React/src/GroupRowSelection/selection-context/types.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-unused-vars */ +import type dxDataGrid from 'devextreme/ui/data_grid'; +import type { IGroupRowReadyParameter } from '../GroupRowComponent'; + +export interface GroupRowSelectionContextType { + selectedRows: Set; + gridInstanceRef: React.MutableRefObject; + groupedColumnsRef: React.MutableRefObject[]>; + hasAnyLoading: boolean; + syncSelection: UseSelectedRowsReturnType['syncSelection']; + isGroupLoading: UseGroupLoadingReturnType['isGroupLoading']; + setGroupLoading: UseGroupLoadingReturnType['setGroupLoading']; + handleGroupSelection: UseGroupSelectionHandlerReturnType; + registerGrid: UseGridInstanceReturnType['registerGrid']; + initializeGroupRow: UseGroupRowHandlerReturnType['groupRowInit']; +} + +export interface UseSelectedRowsReturnType { + selectedRows: Set; + syncSelection: ( + keys: + | (string | number)[] + | ((prev: (string | number)[]) => (string | number)[]), + ) => void; +} + +export type UseGroupSelectionHandlerReturnType = (groupKey: any, childKeys: any[], action: 'select' | 'deselect', gridInstance: dxDataGrid) => Promise; + +export interface UseGridInstanceReturnType { + gridInstanceRef: React.MutableRefObject; + groupedColumnsRef: React.MutableRefObject[]>; + registerGrid: (grid: dxDataGrid) => void; +} + +export interface UseGroupLoadingReturnType { + isGroupLoading: (groupKey: any) => boolean; + setGroupLoading: (groupKey: any, isLoading: boolean) => void; + hasAnyLoading: boolean; +} + +export interface UseGroupRowHandlerReturnType { + groupRowInit: (e: IGroupRowReadyParameter) => Promise; +} diff --git a/React/src/main.tsx b/React/src/main.tsx index 3084387..5177e8f 100644 --- a/React/src/main.tsx +++ b/React/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { GroupRowSelectionProvider } from './GroupRowSelection/selection-context/row-selection-context.tsx'; createRoot(document.getElementById('root') as HTMLElement).render( - + + + , ); diff --git a/React/vitest.config.ts b/React/vitest.config.ts index 26aef92..a5fa012 100644 --- a/React/vitest.config.ts +++ b/React/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: 'jsdom', + environment: 'happy-dom', globals: true, setupFiles: './src/setupTests.ts', },