From bc89be5445da78ed7f960ebb516fa2f5f5771a87 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Fri, 7 Nov 2025 14:38:24 +0400 Subject: [PATCH 01/17] chore: replace jsdom with happy-dom --- React/package-lock.json | 169 ++++++++++++++++++++++++++++++++++++---- React/package.json | 2 +- React/vitest.config.ts | 6 +- 3 files changed, 159 insertions(+), 18 deletions(-) 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/vitest.config.ts b/React/vitest.config.ts index 26aef92..fa85bbe 100644 --- a/React/vitest.config.ts +++ b/React/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: 'jsdom', + environment: "happy-dom", globals: true, - setupFiles: './src/setupTests.ts', + setupFiles: "./src/setupTests.ts", }, }); From fba768e090d39c1da04ec385abddfe34a4da2599 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Mon, 29 Dec 2025 20:23:51 +0400 Subject: [PATCH 02/17] fix: resolve select-all checkboxes loop in React DataGrid --- React/src/App.tsx | 91 ++++++----- .../GroupRowSelection/GroupRowComponent.tsx | 79 ++++++--- .../GroupRowSelectionHelper.tsx | 152 ++++++++++-------- 3 files changed, 193 insertions(+), 129 deletions(-) diff --git a/React/src/App.tsx b/React/src/App.tsx index b972c2a..7ae4125 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,33 +1,40 @@ -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 { useEffect, useRef } from "react"; +import "./App.css"; +import "devextreme/dist/css/dx.material.blue.light.compact.css"; +import * as AspNetData from "devextreme-aspnet-data-nojquery"; import DataGrid, { - Column, type DataGridTypes, GroupPanel, Grouping, type DataGridRef, Lookup, Paging, Selection, -} from 'devextreme-react/data-grid'; -import GroupSelectionHelper from './GroupRowSelection/GroupRowSelectionHelper'; -import GroupRowComponent, { type IGroupRowReadyParameter } from './GroupRowSelection/GroupRowComponent'; -import { useEventCallback } from './hooks'; + Column, + type DataGridTypes, + GroupPanel, + Grouping, + type DataGridRef, + Lookup, + Paging, + Selection, +} from "devextreme-react/data-grid"; +import GroupSelectionHelper from "./GroupRowSelection/GroupRowSelectionHelper"; +import GroupRowComponent, { + type IGroupRowReadyParameter, +} from "./GroupRowSelection/GroupRowComponent"; +import { useEventCallback } from "./hooks"; -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', + key: "OrderID", loadUrl: `${url}/Orders`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; }, }); const customersData = AspNetData.createStore({ - key: 'Value', + key: "Value", loadUrl: `${url}/CustomersLookup`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; }, }); const shippersData = AspNetData.createStore({ - key: 'Value', + key: "Value", loadUrl: `${url}/ShippersLookup`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; @@ -36,23 +43,26 @@ const shippersData = AspNetData.createStore({ function App(): JSX.Element { const dataGrid = useRef(null); - const [helper, setHelper] = useState(); + const helperRef = useRef(); useEffect(() => { if (dataGrid?.current) { - setHelper(new GroupSelectionHelper(dataGrid.current.instance())); + helperRef.current = new GroupSelectionHelper(dataGrid.current.instance()); } - }, [dataGrid, setHelper]); + }, [dataGrid]); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - const groupRowInit = useEventCallback((arg: IGroupRowReadyParameter) => helper?.groupRowInit(arg)); + const groupRowInit = useEventCallback((arg: IGroupRowReadyParameter) => + helperRef.current?.groupRowInit(arg) + ); - const groupCellRender = useEventCallback((group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( - - )); + const groupCellRender = useEventCallback( + (group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( + + ) + ); return (
@@ -68,41 +78,40 @@ function App(): JSX.Element { deferred={true} mode="multiple" allowSelectAll={true} - showCheckBoxesMode='always'> + showCheckBoxesMode="always" + > - + + displayExpr="Text" + > + + - - + displayExpr="Text" + > diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index 98865f1..240e37b 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -1,12 +1,10 @@ -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 { LoadIndicator } from "devextreme-react"; +import "./GroupRowComponent.css"; +import { type DataGridTypes } from "devextreme-react/data-grid"; +import { useEffect, useMemo, useRef, useState } from "react"; +import CheckBox from "devextreme-react/check-box"; +import type { CheckBoxTypes } from "devextreme-react/check-box"; +import { useEventCallback } from "../hooks"; interface GroupRowProps { groupCellData: DataGridTypes.ColumnGroupCellTemplateData; @@ -25,35 +23,63 @@ const GroupRowComponent: React.FC = ({ const [isLoading, setIsLoading] = useState(true); const [checked, setChecked] = useState(false); const [childKeys, setChildKeys] = useState([]); + const skipCheckBoxValueHandling = useRef(false); // 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})`; + if (groupCellData.groupContinuedMessage) + text += ` (${groupCellData.groupContinuedMessage})`; + if (groupCellData.groupContinuesMessage) + text += ` (${groupCellData.groupContinuesMessage})`; return text; - }, [groupCellData.column.caption, groupCellData.displayValue, groupCellData.groupContinuedMessage, groupCellData.groupContinuesMessage]); + }, [ + 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 onValueChange = useEventCallback( + (value: CheckBoxTypes.ValueChangedEvent) => { + if (skipCheckBoxValueHandling.current) { + return; + } + + if (value.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); + setTimeout(() => { + skipCheckBoxValueHandling.current = false; + }, 0); }); - const groupRowKey = useMemo(() => JSON.stringify(groupCellData.row.key), [groupCellData.row.key]); + const groupRowKey = useMemo( + () => JSON.stringify(groupCellData.row.key), + [groupCellData.row.key] + ); useEffect(() => { + skipCheckBoxValueHandling.current = true; // eslint-disable-next-line @typescript-eslint/no-invalid-this - const action = onInitialized?.({ key: groupCellData.row.key, setCheckedState: setCheckedState.bind(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); @@ -61,12 +87,17 @@ const GroupRowComponent: React.FC = ({ }, [groupRowKey]); return ( -
+
+ visible={isLoading} + > { - 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.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); } + if (defaultOptionChangedHandler) { + defaultOptionChangedHandler(e); + } }); } @@ -42,30 +53,46 @@ export default class GroupSelectionHelper { if (!this.groupChildKeys[checkBoxId]) { const filter: any[] = []; arg.key.forEach((key, i) => { - filter.push([this.groupedColumns[i].dataField, '=', key]); + 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(() => { }); + 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(() => { }); + this.getSelectedKeys(this.grid) + .then((selectedKeys) => { + const checkedState: boolean | undefined = this.areKeysSelected( + this.groupChildKeys[checkBoxId], + selectedKeys + ); + arg.setCheckedState(checkedState); + resolve(this.groupChildKeys[checkBoxId]); + }) + .catch(() => {}); } }); @@ -73,24 +100,16 @@ export default class GroupSelectionHelper { } selectionChanged(e: DataGridTypes.SelectionChangedEvent): void { - const groupRows: DataGridTypes.Row[] = e.component.getVisibleRows().filter((r) => r.rowType === 'group'); + 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); - } + + e.component.repaintRows(groupRows.map((g) => g.rowIndex)); } getSelectedKeys(grid: dxDataGrid): Promise { - if (grid.option('selection.deferred')) { + if (grid.option("selection.deferred")) { if (!this.getSelectedKeysPromise) { this.getSelectedKeysPromise = grid.getSelectedRowKeys(); } @@ -99,21 +118,22 @@ export default class GroupSelectionHelper { 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; } - } - - 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; } @@ -124,12 +144,16 @@ export default class GroupSelectionHelper { calcCheckBoxId(grid: dxDataGrid, groupRowKey: string[]): string { const gridId: string = grid.element().id; - return `${gridId}groupCheckBox${groupRowKey.join('')}`; + 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) + 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; From f8620d5ddf97bed3d59d6d57f96a34b4fefdc97d Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Mon, 29 Dec 2025 20:23:57 +0400 Subject: [PATCH 03/17] fix: enhance GroupRowComponent tests for checkbox selection behavior --- React/src/App.test.tsx | 121 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 6 deletions(-) diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index 1f03afe..6d63257 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -1,8 +1,117 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; +// GroupRowComponent.test.tsx +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GroupRowComponent from "./GroupRowSelection/GroupRowComponent"; +import type { DataGridTypes } from "devextreme-react/data-grid"; +import { vi } from "vitest"; +import App from "./App"; -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 mockOnInitialized = vi.fn(); + + 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(); + }); + + test("calls onInitialized and hides loader", async () => { + render(); + + await waitFor(() => { + const allCheckboxes = screen.getAllByRole("checkbox"); + + allCheckboxes.forEach((checkbox) => { + expect(checkbox).toBeVisible(); + }); + }); + }); + + test("selects/deselects rows when checkbox clicked", async () => { + render(); + + const allCheckboxes = await screen.findAllByRole("checkbox"); + 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"); + }); + }); + }); + + 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( + async () => { + const checkboxes = await screen.findAllByRole("checkbox"); + if (checkboxes.length < 10) throw new Error("Not enough checkboxes"); + return checkboxes; + }, + { timeout: 15000 } + ); + await user.click(allCheckboxes[1]); + + const expandButton = container.querySelector(".dx-command-expand div"); + expect(expandButton).toBeInTheDocument(); + + await user.click(expandButton!); + + await waitFor( + async () => { + const afterSelectionCheckboxes = await waitFor(async () => { + const checkboxes = await screen.findAllByRole("checkbox"); + if (checkboxes.length < 10) throw new Error("Not enough checkboxes"); + return checkboxes; + }); + + [1, 2, 3, 4].forEach((index) => { + expect(afterSelectionCheckboxes[index]).toHaveAttribute( + "aria-checked", + "true" + ); + }); + + afterSelectionCheckboxes.forEach((cb, index) => { + if (index < 1) { + expect(cb).toHaveAttribute("aria-checked", "mixed"); + } else if (index > 4) { + expect(cb).toHaveAttribute("aria-checked", "false"); + } + }); + }, + { timeout: 15000 } + ); + }, 30000); }); From 202aa5d17cf1cb77e56d0302dfcbff129f167dc6 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Mon, 5 Jan 2026 21:06:20 +0400 Subject: [PATCH 04/17] feat: implement GroupRowSelection context and refactor related components for checkbox selection --- React/src/App.test.tsx | 50 ++-- React/src/App.tsx | 21 +- .../GroupRowSelection/GroupRowComponent.tsx | 204 +++++++++------- .../GroupRowSelectionHelper.tsx | 230 +++++++----------- .../context/GroupRowSelectionContext.tsx | 146 +++++++++++ React/src/main.tsx | 17 +- 6 files changed, 405 insertions(+), 263 deletions(-) create mode 100644 React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index 6d63257..869ba63 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -5,6 +5,7 @@ import GroupRowComponent from "./GroupRowSelection/GroupRowComponent"; import type { DataGridTypes } from "devextreme-react/data-grid"; import { vi } from "vitest"; import App from "./App"; +import { GroupRowSelectionProvider } from "./GroupRowSelection/context/GroupRowSelectionContext"; describe("GroupRowComponent", () => { const mockSelect = vi.fn(() => Promise.resolve()); @@ -27,30 +28,47 @@ describe("GroupRowComponent", () => { test("should render group text", () => { render( - + + + ); expect(screen.getByText("ShipCountry: USA")).toBeInTheDocument(); }); test("calls onInitialized and hides loader", async () => { - render(); + render( + + + + ); - await waitFor(() => { - const allCheckboxes = screen.getAllByRole("checkbox"); + await waitFor( + () => { + const allCheckboxes = screen.getAllByRole("checkbox"); - allCheckboxes.forEach((checkbox) => { - expect(checkbox).toBeVisible(); - }); - }); + allCheckboxes.forEach((checkbox) => { + expect(checkbox).toBeVisible(); + }); + }, + { timeout: 5000 } + ); }); test("selects/deselects rows when checkbox clicked", async () => { - render(); + render( + + + + ); - const allCheckboxes = await screen.findAllByRole("checkbox"); + const allCheckboxes = await screen.findAllByRole( + "checkbox", + {}, + { timeout: 5000 } + ); const checkbox = allCheckboxes[0]; await userEvent.click(checkbox); @@ -71,7 +89,11 @@ describe("GroupRowComponent", () => { test("selecting checkbox at index 1 selects 2–4 and leaves others unselected", async () => { const user = userEvent.setup(); - const { container } = render(); + const { container } = render( + + + + ); const allCheckboxes = await waitFor( async () => { diff --git a/React/src/App.tsx b/React/src/App.tsx index 7ae4125..60cf954 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from "react"; import "./App.css"; import "devextreme/dist/css/dx.material.blue.light.compact.css"; import * as AspNetData from "devextreme-aspnet-data-nojquery"; @@ -7,12 +6,11 @@ import DataGrid, { type DataGridTypes, GroupPanel, Grouping, - type DataGridRef, Lookup, Paging, Selection, } from "devextreme-react/data-grid"; -import GroupSelectionHelper from "./GroupRowSelection/GroupRowSelectionHelper"; +import { useGroupSelectionHelper } from "./GroupRowSelection/GroupRowSelectionHelper"; import GroupRowComponent, { type IGroupRowReadyParameter, } from "./GroupRowSelection/GroupRowComponent"; @@ -42,24 +40,17 @@ const shippersData = AspNetData.createStore({ }); function App(): JSX.Element { - const dataGrid = useRef(null); - const helperRef = useRef(); + const { gridRef, groupRowInit } = useGroupSelectionHelper(); - useEffect(() => { - if (dataGrid?.current) { - helperRef.current = new GroupSelectionHelper(dataGrid.current.instance()); - } - }, [dataGrid]); - - const groupRowInit = useEventCallback((arg: IGroupRowReadyParameter) => - helperRef.current?.groupRowInit(arg) + const groupRowInitHandler = useEventCallback((arg: IGroupRowReadyParameter) => + groupRowInit(arg) ); const groupCellRender = useEventCallback( (group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( ) ); @@ -67,7 +58,7 @@ function App(): JSX.Element { return (
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 skipCheckBoxValueHandling = useRef(false); + const [childKeys, setChildKeys] = useState([]); + const [isInitializing, setIsInitializing] = useState(true); + const actionInProgressRef = React.useRef(false); - // 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})`; - return text; - }, [ - groupCellData.column.caption, - groupCellData.displayValue, - groupCellData.groupContinuedMessage, - groupCellData.groupContinuesMessage, - ]); - - const onValueChange = useEventCallback( - (value: CheckBoxTypes.ValueChangedEvent) => { - if (skipCheckBoxValueHandling.current) { - return; - } - - if (value.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 { + selectedRows, + isGroupLoading, + hasAnyLoading, + handleGroupSelection, + setGroupLoading, + } = useGroupRowSelection(); + + const { component: gridInstance, row } = groupCellData; + + const isLoading = isGroupLoading(row.key); + + 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 setCheckedState = useEventCallback((value: boolean | undefined) => { - setChecked(value); - setIsLoading(false); - setTimeout(() => { - skipCheckBoxValueHandling.current = false; - }, 0); - }); - - const groupRowKey = useMemo( - () => JSON.stringify(groupCellData.row.key), - [groupCellData.row.key] + const onValueChanged = useCallback( + (e: CheckBoxTypes.ValueChangedEvent) => { + console.log(1); + if (!e.event) return; + if (actionInProgressRef.current) return; + actionInProgressRef.current = true; + + const action = e.value ? "select" : "deselect"; + handleGroupSelection(row.key, childKeys, action, gridInstance).finally( + () => { + actionInProgressRef.current = false; + } + ); + }, + [ + childKeys, + gridInstance, + handleGroupSelection, + hasAnyLoading, + isLoading, + row.key, + ] ); useEffect(() => { - skipCheckBoxValueHandling.current = true; - // 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]); + let isMounted = true; + + setIsInitializing(true); + + const promise = onInitialized({ key: row.key }); + + promise + ?.then((keys: any[]) => { + if (isMounted) { + setChildKeys(keys); + } + }) + .finally(() => { + if (isMounted) { + setIsInitializing(false); + } + }); + + return () => { + isMounted = false; + setGroupLoading(row.key, false); + }; + }, [row.key, onInitialized, setGroupLoading]); + + const stopPropagation = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const groupText = useMemo((): string => { + const { + column, + displayValue, + groupContinuedMessage, + groupContinuesMessage, + } = groupCellData; + let text = `${column.caption}: ${displayValue}`; + if (groupContinuedMessage) text += ` (${groupContinuedMessage})`; + if (groupContinuesMessage) text += ` (${groupContinuesMessage})`; + return text; + }, [groupCellData]); + + const isLocked = isInitializing || (!isLoading && hasAnyLoading); return (
-
- - +
+ {isLocked ? ( + + ) : ( + + )}
- {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 index d4b2328..284de97 100644 --- a/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx +++ b/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx @@ -1,162 +1,116 @@ -import dxDataGrid from "devextreme/ui/data_grid"; -import { type DataGridTypes } from "devextreme-react/data-grid"; -import { type LoadOptions } from "devextreme/common/data"; +import { useEffect, useRef, useCallback } from "react"; +import { useGroupRowSelection } from "./context/GroupRowSelectionContext"; import { isItemsArray } from "devextreme-react/common/data"; +import type { DataGridRef, DataGridTypes } from "devextreme-react/data-grid"; +import { type LoadOptions } from "devextreme/common/data"; +import type dxDataGrid from "devextreme/ui/data_grid"; import { type IGroupRowReadyParameter } from "./GroupRowComponent"; -export default class GroupSelectionHelper { - groupedColumns: DataGridTypes.Column[]; - - grid: dxDataGrid; - - getSelectedKeysPromise: Promise | null; +export function useGroupSelectionHelper() { + const gridRef = useRef(null); + const groupedColumnsRef = useRef([]); + const getSelectedKeysPromiseRef = useRef | null>(null); + const groupChildKeysRef = useRef>({}); + + const { syncSelection } = useGroupRowSelection(); + + const collectGroupedColumns = useCallback((grid: dxDataGrid) => { + return grid + .getVisibleColumns() + .filter((c) => c.groupIndex != null && c.groupIndex >= 0) + .sort((a, b) => (a.groupIndex! > b.groupIndex! ? 1 : -1)); + }, []); + + const calcCheckBoxId = useCallback( + (grid: dxDataGrid, groupRowKey: string[]) => { + return `${grid.element().id}groupCheckBox${groupRowKey.join("")}`; + }, + [] + ); + + const getSelectedKeys = useCallback((grid: dxDataGrid) => { + if (grid.option("selection.deferred")) { + if (!getSelectedKeysPromiseRef.current) { + getSelectedKeysPromiseRef.current = grid.getSelectedRowKeys(); + } + return getSelectedKeysPromiseRef.current; + } + return grid.getSelectedRowKeys(); + }, []); - selectedKeys: any[] = []; + const groupRowInit = useCallback( + (arg: IGroupRowReadyParameter): Promise => { + const grid = gridRef.current?.instance(); + if (!grid) return Promise.resolve([]); - groupChildKeys: Record = {}; + const checkBoxId = calcCheckBoxId(grid, arg.key); - 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); + return new Promise((resolve) => { + if (groupChildKeysRef.current[checkBoxId]) { + resolve(groupChildKeysRef.current[checkBoxId]); + return; } - } - ); - 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]); + filter.push([groupedColumnsRef.current[i].dataField, "=", key]); }); - const loadOptions: LoadOptions = { - filter, - }; - const store = this.grid.getDataSource().store(); + + const loadOptions: LoadOptions = { filter }; + const store = 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]); + const keys = data.map((d) => grid.keyOf(d)); + groupChildKeysRef.current[checkBoxId] = keys; + resolve(keys); + } else { + resolve([]); } }) - .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(() => {}); - } - }); + .catch(() => resolve([])); + }); + }, + [calcCheckBoxId] + ); - return promise; - } + useEffect(() => { + const grid = gridRef.current?.instance(); + if (!grid) return; - selectionChanged(e: DataGridTypes.SelectionChangedEvent): void { - const groupRows: DataGridTypes.Row[] = e.component - .getVisibleRows() - .filter((r) => r.rowType === "group"); - this.getSelectedKeysPromise = null; + groupedColumnsRef.current = collectGroupedColumns(grid); - e.component.repaintRows(groupRows.map((g) => g.rowIndex)); - } + getSelectedKeys(grid) + .then((keys) => syncSelection(keys)) + .catch(() => {}); - getSelectedKeys(grid: dxDataGrid): Promise { - if (grid.option("selection.deferred")) { - if (!this.getSelectedKeysPromise) { - this.getSelectedKeysPromise = grid.getSelectedRowKeys(); - } - return this.getSelectedKeysPromise; - } - return grid.getSelectedRowKeys(); - } - - 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; + const defaultSelectionChanged = grid.option("onSelectionChanged"); + const defaultOptionChanged = grid.option("onOptionChanged"); + + grid.option("onSelectionChanged", (e) => { + getSelectedKeysPromiseRef.current = null; + + getSelectedKeys(e.component).then((keys) => { + syncSelection(keys); }); - } + + defaultSelectionChanged?.(e); + }); + + grid.option("onOptionChanged", (e) => { + if (e.fullName.includes("groupIndex")) { + groupedColumnsRef.current = collectGroupedColumns(grid); + } + defaultOptionChanged?.(e); + }); + }, [collectGroupedColumns, getSelectedKeys, syncSelection]); + + return { + gridRef, + groupRowInit, + getChildRowKeys: (grid: dxDataGrid, key: string[]) => + groupChildKeysRef.current[calcCheckBoxId(grid, key)], + }; } diff --git a/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx b/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx new file mode 100644 index 0000000..84017aa --- /dev/null +++ b/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx @@ -0,0 +1,146 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + type ReactNode, +} from "react"; +import type dxDataGrid from "devextreme/ui/data_grid"; + +interface GroupRowSelectionContextType { + selectedRows: Set; + syncSelection: (rowIds: (string | number)[]) => void; + isGroupLoading: (groupKey: any) => boolean; + hasAnyLoading: boolean; + setGroupLoading: (groupKey: any, isLoading: boolean) => void; + handleGroupSelection: ( + groupKey: any, + childKeys: any[], + action: "select" | "deselect", + gridInstance: dxDataGrid + ) => Promise; +} + +const GroupRowSelectionContext = createContext< + GroupRowSelectionContextType | undefined +>(undefined); + +const serializeKey = (key: any) => + typeof key === "string" || typeof key === "number" + ? String(key) + : JSON.stringify(key, Object.keys(key).sort()); + +export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [selectedRows, setSelectedRows] = useState>( + new Set() + ); + const [loadingGroupKeys, setLoadingGroupKeys] = useState>( + () => new Map() + ); + + const syncSelection = useCallback( + ( + arg: + | (string | number)[] + | ((prevSelectedRows: (string | number)[]) => (string | number)[]) + ) => { + if (typeof arg === "function") { + setSelectedRows((prev) => { + const newRowIds = arg(Array.from(prev)); + return new Set(newRowIds); + }); + return; + } + setSelectedRows(new Set(arg)); + }, + [] + ); + + const isGroupLoading = useCallback( + (groupKey: any) => loadingGroupKeys.has(serializeKey(groupKey)), + [loadingGroupKeys] + ); + + 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 handleGroupSelection = useCallback( + async ( + groupKey: any, + childKeys: any[], + action: "select" | "deselect", + gridInstance: dxDataGrid + ) => { + if (!gridInstance) return; + + setGroupLoading(groupKey, true); + + try { + if (action === "select") { + syncSelection((prevSelectedRows) => { + const next = new Set(prevSelectedRows); + childKeys.forEach((key) => next.add(key)); + return Array.from(next); + }); + + await gridInstance.selectRows(childKeys, true); + } else { + syncSelection((prevSelectedRows) => { + const next = new Set(prevSelectedRows); + childKeys.forEach((key) => next.delete(key)); + return Array.from(next); + }); + await gridInstance.deselectRows(childKeys); + } + } catch (error) { + console.error("Group selection failed", error); + } finally { + setGroupLoading(groupKey, false); + } + }, + [setGroupLoading] + ); + + return ( + 0, + setGroupLoading, + handleGroupSelection, + }} + > + {children} + + ); +}; + +export const useGroupRowSelection = () => { + const context = useContext(GroupRowSelectionContext); + if (!context) { + throw new Error( + "useGroupRowSelection must be used within GroupRowSelectionProvider" + ); + } + return context; +}; diff --git a/React/src/main.tsx b/React/src/main.tsx index 3084387..2717349 100644 --- a/React/src/main.tsx +++ b/React/src/main.tsx @@ -1,10 +1,13 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { GroupRowSelectionProvider } from "./GroupRowSelection/context/GroupRowSelectionContext.tsx"; -createRoot(document.getElementById('root') as HTMLElement).render( +createRoot(document.getElementById("root") as HTMLElement).render( - - , + + + + ); From 704faa2a92078349643bf8264d5e293edb2169f9 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Mon, 5 Jan 2026 21:18:46 +0400 Subject: [PATCH 05/17] feat: refactor GroupRowSelection context and hooks for improved grid handling and selection management --- React/src/App.tsx | 10 +- .../GroupRowSelection/GroupRowComponent.tsx | 1 - .../GroupRowSelectionHelper.tsx | 61 +------ .../context/GroupRowSelectionContext.tsx | 131 ++------------ .../src/GroupRowSelection/context/helpers.ts | 4 + React/src/GroupRowSelection/context/hooks.ts | 168 ++++++++++++++++++ React/src/GroupRowSelection/context/types.ts | 18 ++ 7 files changed, 222 insertions(+), 171 deletions(-) create mode 100644 React/src/GroupRowSelection/context/helpers.ts create mode 100644 React/src/GroupRowSelection/context/hooks.ts create mode 100644 React/src/GroupRowSelection/context/types.ts diff --git a/React/src/App.tsx b/React/src/App.tsx index 60cf954..e96e162 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -15,6 +15,7 @@ import GroupRowComponent, { type IGroupRowReadyParameter, } from "./GroupRowSelection/GroupRowComponent"; import { useEventCallback } from "./hooks"; +import { useGroupRowSelection } from "./GroupRowSelection/context/GroupRowSelectionContext"; const url = "https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi"; const dataSource = AspNetData.createStore({ @@ -40,7 +41,8 @@ const shippersData = AspNetData.createStore({ }); function App(): JSX.Element { - const { gridRef, groupRowInit } = useGroupSelectionHelper(); + const { groupRowInit } = useGroupSelectionHelper(); + const { registerGrid } = useGroupRowSelection(); const groupRowInitHandler = useEventCallback((arg: IGroupRowReadyParameter) => groupRowInit(arg) @@ -58,12 +60,16 @@ function App(): JSX.Element { return (
{ + if (!e.component) return; + + registerGrid(e.component); + }} > = ({ const onValueChanged = useCallback( (e: CheckBoxTypes.ValueChangedEvent) => { - console.log(1); if (!e.event) return; if (actionInProgressRef.current) return; actionInProgressRef.current = true; diff --git a/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx b/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx index 284de97..778acd3 100644 --- a/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx +++ b/React/src/GroupRowSelection/GroupRowSelectionHelper.tsx @@ -1,25 +1,15 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useRef, useCallback } from "react"; import { useGroupRowSelection } from "./context/GroupRowSelectionContext"; import { isItemsArray } from "devextreme-react/common/data"; -import type { DataGridRef, DataGridTypes } from "devextreme-react/data-grid"; import { type LoadOptions } from "devextreme/common/data"; import type dxDataGrid from "devextreme/ui/data_grid"; import { type IGroupRowReadyParameter } from "./GroupRowComponent"; export function useGroupSelectionHelper() { - const gridRef = useRef(null); - const groupedColumnsRef = useRef([]); - const getSelectedKeysPromiseRef = useRef | null>(null); const groupChildKeysRef = useRef>({}); - const { syncSelection } = useGroupRowSelection(); - - const collectGroupedColumns = useCallback((grid: dxDataGrid) => { - return grid - .getVisibleColumns() - .filter((c) => c.groupIndex != null && c.groupIndex >= 0) - .sort((a, b) => (a.groupIndex! > b.groupIndex! ? 1 : -1)); - }, []); + const { handleGroupSelection, gridInstanceRef, groupedColumnsRef } = + useGroupRowSelection(); const calcCheckBoxId = useCallback( (grid: dxDataGrid, groupRowKey: string[]) => { @@ -28,19 +18,9 @@ export function useGroupSelectionHelper() { [] ); - const getSelectedKeys = useCallback((grid: dxDataGrid) => { - if (grid.option("selection.deferred")) { - if (!getSelectedKeysPromiseRef.current) { - getSelectedKeysPromiseRef.current = grid.getSelectedRowKeys(); - } - return getSelectedKeysPromiseRef.current; - } - return grid.getSelectedRowKeys(); - }, []); - const groupRowInit = useCallback( (arg: IGroupRowReadyParameter): Promise => { - const grid = gridRef.current?.instance(); + const grid = gridInstanceRef.current?.instance(); if (!grid) return Promise.resolve([]); const checkBoxId = calcCheckBoxId(grid, arg.key); @@ -76,41 +56,10 @@ export function useGroupSelectionHelper() { [calcCheckBoxId] ); - useEffect(() => { - const grid = gridRef.current?.instance(); - if (!grid) return; - - groupedColumnsRef.current = collectGroupedColumns(grid); - - getSelectedKeys(grid) - .then((keys) => syncSelection(keys)) - .catch(() => {}); - - const defaultSelectionChanged = grid.option("onSelectionChanged"); - const defaultOptionChanged = grid.option("onOptionChanged"); - - grid.option("onSelectionChanged", (e) => { - getSelectedKeysPromiseRef.current = null; - - getSelectedKeys(e.component).then((keys) => { - syncSelection(keys); - }); - - defaultSelectionChanged?.(e); - }); - - grid.option("onOptionChanged", (e) => { - if (e.fullName.includes("groupIndex")) { - groupedColumnsRef.current = collectGroupedColumns(grid); - } - defaultOptionChanged?.(e); - }); - }, [collectGroupedColumns, getSelectedKeys, syncSelection]); - return { - gridRef, groupRowInit, getChildRowKeys: (grid: dxDataGrid, key: string[]) => groupChildKeysRef.current[calcCheckBoxId(grid, key)], + handleGroupSelection, }; } diff --git a/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx b/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx index 84017aa..0751d4c 100644 --- a/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx +++ b/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx @@ -1,133 +1,40 @@ -import React, { - createContext, - useContext, - useState, - useCallback, - type ReactNode, -} from "react"; -import type dxDataGrid from "devextreme/ui/data_grid"; - -interface GroupRowSelectionContextType { - selectedRows: Set; - syncSelection: (rowIds: (string | number)[]) => void; - isGroupLoading: (groupKey: any) => boolean; - hasAnyLoading: boolean; - setGroupLoading: (groupKey: any, isLoading: boolean) => void; - handleGroupSelection: ( - groupKey: any, - childKeys: any[], - action: "select" | "deselect", - gridInstance: dxDataGrid - ) => Promise; -} +import React, { createContext, useContext, type ReactNode } from "react"; +import { + useGridInstance, + useGroupLoading, + useGroupSelectionHandler, + useSelectedRows, +} from "./hooks"; +import type { GroupRowSelectionContextType } from "./types"; const GroupRowSelectionContext = createContext< GroupRowSelectionContextType | undefined >(undefined); -const serializeKey = (key: any) => - typeof key === "string" || typeof key === "number" - ? String(key) - : JSON.stringify(key, Object.keys(key).sort()); - export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const [selectedRows, setSelectedRows] = useState>( - new Set() - ); - const [loadingGroupKeys, setLoadingGroupKeys] = useState>( - () => new Map() - ); - - const syncSelection = useCallback( - ( - arg: - | (string | number)[] - | ((prevSelectedRows: (string | number)[]) => (string | number)[]) - ) => { - if (typeof arg === "function") { - setSelectedRows((prev) => { - const newRowIds = arg(Array.from(prev)); - return new Set(newRowIds); - }); - return; - } - setSelectedRows(new Set(arg)); - }, - [] - ); - - const isGroupLoading = useCallback( - (groupKey: any) => loadingGroupKeys.has(serializeKey(groupKey)), - [loadingGroupKeys] - ); - - 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 handleGroupSelection = useCallback( - async ( - groupKey: any, - childKeys: any[], - action: "select" | "deselect", - gridInstance: dxDataGrid - ) => { - if (!gridInstance) return; - - setGroupLoading(groupKey, true); - - try { - if (action === "select") { - syncSelection((prevSelectedRows) => { - const next = new Set(prevSelectedRows); - childKeys.forEach((key) => next.add(key)); - return Array.from(next); - }); - - await gridInstance.selectRows(childKeys, true); - } else { - syncSelection((prevSelectedRows) => { - const next = new Set(prevSelectedRows); - childKeys.forEach((key) => next.delete(key)); - return Array.from(next); - }); - await gridInstance.deselectRows(childKeys); - } - } catch (error) { - console.error("Group selection failed", error); - } finally { - setGroupLoading(groupKey, false); - } - }, - [setGroupLoading] + const { selectedRows, syncSelection } = useSelectedRows(); + const { setGroupLoading, isGroupLoading, hasAnyLoading } = useGroupLoading(); + const { gridInstanceRef, groupedColumnsRef, registerGrid } = + useGridInstance(syncSelection); + const handleGroupSelection = useGroupSelectionHandler( + syncSelection, + setGroupLoading ); return ( 0, setGroupLoading, handleGroupSelection, + registerGrid, }} > {children} diff --git a/React/src/GroupRowSelection/context/helpers.ts b/React/src/GroupRowSelection/context/helpers.ts new file mode 100644 index 0000000..aa136b0 --- /dev/null +++ b/React/src/GroupRowSelection/context/helpers.ts @@ -0,0 +1,4 @@ +export const serializeKey = (key: any) => + typeof key === "string" || typeof key === "number" + ? String(key) + : JSON.stringify(key, Object.keys(key).sort()); diff --git a/React/src/GroupRowSelection/context/hooks.ts b/React/src/GroupRowSelection/context/hooks.ts new file mode 100644 index 0000000..b7b53a1 --- /dev/null +++ b/React/src/GroupRowSelection/context/hooks.ts @@ -0,0 +1,168 @@ +import { useState, useCallback, useMemo, useRef } from "react"; +import { serializeKey } from "./helpers"; +import type dxDataGrid from "devextreme/ui/data_grid"; + +export const useSelectedRows = () => { + const [selectedRows, setSelectedRows] = useState>( + new Set() + ); + + const syncSelection = useCallback( + ( + arg: + | (string | number)[] + | ((prevSelectedRows: (string | number)[]) => (string | number)[]) + ) => { + 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 const useGroupLoading = () => { + 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) => loadingGroupKeys.has(serializeKey(groupKey)), + [loadingGroupKeys] + ); + + const hasAnyLoading = useMemo( + () => loadingGroupKeys.size > 0, + [loadingGroupKeys] + ); + + return { setGroupLoading, isGroupLoading, hasAnyLoading }; +}; + +export const useGridInstance = ( + syncSelection: ( + keys: + | (string | number)[] + | ((prev: (string | number)[]) => (string | number)[]) + ) => void +) => { + const gridInstanceRef = useRef | null>(null); + const groupedColumnsRef = useRef[]>([]); + const getSelectedKeysPromiseRef = useRef | null>(null); + + const collectGroupedColumns = useCallback((grid: dxDataGrid) => { + return grid + .getVisibleColumns() + .filter((c) => c.groupIndex != null && c.groupIndex >= 0) + .sort((a, b) => (a.groupIndex! > b.groupIndex! ? 1 : -1)); + }, []); + + const getSelectedKeys = useCallback((grid: dxDataGrid) => { + if (grid.option("selection.deferred")) { + if (!getSelectedKeysPromiseRef.current) { + getSelectedKeysPromiseRef.current = grid.getSelectedRowKeys(); + } + return getSelectedKeysPromiseRef.current; + } + return grid.getSelectedRowKeys(); + }, []); + + const registerGrid = useCallback( + (grid: dxDataGrid) => { + gridInstanceRef.current = grid; + groupedColumnsRef.current = collectGroupedColumns(grid); + + getSelectedKeys(grid) + .then((keys: (string | number)[]) => syncSelection(keys)) + .catch(() => {}); + + const defaultSelectionChanged = grid.option("onSelectionChanged"); + const defaultOptionChanged = grid.option("onOptionChanged"); + + grid.option("onSelectionChanged", (e) => { + getSelectedKeysPromiseRef.current = null; + getSelectedKeys(e.component).then((keys: (string | number)[]) => + syncSelection(keys) + ); + defaultSelectionChanged?.(e); + }); + + grid.option("onOptionChanged", (e) => { + if (e.fullName.includes("groupIndex")) { + groupedColumnsRef.current = collectGroupedColumns(grid); + } + defaultOptionChanged?.(e); + }); + }, + [collectGroupedColumns, getSelectedKeys, syncSelection] + ); + + return { gridInstanceRef, groupedColumnsRef, registerGrid }; +}; + +export const useGroupSelectionHandler = ( + syncSelection: ( + arg: + | (string | number)[] + | ((prev: (string | number)[]) => (string | number)[]) + ) => void, + setGroupLoading: (groupKey: any, isLoading: boolean) => void +) => { + return useCallback( + async ( + groupKey: any, + childKeys: any[], + action: "select" | "deselect", + gridInstance: dxDataGrid + ) => { + if (!gridInstance) return; + setGroupLoading(groupKey, true); + + try { + if (action === "select") { + syncSelection((prevSelectedRows) => { + const next = new Set(prevSelectedRows); + childKeys.forEach((key) => next.add(key)); + return Array.from(next); + }); + await gridInstance.selectRows(childKeys, true); + } else { + syncSelection((prevSelectedRows) => { + const next = new Set(prevSelectedRows); + childKeys.forEach((key) => next.delete(key)); + return Array.from(next); + }); + await gridInstance.deselectRows(childKeys); + } + } catch (error) { + console.error("Group selection failed", error); + } finally { + setGroupLoading(groupKey, false); + } + }, + [syncSelection, setGroupLoading] + ); +}; diff --git a/React/src/GroupRowSelection/context/types.ts b/React/src/GroupRowSelection/context/types.ts new file mode 100644 index 0000000..cd4bb51 --- /dev/null +++ b/React/src/GroupRowSelection/context/types.ts @@ -0,0 +1,18 @@ +import type dxDataGrid from "devextreme/ui/data_grid"; + +export interface GroupRowSelectionContextType { + selectedRows: Set; + gridInstanceRef: React.MutableRefObject | null>; + groupedColumnsRef: React.MutableRefObject[]>; + hasAnyLoading: boolean; + syncSelection: (rowIds: (string | number)[]) => void; + isGroupLoading: (groupKey: any) => boolean; + setGroupLoading: (groupKey: any, isLoading: boolean) => void; + handleGroupSelection: ( + groupKey: any, + childKeys: any[], + action: "select" | "deselect", + gridInstance: dxDataGrid + ) => Promise; + registerGrid: (grid: dxDataGrid) => void; +} From cf67e95cd8b6fbbb38a266d55000ad3c9d18d218 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Thu, 8 Jan 2026 18:54:55 +0400 Subject: [PATCH 06/17] refactor: reorganize selection context and related components for improved group row selection handling --- React/src/App.tsx | 37 +++++++++++-------- .../GroupRowSelection/GroupRowComponent.tsx | 2 +- ...nHelper.tsx => GroupRowSelectionHelper.ts} | 6 +-- .../{context => selection-context}/helpers.ts | 0 .../{context => selection-context}/hooks.ts | 0 .../row-selection-context.tsx} | 0 .../{context => selection-context}/types.ts | 0 React/src/main.tsx | 2 +- 8 files changed, 26 insertions(+), 21 deletions(-) rename React/src/GroupRowSelection/{GroupRowSelectionHelper.tsx => GroupRowSelectionHelper.ts} (89%) rename React/src/GroupRowSelection/{context => selection-context}/helpers.ts (100%) rename React/src/GroupRowSelection/{context => selection-context}/hooks.ts (100%) rename React/src/GroupRowSelection/{context/GroupRowSelectionContext.tsx => selection-context/row-selection-context.tsx} (100%) rename React/src/GroupRowSelection/{context => selection-context}/types.ts (100%) diff --git a/React/src/App.tsx b/React/src/App.tsx index e96e162..c4ed273 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,6 +1,3 @@ -import "./App.css"; -import "devextreme/dist/css/dx.material.blue.light.compact.css"; -import * as AspNetData from "devextreme-aspnet-data-nojquery"; import DataGrid, { Column, type DataGridTypes, @@ -10,12 +7,16 @@ import DataGrid, { Paging, Selection, } from "devextreme-react/data-grid"; +import * as AspNetData from "devextreme-aspnet-data-nojquery"; +import { useEventCallback } from "./hooks"; import { useGroupSelectionHelper } from "./GroupRowSelection/GroupRowSelectionHelper"; +import { useGroupRowSelection } from "./GroupRowSelection/selection-context/row-selection-context"; import GroupRowComponent, { type IGroupRowReadyParameter, } from "./GroupRowSelection/GroupRowComponent"; -import { useEventCallback } from "./hooks"; -import { useGroupRowSelection } from "./GroupRowSelection/context/GroupRowSelectionContext"; + +import "./App.css"; +import "devextreme/dist/css/dx.material.blue.light.compact.css"; const url = "https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi"; const dataSource = AspNetData.createStore({ @@ -57,28 +58,32 @@ function App(): JSX.Element { ) ); + const handleInitialized = useEventCallback( + (e: DataGridTypes.InitializedEvent) => { + if (!e.component) return; + + registerGrid(e.component); + } + ); + return (
{ - if (!e.component) return; - - registerGrid(e.component); - }} + remoteOperations + dataSource={dataSource} + onInitialized={handleInitialized} > - + >({}); diff --git a/React/src/GroupRowSelection/context/helpers.ts b/React/src/GroupRowSelection/selection-context/helpers.ts similarity index 100% rename from React/src/GroupRowSelection/context/helpers.ts rename to React/src/GroupRowSelection/selection-context/helpers.ts diff --git a/React/src/GroupRowSelection/context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts similarity index 100% rename from React/src/GroupRowSelection/context/hooks.ts rename to React/src/GroupRowSelection/selection-context/hooks.ts diff --git a/React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx similarity index 100% rename from React/src/GroupRowSelection/context/GroupRowSelectionContext.tsx rename to React/src/GroupRowSelection/selection-context/row-selection-context.tsx diff --git a/React/src/GroupRowSelection/context/types.ts b/React/src/GroupRowSelection/selection-context/types.ts similarity index 100% rename from React/src/GroupRowSelection/context/types.ts rename to React/src/GroupRowSelection/selection-context/types.ts diff --git a/React/src/main.tsx b/React/src/main.tsx index 2717349..afe7fb3 100644 --- a/React/src/main.tsx +++ b/React/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; -import { GroupRowSelectionProvider } from "./GroupRowSelection/context/GroupRowSelectionContext.tsx"; +import { GroupRowSelectionProvider } from "./GroupRowSelection/selection-context/row-selection-context.tsx"; createRoot(document.getElementById("root") as HTMLElement).render( From 8037ff36fe6026230247d68d1f61965aa53caa7e Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Thu, 8 Jan 2026 18:55:27 +0400 Subject: [PATCH 07/17] refactor: optimize handleInitialized function using useCallback for better performance --- React/src/App.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/React/src/App.tsx b/React/src/App.tsx index c4ed273..db78af8 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import DataGrid, { Column, type DataGridTypes, @@ -49,6 +50,15 @@ function App(): JSX.Element { groupRowInit(arg) ); + const handleInitialized = useCallback( + (e: DataGridTypes.InitializedEvent) => { + if (!e.component) return; + + registerGrid(e.component); + }, + [registerGrid] + ); + const groupCellRender = useEventCallback( (group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( { - if (!e.component) return; - - registerGrid(e.component); - } - ); - return (
Date: Thu, 8 Jan 2026 20:13:22 +0400 Subject: [PATCH 08/17] refactor: enhance GroupRowComponent and selection context for improved loading handling and selection synchronization --- .../GroupRowSelection/GroupRowComponent.tsx | 36 ++++---- .../selection-context/hooks.ts | 91 +++++++++++++------ .../row-selection-context.tsx | 8 +- 3 files changed, 88 insertions(+), 47 deletions(-) diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index 27fb5be..f34f61d 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useState, useCallback } from "react"; +import React, { + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from "react"; import { useGroupRowSelection } from "./selection-context/row-selection-context"; import CheckBox, { type CheckBoxTypes } from "devextreme-react/check-box"; import { LoadIndicator } from "devextreme-react"; @@ -22,19 +28,19 @@ const GroupRowComponent: React.FC = ({ }) => { const [childKeys, setChildKeys] = useState([]); const [isInitializing, setIsInitializing] = useState(true); - const actionInProgressRef = React.useRef(false); + const actionInProgressRef = useRef(false); const { selectedRows, - isGroupLoading, - hasAnyLoading, handleGroupSelection, + isGroupLoading, setGroupLoading, } = useGroupRowSelection(); const { component: gridInstance, row } = groupCellData; const isLoading = isGroupLoading(row.key); + const [blocked, setBlocked] = useState(false); const checkedValue = useMemo(() => { if (!childKeys.length) return false; @@ -53,21 +59,19 @@ const GroupRowComponent: React.FC = ({ 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); + }, 100); } ); }, - [ - childKeys, - gridInstance, - handleGroupSelection, - hasAnyLoading, - isLoading, - row.key, - ] + [childKeys, gridInstance, handleGroupSelection, isLoading, row.key] ); useEffect(() => { @@ -112,7 +116,7 @@ const GroupRowComponent: React.FC = ({ return text; }, [groupCellData]); - const isLocked = isInitializing || (!isLoading && hasAnyLoading); + const showLoading = isInitializing || isLoading; return (
= ({ onClick={stopPropagation} style={{ marginRight: "10px", width: iconSize, height: iconSize }} > - {isLocked ? ( + {showLoading ? ( ) : ( )}
- {groupText} + {groupText}
); }; diff --git a/React/src/GroupRowSelection/selection-context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts index b7b53a1..eed4e97 100644 --- a/React/src/GroupRowSelection/selection-context/hooks.ts +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -50,7 +50,23 @@ export const useGroupLoading = () => { }, []); const isGroupLoading = useCallback( - (groupKey: any) => loadingGroupKeys.has(serializeKey(groupKey)), + (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] ); @@ -67,11 +83,14 @@ export const useGridInstance = ( keys: | (string | number)[] | ((prev: (string | number)[]) => (string | number)[]) - ) => void + ) => void, + hasAnyLoading?: boolean ) => { const gridInstanceRef = useRef | null>(null); const groupedColumnsRef = useRef[]>([]); - const getSelectedKeysPromiseRef = useRef | null>(null); + + const latestRequestIdRef = useRef(0); + const isFetchingRef = useRef(false); const collectGroupedColumns = useCallback((grid: dxDataGrid) => { return grid @@ -81,15 +100,30 @@ export const useGridInstance = ( }, []); const getSelectedKeys = useCallback((grid: dxDataGrid) => { - if (grid.option("selection.deferred")) { - if (!getSelectedKeysPromiseRef.current) { - getSelectedKeysPromiseRef.current = grid.getSelectedRowKeys(); - } - return getSelectedKeysPromiseRef.current; - } return grid.getSelectedRowKeys(); }, []); + const triggerFullSync = useCallback( + (grid: dxDataGrid) => { + isFetchingRef.current = true; + const currentId = ++latestRequestIdRef.current; + + getSelectedKeys(grid) + .then((keys) => { + if (latestRequestIdRef.current === currentId) { + syncSelection(keys); + isFetchingRef.current = false; + } + }) + .catch(() => { + if (latestRequestIdRef.current === currentId) { + isFetchingRef.current = false; + } + }); + }, + [getSelectedKeys, syncSelection] + ); + const registerGrid = useCallback( (grid: dxDataGrid) => { gridInstanceRef.current = grid; @@ -99,18 +133,26 @@ export const useGridInstance = ( .then((keys: (string | number)[]) => syncSelection(keys)) .catch(() => {}); - const defaultSelectionChanged = grid.option("onSelectionChanged"); const defaultOptionChanged = grid.option("onOptionChanged"); - grid.option("onSelectionChanged", (e) => { - getSelectedKeysPromiseRef.current = null; - getSelectedKeys(e.component).then((keys: (string | number)[]) => - syncSelection(keys) - ); - defaultSelectionChanged?.(e); - }); - grid.option("onOptionChanged", (e) => { + if (e.fullName === "selectionFilter") { + const selectAllAction = e.value === null; + const deselectAllAction = + e.previousValue === null && + Array.isArray(e.value) && + e.value.length === 0; + + if (selectAllAction || deselectAllAction) { + triggerFullSync(grid); + } else { + if (!hasAnyLoading) { + grid.getSelectedRowKeys().then((selectedKeys) => { + syncSelection(selectedKeys); + }); + } + } + } if (e.fullName.includes("groupIndex")) { groupedColumnsRef.current = collectGroupedColumns(grid); } @@ -143,20 +185,13 @@ export const useGroupSelectionHandler = ( try { if (action === "select") { - syncSelection((prevSelectedRows) => { - const next = new Set(prevSelectedRows); - childKeys.forEach((key) => next.add(key)); - return Array.from(next); - }); await gridInstance.selectRows(childKeys, true); } else { - syncSelection((prevSelectedRows) => { - const next = new Set(prevSelectedRows); - childKeys.forEach((key) => next.delete(key)); - return Array.from(next); - }); await gridInstance.deselectRows(childKeys); } + + const selectedKeys = await gridInstance.getSelectedRowKeys(); + syncSelection(selectedKeys); } catch (error) { console.error("Group selection failed", error); } finally { diff --git a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx index 0751d4c..08f9e6e 100644 --- a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx +++ b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx @@ -14,10 +14,12 @@ const GroupRowSelectionContext = createContext< export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const { selectedRows, syncSelection } = useSelectedRows(); const { setGroupLoading, isGroupLoading, hasAnyLoading } = useGroupLoading(); - const { gridInstanceRef, groupedColumnsRef, registerGrid } = - useGridInstance(syncSelection); + const { selectedRows, syncSelection } = useSelectedRows(); + const { gridInstanceRef, groupedColumnsRef, registerGrid } = useGridInstance( + syncSelection, + hasAnyLoading + ); const handleGroupSelection = useGroupSelectionHandler( syncSelection, setGroupLoading From 653fb0ed8792ac3a9a6439be153da0e8c8e0a88f Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Thu, 8 Jan 2026 20:14:17 +0400 Subject: [PATCH 09/17] fix: selection imitation test for React Context API --- React/src/App.test.tsx | 88 ++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index 869ba63..cbb3cac 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -5,7 +5,7 @@ import GroupRowComponent from "./GroupRowSelection/GroupRowComponent"; import type { DataGridTypes } from "devextreme-react/data-grid"; import { vi } from "vitest"; import App from "./App"; -import { GroupRowSelectionProvider } from "./GroupRowSelection/context/GroupRowSelectionContext"; +import { GroupRowSelectionProvider } from "./GroupRowSelection/selection-context/row-selection-context"; describe("GroupRowComponent", () => { const mockSelect = vi.fn(() => Promise.resolve()); @@ -38,7 +38,7 @@ describe("GroupRowComponent", () => { expect(screen.getByText("ShipCountry: USA")).toBeInTheDocument(); }); - test("calls onInitialized and hides loader", async () => { + test("calls onInitialized and shows checkboxes", async () => { render( @@ -48,7 +48,7 @@ describe("GroupRowComponent", () => { await waitFor( () => { const allCheckboxes = screen.getAllByRole("checkbox"); - + expect(allCheckboxes.length).toBeGreaterThan(0); allCheckboxes.forEach((checkbox) => { expect(checkbox).toBeVisible(); }); @@ -57,19 +57,18 @@ describe("GroupRowComponent", () => { ); }); - test("selects/deselects rows when checkbox clicked", async () => { + test("selects and deselects rows when checkbox clicked", async () => { render( ); - const allCheckboxes = await screen.findAllByRole( - "checkbox", - {}, - { timeout: 5000 } - ); + const allCheckboxes = await waitFor(() => screen.getAllByRole("checkbox"), { + timeout: 5000, + }); const checkbox = allCheckboxes[0]; + await userEvent.click(checkbox); await waitFor(() => { @@ -89,6 +88,7 @@ describe("GroupRowComponent", () => { test("selecting checkbox at index 1 selects 2–4 and leaves others unselected", async () => { const user = userEvent.setup(); + const { container } = render( @@ -96,44 +96,66 @@ describe("GroupRowComponent", () => { ); const allCheckboxes = await waitFor( - async () => { - const checkboxes = await screen.findAllByRole("checkbox"); - if (checkboxes.length < 10) throw new Error("Not enough checkboxes"); + () => { + const checkboxes = screen.getAllByRole("checkbox"); + if (checkboxes.length < 10) throw new Error("Grid not loaded"); return checkboxes; }, { timeout: 15000 } ); + await user.click(allCheckboxes[1]); - const expandButton = container.querySelector(".dx-command-expand div"); - expect(expandButton).toBeInTheDocument(); + 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"); - await user.click(expandButton!); + if (!expandButton) { + throw new Error("Expand button not found - cannot proceed with test"); + } + + await user.click(expandButton); await waitFor( - async () => { - const afterSelectionCheckboxes = await waitFor(async () => { - const checkboxes = await screen.findAllByRole("checkbox"); - if (checkboxes.length < 10) throw new Error("Not enough checkboxes"); - return checkboxes; - }); + () => { + const checkboxes = screen.getAllByRole("checkbox"); - [1, 2, 3, 4].forEach((index) => { - expect(afterSelectionCheckboxes[index]).toHaveAttribute( - "aria-checked", - "true" - ); - }); + if (checkboxes.length <= allCheckboxes.length) { + throw new Error("Rows did not expand yet"); + } + + checkboxes.forEach((cb, index) => { + const state = cb.getAttribute("aria-checked"); - afterSelectionCheckboxes.forEach((cb, index) => { - if (index < 1) { - expect(cb).toHaveAttribute("aria-checked", "mixed"); - } else if (index > 4) { - expect(cb).toHaveAttribute("aria-checked", "false"); + 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: 15000 } + { timeout: 10000 } ); }, 30000); }); From b35b3e4c1d4cfb9e94f0a9e492bbaa23c8681f32 Mon Sep 17 00:00:00 2001 From: DevExpressExampleBot Date: Thu, 8 Jan 2026 20:22:04 +0400 Subject: [PATCH 10/17] README auto update [skip ci] --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index e1d0156..7caf3bb 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) From 5f2088fc7d96261996f81848a6e2616402d1dc1e Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Tue, 20 Jan 2026 20:38:02 +0400 Subject: [PATCH 11/17] fix: double requests on group selection --- .../GroupRowSelection/GroupRowComponent.tsx | 6 +-- .../selection-context/hooks.ts | 41 +++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index f34f61d..07ac910 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -67,11 +67,11 @@ const GroupRowComponent: React.FC = ({ setTimeout(() => { setBlocked(false); - }, 100); - } + }, 200); + }, ); }, - [childKeys, gridInstance, handleGroupSelection, isLoading, row.key] + [childKeys, gridInstance, handleGroupSelection, isLoading, row.key], ); useEffect(() => { diff --git a/React/src/GroupRowSelection/selection-context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts index eed4e97..3ee9638 100644 --- a/React/src/GroupRowSelection/selection-context/hooks.ts +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -4,14 +4,14 @@ import type dxDataGrid from "devextreme/ui/data_grid"; export const useSelectedRows = () => { const [selectedRows, setSelectedRows] = useState>( - new Set() + new Set(), ); const syncSelection = useCallback( ( arg: | (string | number)[] - | ((prevSelectedRows: (string | number)[]) => (string | number)[]) + | ((prevSelectedRows: (string | number)[]) => (string | number)[]), ) => { if (typeof arg === "function") { setSelectedRows((prev) => { @@ -22,7 +22,7 @@ export const useSelectedRows = () => { } setSelectedRows(new Set(arg)); }, - [] + [], ); return { selectedRows, syncSelection }; @@ -30,7 +30,7 @@ export const useSelectedRows = () => { export const useGroupLoading = () => { const [loadingGroupKeys, setLoadingGroupKeys] = useState>( - () => new Map() + () => new Map(), ); const setGroupLoading = useCallback((groupKey: any, isLoading: boolean) => { @@ -67,12 +67,12 @@ export const useGroupLoading = () => { return false; }, - [loadingGroupKeys] + [loadingGroupKeys], ); const hasAnyLoading = useMemo( () => loadingGroupKeys.size > 0, - [loadingGroupKeys] + [loadingGroupKeys], ); return { setGroupLoading, isGroupLoading, hasAnyLoading }; @@ -82,9 +82,9 @@ export const useGridInstance = ( syncSelection: ( keys: | (string | number)[] - | ((prev: (string | number)[]) => (string | number)[]) + | ((prev: (string | number)[]) => (string | number)[]), ) => void, - hasAnyLoading?: boolean + hasAnyLoading?: boolean, ) => { const gridInstanceRef = useRef | null>(null); const groupedColumnsRef = useRef[]>([]); @@ -121,7 +121,7 @@ export const useGridInstance = ( } }); }, - [getSelectedKeys, syncSelection] + [getSelectedKeys, syncSelection], ); const registerGrid = useCallback( @@ -138,12 +138,12 @@ export const useGridInstance = ( grid.option("onOptionChanged", (e) => { if (e.fullName === "selectionFilter") { const selectAllAction = e.value === null; - const deselectAllAction = - e.previousValue === null && - Array.isArray(e.value) && - e.value.length === 0; + const isDeselectAction = + e.previousValue?.length > e.value?.length && e.value !== null; - if (selectAllAction || deselectAllAction) { + if (isDeselectAction) syncSelection(e.value); + + if (selectAllAction) { triggerFullSync(grid); } else { if (!hasAnyLoading) { @@ -159,7 +159,7 @@ export const useGridInstance = ( defaultOptionChanged?.(e); }); }, - [collectGroupedColumns, getSelectedKeys, syncSelection] + [collectGroupedColumns, getSelectedKeys, syncSelection], ); return { gridInstanceRef, groupedColumnsRef, registerGrid }; @@ -169,16 +169,16 @@ export const useGroupSelectionHandler = ( syncSelection: ( arg: | (string | number)[] - | ((prev: (string | number)[]) => (string | number)[]) + | ((prev: (string | number)[]) => (string | number)[]), ) => void, - setGroupLoading: (groupKey: any, isLoading: boolean) => void + setGroupLoading: (groupKey: any, isLoading: boolean) => void, ) => { return useCallback( async ( groupKey: any, childKeys: any[], action: "select" | "deselect", - gridInstance: dxDataGrid + gridInstance: dxDataGrid, ) => { if (!gridInstance) return; setGroupLoading(groupKey, true); @@ -189,15 +189,12 @@ export const useGroupSelectionHandler = ( } else { await gridInstance.deselectRows(childKeys); } - - const selectedKeys = await gridInstance.getSelectedRowKeys(); - syncSelection(selectedKeys); } catch (error) { console.error("Group selection failed", error); } finally { setGroupLoading(groupKey, false); } }, - [syncSelection, setGroupLoading] + [syncSelection, setGroupLoading], ); }; From 8e92da45f4932dd64f9d28747b56ed32a15cfd07 Mon Sep 17 00:00:00 2001 From: DevExpressExampleBot Date: Thu, 29 Jan 2026 21:41:27 +0400 Subject: [PATCH 12/17] update vale workflow (fix-react-datagrid-select-all-checkboxes-loop-context-approach) --- .github/workflows/vale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/vale.yml b/.github/workflows/vale.yml index fc11f81..f8cb0c9 100644 --- a/.github/workflows/vale.yml +++ b/.github/workflows/vale.yml @@ -24,7 +24,7 @@ jobs: - name: vale linter check uses: DevExpress/vale-action@reviewdog with: - files: '["README.md", "readme.md", "Readme.md"]' + files: '["[Rr]eadme.md"]' fail_on_error: true filter_mode: nofilter - reporter: github-check + reporter: github-check \ No newline at end of file From d43775aac24eac2d13bd2bfe4f788f1e371bb467 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Fri, 30 Jan 2026 21:54:00 +0400 Subject: [PATCH 13/17] refactor: simplify GroupRowComponent by removing onInitialized prop and integrating group row initialization logic --- React/src/App.test.tsx | 22 +++---- React/src/App.tsx | 19 ++---- .../GroupRowSelection/GroupRowComponent.tsx | 63 ++++++++++-------- .../GroupRowSelectionHelper.ts | 65 ------------------- .../selection-context/hooks.ts | 65 ++++++++++++++++++- .../row-selection-context.tsx | 12 +++- .../selection-context/types.ts | 4 +- 7 files changed, 125 insertions(+), 125 deletions(-) delete mode 100644 React/src/GroupRowSelection/GroupRowSelectionHelper.ts diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index cbb3cac..4797d13 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -10,7 +10,6 @@ import { GroupRowSelectionProvider } from "./GroupRowSelection/selection-context describe("GroupRowComponent", () => { const mockSelect = vi.fn(() => Promise.resolve()); const mockDeselect = vi.fn(() => Promise.resolve()); - const mockOnInitialized = vi.fn(); const mockGroupData: DataGridTypes.ColumnGroupCellTemplateData = { column: { caption: "ShipCountry" }, @@ -29,11 +28,8 @@ describe("GroupRowComponent", () => { test("should render group text", () => { render( - - + + , ); expect(screen.getByText("ShipCountry: USA")).toBeInTheDocument(); }); @@ -42,7 +38,7 @@ describe("GroupRowComponent", () => { render( - + , ); await waitFor( @@ -53,7 +49,7 @@ describe("GroupRowComponent", () => { expect(checkbox).toBeVisible(); }); }, - { timeout: 5000 } + { timeout: 5000 }, ); }); @@ -61,7 +57,7 @@ describe("GroupRowComponent", () => { render( - + , ); const allCheckboxes = await waitFor(() => screen.getAllByRole("checkbox"), { @@ -92,7 +88,7 @@ describe("GroupRowComponent", () => { const { container } = render( - + , ); const allCheckboxes = await waitFor( @@ -101,7 +97,7 @@ describe("GroupRowComponent", () => { if (checkboxes.length < 10) throw new Error("Grid not loaded"); return checkboxes; }, - { timeout: 15000 } + { timeout: 15000 }, ); await user.click(allCheckboxes[1]); @@ -146,7 +142,7 @@ describe("GroupRowComponent", () => { if (index > 1 && index <= 4) { if (state !== "true") { throw new Error( - `Row ${index} should be selected but was ${state}` + `Row ${index} should be selected but was ${state}`, ); } return; @@ -155,7 +151,7 @@ describe("GroupRowComponent", () => { expect(state).toBe("false"); }); }, - { timeout: 10000 } + { timeout: 10000 }, ); }, 30000); }); diff --git a/React/src/App.tsx b/React/src/App.tsx index db78af8..ee8ee54 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -10,11 +10,8 @@ import DataGrid, { } from "devextreme-react/data-grid"; import * as AspNetData from "devextreme-aspnet-data-nojquery"; import { useEventCallback } from "./hooks"; -import { useGroupSelectionHelper } from "./GroupRowSelection/GroupRowSelectionHelper"; import { useGroupRowSelection } from "./GroupRowSelection/selection-context/row-selection-context"; -import GroupRowComponent, { - type IGroupRowReadyParameter, -} from "./GroupRowSelection/GroupRowComponent"; +import GroupRowComponent from "./GroupRowSelection/GroupRowComponent"; import "./App.css"; import "devextreme/dist/css/dx.material.blue.light.compact.css"; @@ -43,29 +40,21 @@ const shippersData = AspNetData.createStore({ }); function App(): JSX.Element { - const { groupRowInit } = useGroupSelectionHelper(); const { registerGrid } = useGroupRowSelection(); - const groupRowInitHandler = useEventCallback((arg: IGroupRowReadyParameter) => - groupRowInit(arg) - ); - const handleInitialized = useCallback( (e: DataGridTypes.InitializedEvent) => { if (!e.component) return; registerGrid(e.component); }, - [registerGrid] + [registerGrid], ); const groupCellRender = useEventCallback( (group: DataGridTypes.ColumnGroupCellTemplateData): JSX.Element => ( - - ) + + ), ); return ( diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index 07ac910..2fc57b5 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -13,7 +13,6 @@ import "./GroupRowComponent.css"; interface GroupRowProps { groupCellData: DataGridTypes.ColumnGroupCellTemplateData; - onInitialized: (param: IGroupRowReadyParameter) => Promise | undefined; } export interface IGroupRowReadyParameter { @@ -22,10 +21,18 @@ export interface IGroupRowReadyParameter { const iconSize = 18; -const GroupRowComponent: React.FC = ({ - groupCellData, - onInitialized, -}) => { +const groupRowFlexStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", +}; + +const groupSelectionFrontStyle: React.CSSProperties = { + marginRight: "10px", + width: iconSize, + height: iconSize, +}; + +const GroupRowComponent: React.FC = ({ groupCellData }) => { const [childKeys, setChildKeys] = useState([]); const [isInitializing, setIsInitializing] = useState(true); const actionInProgressRef = useRef(false); @@ -35,6 +42,7 @@ const GroupRowComponent: React.FC = ({ handleGroupSelection, isGroupLoading, setGroupLoading, + initializeGroupRow, } = useGroupRowSelection(); const { component: gridInstance, row } = groupCellData; @@ -74,30 +82,30 @@ const GroupRowComponent: React.FC = ({ [childKeys, gridInstance, handleGroupSelection, isLoading, row.key], ); - useEffect(() => { - let isMounted = true; - - setIsInitializing(true); + const onRowInitialized = useCallback( + (e: IGroupRowReadyParameter) => { + setIsInitializing(true); - const promise = onInitialized({ key: row.key }); + const promise = initializeGroupRow(e); - promise - ?.then((keys: any[]) => { - if (isMounted) { + promise + ?.then((keys: any[]) => { setChildKeys(keys); - } - }) - .finally(() => { - if (isMounted) { + }) + .finally(() => { setIsInitializing(false); - } - }); + }); + }, + [initializeGroupRow], + ); + + useEffect(() => { + onRowInitialized({ key: row.key }); return () => { - isMounted = false; setGroupLoading(row.key, false); }; - }, [row.key, onInitialized, setGroupLoading]); + }, [row.key, initializeGroupRow, setGroupLoading]); const stopPropagation = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -118,15 +126,16 @@ const GroupRowComponent: React.FC = ({ const showLoading = isInitializing || isLoading; + const groupTextStyleDynamic: React.CSSProperties = { + opacity: blocked ? 0.5 : 1, + }; + return ( -
+
{showLoading ? ( @@ -139,7 +148,7 @@ const GroupRowComponent: React.FC = ({ /> )}
- {groupText} + {groupText}
); }; diff --git a/React/src/GroupRowSelection/GroupRowSelectionHelper.ts b/React/src/GroupRowSelection/GroupRowSelectionHelper.ts deleted file mode 100644 index e40b813..0000000 --- a/React/src/GroupRowSelection/GroupRowSelectionHelper.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useRef, useCallback } from "react"; -import { useGroupRowSelection } from "./selection-context/row-selection-context"; -import { isItemsArray } from "devextreme-react/common/data"; -import type { LoadOptions } from "devextreme/common/data"; -import type dxDataGrid from "devextreme/ui/data_grid"; -import type { IGroupRowReadyParameter } from "./GroupRowComponent"; - -export function useGroupSelectionHelper() { - const groupChildKeysRef = useRef>({}); - - const { handleGroupSelection, gridInstanceRef, groupedColumnsRef } = - useGroupRowSelection(); - - const calcCheckBoxId = useCallback( - (grid: dxDataGrid, groupRowKey: string[]) => { - return `${grid.element().id}groupCheckBox${groupRowKey.join("")}`; - }, - [] - ); - - const groupRowInit = useCallback( - (arg: IGroupRowReadyParameter): Promise => { - const grid = gridInstanceRef.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: any[] = []; - 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 = data.map((d) => grid.keyOf(d)); - groupChildKeysRef.current[checkBoxId] = keys; - resolve(keys); - } else { - resolve([]); - } - }) - .catch(() => resolve([])); - }); - }, - [calcCheckBoxId] - ); - - return { - groupRowInit, - getChildRowKeys: (grid: dxDataGrid, key: string[]) => - groupChildKeysRef.current[calcCheckBoxId(grid, key)], - handleGroupSelection, - }; -} diff --git a/React/src/GroupRowSelection/selection-context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts index 3ee9638..b01acc7 100644 --- a/React/src/GroupRowSelection/selection-context/hooks.ts +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -1,6 +1,11 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { serializeKey } from "./helpers"; import type dxDataGrid from "devextreme/ui/data_grid"; +import type { IGroupRowReadyParameter } from "../GroupRowComponent"; +import { + isItemsArray, + type LoadOptions, +} from "devextreme-react/cjs/common/data"; export const useSelectedRows = () => { const [selectedRows, setSelectedRows] = useState>( @@ -137,7 +142,7 @@ export const useGridInstance = ( grid.option("onOptionChanged", (e) => { if (e.fullName === "selectionFilter") { - const selectAllAction = e.value === null; + const selectAllAction = e.value === null || e.value.length === 0; const isDeselectAction = e.previousValue?.length > e.value?.length && e.value !== null; @@ -198,3 +203,61 @@ export const useGroupSelectionHandler = ( [syncSelection, setGroupLoading], ); }; + +export function useGroupRowHandler( + gridRef: React.RefObject, + groupedColumnsRef: React.MutableRefObject[]>, +) { + const groupChildKeysRef = useRef>({}); + + const calcCheckBoxId = useCallback( + (grid: dxDataGrid, groupRowKey: string[]) => { + return `${grid.element().id}groupCheckBox${groupRowKey.join("")}`; + }, + [], + ); + + const groupRowInit = useCallback( + (arg: IGroupRowReadyParameter): Promise => { + 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: any[] = []; + 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 = data.map((d) => grid.keyOf(d)); + groupChildKeysRef.current[checkBoxId] = keys; + resolve(keys); + } else { + resolve([]); + } + }) + .catch(() => resolve([])); + }); + }, + [calcCheckBoxId], + ); + + return { + groupRowInit, + getChildRowKeys: (grid: dxDataGrid, key: string[]) => + groupChildKeysRef.current[calcCheckBoxId(grid, key)], + }; +} diff --git a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx index 08f9e6e..49805ec 100644 --- a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx +++ b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; import { useGridInstance, useGroupLoading, + useGroupRowHandler, useGroupSelectionHandler, useSelectedRows, } from "./hooks"; @@ -18,11 +19,15 @@ export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ const { selectedRows, syncSelection } = useSelectedRows(); const { gridInstanceRef, groupedColumnsRef, registerGrid } = useGridInstance( syncSelection, - hasAnyLoading + hasAnyLoading, ); const handleGroupSelection = useGroupSelectionHandler( syncSelection, - setGroupLoading + setGroupLoading, + ); + const { groupRowInit } = useGroupRowHandler( + gridInstanceRef, + groupedColumnsRef, ); return ( @@ -37,6 +42,7 @@ export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ setGroupLoading, handleGroupSelection, registerGrid, + initializeGroupRow: groupRowInit, }} > {children} @@ -48,7 +54,7 @@ export const useGroupRowSelection = () => { const context = useContext(GroupRowSelectionContext); if (!context) { throw new Error( - "useGroupRowSelection must be used within GroupRowSelectionProvider" + "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 index cd4bb51..02f4832 100644 --- a/React/src/GroupRowSelection/selection-context/types.ts +++ b/React/src/GroupRowSelection/selection-context/types.ts @@ -1,4 +1,5 @@ import type dxDataGrid from "devextreme/ui/data_grid"; +import type { IGroupRowReadyParameter } from "../GroupRowComponent"; export interface GroupRowSelectionContextType { selectedRows: Set; @@ -12,7 +13,8 @@ export interface GroupRowSelectionContextType { groupKey: any, childKeys: any[], action: "select" | "deselect", - gridInstance: dxDataGrid + gridInstance: dxDataGrid, ) => Promise; registerGrid: (grid: dxDataGrid) => void; + initializeGroupRow: (e: IGroupRowReadyParameter) => Promise; } From 6aedbdc61c3486ae8a8d91e97033aace68957285 Mon Sep 17 00:00:00 2001 From: DevExpressExampleBot Date: Mon, 2 Feb 2026 21:04:37 +0400 Subject: [PATCH 14/17] update vale workflow (fix-react-datagrid-select-all-checkboxes-loop-context-approach) --- .github/workflows/vale.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/vale.yml b/.github/workflows/vale.yml index f8cb0c9..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: '["[Rr]eadme.md"]' + files: ${{ steps.detect.outputs.files }} fail_on_error: true filter_mode: nofilter reporter: github-check \ No newline at end of file From 5495459fa8e0a5c816ad156b98b48f7336f64792 Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Mon, 9 Feb 2026 22:48:05 +0400 Subject: [PATCH 15/17] fix: avoid sending request on deselect action --- .../selection-context/hooks.ts | 33 +++++++++++-------- .../row-selection-context.tsx | 5 +-- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/React/src/GroupRowSelection/selection-context/hooks.ts b/React/src/GroupRowSelection/selection-context/hooks.ts index b01acc7..719ae42 100644 --- a/React/src/GroupRowSelection/selection-context/hooks.ts +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -95,7 +95,8 @@ export const useGridInstance = ( const groupedColumnsRef = useRef[]>([]); const latestRequestIdRef = useRef(0); - const isFetchingRef = useRef(false); + + const prevSelectedRowsRef = useRef>(new Set()); const collectGroupedColumns = useCallback((grid: dxDataGrid) => { return grid @@ -110,19 +111,16 @@ export const useGridInstance = ( const triggerFullSync = useCallback( (grid: dxDataGrid) => { - isFetchingRef.current = true; const currentId = ++latestRequestIdRef.current; getSelectedKeys(grid) .then((keys) => { if (latestRequestIdRef.current === currentId) { syncSelection(keys); - isFetchingRef.current = false; } }) .catch(() => { if (latestRequestIdRef.current === currentId) { - isFetchingRef.current = false; } }); }, @@ -141,22 +139,34 @@ export const useGridInstance = ( 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 = - e.previousValue?.length > e.value?.length && e.value !== null; - - if (isDeselectAction) syncSelection(e.value); + (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) { + if (!hasAnyLoading && !isDeselectAction) { grid.getSelectedRowKeys().then((selectedKeys) => { syncSelection(selectedKeys); }); } } + prevSelectedRowsRef.current = new Set(e.value); } if (e.fullName.includes("groupIndex")) { groupedColumnsRef.current = collectGroupedColumns(grid); @@ -171,11 +181,6 @@ export const useGridInstance = ( }; export const useGroupSelectionHandler = ( - syncSelection: ( - arg: - | (string | number)[] - | ((prev: (string | number)[]) => (string | number)[]), - ) => void, setGroupLoading: (groupKey: any, isLoading: boolean) => void, ) => { return useCallback( @@ -200,7 +205,7 @@ export const useGroupSelectionHandler = ( setGroupLoading(groupKey, false); } }, - [syncSelection, setGroupLoading], + [setGroupLoading], ); }; diff --git a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx index 49805ec..6cdd0fa 100644 --- a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx +++ b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx @@ -21,10 +21,7 @@ export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ syncSelection, hasAnyLoading, ); - const handleGroupSelection = useGroupSelectionHandler( - syncSelection, - setGroupLoading, - ); + const handleGroupSelection = useGroupSelectionHandler(setGroupLoading); const { groupRowInit } = useGroupRowHandler( gridInstanceRef, groupedColumnsRef, From a059f3569e85d496fd80b9be2d45255bc66ee27f Mon Sep 17 00:00:00 2001 From: LeSe0-devexpress Date: Tue, 10 Feb 2026 21:34:33 +0400 Subject: [PATCH 16/17] refactor: standardize string quotes and improve code consistency across components --- React/src/App.test.tsx | 75 ++++---- React/src/App.tsx | 24 +-- .../GroupRowSelection/GroupRowComponent.tsx | 28 +-- .../selection-context/helpers.ts | 10 +- .../selection-context/hooks.ts | 162 ++++++++---------- .../row-selection-context.tsx | 65 ++++--- .../selection-context/types.ts | 47 +++-- React/src/main.tsx | 14 +- React/vitest.config.ts | 6 +- 9 files changed, 233 insertions(+), 198 deletions(-) diff --git a/React/src/App.test.tsx b/React/src/App.test.tsx index 4797d13..9f6d76c 100644 --- a/React/src/App.test.tsx +++ b/React/src/App.test.tsx @@ -1,20 +1,20 @@ // GroupRowComponent.test.tsx -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import GroupRowComponent from "./GroupRowSelection/GroupRowComponent"; -import type { DataGridTypes } from "devextreme-react/data-grid"; -import { vi } from "vitest"; -import App from "./App"; -import { GroupRowSelectionProvider } from "./GroupRowSelection/selection-context/row-selection-context"; - -describe("GroupRowComponent", () => { +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'; + +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"] }, + column: { caption: 'ShipCountry' }, + displayValue: 'USA', + row: { key: ['USA'] }, component: { selectRows: mockSelect, deselectRows: mockDeselect, @@ -25,16 +25,17 @@ describe("GroupRowComponent", () => { vi.clearAllMocks(); }); - test("should render group text", () => { + test('should render group text', () => { render( , ); - expect(screen.getByText("ShipCountry: USA")).toBeInTheDocument(); + expect(screen.getByText('ShipCountry: USA')).toBeInTheDocument(); }); - test("calls onInitialized and shows checkboxes", async () => { + // eslint-disable-next-line @typescript-eslint/space-before-function-paren + test('calls onInitialized and shows checkboxes', async() => { render( @@ -43,7 +44,7 @@ describe("GroupRowComponent", () => { await waitFor( () => { - const allCheckboxes = screen.getAllByRole("checkbox"); + const allCheckboxes = screen.getAllByRole('checkbox'); expect(allCheckboxes.length).toBeGreaterThan(0); allCheckboxes.forEach((checkbox) => { expect(checkbox).toBeVisible(); @@ -53,14 +54,15 @@ describe("GroupRowComponent", () => { ); }); - test("selects and deselects rows when checkbox clicked", async () => { + // 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"), { + const allCheckboxes = await waitFor(() => screen.getAllByRole('checkbox'), { timeout: 5000, }); const checkbox = allCheckboxes[0]; @@ -69,7 +71,7 @@ describe("GroupRowComponent", () => { await waitFor(() => { allCheckboxes.forEach((cb) => { - expect(cb).toHaveAttribute("aria-checked", "true"); + expect(cb).toHaveAttribute('aria-checked', 'true'); }); }); @@ -77,12 +79,13 @@ describe("GroupRowComponent", () => { await waitFor(() => { allCheckboxes.forEach((cb) => { - expect(cb).toHaveAttribute("aria-checked", "false"); + expect(cb).toHaveAttribute('aria-checked', 'false'); }); }); }); - test("selecting checkbox at index 1 selects 2–4 and leaves others unselected", async () => { + // 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( @@ -93,8 +96,8 @@ describe("GroupRowComponent", () => { const allCheckboxes = await waitFor( () => { - const checkboxes = screen.getAllByRole("checkbox"); - if (checkboxes.length < 10) throw new Error("Grid not loaded"); + const checkboxes = screen.getAllByRole('checkbox'); + if (checkboxes.length < 10) throw new Error('Grid not loaded'); return checkboxes; }, { timeout: 15000 }, @@ -103,44 +106,44 @@ describe("GroupRowComponent", () => { 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 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"); + const expandButton = container.querySelector('.dx-datagrid-group-closed'); if (!expandButton) { - throw new Error("Expand button not found - cannot proceed with test"); + throw new Error('Expand button not found - cannot proceed with test'); } await user.click(expandButton); await waitFor( () => { - const checkboxes = screen.getAllByRole("checkbox"); + const checkboxes = screen.getAllByRole('checkbox'); if (checkboxes.length <= allCheckboxes.length) { - throw new Error("Rows did not expand yet"); + throw new Error('Rows did not expand yet'); } checkboxes.forEach((cb, index) => { - const state = cb.getAttribute("aria-checked"); + const state = cb.getAttribute('aria-checked'); if (index === 0) { - expect(["true", "mixed"]).toContain(state); + expect(['true', 'mixed']).toContain(state); return; } if (index === 1) { - expect(["true", "mixed"]).toContain(state); + expect(['true', 'mixed']).toContain(state); return; } if (index > 1 && index <= 4) { - if (state !== "true") { + if (state !== 'true') { throw new Error( `Row ${index} should be selected but was ${state}`, ); @@ -148,7 +151,7 @@ describe("GroupRowComponent", () => { return; } - expect(state).toBe("false"); + expect(state).toBe('false'); }); }, { timeout: 10000 }, diff --git a/React/src/App.tsx b/React/src/App.tsx index ee8ee54..4623eb1 100644 --- a/React/src/App.tsx +++ b/React/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback } from 'react'; import DataGrid, { Column, type DataGridTypes, @@ -7,32 +7,32 @@ import DataGrid, { Lookup, Paging, Selection, -} from "devextreme-react/data-grid"; -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"; +} from 'devextreme-react/data-grid'; +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"; +import './App.css'; +import 'devextreme/dist/css/dx.material.blue.light.compact.css'; -const url = "https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi"; +const url = 'https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi'; const dataSource = AspNetData.createStore({ - key: "OrderID", + key: 'OrderID', loadUrl: `${url}/Orders`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; }, }); const customersData = AspNetData.createStore({ - key: "Value", + key: 'Value', loadUrl: `${url}/CustomersLookup`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; }, }); const shippersData = AspNetData.createStore({ - key: "Value", + key: 'Value', loadUrl: `${url}/ShippersLookup`, onBeforeSend(_method, ajaxOptions) { ajaxOptions.xhrFields = { withCredentials: true }; diff --git a/React/src/GroupRowSelection/GroupRowComponent.tsx b/React/src/GroupRowSelection/GroupRowComponent.tsx index 2fc57b5..bb27fac 100644 --- a/React/src/GroupRowSelection/GroupRowComponent.tsx +++ b/React/src/GroupRowSelection/GroupRowComponent.tsx @@ -4,12 +4,12 @@ import React, { useState, useCallback, useRef, -} from "react"; -import { useGroupRowSelection } from "./selection-context/row-selection-context"; -import CheckBox, { type CheckBoxTypes } from "devextreme-react/check-box"; -import { LoadIndicator } from "devextreme-react"; -import { type DataGridTypes } from "devextreme-react/data-grid"; -import "./GroupRowComponent.css"; +} from 'react'; +import CheckBox, { type CheckBoxTypes } from 'devextreme-react/check-box'; +import { LoadIndicator } from 'devextreme-react'; +import { type DataGridTypes } from 'devextreme-react/data-grid'; +import { useGroupRowSelection } from './selection-context/row-selection-context'; +import './GroupRowComponent.css'; interface GroupRowProps { groupCellData: DataGridTypes.ColumnGroupCellTemplateData; @@ -22,17 +22,17 @@ export interface IGroupRowReadyParameter { const iconSize = 18; const groupRowFlexStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', }; const groupSelectionFrontStyle: React.CSSProperties = { - marginRight: "10px", + marginRight: '10px', width: iconSize, height: iconSize, }; -const GroupRowComponent: React.FC = ({ groupCellData }) => { +function GroupRowComponent({ groupCellData }: GroupRowProps): JSX.Element { const [childKeys, setChildKeys] = useState([]); const [isInitializing, setIsInitializing] = useState(true); const actionInProgressRef = useRef(false); @@ -68,7 +68,7 @@ const GroupRowComponent: React.FC = ({ groupCellData }) => { actionInProgressRef.current = true; setBlocked(true); - const action = e.value ? "select" : "deselect"; + const action = e.value ? 'select' : 'deselect'; handleGroupSelection(row.key, childKeys, action, gridInstance).finally( () => { actionInProgressRef.current = false; @@ -99,10 +99,10 @@ const GroupRowComponent: React.FC = ({ groupCellData }) => { [initializeGroupRow], ); - useEffect(() => { + useEffect((): (() => void) => { onRowInitialized({ key: row.key }); - return () => { + return (): void => { setGroupLoading(row.key, false); }; }, [row.key, initializeGroupRow, setGroupLoading]); @@ -151,6 +151,6 @@ const GroupRowComponent: React.FC = ({ groupCellData }) => { {groupText}
); -}; +} export default React.memo(GroupRowComponent); diff --git a/React/src/GroupRowSelection/selection-context/helpers.ts b/React/src/GroupRowSelection/selection-context/helpers.ts index aa136b0..00c3b27 100644 --- a/React/src/GroupRowSelection/selection-context/helpers.ts +++ b/React/src/GroupRowSelection/selection-context/helpers.ts @@ -1,4 +1,6 @@ -export const serializeKey = (key: any) => - typeof key === "string" || typeof key === "number" - ? String(key) - : JSON.stringify(key, Object.keys(key).sort()); +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 index 719ae42..e524885 100644 --- a/React/src/GroupRowSelection/selection-context/hooks.ts +++ b/React/src/GroupRowSelection/selection-context/hooks.ts @@ -1,24 +1,28 @@ -import { useState, useCallback, useMemo, useRef } from "react"; -import { serializeKey } from "./helpers"; -import type dxDataGrid from "devextreme/ui/data_grid"; -import type { IGroupRowReadyParameter } from "../GroupRowComponent"; +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"; - -export const useSelectedRows = () => { +} 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: - | (string | number)[] - | ((prevSelectedRows: (string | number)[]) => (string | number)[]), - ) => { - if (typeof arg === "function") { + const syncSelection = useCallback( + (arg) => { + if (typeof arg === 'function') { setSelectedRows((prev) => { const newRowIds = arg(Array.from(prev)); return new Set(newRowIds); @@ -31,9 +35,9 @@ export const useSelectedRows = () => { ); return { selectedRows, syncSelection }; -}; +} -export const useGroupLoading = () => { +export function useGroupLoading(): UseGroupLoadingReturnType { const [loadingGroupKeys, setLoadingGroupKeys] = useState>( () => new Map(), ); @@ -44,8 +48,9 @@ export const useGroupLoading = () => { const next = new Map(prev); const current = next.get(sKey) ?? 0; - if (isLoading) next.set(sKey, current + 1); - else { + if (isLoading) { + next.set(sKey, current + 1); + } else { const nextCount = current - 1; if (nextCount <= 0) next.delete(sKey); else next.set(sKey, nextCount); @@ -81,33 +86,31 @@ export const useGroupLoading = () => { ); return { setGroupLoading, isGroupLoading, hasAnyLoading }; -}; - -export const useGridInstance = ( - syncSelection: ( - keys: - | (string | number)[] - | ((prev: (string | number)[]) => (string | number)[]), - ) => void, +} + +export function useGridInstance( + syncSelection: UseSelectedRowsReturnType['syncSelection'], hasAnyLoading?: boolean, -) => { - const gridInstanceRef = useRef | null>(null); +): UseGridInstanceReturnType { + const gridInstanceRef = useRef(null); const groupedColumnsRef = useRef[]>([]); const latestRequestIdRef = useRef(0); const prevSelectedRowsRef = useRef>(new Set()); - const collectGroupedColumns = useCallback((grid: dxDataGrid) => { - return grid + const collectGroupedColumns = useCallback( + (grid: dxDataGrid) => grid .getVisibleColumns() .filter((c) => c.groupIndex != null && c.groupIndex >= 0) - .sort((a, b) => (a.groupIndex! > b.groupIndex! ? 1 : -1)); - }, []); + .sort((a, b) => ((a.groupIndex ?? 0) > (b.groupIndex ?? 0) ? 1 : -1)), + [], + ); - const getSelectedKeys = useCallback((grid: dxDataGrid) => { - return grid.getSelectedRowKeys(); - }, []); + const getSelectedKeys = useCallback( + (grid: dxDataGrid) => grid.getSelectedRowKeys(), + [], + ); const triggerFullSync = useCallback( (grid: dxDataGrid) => { @@ -119,10 +122,7 @@ export const useGridInstance = ( syncSelection(keys); } }) - .catch(() => { - if (latestRequestIdRef.current === currentId) { - } - }); + .catch(() => {}); }, [getSelectedKeys, syncSelection], ); @@ -136,39 +136,29 @@ export const useGridInstance = ( .then((keys: (string | number)[]) => syncSelection(keys)) .catch(() => {}); - const defaultOptionChanged = grid.option("onOptionChanged"); + const defaultOptionChanged = grid.option('onOptionChanged'); - grid.option("onOptionChanged", (e) => { + grid.option('onOptionChanged', (e) => { if (!prevSelectedRowsRef?.current) return; - if (e.fullName === "selectionFilter") { + 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, - ), - ), - ); + 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); - }); - } + } else if (!hasAnyLoading && !isDeselectAction) { + grid.getSelectedRowKeys().then((selectedKeys) => { + syncSelection(selectedKeys); + }).catch(() => {}); } prevSelectedRowsRef.current = new Set(e.value); } - if (e.fullName.includes("groupIndex")) { + if (e.fullName.includes('groupIndex')) { groupedColumnsRef.current = collectGroupedColumns(grid); } defaultOptionChanged?.(e); @@ -178,52 +168,52 @@ export const useGridInstance = ( ); return { gridInstanceRef, groupedColumnsRef, registerGrid }; -}; - -export const useGroupSelectionHandler = ( - setGroupLoading: (groupKey: any, isLoading: boolean) => void, -) => { - return useCallback( - async ( - groupKey: any, - childKeys: any[], - action: "select" | "deselect", - gridInstance: dxDataGrid, +} + +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") { + if (action === 'select') { await gridInstance.selectRows(childKeys, true); } else { await gridInstance.deselectRows(childKeys); } } catch (error) { - console.error("Group selection failed", 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[]) => { - return `${grid.element().id}groupCheckBox${groupRowKey.join("")}`; - }, + (grid: dxDataGrid, groupRowKey: string[]) => `${grid.element().id}groupCheckBox${groupRowKey.join('')}`, [], ); - const groupRowInit = useCallback( - (arg: IGroupRowReadyParameter): Promise => { + const groupRowInit = useCallback( + (arg) => { const grid = gridRef.current?.instance(); if (!grid) return Promise.resolve([]); @@ -235,9 +225,9 @@ export function useGroupRowHandler( return; } - const filter: any[] = []; + const filter: string[][] = []; arg.key.forEach((key, i) => { - filter.push([groupedColumnsRef.current[i].dataField, "=", key]); + filter.push([groupedColumnsRef.current[i].dataField, '=', key]); }); const loadOptions: LoadOptions = { filter }; @@ -247,7 +237,7 @@ export function useGroupRowHandler( .load(loadOptions) .then((data) => { if (isItemsArray(data)) { - const keys = data.map((d) => grid.keyOf(d)); + const keys: number[] = data.map((d) => grid.keyOf(d) as number); groupChildKeysRef.current[checkBoxId] = keys; resolve(keys); } else { @@ -262,7 +252,5 @@ export function useGroupRowHandler( return { groupRowInit, - getChildRowKeys: (grid: dxDataGrid, key: string[]) => - groupChildKeysRef.current[calcCheckBoxId(grid, key)], }; } diff --git a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx index 6cdd0fa..542d61b 100644 --- a/React/src/GroupRowSelection/selection-context/row-selection-context.tsx +++ b/React/src/GroupRowSelection/selection-context/row-selection-context.tsx @@ -1,20 +1,23 @@ -import React, { createContext, useContext, type ReactNode } from "react"; +import { + createContext, + useContext, + useMemo, + type ReactNode, +} from 'react'; import { useGridInstance, useGroupLoading, useGroupRowHandler, useGroupSelectionHandler, useSelectedRows, -} from "./hooks"; -import type { GroupRowSelectionContextType } from "./types"; +} from './hooks'; +import type { GroupRowSelectionContextType } from './types'; const GroupRowSelectionContext = createContext< - GroupRowSelectionContextType | undefined +GroupRowSelectionContextType | undefined >(undefined); -export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { +export function GroupRowSelectionProvider({ children }: { children: ReactNode }): JSX.Element { const { setGroupLoading, isGroupLoading, hasAnyLoading } = useGroupLoading(); const { selectedRows, syncSelection } = useSelectedRows(); const { gridInstanceRef, groupedColumnsRef, registerGrid } = useGridInstance( @@ -27,32 +30,48 @@ export const GroupRowSelectionProvider: React.FC<{ children: ReactNode }> = ({ 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 const useGroupRowSelection = () => { +export function useGroupRowSelection(): GroupRowSelectionContextType { const context = useContext(GroupRowSelectionContext); if (!context) { throw new Error( - "useGroupRowSelection must be used within GroupRowSelectionProvider", + '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 index 02f4832..64ac23f 100644 --- a/React/src/GroupRowSelection/selection-context/types.ts +++ b/React/src/GroupRowSelection/selection-context/types.ts @@ -1,20 +1,43 @@ -import type dxDataGrid from "devextreme/ui/data_grid"; -import type { IGroupRowReadyParameter } from "../GroupRowComponent"; +/* 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 | null>; + gridInstanceRef: React.MutableRefObject; groupedColumnsRef: React.MutableRefObject[]>; hasAnyLoading: boolean; - syncSelection: (rowIds: (string | number)[]) => void; + 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; - handleGroupSelection: ( - groupKey: any, - childKeys: any[], - action: "select" | "deselect", - gridInstance: dxDataGrid, - ) => Promise; - registerGrid: (grid: dxDataGrid) => void; - initializeGroupRow: (e: IGroupRowReadyParameter) => Promise; + hasAnyLoading: boolean; +} + +export interface UseGroupRowHandlerReturnType { + groupRowInit: (e: IGroupRowReadyParameter) => Promise; } diff --git a/React/src/main.tsx b/React/src/main.tsx index afe7fb3..5177e8f 100644 --- a/React/src/main.tsx +++ b/React/src/main.tsx @@ -1,13 +1,13 @@ -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"; +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( +createRoot(document.getElementById('root') as HTMLElement).render( - +
, ); diff --git a/React/vitest.config.ts b/React/vitest.config.ts index fa85bbe..a5fa012 100644 --- a/React/vitest.config.ts +++ b/React/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: "happy-dom", + environment: 'happy-dom', globals: true, - setupFiles: "./src/setupTests.ts", + setupFiles: './src/setupTests.ts', }, }); From f07cb6dcce29b46de879be688b4b439584d3b7cd Mon Sep 17 00:00:00 2001 From: DevExpressExampleBot Date: Thu, 12 Feb 2026 22:25:49 +0400 Subject: [PATCH 17/17] Normalize README phrase [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7caf3bb..4ce4dde 100644 --- a/README.md +++ b/README.md @@ -48,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)