diff --git a/package-lock.json b/package-lock.json index c084c3c918..c5cc546ec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", "@tanstack/react-query": "5.90.21", + "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", @@ -177,6 +178,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -2394,6 +2396,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2416,6 +2419,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -2492,6 +2496,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2669,6 +2674,7 @@ "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz", "integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==", "license": "AGPL-3.0", + "peer": true, "dependencies": { "@cospired/i18n-iso-languages": "4.2.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -3440,6 +3446,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -5409,6 +5416,7 @@ "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.1.tgz", "integrity": "sha512-c/cWnvZsGS7xyq0tJpssmv2oyfYG6Fuawy6EzWy8CYiQ4oD67EVuSwBInCfSJoNZhvvkUE+4B/YhDIRGUVDz5w==", "license": "Apache-2.0", + "peer": true, "workspaces": [ "example", "component-generator", @@ -6156,6 +6164,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -6179,6 +6188,7 @@ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", @@ -6204,7 +6214,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { "version": "3.1.0", @@ -6452,6 +6463,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -6576,6 +6588,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6744,8 +6789,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -7141,6 +7185,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -7152,6 +7197,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -7290,6 +7336,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -7336,6 +7383,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -8015,6 +8063,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8104,6 +8153,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8599,6 +8649,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -8782,6 +8833,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -9158,6 +9210,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9516,7 +9569,8 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/clean-css": { "version": "5.3.3", @@ -10867,8 +10921,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -11060,6 +11113,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", + "peer": true, "engines": { "node": ">4.0" } @@ -11404,6 +11458,7 @@ "integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -11460,6 +11515,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "license": "MIT", + "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -11500,6 +11556,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.1.0.tgz", "integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==", "license": "MIT", + "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, @@ -11975,7 +12032,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -11985,7 +12041,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -11998,6 +12053,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.7", "aria-query": "^5.1.3", @@ -12034,6 +12090,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -12064,6 +12121,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.1.tgz", "integrity": "sha512-Ck77j8hF7l9N4S/rzSLOWEKpn994YH6iwUK8fr9mXIaQvGpQYmOnQLbiue1u5kI5T1y+gdgqosnEAO9NCz0DBg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13115,6 +13173,7 @@ } ], "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", @@ -15280,6 +15339,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -16688,7 +16748,8 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.23", @@ -17278,6 +17339,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -17896,6 +17958,7 @@ "integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -18509,6 +18572,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -19228,6 +19292,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19313,7 +19378,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -19329,7 +19393,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -19342,8 +19405,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process": { "version": "0.11.10", @@ -19378,6 +19440,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -19651,6 +19714,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -19829,6 +19893,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -19871,6 +19936,7 @@ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -20131,6 +20197,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -20162,6 +20229,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", "integrity": "sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -20251,6 +20319,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" @@ -20618,6 +20687,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -21095,6 +21165,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -21226,6 +21297,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22233,6 +22305,7 @@ "integrity": "sha512-+xU0IA1StzqAqFs/QtXkK+XJa7wpS4X5H+JQccRKsRCElgeLGocFU1U/UMvMUylKFw6vwGV+Y/a2wb2pm5rFFQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@bundled-es-modules/deepmerge": "^4.3.1", "@bundled-es-modules/glob": "^10.4.2", @@ -22327,6 +22400,7 @@ "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.3.1", "@csstools/css-tokenizer": "^2.2.0", @@ -22953,6 +23027,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22964,7 +23039,8 @@ "version": "5.10.9", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1" + "license": "LGPL-2.1", + "peer": true }, "node_modules/tmp": { "version": "0.2.5", @@ -23106,6 +23182,7 @@ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "license": "MIT", + "peer": true, "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", @@ -23270,7 +23347,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -23319,6 +23397,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -23418,6 +23497,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23560,6 +23640,7 @@ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23841,6 +23922,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -23966,6 +24048,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -24070,6 +24153,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -24156,6 +24240,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -24692,6 +24777,7 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/lodash": "^4.14.175", diff --git a/package.json b/package.json index 2b279b711d..403c475780 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-query": "5.90.21", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 60ad85b5c7..5e77449649 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -57,9 +57,16 @@ export const apiUrls = { * @param pageIndex Zero-indexed page number * @param pageSize How many tags per page to load */ - tagList: (taxonomyId: number, pageIndex: number, pageSize: number) => makeUrl(`${taxonomyId}/tags/`, { - page: (pageIndex + 1), page_size: pageSize, - }), + tagList: (taxonomyId: number, pageIndex: number | null, pageSize: number | null, fullDepthThreshold?: number) => { + if (pageIndex === null) { + return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepthThreshold || 0 }); + } + return makeUrl(`${taxonomyId}/tags/`, { + page: (pageIndex ?? 0) + 1, + page_size: pageSize ?? 10, + full_depth_threshold: fullDepthThreshold || 0, + }); + }, /** * Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future. */ @@ -74,6 +81,8 @@ export const apiUrls = { tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`), /** URL to plan (preview what would happen) a taxonomy import */ tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`), + createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`), + updateTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`), } satisfies Record string>; /** diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index 78b7349556..c4454acc49 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -11,6 +11,7 @@ import MockAdapter from 'axios-mock-adapter'; import { apiUrls } from './api'; import { + useCreateTag, useImportPlan, useImportTags, useImportNewTaxonomy, @@ -105,4 +106,12 @@ describe('import taxonomy api calls', () => { expect(result.current.error).toEqual(Error('test error')); expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1)); }); + + it('should surface duplicate tag error returned as an array', async () => { + const duplicateError = "Tag with value 'ab' already exists for taxonomy."; + axiosMock.onPost(apiUrls.createTag(1)).reply(400, [duplicateError]); + const { result } = renderHook(() => useCreateTag(1), { wrapper }); + + await expect(result.current.mutateAsync({ value: 'ab' })).rejects.toEqual(Error(duplicateError)); + }); }); diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index f8856c86ba..d887ab0c26 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -16,6 +16,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { apiUrls, ALL_TAXONOMIES } from './api'; import * as api from './api'; import type { QueryOptions, TagListData } from './types'; +import { EXPECTED_MAX_TAXONOMY_ITEMS } from './constants'; // Query key patterns. Allows an easy way to clear all data related to a given taxonomy. // https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst @@ -65,6 +66,37 @@ export const taxonomyQueryKeys = { importPlan: (taxonomyId: number, fileId: string) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId], } satisfies Record (string | number)[])>; +const getApiErrorMessage = (err: unknown): string => { + const error = err as { message?: string; response?: { data?: unknown } }; + const responseData = error?.response?.data; + + if (Array.isArray(responseData)) { + const firstMessage = responseData.find((item): item is string => typeof item === 'string' && item.trim().length > 0); + if (firstMessage) { + return firstMessage; + } + } + + if (typeof responseData === 'string' && responseData.trim().length > 0) { + return responseData; + } + + if (responseData && typeof responseData === 'object') { + const objectData = responseData as { error?: string; detail?: string; message?: string }; + if (typeof objectData.error === 'string' && objectData.error.trim().length > 0) { + return objectData.error; + } + if (typeof objectData.detail === 'string' && objectData.detail.trim().length > 0) { + return objectData.detail; + } + if (typeof objectData.message === 'string' && objectData.message.trim().length > 0) { + return objectData.message; + } + } + + return error?.message || 'Unexpected error'; +}; + /** * Builds the query to get the taxonomy list * @param {string} [org] Filter the list to only show taxonomies assigned to this org @@ -139,7 +171,7 @@ export const useImportTags = () => { const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData); return camelCaseObject(data); } catch (err) { - throw new Error((err as any).response?.data?.error || (err as any).message); + throw new Error(getApiErrorMessage(err)); } }, onSuccess: (data) => { @@ -170,7 +202,7 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData); return data.plan as string; } catch (err) { - throw new Error((err as any).response?.data?.error || (err as any).message); + throw new Error(getApiErrorMessage(err)); } }, retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times! @@ -180,13 +212,17 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery * Use the list of tags in a taxonomy. */ export const useTagListData = (taxonomyId: number, options: QueryOptions) => { - const { pageIndex, pageSize } = options; + const { pageIndex, pageSize, enabled = true } = options; // eslint-disable-line return useQuery({ - queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize), + // queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize), + queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), // For now, ignore pagination in the query key. queryFn: async () => { - const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize)); + const { data } = await getAuthenticatedHttpClient().get( + apiUrls.tagList(taxonomyId, null, null, EXPECTED_MAX_TAXONOMY_ITEMS), + ); return camelCaseObject(data) as TagListData; }, + enabled, }); }; @@ -202,3 +238,51 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue return camelCaseObject(response.data) as TagListData; }, }); + +export const useCreateTag = (taxonomyId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ value, parentTagValue }: { value: string, parentTagValue?: string }) => { + try { + await getAuthenticatedHttpClient().post( + apiUrls.createTag(taxonomyId), + { tag: value, parent_tag_value: parentTagValue }, + ); + } catch (err) { + throw new Error(getApiErrorMessage(err)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + }); + // In the metadata, 'tagsCount' (and possibly other fields) will have changed: + queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + }, + }); +}; + +export const useUpdateTag = (taxonomyId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ value, originalValue }: { value: string, originalValue: string }) => { + try { + await getAuthenticatedHttpClient().patch( + apiUrls.updateTag(taxonomyId), + { tag: originalValue, updated_tag_value: value }, + ); + } catch (err) { + throw new Error(getApiErrorMessage(err)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + }); + // In the metadata, 'tagsCount' (and possibly other fields) will have changed: + queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + }, + }); +}; diff --git a/src/taxonomy/data/constants.ts b/src/taxonomy/data/constants.ts new file mode 100644 index 0000000000..d4934797e7 --- /dev/null +++ b/src/taxonomy/data/constants.ts @@ -0,0 +1,8 @@ +/** + * The maximum number of taxonomy items expected. + * This is used to set `full_depth_threshold` for the tag list API endpoint, + * which determines when to include the `full_depth` field in the response. + * Right now we expect to load all tags for a taxonomy in one request, + * and we just set this number really high to avoid any edge cases. + */ +export const EXPECTED_MAX_TAXONOMY_ITEMS = 100000000; diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index dbc7186031..27a27b566c 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -32,6 +32,7 @@ export interface TaxonomyListData { export interface QueryOptions { pageIndex: number; pageSize: number; + enabled?: boolean; } export interface TagData { @@ -47,6 +48,8 @@ export interface TagData { usageCount?: number; /** Database ID. Don't rely on this, as it is not present for free-text tags. */ _id?: string; + canChangeTag?: boolean; + canDeleteTag?: boolean; } export interface TagListData { @@ -55,6 +58,7 @@ export interface TagListData { next: string; numPages: number; previous: string; + canAddTag?: boolean; results: TagData[]; start: number; } diff --git a/src/taxonomy/tag-list/OptionalExpandLink.test.tsx b/src/taxonomy/tag-list/OptionalExpandLink.test.tsx new file mode 100644 index 0000000000..72091a28cc --- /dev/null +++ b/src/taxonomy/tag-list/OptionalExpandLink.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import OptionalExpandLink from './OptionalExpandLink'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const createMockRow = ({ + canExpand = true, + isExpanded = false, + toggleHandler = jest.fn(), +} = {}) => ({ + getCanExpand: () => canExpand, + getIsExpanded: () => isExpanded, + getToggleExpandedHandler: () => toggleHandler, +}) as any; + +describe('OptionalExpandLink', () => { + it('hides expand button when row cannot expand', () => { + render(, { wrapper }); + const button = screen.getByRole('button', { hidden: true }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders show subtags control and toggles for collapsed row', () => { + const toggleHandler = jest.fn(); + const row = createMockRow({ canExpand: true, isExpanded: false, toggleHandler }); + + render(, { wrapper }); + const button = screen.getByRole('button', { name: 'Show Subtags' }); + + expect(button).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(button); + expect(toggleHandler).toHaveBeenCalled(); + }); + + it('renders hide subtags control for expanded row', () => { + const row = createMockRow({ canExpand: true, isExpanded: true }); + + render(, { wrapper }); + const button = screen.getByRole('button', { name: 'Hide Subtags' }); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/taxonomy/tag-list/OptionalExpandLink.tsx b/src/taxonomy/tag-list/OptionalExpandLink.tsx new file mode 100644 index 0000000000..edfa6580f2 --- /dev/null +++ b/src/taxonomy/tag-list/OptionalExpandLink.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { IconButton } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import { Row } from '@tanstack/react-table'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import type { TreeRowData } from '../tree-table/types'; +import messages from './messages'; + +interface OptionalExpandLinkProps { + row?: Row; + forceHide?: boolean; +} + +/** OptionalExpandLink + * Renders an optional expand/collapse button for a tanstack/react-table row. + * + * For simplicity, this just hides the button if the row can't be expanded, + * in order to maintain a correctly-sized placeholder. + */ +const OptionalExpandLink = ({ row, forceHide = false }: OptionalExpandLinkProps) => { + const intl = useIntl(); + const canExpand = !!row?.getCanExpand() && !forceHide; + + if (!canExpand) { + return ( + + ); + } + + const isExpanded = !!row?.getIsExpanded(); + const buttonLabel = isExpanded + ? intl.formatMessage(messages.hideSubtagsButtonLabel) + : intl.formatMessage(messages.showSubtagsButtonLabel); + + return ( + + ); +}; + +export default OptionalExpandLink; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx deleted file mode 100644 index 16cc963878..0000000000 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-check -import React, { useState } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { DataTable } from '@openedx/paragon'; -import { isEqual } from 'lodash'; -import Proptypes from 'prop-types'; - -import { LoadingSpinner } from '../../generic/Loading'; -import messages from './messages'; -import { useTagListData, useSubTags } from '../data/apiHooks'; - -const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => { - const subTagsData = useSubTags(taxonomyId, parentTagValue); - - if (subTagsData.isPending) { - return ; - } - if (subTagsData.isError) { - return ; - } - - return ( -
    - {subTagsData.data.results.map(tagData => ( -
  • - {tagData.value} {tagData.descendantCount > 0 ? `(${tagData.descendantCount})` : null} -
  • - ))} -
- ); -}; - -SubTagsExpanded.propTypes = { - taxonomyId: Proptypes.number.isRequired, - parentTagValue: Proptypes.string.isRequired, -}; - -/** - * An "Expand" toggle to show/hide subtags, but one which is hidden if the given tag row has no subtags. - */ -const OptionalExpandLink = ({ row }) => ( - row.original.childCount > 0 ?
: null -); -OptionalExpandLink.propTypes = DataTable.ExpandRow.propTypes; - -/** - * Custom DataTable cell to join tag value with child count - */ -const TagValue = ({ row }) => ( - <> - {row.original.value} - {` (${row.original.descendantCount})`} - -); -TagValue.propTypes = { - row: Proptypes.shape({ - original: Proptypes.shape({ - value: Proptypes.string.isRequired, - childCount: Proptypes.number.isRequired, - descendantCount: Proptypes.number.isRequired, - }).isRequired, - }).isRequired, -}; - -const TagListTable = ({ taxonomyId }) => { - const intl = useIntl(); - const [options, setOptions] = useState({ - pageIndex: 0, - pageSize: 100, - }); - const { isLoading, data: tagList } = useTagListData(taxonomyId, options); - - const fetchData = (args) => { - if (!isEqual(args, options)) { - setOptions({ ...args }); - } - }; - - return ( -
- ( - - )} - columns={[ - { - Header: intl.formatMessage(messages.tagListColumnValueHeader), - Cell: TagValue, - }, - { - id: 'expander', - Header: DataTable.ExpandAll, - Cell: OptionalExpandLink, - }, - ]} - > - - - {tagList?.numPages !== undefined && tagList?.numPages > 1 - && } - -
- ); -}; - -TagListTable.propTypes = { - taxonomyId: Proptypes.number.isRequired, -}; - -export default TagListTable; diff --git a/src/taxonomy/tag-list/TagListTable.scss b/src/taxonomy/tag-list/TagListTable.scss index ad5c23467b..c1ddef1079 100644 --- a/src/taxonomy/tag-list/TagListTable.scss +++ b/src/taxonomy/tag-list/TagListTable.scss @@ -1,12 +1,5 @@ .tag-list-table { - table tr:first-child > th:nth-child(2) > span { - // Used to move "Expand all" button to the right. - // Find the first of the second of the first of the . - // - // The approach of the expand buttons cannot be applied here since the - // table headers are rendered differently and at the component level - // there is no control of this style. - display: flex; - justify-content: flex-end; + tr:nth-child(even) { + background-color: var(--pgn-color-light-200); } } diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index ac37792e18..734be2cfab 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -1,31 +1,50 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; import { - render, waitFor, screen, within, + render, waitFor, waitForElementToBeRemoved, screen, within, + fireEvent, act, cleanup, } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../../store'; +import * as apiHooksModule from '../data/apiHooks'; +import * as hooksModule from './hooks'; +import * as treeTableModule from '../tree-table'; import TagListTable from './TagListTable'; let store; let axiosMock; const queryClient = new QueryClient(); +const adminUser = { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], +}; +const nonAdminUser = { + ...adminUser, + administrator: false, +}; -const RootWrapper = () => ( +const RootWrapper = ({ maxDepth = 3 }) => ( - + ); +RootWrapper.propTypes = { + maxDepth: PropTypes.number, +}; + const tagDefaults = { depth: 0, external_id: null, parent_value: null }; const mockTagsResponse = { next: null, @@ -59,8 +78,35 @@ const mockTagsResponse = { _id: 1003, sub_tags_url: '/request/to/load/subtags/3', }, + { + ...tagDefaults, + depth: 1, + value: 'the child tag', + child_count: 0, + _id: 1111, + sub_tags_url: null, + parent_value: 'root tag 1', + }, + { + ...tagDefaults, + depth: 2, + value: 'the grandchild tag', + child_count: 0, + _id: 1111, + sub_tags_url: null, + parent_value: 'the child tag', + }, ], }; + +const mockTagResponseDisallowingEdits = { + ...mockTagsResponse, + results: mockTagsResponse.results.map(tag => ({ + ...tag, + can_change_tag: false, + can_delete_tag: false, + })), +}; const mockTagsPaginationResponse = { next: null, previous: null, @@ -70,7 +116,7 @@ const mockTagsPaginationResponse = { start: 0, results: [], }; -const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?page=1&page_size=100'; +const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=100000000'; const subTagsResponse = { next: null, previous: null, @@ -90,62 +136,137 @@ const subTagsResponse = { ], }; const subTagsUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&parent_tag=root+tag+1'; +const createTagUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/'; + +const renderTagListTable = (maxDepth = 3) => render(); + +const flushReactUpdates = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +const waitForRootTag = async () => { + const tag = await screen.findByText('root tag 1'); + expect(tag).toBeInTheDocument(); + return tag; +}; + +const getDraftRows = () => screen.getAllByRole('row').filter(row => row.querySelector('input')); + +const expectNoDraftRows = () => { + expect(getDraftRows().length).toBe(0); +}; + +const openTopLevelDraftRow = async () => { + const addButton = await screen.findByLabelText('Create Tag'); + await act(async () => { + fireEvent.click(addButton); + }); + const creatingRow = await screen.findByTestId('creating-top-row'); + const input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + return { creatingRow, input }; +}; + +const openActionsMenuForTag = (tagName, actionButtonName = /actions/i) => { + if (!screen.queryAllByText(tagName)?.length) { + // expand all + const expandButton = screen.queryAllByText('Expand All')?.[0]; + act(() => { + if (expandButton) { + fireEvent.click(expandButton); + } + }); + } + const row = screen.getByText(tagName).closest('tr'); + const actionsButton = within(row).getByRole('button', { name: actionButtonName }); + act(() => { + fireEvent.click(actionsButton); + }); + return row; +}; + +const openSubtagDraftRow = async ({ + tagName, + actionButtonName = /actions/i, + addSubtagIndex = 0, +}) => { + openActionsMenuForTag(tagName, actionButtonName); + fireEvent.click(screen.getAllByText('Add Subtag')[addSubtagIndex]); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + expect(input).toBeInTheDocument(); + return { rows, draftRow, input }; +}; + +const openRenameDraftRow = async (tagName = 'root tag 1') => { + openActionsMenuForTag(tagName); + fireEvent.click(screen.getByText('Rename')); + const input = screen.getByRole('textbox'); + const row = input.closest('tr'); + expect(row).toBeInTheDocument(); + const saveButton = within(row).getByText('Save'); + const cancelButton = within(row).getByText('Cancel'); + return { + row, + input, + saveButton, + cancelButton, + }; +}; describe('', () => { beforeAll(async () => { initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, + authenticatedUser: adminUser, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); beforeEach(async () => { + initializeMockApp({ + authenticatedUser: adminUser, + }); store = initializeStore(); - axiosMock.reset(); + queryClient.clear(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); + renderTagListTable(); + await waitForRootTag(); + await flushReactUpdates(); }); - it('shows the spinner before the query is complete', async () => { - // Simulate an actual slow response from the API: - let resolveResponse; - const promise = new Promise(resolve => { resolveResponse = resolve; }); - axiosMock.onGet(rootTagsListUrl).reply(() => promise); - render(); - const spinner = screen.getByRole('status'); - expect(spinner.textContent).toEqual('loading'); - resolveResponse([200, {}]); - const noFoundComponent = await screen.findByText('No results found'); - expect(noFoundComponent).toBeInTheDocument(); + it('has a valid tr -> td structure when the table is expanded to show subtags', async () => { + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + const allCells = screen.getAllByRole('cell'); + allCells.forEach(cell => { + const nestedTr = cell.querySelector('tr'); + expect(nestedTr).toBeNull(); + }); }); it('should render page correctly', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - render(); - const tag = await screen.findByText('root tag 1'); - expect(tag).toBeInTheDocument(); - const rows = screen.getAllByRole('row'); expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[0]).getAllByRole('columnheader')[0].textContent).toEqual('Tag name'); - expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1 (14)'); + expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1'); }); it('should render page correctly with subtags', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); - render(); - const expandButton = screen.getAllByLabelText('Expand row')[0]; - expandButton.click(); + const expandButton = await screen.findByLabelText('Show Subtags'); + fireEvent.click(expandButton); const childTag = await screen.findByText('the child tag'); expect(childTag).toBeInTheDocument(); }); - it('should not render pagination footer', async () => { + it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - render(); + renderTagListTable(); await waitFor(() => { expect(screen.queryByRole('navigation', { name: /table pagination/i, @@ -153,13 +274,993 @@ describe('', () => { }); }); - it('should render pagination footer', async () => { + // temporarily skipped because pagination is not implemented yet + it.skip('should render pagination footer', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); - render(); + renderTagListTable(); const tableFooter = await screen.findAllByRole('navigation', { name: /table pagination/i, }); expect(tableFooter[0]).toBeInTheDocument(); - expect(tableFooter[1]).toBeInTheDocument(); + }); + + // temporarily skipped because pagination is not implemented yet + it.skip('should render correct number of items in pagination footer', async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); + renderTagListTable(); + const paginationButtons = await screen.findByText('Page 1 of 2'); + expect(paginationButtons).toBeInTheDocument(); + }); + + describe('Create a new top-level tag', () => { + it('should disable tag creation buttons if the taxonomy includes `can_add_tag: false`', async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, { + ...mockTagsResponse, + can_add_tag: false, + }); + cleanup(); + queryClient.clear(); + renderTagListTable(); + await waitForRootTag(); + + openActionsMenuForTag('root tag 1'); + expect(screen.getByText('Add Subtag')).toBeDisabled(); + expect(screen.getByLabelText('Create Tag')).toBeDisabled(); + }); + + describe('with editable user and loaded taxonomy', () => { + it('should add draft row when top-level "Add tag" button is clicked', async () => { + const { creatingRow } = await openTopLevelDraftRow(); + + expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); + expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); + }); + + it('should create a new tag when the draft row is saved', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ + tag: 'a new tag', + })); + }); + }); + + it('should not create a new tag when the draft row is cancelled', async () => { + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const cancelButton = within(creatingRow).getByText('Cancel'); + fireEvent.click(cancelButton); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expect(screen.queryByText('a new tag')).not.toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + + it('should not create a new tag when the escape button is pressed', async () => { + const { input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expect(screen.queryByText('a new tag')).not.toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + it('should show a loading spinner when saving a new tag', async () => { + axiosMock.onPost(createTagUrl).reply(() => new Promise(resolve => { + setTimeout(() => { + resolve([201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }]); + }, 100); + })); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const spinner = await screen.findByRole('status'); + expect(spinner.textContent).toEqual('Saving...'); + }); + + it('should show a newly created top-level tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + let newTag; + await waitFor(() => { + newTag = screen.getByText('a new tag'); + expect(newTag).toBeInTheDocument(); + }); + // expect the new tag to be the first row after the header, that is, the top of the list + const rows = screen.getAllByRole('row'); + expect(rows[1]).toContainElement(newTag); + expectNoDraftRows(); + + // expect only one get request to have been made, that is, the table should not have been refreshed + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should show a toast message when a new tag is successfully saved', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const toast = await screen.findByText('Tag "a new tag" created successfully'); + expect(toast).toBeInTheDocument(); + }); + + it('should add a temporary row to the top of the table', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'xyz tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'xyz tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + // no input row should be in the document + await waitFor(() => { + expectNoDraftRows(); + }); + const temporaryRow = await screen.findByText('xyz tag'); + expect(temporaryRow).toBeInTheDocument(); + }); + + // temporarily skipped because pagination is not implemented yet + it.skip('should refresh the table and remove the temporary row when a pagination button is clicked', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'xyz tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'xyz tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const temporaryRow = await screen.findByText('xyz tag'); + // temporaryRow should be at the top of the table, that is, the first row after the header + const rows = screen.getAllByRole('row'); + expect(rows[1]).toContainElement(temporaryRow); + + // Simulate clicking a pagination button + const paginationButton = await screen.findByRole('button', { name: 'Go to page 2' }); + fireEvent.click(paginationButton); + + await waitFor(() => { + // A get request should have refreshed the table data + expect(axiosMock.history.get.length).toBeGreaterThan(1); + const xyzTagRow = screen.queryByText('xyz tag'); + expect(xyzTagRow).toBeInTheDocument(); + // expect the row to not be the first row after the header + expect(rows[1]).not.toContainElement(xyzTagRow); + }); + }); + + // a bit flaky when ran together with other tests - any way to improve this? + it('should allow adding multiple tags consecutively without a page refresh', async () => { + // clear axios mock history + axiosMock.reset(); + axiosMock.onPost(createTagUrl).reply(config => { + const requestData = JSON.parse(config.data); + return [201, { + ...tagDefaults, + value: requestData.tag, + child_count: 0, + descendant_count: 0, + _id: Math.floor(Math.random() * 10000), + }]; + }); + let addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + let creatingRow = await screen.findByTestId('creating-top-row'); + let input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Tag A' } }); + let saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const tagA = await screen.findByText('Tag A'); + expect(tagA).toBeInTheDocument(); + + addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + creatingRow = await screen.findByTestId('creating-top-row'); + input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Tag B' } }); + saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const tagB = await screen.findByText('Tag B'); + expect(tagB).toBeInTheDocument(); + + // expect Tag B to be above Tag A in the list + const rows = screen.getAllByRole('row'); + const tagBRowIndex = rows.findIndex(row => within(row).queryByText('Tag B')); + const tagARowIndex = rows.findIndex(row => within(row).queryByText('Tag A')); + expect(tagBRowIndex).toBeLessThan(tagARowIndex); + + // no additional get requests should have been made, that is, the table should not have been refreshed + expect(axiosMock.history.get.length).toBe(0); + }); + + it('should disable the Save button when the input is empty', async () => { + const addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + expect(input).toBeInTheDocument(); + const saveButton = within(draftRow[1]).getByText('Save'); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: 'a new tag' } }); + expect(saveButton).not.toBeDisabled(); + }); + + it('should disable the Save button when the input only contains whitespace', async () => { + const addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + expect(input).toBeInTheDocument(); + const saveButton = within(draftRow[1]).getByText('Save'); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: ' ' } }); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: ' a ' } }); + expect(saveButton).not.toBeDisabled(); + }); + + it('should trim leading and trailing whitespace from the tag name before save', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'Tag A', + child_count: 0, + descendant_count: 0, + _id: 4567, + }); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: ' Tag A ' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ tag: 'Tag A' })); + }); + }); + + it('should disable save and show an inline validation error for invalid characters', async () => { + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'invalid;tag' } }); + }); + + expect(saveButton).toBeDisabled(); + expect(screen.getByText(/invalid character/i)).toBeInTheDocument(); + }); + + it('should show an inline duplicate-name error when the entered root tag already exists', async () => { + axiosMock.onPost(createTagUrl).reply(400, ['Tag with this name already exists']); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: 'root tag 1' } }); + fireEvent.click(saveButton); + + expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument(); + }); + + it('should keep the inline row and show a failure toast when save request fails', async () => { + axiosMock.onPost(createTagUrl).reply(500, { + error: 'Internal server error', + }); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: 'will fail' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + const rows = screen.getAllByRole('row'); + const draftRows = rows.filter(row => row.querySelector('input')); + expect(draftRows.length).toBe(1); + }); + + // Banner error message should be shown at the top of the table + expect(await screen.findByText('Error saving changes')).toBeInTheDocument(); + + // Toast message to indicate that the save failed + expect(await screen.findByText('Error creating tag: Internal server error')).toBeInTheDocument(); + // expect the input to retain the value that was entered before + expect(draftRow[1].querySelector('input').value).toEqual('will fail'); + // expect the new tag to not be in the document outside the input field + expect(screen.queryByText('will fail')).not.toBeInTheDocument(); + }); + + it('should disable Add Tag button when the draft row is displayed', async () => { + fireEvent.click(await screen.findByLabelText('Create Tag')); + const addButton = await screen.findByLabelText('Create Tag'); + expect(addButton).toBeDisabled(); + }); + }); + + it('should hide Add Tag for users without taxonomy edit permissions', async () => { + initializeMockApp({ authenticatedUser: nonAdminUser }); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + + expect(screen.queryByText('Add Tag')).not.toBeInTheDocument(); + }); + }); + + describe('Create a new subtag', () => { + it('should show an Add sub-tag option in the parent tag actions', async () => { + expect(screen.queryAllByText('Add Subtag').length).toBe(0); + // user clicks on row actions for root tag 1 + openActionsMenuForTag('root tag 1'); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + }); + + it('should render an inline add-subtag row with input, placeholder, and action buttons', async () => { + const { rows } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + const draftRows = rows.filter(tableRow => tableRow.querySelector('input')); + expect(draftRows[0].querySelector('input')).toBeInTheDocument(); + // expect the draft row to be directly beneath the parent tag row + const parentRowIndex = rows.findIndex(tableRow => within(tableRow).queryByText('root tag 1')); + const draftRowIndex = rows.findIndex(tableRow => tableRow.querySelector('input')); + expect(draftRowIndex).toBe(parentRowIndex + 1); + expect(draftRows[0].querySelector('input')).toBeInTheDocument(); + expect(within(draftRows[0]).getByText('Cancel')).toBeInTheDocument(); + expect(within(draftRows[0]).getByText('Save')).toBeInTheDocument(); + }); + + it('should remove add-subtag row and avoid create request when cancelled', async () => { + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + fireEvent.change(input, { target: { value: 'new subtag' } }); + fireEvent.click(within(draftRow).getByText('Cancel')); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expectNoDraftRows(); + }); + }); + + it('should remove add-subtag row and avoid create request on escape key', async () => { + const { input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + + fireEvent.change(input, { target: { value: 'new subtag' } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expectNoDraftRows(); + }); + }); + + it('should disable Save and show required-name inline error for empty sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(tableRow => tableRow.querySelector('input')); + const saveButton = within(draftRow).getByText('Save'); + const input = draftRow.querySelector('input'); + act(() => { + fireEvent.change(input, { target: { value: ' ' } }); + }); + + expect(saveButton).toBeDisabled(); + expect(within(draftRow).getByText(/Name is required/i)).toBeInTheDocument(); + }); + + it('should keep Save disabled for whitespace-only sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(tableRow => tableRow.querySelector('input')); + const input = draftRow.querySelector('input'); + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.change(input, { target: { value: ' ' } }); + expect(saveButton).toBeDisabled(); + }); + + it('should disable Save and show invalid-character error for sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.change(input, { target: { value: 'invalid;name' } }); + expect(saveButton).toBeDisabled(); + expect(within(draftRow).getByText(/invalid character/i)).toBeInTheDocument(); + }); + + it('should keep inline row and show failure feedback when sub-tag save fails', async () => { + axiosMock.onPost(createTagUrl).reply(500, { + error: 'Internal server error', + }); + + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + fireEvent.change(input, { target: { value: 'subtag fail' } }); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(getDraftRows().length).toBe(1); + }); + expect(await screen.findByText(/Error creating tag:/i)).toBeInTheDocument(); + }); + + it('should hide or disable Add sub-tag actions when user lacks edit permissions', async () => { + initializeMockApp({ authenticatedUser: nonAdminUser }); + const addSubtagActions = screen.queryAllByText('Add Subtag'); + if (addSubtagActions.length === 0) { + expect(addSubtagActions.length).toBe(0); + } else { + addSubtagActions.forEach(action => { + expect(action).toBeDisabled(); + }); + } + }); + }); + + describe('Tag Rename Errors', () => { + it('should keep the inline row and show a failure toast when save request fails', async () => { + axiosMock.onPatch().reply(500, { + error: 'Internal server error', + }); + const { input } = await openRenameDraftRow('root tag 1'); + + fireEvent.change(input, { target: { value: 'will fail' } }); + act(() => { + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + }); + + let draftRow; + await waitFor(() => { + const rows = screen.getAllByRole('row'); + const draftRows = rows.filter(row => row.querySelector('input')); + expect(draftRows.length).toBe(1); + draftRow = draftRows[0]; // eslint-disable-line prefer-destructuring + }); + + // Banner error message should be shown at the top of the table + expect(await screen.findByText('Error saving changes')).toBeInTheDocument(); + + // Toast message to indicate that the save failed + expect(await screen.findByText('Error saving changes')).toBeInTheDocument(); + expect(await screen.findByText('Internal server error')).toBeInTheDocument(); + + // expect the input to retain the value that was entered before + expect(draftRow.querySelector('input').value).toEqual('will fail'); + // expect the new tag to not be in the document outside the input field + expect(screen.queryByText('will fail')).not.toBeInTheDocument(); + }); + }); + + const tagDepthScenarios = [ + { + description: 'Rename a top-level tag', + tagName: 'root tag 1', + }, + { description: 'Rename a sub-tag', tagName: 'the child tag' }, + { description: 'Rename a grandchild tag', tagName: 'the grandchild tag' }, + ]; + + tagDepthScenarios.forEach(({ description, tagName }) => { + describe(description, () => { + beforeEach(async () => { + axiosMock.resetHistory(); + }); + it('should disable tag edit buttons if the taxonomy includes `can_add_tag: false`', async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, { + ...mockTagsResponse, + can_add_tag: false, + }); + cleanup(); + queryClient.clear(); + renderTagListTable(); + await waitForRootTag(); + + openActionsMenuForTag(tagName); + expect(screen.getByText('Rename')).toBeInTheDocument(); + expect(screen.getByText('Rename')).toBeDisabled(); + }); + it('should disable tag edit buttons if tag includes `can_edit: false`', async () => { + axiosMock.reset(); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagResponseDisallowingEdits); + cleanup(); + queryClient.clear(); + renderTagListTable(); + await waitForRootTag(); + + openActionsMenuForTag(tagName); + expect(screen.getByText('Rename')).toBeInTheDocument(); + expect(screen.getByText('Rename')).toBeDisabled(); + }); + + it('should show tag actions menu', async () => { + openActionsMenuForTag(tagName); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + expect(screen.getByText('Rename')).toBeInTheDocument(); + }); + it('should show editable input and action buttons when Rename is selected from actions menu', async () => { + const { row } = await openRenameDraftRow(tagName); + expect(within(row).getByRole('textbox')).toBeInTheDocument(); + // expect the input to be pre-filled with the current tag name + expect(within(row).getByRole('textbox').value).toEqual(tagName); + expect(within(row).getByText('Save')).toBeInTheDocument(); + expect(within(row).getByText('Cancel')).toBeInTheDocument(); + }); + it('should disable Save button until the tag name is changed', async () => { + const { input, saveButton } = await openRenameDraftRow(tagName); + + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: `${tagName} updated` } }); + expect(saveButton).not.toBeDisabled(); + }); + it('should save changes and show success toast when Enter is pressed', async () => { + axiosMock.onPatch(/.*/).reply(200, {}); + const { input } = await openRenameDraftRow(tagName); + + fireEvent.change(input, { target: { value: `${tagName} updated` } }); + act(() => { + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + }); + + await waitFor(() => { + expect(axiosMock.history.patch.length).toBe(1); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ + tag: tagName, + updated_tag_value: `${tagName} updated`, + })); + }); + expect(await screen.findByText(`Tag "${tagName} updated" updated successfully`)).toBeInTheDocument(); + }); + it('should save changes and show success toast when Save is clicked', async () => { + axiosMock.onPatch(/.*/).reply(200, {}); + const { input, saveButton } = await openRenameDraftRow(tagName); + + fireEvent.change(input, { target: { value: `${tagName} updated` } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.history.patch.length).toBe(1); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ + tag: tagName, + updated_tag_value: `${tagName} updated`, + })); + }); + expect(await screen.findByText(`Tag "${tagName} updated" updated successfully`)).toBeInTheDocument(); + }); + it('should cancel editing and revert to original name when Esc is pressed', async () => { + const { input } = await openRenameDraftRow(tagName); + + fireEvent.change(input, { target: { value: `${tagName} updated` } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText(tagName)).toBeInTheDocument(); + expect(axiosMock.history.patch.length).toBe(0); + }); + it('should cancel editing and revert to original name when Cancel is clicked', async () => { + const { input, cancelButton } = await openRenameDraftRow(tagName); + + fireEvent.change(input, { target: { value: `${tagName} updated` } }); + fireEvent.click(cancelButton); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText(tagName)).toBeInTheDocument(); + expect(axiosMock.history.patch.length).toBe(0); + }); + }); + }); + + describe('Nested behavior', () => { + beforeEach(async () => { + axiosMock.resetHistory(); + }); + + it('should keep the parent-child relationships in the updated tree data when renaming a parent tag', async () => { + // this only tests that the frontend is updated correctly before reloading data; + // the rest is covered by the backend tests for the rename endpoint + + axiosMock.onPatch(/.*/).reply(200, {}); + const { input, saveButton } = await openRenameDraftRow('root tag 1'); + + fireEvent.change(input, { target: { value: 'root tag 1 updated' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.history.patch.length).toBe(1); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ + tag: 'root tag 1', + updated_tag_value: 'root tag 1 updated', + })); + }); + // make sure rows are not already expanded by checking that the child tag is not visible before expanding + expect(screen.queryAllByText('the child tag')?.length).toBeFalsy(); + fireEvent.click(await screen.findByLabelText('Show Subtags')); + // expect the child tag to still be present under the renamed parent tag + expect(await screen.findByText('the child tag')).toBeInTheDocument(); + // expect the grandchild tag to still be present under the child tag + openActionsMenuForTag('the child tag'); + fireEvent.click(await screen.findByLabelText('Show Subtags')); + expect(await screen.findByText('the grandchild tag')).toBeInTheDocument(); + }); + }); + + describe('At smaller max depth', () => { + beforeEach(async () => { + const maxDepth = 2; + // clear all previously rendered react + cleanup(); + axiosMock.onGet(rootTagsListUrl).reply(200, { + ...mockTagsResponse, + max_depth: maxDepth, + }); + // re-render with a smaller max depth to allow nested sub-tags + renderTagListTable(maxDepth); + await waitForRootTag(); + await flushReactUpdates(); + }); + it('should only allow adding sub-tags up to the taxonomy max depth', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + + // open actions menu for depth 0 root tag + openActionsMenuForTag('root tag 1'); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + + await screen.findByText('the child tag'); + await screen.findByText('the grandchild tag'); + + // depth 1 is not innermost when maxDepth=2, so adding another sub-tag is allowed + const childTagRow = screen.getByText('the child tag').closest('tr'); + expect(within(childTagRow).getByRole('button', { name: /actions/i })).toBeInTheDocument(); + + // depth 2 is innermost when maxDepth=2, so no add-subtag action should be shown + const grandchildTagRow = screen.getByText('the grandchild tag').closest('tr'); + expect(within(grandchildTagRow).queryByRole('button', { name: /actions/i })).toBeInTheDocument(); + openActionsMenuForTag('the grandchild tag'); + expect(within(grandchildTagRow).getByText('Rename')).toBeInTheDocument(); + expect(within(grandchildTagRow).getByText('Add Subtag')).toBeDisabled(); + }); + }); +}); + +// These async creation flows are intentionally isolated because they pass individually +// but can be flaky when interleaved with the larger suite's async/query timing. +describe(' isolated async subtag tests', () => { + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: adminUser, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: adminUser, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', async () => { + // Simulate an actual slow response from the API: + let resolveResponse; + const promise = new Promise(resolve => { resolveResponse = resolve; }); + axiosMock.onGet(rootTagsListUrl).reply(() => promise); + renderTagListTable(); + const spinner = await screen.findByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + resolveResponse([200, { results: [] }]); + await waitForElementToBeRemoved(() => screen.queryByRole('status')); + const noFoundComponent = await screen.findByText('No results found'); + expect(noFoundComponent).toBeInTheDocument(); + }); + + describe('with loaded root tags', () => { + beforeEach(async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + cleanup(); + renderTagListTable(); + await waitForRootTag(); + await flushReactUpdates(); + }); + + it('should create and render a new sub-tag under the selected parent', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'child-new', + child_count: 0, + descendant_count: 0, + _id: 2222, + parent_value: 'root tag 1', + }); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + + fireEvent.change(input, { target: { value: 'child-new' } }); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('child-new')).toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + + it('should show a newly created sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'child appears immediately', + child_count: 0, + descendant_count: 0, + _id: 3333, + parent_value: 'root tag 1', + }); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + fireEvent.change(input, { target: { value: 'child appears immediately' } }); + expect(screen.queryByText('child appears immediately')).toBeNull(); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(screen.queryByText('child appears immediately')).toBeInTheDocument(); + }); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a nested sub-tag under a sub-tag', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'nested child', + child_count: 0, + descendant_count: 0, + _id: 4444, + parent_value: 'the child tag', + }); + + const expandButton = screen.getAllByLabelText('Show Subtags')[0]; + fireEvent.click(expandButton); + + await screen.findByText('the child tag'); + const { input } = await openSubtagDraftRow({ + tagName: 'the child tag', + actionButtonName: /more actions for tag "the child tag"/i, + }); + fireEvent.change(input, { target: { value: 'nested child' } }); + fireEvent.click(within(input.closest('tr')).getByText('Save')); + + expect(await screen.findByText('nested child')).toBeInTheDocument(); + }); + + it('should show a newly created nested sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'nested child appears immediately', + child_count: 0, + descendant_count: 0, + _id: 5555, + parent_value: 'the child tag', + }); + + const expandButton = screen.getAllByLabelText('Show Subtags')[0]; + fireEvent.click(expandButton); + await screen.findByText('the child tag'); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'the child tag' }); + fireEvent.change(input, { target: { value: 'nested child appears immediately' } }); + + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.click(saveButton); + + expect(await screen.findByText('nested child appears immediately')).toBeInTheDocument(); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a great-grandchild sub-tag under a grandchild tag', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'great grandchild', + child_count: 0, + descendant_count: 0, + _id: 6666, + parent_value: 'the grandchild tag', + }); + + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the grandchild tag'); + + const { input } = await openSubtagDraftRow({ + tagName: 'the grandchild tag', + actionButtonName: /more actions for tag "the grandchild tag"/i, + }); + fireEvent.change(input, { target: { value: 'great grandchild' } }); + fireEvent.click(within(input.closest('tr')).getByText('Save')); + + expect(await screen.findByText('great grandchild')).toBeInTheDocument(); + }); + + it('should show a newly created great-grandchild sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'great grandchild appears immediately', + child_count: 0, + descendant_count: 0, + _id: 7777, + parent_value: 'the grandchild tag', + }); + + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the grandchild tag'); + + const { draftRow, input } = await openSubtagDraftRow({ + tagName: 'the grandchild tag', + actionButtonName: /more actions for tag "the grandchild tag"/i, + }); + fireEvent.change(input, { target: { value: 'great grandchild appears immediately' } }); + + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.click(saveButton); + + expect(await screen.findByText('great grandchild appears immediately')).toBeInTheDocument(); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a sub-tag at depth 2 when maxDepth is 3', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + + await screen.findByText('the grandchild tag'); + const grandchildRow = screen.getByText('the grandchild tag').closest('tr'); + const grandchildActionsButton = within(grandchildRow).getByRole('button', { + name: /more actions for tag "the grandchild tag"/i, + }); + + fireEvent.click(grandchildActionsButton); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + }); + }); +}); + +describe(' pagination transition behavior', () => { + let tableViewProps; + const mockEnterViewMode = jest.fn(); + + const mockTableMode = (tableMode) => { + jest.spyOn(hooksModule, 'useTableModes').mockReturnValue({ + tableMode, + enterDraftMode: jest.fn(), + exitDraftWithoutSave: jest.fn(), + enterPreviewMode: jest.fn(), + enterViewMode: mockEnterViewMode, + }); + }; + + beforeEach(() => { + tableViewProps = null; + mockEnterViewMode.mockReset(); + store = initializeStore(); + queryClient.clear(); + + jest.spyOn(apiHooksModule, 'useTagListData').mockReturnValue({ + isLoading: false, + data: { + results: [], + numPages: 1, + }, + }); + jest.spyOn(apiHooksModule, 'useCreateTag').mockReturnValue({ + isPending: false, + mutateAsync: jest.fn(), + }); + jest.spyOn(hooksModule, 'useEditActions').mockReturnValue({ + handleCreateTag: jest.fn(), + handleUpdateTag: jest.fn(), + validate: jest.fn(() => true), + }); + jest.spyOn(treeTableModule, 'TableView').mockImplementation((props) => { + tableViewProps = props; + return
; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('transitions from preview mode back to view mode on pagination changes', async () => { + mockTableMode('preview'); + + renderTagListTable(); + + expect(await screen.findByTestId('mock-table-view')).toBeInTheDocument(); + expect(tableViewProps).toBeTruthy(); + + act(() => { + tableViewProps.handlePaginationChange({ pageIndex: 1, pageSize: 100 }); + }); + + expect(mockEnterViewMode).toHaveBeenCalled(); + }); + + it('does not transition to view mode on pagination changes when already in view mode', async () => { + mockTableMode('view'); + + renderTagListTable(); + + expect(await screen.findByTestId('mock-table-view')).toBeInTheDocument(); + expect(tableViewProps).toBeTruthy(); + + act(() => { + tableViewProps.handlePaginationChange({ pageIndex: 1, pageSize: 100 }); + }); + + expect(mockEnterViewMode).not.toHaveBeenCalled(); }); }); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx new file mode 100644 index 0000000000..aa98118993 --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -0,0 +1,171 @@ +import React, { + useState, + useMemo, + useEffect, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import type { PaginationState } from '@tanstack/react-table'; +import { useTagListData, useCreateTag, useUpdateTag } from '../data/apiHooks'; +import { TagTree } from './tagTree'; +import { TableView } from '../tree-table'; +import type { + RowId, + TreeColumnDef, + TreeRowData, +} from '../tree-table/types'; +import { + TABLE_MODES, +} from './constants'; +import { getColumns } from './tagColumns'; +import { useTableModes, useEditActions } from './hooks'; + +interface TagListTableProps { + taxonomyId: number; + maxDepth: number; +} + +const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { + // The table has a VIEW, DRAFT, and a PREVIEW mode. It starts in VIEW mode. + // It switches to DRAFT mode when a user edits or creates a tag. + // It switches to PREVIEW mode after saving changes, and only switches to VIEW when + // the user refreshes the page, orders a column, or navigates to a different page. + // During DRAFT and PREVIEW mode the table makes POST requests and receives + // success or failure responses. + // However, the table does not refresh to show the updated data from the backend. + // This allows us to show the newly created or updated tag in the same place without reordering. + const intl = useIntl(); + + const [creatingParentId, setCreatingParentId] = useState(null); + const [editingRowId, setEditingRowId] = useState(null); + const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); + const [tagTree, setTagTree] = useState(null); + const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); + const [activeActionMenuRowId, setActiveActionMenuRowId] = useState(null); + const [draftError, setDraftError] = useState(''); + const treeData = (tagTree?.getAllAsDeepCopy() || []) as unknown as TreeRowData[]; + const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null; + + // TABLE MODES + const { + tableMode, enterDraftMode, exitDraftWithoutSave, enterPreviewMode, enterViewMode, + } = useTableModes(); + + // PAGINATION + // TODO: Fix and enable pagination. For now, disable pagination on the api hook side. + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + const handlePaginationChange = (updater: React.SetStateAction) => { + if (tableMode === TABLE_MODES.PREVIEW) { + enterViewMode(); + } + setPagination(updater); + }; + + // API HOOKS + const { isLoading, data: tagList } = useTagListData(taxonomyId, { + ...pagination, + enabled: tableMode === TABLE_MODES.VIEW, + }); + const createTagMutation = useCreateTag(taxonomyId); + const updateTagMutation = useUpdateTag(taxonomyId); + const pageCount = tagList?.numPages ?? -1; + + // Custom Edit Actions Hook - handles table mode transitions, API calls, + // and updating the table without a full data reload when creating or editing tags. + const { handleCreateTag, handleUpdateTag, validate } = useEditActions({ + setTagTree, + setDraftError, + createTagMutation, + updateTagMutation, + enterPreviewMode, + setToast, + intl, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + }); + + const columns = useMemo( + () => getColumns({ + intl, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + onStartDraft: enterDraftMode, + setActiveActionMenuRowId, + hasOpenDraft, + canAddTag: tagList?.canAddTag !== false, + draftError, + setDraftError, + isSavingDraft: createTagMutation.isPending, + maxDepth, + }), + [ + intl, + isCreatingTopTag, + tableMode, + activeActionMenuRowId, + hasOpenDraft, + creatingParentId, + tagList?.canAddTag, + draftError, + createTagMutation.isPending, + maxDepth, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + enterDraftMode, + setActiveActionMenuRowId, + setDraftError, + ], + ); + + // RELOAD DATA IN VIEW MODE + useEffect(() => { + // Get row data in VIEW mode. Otherwise keep current data to avoid disrupting + // users while they edit or create a tag. + if (tableMode === TABLE_MODES.VIEW && tagList?.results) { + const tree = new TagTree(tagList?.results); + if (tree) { + setTagTree(tree); + } + } + }, [tagList?.results, tableMode]); + + return ( + + ); +}; + +export default TagListTable; diff --git a/src/taxonomy/tag-list/constants.ts b/src/taxonomy/tag-list/constants.ts new file mode 100644 index 0000000000..5ac09eb66b --- /dev/null +++ b/src/taxonomy/tag-list/constants.ts @@ -0,0 +1,25 @@ +const TABLE_MODES = { + VIEW: 'view', + DRAFT: 'draft', + PREVIEW: 'preview', +}; + +const TRANSITION_TABLE = { + [TABLE_MODES.VIEW]: [TABLE_MODES.VIEW, TABLE_MODES.DRAFT], + [TABLE_MODES.DRAFT]: [TABLE_MODES.DRAFT, TABLE_MODES.PREVIEW], + [TABLE_MODES.PREVIEW]: [TABLE_MODES.PREVIEW, TABLE_MODES.DRAFT, TABLE_MODES.VIEW], +}; + +const TABLE_MODE_ACTIONS = { + TRANSITION: 'transition', +}; + +// forbidden characters: '\t', '>', ';' +const TAG_NAME_PATTERN = /^[^\t>;]*$/; + +export { + TABLE_MODES, + TRANSITION_TABLE, + TABLE_MODE_ACTIONS, + TAG_NAME_PATTERN, +}; diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx new file mode 100644 index 0000000000..20d3aa4c3d --- /dev/null +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { IntlProvider, useIntl } from '@edx/frontend-platform/i18n'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { TagTree } from './tagTree'; +import { useEditActions, useTableModes } from './hooks'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const getIntl = () => { + const { result } = renderHook(() => useIntl(), { wrapper }); + return result.current; +}; + +describe('useTableModes', () => { + it('supports valid transitions from view to draft to preview', () => { + const { result } = renderHook(() => useTableModes()); + + expect(result.current.tableMode).toEqual('view'); + + act(() => { + result.current.enterDraftMode(); + }); + expect(result.current.tableMode).toEqual('draft'); + + act(() => { + result.current.enterPreviewMode(); + }); + expect(result.current.tableMode).toEqual('preview'); + }); + + it('throws when transition is invalid for the current mode', () => { + const { result } = renderHook(() => useTableModes()); + + act(() => { + result.current.enterDraftMode(); + }); + + expect(() => { + act(() => { + result.current.enterViewMode(); + }); + }).toThrow('Invalid table mode transition from draft to view'); + }); +}); + +describe('useEditActions', () => { + const buildActions = (overrides = {}) => { + const intl = getIntl(); + const createTagMutation = { mutateAsync: jest.fn() }; + // mock updateTagMutation to have a function `mutateAsync` that returns a resolved promise + const updateTagMutation = { mutateAsync: jest.fn() }; + const setTagTree = jest.fn(); + const setDraftError = jest.fn(); + const enterPreviewMode = jest.fn(); + const setToast = jest.fn(); + const setIsCreatingTopTag = jest.fn(); + const setCreatingParentId = jest.fn(); + const exitDraftWithoutSave = jest.fn(); + const setEditingRowId = jest.fn(); + + const actions = useEditActions({ // eslint-disable-line react-hooks/rules-of-hooks + setTagTree, + setDraftError, + createTagMutation: createTagMutation as any, + enterPreviewMode, + setToast, + intl, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + updateTagMutation: updateTagMutation as any, + ...(overrides as any), + }); + + return { + actions, + createTagMutation, + setTagTree, + setDraftError, + enterPreviewMode, + setToast, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + }; + }; + + it('throws inline validation error in hard mode for invalid characters', () => { + const { actions } = buildActions(); + expect(() => actions.validate('invalid;tag', 'hard')).toThrow('Invalid character in tag name'); + }); + + it('sets an inline validation error and returns false in soft mode', () => { + const { actions, setDraftError } = buildActions(); + + const isValid = actions.validate(' ', 'soft'); + + expect(isValid).toBe(false); + expect(setDraftError).toHaveBeenCalledWith('Name is required'); + }); + + it('adds a new root node when table data is initially empty', () => { + let updatedTree: any = null; + const setTagTree = jest.fn((updater: (current: TagTree | null) => TagTree) => { + updatedTree = updater(null); + }); + + const { actions } = buildActions({ setTagTree }); + actions.updateTableWithoutDataReload('brand new root'); + + expect(updatedTree.getTagAsDeepCopy('brand new root')).not.toBeNull(); + }); + + it('does not transition to preview when update value is unchanged after trimming', async () => { + const { + actions, + enterPreviewMode, + setToast, + setEditingRowId, + } = buildActions(); + + await actions.handleUpdateTag(' same value ', 'same value'); + + expect(enterPreviewMode).not.toHaveBeenCalled(); + expect(setToast).not.toHaveBeenCalled(); + expect(setEditingRowId).toHaveBeenCalledWith(null); + }); + + it('shows success toast and enters preview when update value changes', async () => { + const { + actions, + enterPreviewMode, + setToast, + setEditingRowId, + } = buildActions(); + + await actions.handleUpdateTag('updated', 'original'); + + await waitFor(() => { + expect(enterPreviewMode).toHaveBeenCalled(); + }); + expect(setToast).toHaveBeenCalledWith({ + show: true, + message: 'Tag "updated" updated successfully', + variant: 'success', + }); + expect(setEditingRowId).toHaveBeenCalledWith(null); + }); + + it('keeps draft open and shows failure toast when createTag request fails', async () => { + const { + actions, + createTagMutation, + setDraftError, + setToast, + } = buildActions(); + createTagMutation.mutateAsync.mockRejectedValue(new Error('server failed')); + + await actions.handleCreateTag('new tag'); + + expect(setDraftError).toHaveBeenCalledWith('server failed'); + expect(setToast).toHaveBeenCalledWith({ + show: true, + message: 'Error creating tag: server failed', + variant: 'danger', + }); + }); +}); diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts new file mode 100644 index 0000000000..7ac6e791e6 --- /dev/null +++ b/src/taxonomy/tag-list/hooks.ts @@ -0,0 +1,241 @@ +import { useReducer } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { useCreateTag, useUpdateTag } from '../data/apiHooks'; +import { TagTree } from './tagTree'; +import type { TagTreeNode } from './tagTree'; +import type { RowId } from '../tree-table/types'; +import { + TABLE_MODES, + TRANSITION_TABLE, + TABLE_MODE_ACTIONS, + TAG_NAME_PATTERN, +} from './constants'; + +import messages from './messages'; + +export interface TableModeAction { + type: string; + targetMode: string; +} + +interface UseTableModesReturn { + tableMode: string; + enterDraftMode: () => void; + exitDraftWithoutSave: () => void; + enterPreviewMode: () => void; + enterViewMode: () => void; +} + +interface UseEditActionsParams { + setTagTree: React.Dispatch>; + setDraftError: React.Dispatch>; + createTagMutation: ReturnType; + enterPreviewMode: () => void; + setToast: React.Dispatch>; + intl: ReturnType; + setIsCreatingTopTag: React.Dispatch>; + setCreatingParentId: React.Dispatch>; + exitDraftWithoutSave: () => void; + setEditingRowId: React.Dispatch>; + updateTagMutation: ReturnType; +} + +const getInlineValidationMessage = (value: string, intl: ReturnType): string => { + const trimmed = value.trim(); + if (!trimmed) { + return intl.formatMessage(messages.nameRequired); + } + if (!TAG_NAME_PATTERN.test(trimmed)) { + return intl.formatMessage(messages.invalidCharacterInTagName); + } + return ''; +}; + +const tableModeReducer = (currentMode: string, action: TableModeAction): string => { + if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { + throw new Error(`Unknown table mode action: ${action?.type}`); + } + + const { targetMode } = action; + if (TRANSITION_TABLE[currentMode].includes(targetMode)) { + return targetMode; + } + + throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); +}; + +const useTableModes = (): UseTableModesReturn => { + const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); + + const transitionTableMode = (targetMode: string) => { + dispatchTableMode({ type: TABLE_MODE_ACTIONS.TRANSITION, targetMode }); + }; + + const enterDraftMode = () => transitionTableMode(TABLE_MODES.DRAFT); + const exitDraftWithoutSave = () => transitionTableMode(TABLE_MODES.PREVIEW); + const enterPreviewMode = () => transitionTableMode(TABLE_MODES.PREVIEW); + const enterViewMode = () => transitionTableMode(TABLE_MODES.VIEW); + + return { + tableMode, enterDraftMode, exitDraftWithoutSave, enterPreviewMode, enterViewMode, + }; +}; + +const useEditActions = ({ + setTagTree, + setDraftError, + createTagMutation, + enterPreviewMode, + setToast, + intl, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + updateTagMutation, +}: UseEditActionsParams) => { + // TODO: Move this to tagTree together with very solid tests. + const flattenRows = (nodes: TagTreeNode[]): TagTreeNode[] => { + const result: TagTreeNode[] = []; + + nodes.forEach((node) => { + const { subRows = [], ...rest } = node; + result.push({ ...rest, subRows: undefined }); + if (subRows.length > 0) { + result.push(...flattenRows(subRows)); + } + }); + + return result; + }; + + const renameNode = (nodes: TagTreeNode[], oldValue: string, newValue: string): TagTreeNode[] => ( + nodes.map((node) => { + const renamedNode = { + ...node, + parentValue: node.parentValue === oldValue ? newValue : node.parentValue, + value: node.value === oldValue ? newValue : node.value, + }; + + if (!node.subRows?.length) { + return renamedNode; + } + + return { + ...renamedNode, + subRows: renameNode(node.subRows, oldValue, newValue), + }; + }) + ); + + const updateTableAfterRename = (oldValue: string, newValue: string) => { + setTagTree((currentTagTree) => { + if (!currentTagTree) { + return currentTagTree; + } + + const renamedTreeRows = renameNode(currentTagTree.getAllAsDeepCopy(), oldValue, newValue); + return new TagTree(flattenRows(renamedTreeRows)); + }); + }; + + const updateTableWithoutDataReload = (value: string, parentTagValue: string | null = null) => { + setTagTree((currentTagTree) => { + const nextTree = currentTagTree || new TagTree([]); + const parentTag = parentTagValue ? nextTree.getTagAsDeepCopy(parentTagValue) : null; + + nextTree.addNode({ + id: Date.now(), + value, + parentValue: parentTagValue, + depth: parentTag ? parentTag.depth + 1 : 0, + childCount: 0, + descendantCount: 0, + subTagsUrl: null, + externalId: null, + }, parentTagValue); + + return nextTree; + }); + }; + + const validate = (value: string, mode: 'soft' | 'hard' = 'hard'): boolean => { + const validationError = getInlineValidationMessage(value, intl); + if (validationError) { + if (mode === 'hard') { + throw new Error(validationError); + } + setDraftError(validationError); + return false; + } + + setDraftError(''); + return true; + }; + + const handleCreateTag = async (value: string, parentTagValue?: string) => { + const trimmed = value.trim(); + + if (!validate(trimmed, 'soft')) { + return; + } + + try { + setDraftError(''); + await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); + updateTableWithoutDataReload(trimmed, parentTagValue || null); + enterPreviewMode(); + setToast({ + show: true, + message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), + variant: 'success', + }); + setIsCreatingTopTag(false); + setCreatingParentId(null); + } catch (error) { + const message = intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: (error as Error)?.message }); + setDraftError((error as Error)?.message || intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: '' })); + setToast({ show: true, message, variant: 'danger' }); + } + }; + + const handleUpdateTag = async (value: string, originalValue: string) => { + const trimmed = value.trim(); + if (!validate(trimmed, 'soft')) { + return; + } + + if (trimmed === originalValue) { + setEditingRowId(null); + exitDraftWithoutSave(); + return; + } + + try { + setDraftError(''); + await updateTagMutation.mutateAsync({ value: trimmed, originalValue }); + updateTableAfterRename(originalValue, trimmed); + enterPreviewMode(); + setEditingRowId(null); + setToast({ + show: true, + message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }), + variant: 'success', + }); + } catch (error) { + const message = intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: (error as Error)?.message }); + setDraftError((error as Error)?.message || intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: '' })); + setToast({ show: true, message, variant: 'danger' }); + } + }; + + return { + updateTableWithoutDataReload, + handleCreateTag, + handleUpdateTag, + validate, + }; +}; + +export { useTableModes, useEditActions }; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 77c0efa11a..f40f5f4d05 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -1,10 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - noResultsFoundMessage: { - id: 'course-authoring.tag-list.no-results-found.message', - defaultMessage: 'No results found', - }, tagListColumnValueHeader: { id: 'course-authoring.tag-list.column.value.header', defaultMessage: 'Tag name', @@ -13,6 +9,58 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.error', defaultMessage: 'Error: unable to load child tags', }, + tagCreationSuccessMessage: { + id: 'course-authoring.tag-list.creation-success', + defaultMessage: 'Tag "{name}" created successfully', + }, + tagCreationErrorMessage: { + id: 'course-authoring.tag-list.creation-error', + defaultMessage: 'Error creating tag: {errorMessage}', + }, + tagUpdateSuccessMessage: { + id: 'course-authoring.tag-list.update-success', + defaultMessage: 'Tag "{name}" updated successfully', + }, + addSubtag: { + id: 'course-authoring.tag-list.add-subtag', + defaultMessage: 'Add Subtag', + }, + nameRequired: { + id: 'course-authoring.tag-list.validation.name-required', + defaultMessage: 'Name is required', + }, + invalidCharacterInTagName: { + id: 'course-authoring.tag-list.validation.invalid-character', + defaultMessage: 'Invalid character in tag name', + }, + createNewTagTooltip: { + id: 'course-authoring.tag-list.create-new-tag.tooltip', + defaultMessage: 'Create a new tag', + }, + createTagButtonLabel: { + id: 'course-authoring.tag-list.create-tag.button-label', + defaultMessage: 'Create Tag', + }, + moreActionsForTag: { + id: 'course-authoring.tag-list.more-actions-for-tag', + defaultMessage: 'More actions for tag "{tagName}"', + }, + showSubtagsButtonLabel: { + id: 'course-authoring.tag-list.show-subtags.button-label', + defaultMessage: 'Show Subtags', + }, + hideSubtagsButtonLabel: { + id: 'course-authoring.tag-list.hide-subtags.button-label', + defaultMessage: 'Hide Subtags', + }, + tagUpdateErrorMessage: { + id: 'course-authoring.tag-list.update-error', + defaultMessage: 'Error updating tag: {errorMessage}', + }, + renameTag: { + id: 'course-authoring.tag-list.rename-tag', + defaultMessage: 'Rename', + }, }); export default messages; diff --git a/src/taxonomy/tag-list/mockData.ts b/src/taxonomy/tag-list/mockData.ts new file mode 100644 index 0000000000..695bee619b --- /dev/null +++ b/src/taxonomy/tag-list/mockData.ts @@ -0,0 +1,1389 @@ +import { TagData, TagTreeNode } from './tagTree'; + +export const rawData: TagData[] = [ + { + value: 'ab', + externalId: null, + childCount: 2, + descendantCount: 4, + depth: 0, + parentValue: null, + id: 31, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'aaa', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 49, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=aaa&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'aa', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'aaa', + id: 52, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'ab2', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 50, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab2&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'S3', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ab2', + id: 51, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Brass2', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 36, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celli', + externalId: null, + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 34, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Celli&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'ViolaDaGamba', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Celli', + id: 42, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ViolaDaGamba&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Soprano', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ViolaDaGamba', + id: 46, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Contrabass', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 35, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electrodrum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 38, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electronic instruments', + externalId: 'ELECTRIC', + childCount: 2, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 3, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Electronic+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Synthesizer', + externalId: 'SYNTH', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 25, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Theramin', + externalId: 'THERAMIN', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 9, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Fiddle', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 54, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'grand piano', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 48, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Horns', + externalId: null, + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 55, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Horns&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'English Horn', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Horns', + id: 56, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=English+Horn&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Small English Horn', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'English Horn', + id: 57, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Keyboard', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 37, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Kid drum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 33, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mezzosopranocello', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 41, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oriental', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 53, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Percussion instruments', + externalId: 'PERCUSS', + childCount: 4, + descendantCount: 11, + depth: 0, + parentValue: null, + id: 2, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Percussion+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Chordophone', + externalId: 'CHORD', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 10, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Chordophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Piano', + externalId: 'PIANO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Chordophone', + id: 29, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Drum', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 45, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Drum&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'bass drum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Drum', + id: 47, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Idiophone', + externalId: 'BELLS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Percussion instruments', + id: 5, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Idiophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celesta', + externalId: 'CELESTA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 26, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Hi-hat', + externalId: 'HI-HAT', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 27, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Membranophone', + externalId: 'DRUMS', + childCount: 2, + descendantCount: 3, + depth: 1, + parentValue: 'Percussion instruments', + id: 6, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Membranophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Cajón', + externalId: 'CAJÓN', + childCount: 1, + descendantCount: 1, + depth: 2, + parentValue: 'Membranophone', + id: 7, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Caj%C3%B3n&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tabla', + externalId: 'TABLA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Membranophone', + id: 28, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Recorder', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 39, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'String instruments', + externalId: 'STRINGS', + childCount: 3, + descendantCount: 9, + depth: 0, + parentValue: null, + id: 4, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=String+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Bowed strings', + externalId: 'BOW', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 18, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Bowed+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Cello', + externalId: 'CELLO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 20, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Viola', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 44, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Violin', + externalId: 'VIOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 19, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Other strings', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'String instruments', + id: 43, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Plucked strings', + externalId: 'PLUCK', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 14, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Plucked+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Banjo', + externalId: 'BANJO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 17, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Harp', + externalId: 'HARP', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 16, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mandolin', + externalId: 'MANDOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 15, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Subbass', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 40, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpets', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 30, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Wind instruments', + externalId: 'WINDS', + childCount: 2, + descendantCount: 7, + depth: 0, + parentValue: null, + id: 1, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Wind+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Brass', + externalId: 'BRASS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Wind instruments', + id: 11, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Brass&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpet', + externalId: 'TRUMPET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 23, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tuba', + externalId: 'TUBA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 24, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Woodwinds', + externalId: 'WOODS', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'Wind instruments', + id: 12, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Woodwinds&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Clarinet', + externalId: 'CLARINET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 21, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Flute', + externalId: 'FLUTE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 13, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oboe', + externalId: 'OBOE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 22, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Xyllophones', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 32, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, +]; + +export const treeRowData: TagTreeNode[] = [ + { + value: 'ab', + externalId: null, + childCount: 2, + descendantCount: 4, + depth: 0, + parentValue: null, + id: 31, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'aaa', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 49, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=aaa&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'aa', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'aaa', + id: 52, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'ab2', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 50, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab2&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'S3', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ab2', + id: 51, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Brass2', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 36, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celli', + externalId: null, + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 34, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Celli&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'ViolaDaGamba', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Celli', + id: 42, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ViolaDaGamba&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Soprano', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ViolaDaGamba', + id: 46, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Contrabass', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 35, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electrodrum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 38, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electronic instruments', + externalId: 'ELECTRIC', + childCount: 2, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 3, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Electronic+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Synthesizer', + externalId: 'SYNTH', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 25, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Theramin', + externalId: 'THERAMIN', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 9, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Fiddle', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 54, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'grand piano', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 48, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Horns', + externalId: null, + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 55, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Horns&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'English Horn', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Horns', + id: 56, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=English+Horn&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Small English Horn', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'English Horn', + id: 57, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Keyboard', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 37, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Kid drum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 33, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mezzosopranocello', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 41, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oriental', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 53, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Percussion instruments', + externalId: 'PERCUSS', + childCount: 4, + descendantCount: 11, + depth: 0, + parentValue: null, + id: 2, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Percussion+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Chordophone', + externalId: 'CHORD', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 10, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Chordophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Piano', + externalId: 'PIANO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Chordophone', + id: 29, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Drum', + externalId: null, + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 45, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Drum&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'bass drum', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Drum', + id: 47, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Idiophone', + externalId: 'BELLS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Percussion instruments', + id: 5, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Idiophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Celesta', + externalId: 'CELESTA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 26, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Hi-hat', + externalId: 'HI-HAT', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 27, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Membranophone', + externalId: 'DRUMS', + childCount: 2, + descendantCount: 3, + depth: 1, + parentValue: 'Percussion instruments', + id: 6, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Membranophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Cajón', + externalId: 'CAJÓN', + childCount: 1, + descendantCount: 1, + depth: 2, + parentValue: 'Membranophone', + id: 7, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Caj%C3%B3n&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tabla', + externalId: 'TABLA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Membranophone', + id: 28, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Recorder', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 39, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'String instruments', + externalId: 'STRINGS', + childCount: 3, + descendantCount: 9, + depth: 0, + parentValue: null, + id: 4, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=String+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Bowed strings', + externalId: 'BOW', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 18, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Bowed+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Cello', + externalId: 'CELLO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 20, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Viola', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 44, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Violin', + externalId: 'VIOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 19, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Other strings', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'String instruments', + id: 43, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Plucked strings', + externalId: 'PLUCK', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 14, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Plucked+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Banjo', + externalId: 'BANJO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 17, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Harp', + externalId: 'HARP', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 16, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mandolin', + externalId: 'MANDOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 15, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Subbass', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 40, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpets', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 30, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Wind instruments', + externalId: 'WINDS', + childCount: 2, + descendantCount: 7, + depth: 0, + parentValue: null, + id: 1, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Wind+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Brass', + externalId: 'BRASS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Wind instruments', + id: 11, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Brass&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Trumpet', + externalId: 'TRUMPET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 23, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tuba', + externalId: 'TUBA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 24, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Woodwinds', + externalId: 'WOODS', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'Wind instruments', + id: 12, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Woodwinds&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Clarinet', + externalId: 'CLARINET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 21, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Flute', + externalId: 'FLUTE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 13, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oboe', + externalId: 'OBOE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 22, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Xyllophones', + externalId: null, + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 32, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, +]; diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx new file mode 100644 index 0000000000..d510ab3ca7 --- /dev/null +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -0,0 +1,173 @@ +import { + Button, + Icon, + IconButton, + IconButtonWithTooltip, + Dropdown, +} from '@openedx/paragon'; +import { + AddCircle, + MoreVert, +} from '@openedx/paragon/icons'; +import type { Row } from '@tanstack/react-table'; +import type { IntlShape } from 'react-intl'; + +import messages from './messages'; +import type { + RowId, + TreeColumnDef, + TreeRowData, +} from '../tree-table/types'; +import OptionalExpandLink from './OptionalExpandLink'; + +const EDITABLE_COLUMNS = ['value']; + +interface TagListRowData extends TreeRowData { + depth: number; + childCount: number; + descendantCount: number; + isNew?: boolean; + isEditing?: boolean; +} + +const asTagListRowData = (row: Row): TagListRowData => ( + row.original as unknown as TagListRowData +); + +interface GetColumnsArgs { + intl: IntlShape; + setIsCreatingTopTag: (isCreating: boolean) => void; + setCreatingParentId: (id: RowId | null) => void; + handleUpdateTag: (value: string, originalValue: string) => void; + setEditingRowId: (id: RowId | null) => void; + onStartDraft: () => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + canAddTag: boolean; + draftError: string; + setDraftError: (error: string) => void; + isSavingDraft: boolean; + maxDepth: number; +} + +function getColumns({ + intl, + setIsCreatingTopTag, + setCreatingParentId, + setEditingRowId, + onStartDraft, + setActiveActionMenuRowId, + hasOpenDraft, + canAddTag, + setDraftError, + maxDepth, +}: GetColumnsArgs): TreeColumnDef[] { + const reachedMaxDepth = (row: Row) => row.depth >= maxDepth; + const draftInProgressHintId = 'tag-list-draft-in-progress-hint'; + + return [ + { + id: 'value', + accessorFn: (row) => row.value, + header: intl.formatMessage(messages.tagListColumnValueHeader), + cell: ({ row }) => { + const { + value, + } = asTagListRowData(row); + + return ( + + + {value} + + ); + }, + }, + { + id: 'actions', + header: () => ( +
+ {intl.formatMessage(messages.createNewTagTooltip)}
} + src={AddCircle} + alt={intl.formatMessage(messages.createTagButtonLabel)} + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopTag(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft || !canAddTag} + aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} + /> +
+ ), + cell: ({ row }) => { + const rowData = asTagListRowData(row); + + if (rowData.isNew || rowData.isEditing) { + return
; + } + + const disableAddSubtag = hasOpenDraft || !canAddTag; + const disableEditTag = hasOpenDraft || !canAddTag || row.original.canChangeTag === false; + + const startSubtagDraft = () => { + onStartDraft(); + setDraftError(''); + setCreatingParentId(rowData.id); + setEditingRowId(null); + setIsCreatingTopTag(false); + setActiveActionMenuRowId(null); + row.toggleExpanded(true); + }; + + const editTag = () => { + onStartDraft(); + setDraftError(''); + setEditingRowId(`${rowData.id}:${rowData.value}`); + setCreatingParentId(null); + setIsCreatingTopTag(false); + setActiveActionMenuRowId(null); + }; + + return ( +
+ + + + + {intl.formatMessage(messages.addSubtag)} + + + {intl.formatMessage(messages.renameTag)} + + + +
+ ); + }, + }, + ]; +} + +export { getColumns, EDITABLE_COLUMNS }; diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts new file mode 100644 index 0000000000..f19885faf1 --- /dev/null +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -0,0 +1,328 @@ +import { rawData, treeRowData } from './mockData'; +import { TagTree } from './tagTree'; +import TagTreeError from './tagTreeError'; + +const newSubtagChildRow = { + value: 'newChild', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 8, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, +}; + +describe('TagTree', () => { + it('builds a tree structure from flat tag data', () => { + const tree = new TagTree(rawData); + expect(tree.getAllAsDeepCopy()).toEqual(treeRowData); + }); + + it('handles empty data', () => { + const tree = new TagTree([]); + expect(tree.getAllAsDeepCopy()).toEqual([]); + }); + + it('gets all rows as deep copy', () => { + const tree = new TagTree(rawData); + const nodes = tree.getAllAsDeepCopy(); + expect(nodes).toEqual(treeRowData); + }); + + it('gets a node by value', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('ab'); + expect(node).not.toBeNull(); + expect(node?.value).toBe('ab'); + }); + + it('gets a deep copy when getting a node so that direct mutations do not affect the original tree', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('ab'); + expect(node?.externalId).toBeNull(); + + if (node) { + node.externalId = 'modified'; + } + const originalNode = tree.getTagAsDeepCopy('ab'); + expect(originalNode?.externalId).toBeNull(); + }); + + it('returns null for non-existent node', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('nonExistent'); + expect(node).toBeNull(); + }); + + it('creates a new top-level row', () => { + const tree = new TagTree(rawData); + const newRow = { + value: 'newTopLevel', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 7, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + tree.addNode(newRow, null); + expect(tree.getAllAsDeepCopy()).toContainEqual(newRow); + }); + + it('creates a new child row', () => { + const tree = new TagTree(rawData); + tree.addNode(newSubtagChildRow, 'ab'); + const parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows).toContainEqual(newSubtagChildRow); + }); + + it('edits a node value', () => { + const tree = new TagTree(rawData); + tree.addNode(newSubtagChildRow, 'ab'); + tree.editTagValue('ab', 'editedAb'); + expect(tree.getTagAsDeepCopy('editedAb')).not.toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toBeNull(); + expect(tree.getTagAsDeepCopy('editedAb')?.value).toBe('editedAb'); + expect(tree.getTagAsDeepCopy('editedAb')?.subRows).toContainEqual(newSubtagChildRow); + }); + + it('deletes a top-level node and its children', () => { + const tree = new TagTree(rawData); + tree.addNode(newSubtagChildRow, 'ab'); + tree.removeNode('ab'); + expect(tree.getTagAsDeepCopy('ab')).toBeNull(); + expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); + }); + + it('deletes a child node', () => { + const tree = new TagTree(rawData); + tree.addNode(newSubtagChildRow, 'ab'); + tree.removeNode('newChild', 'ab'); + const parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows).not.toContainEqual(newSubtagChildRow); + }); + + it('returns null and leaves tree unchanged when removing a non-existent node', () => { + const tree = new TagTree(rawData); + const before = tree.getTagAsDeepCopy('ab'); + + const removed = tree.removeNode('does-not-exist'); + + expect(removed).toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toEqual(before); + }); + + it('returns null and leaves tree unchanged when editing a non-existent node', () => { + const tree = new TagTree(rawData); + const before = tree.getTagAsDeepCopy('ab'); + + const edited = tree.editTagValue('does-not-exist', 'new-value'); + + expect(edited).toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toEqual(before); + }); + + it('does not add a node when parentValue is provided but parent does not exist', () => { + const tree = new TagTree(rawData); + const rowCountBefore = tree.getAllAsDeepCopy().length; + + tree.addNode(newSubtagChildRow, 'missing-parent'); + + expect(tree.getAllAsDeepCopy()).toHaveLength(rowCountBefore); + expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); + }); + + it('treats orphaned nodes as roots during tree construction', () => { + const orphanData = [ + { + value: 'orphan', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 900, + parentValue: 'missing-parent', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }, + ]; + + const tree = new TagTree(orphanData); + + expect(tree.getAllAsDeepCopy()).toHaveLength(1); + expect(tree.getAllAsDeepCopy()[0].value).toBe('orphan'); + }); + + it('rejects duplicate tag values during tree construction', () => { + const duplicateValueData = [ + { + value: 'dup', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1001, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }, + { + value: 'dup', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1002, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }, + ]; + + expect(() => new TagTree(duplicateValueData)).toThrow(TagTreeError); + }); + + it('rejects cycles in parent/child relationships during tree construction', () => { + const cyclicData = [ + { + value: 'a', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1101, + parentValue: 'b', + subTagsUrl: null, + childCount: 1, + descendantCount: 1, + depth: 0, + }, + { + value: 'b', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1102, + parentValue: 'a', + subTagsUrl: null, + childCount: 1, + descendantCount: 1, + depth: 1, + }, + ]; + + expect(() => new TagTree(cyclicData)).toThrow(TagTreeError); + }); + + it('throws TagTreeError when editing a tag value to one that already exists', () => { + const tree = new TagTree(rawData); + + expect(() => tree.editTagValue('ab', 'Brass2')).toThrow(TagTreeError); + }); + + it('throws TagTreeError when adding a node with a value that already exists', () => { + const tree = new TagTree(rawData); + const newNode = { + value: 'ab', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 999, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + + expect(() => tree.addNode(newNode)).toThrow(TagTreeError); + }); + + it('adds new top-level rows to the beginning of the tree', () => { + const tree = new TagTree(rawData); + const newNode = { + value: 'new row', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1000, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + + tree.addNode(newNode, null); // Add as the first child of the root + + expect(tree.getAllAsDeepCopy()[0]).toEqual(newNode); + const nextNewNode = { + value: 'another new row', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1001, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + tree.addNode(nextNewNode, null); // Add another top-level node + expect(tree.getAllAsDeepCopy()[0]).toEqual(nextNewNode); + expect(tree.getAllAsDeepCopy()[1]).toEqual(newNode); + }); + + it('adds new child rows to the beginning of the parent node children', () => { + const tree = new TagTree(rawData); + const newChild = { + value: 'new child', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1002, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }; + + tree.addNode(newChild, 'ab'); // Add as the first child of 'ab' + + let parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows?.[0]).toEqual(newChild); + + const nextNewChild = { + value: 'another new child', + externalId: null, + canChangeTag: true, + canDeleteTag: true, + id: 1003, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }; + tree.addNode(nextNewChild, 'ab'); // Add another child to 'ab' + parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows?.[0]).toEqual(nextNewChild); + expect(parentNode?.subRows?.[1]).toEqual(newChild); + }); + + it('returns a flattened list of all nodes including subRows', () => { + const tree = new TagTree(rawData); + const flattened = tree.getAllFlattenedAsCopy(); + const expectedValues = rawData.map(item => item.value); + expect(flattened.map(node => node.value)).toEqual(expectedValues); + }); +}); diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts new file mode 100644 index 0000000000..992a5f2503 --- /dev/null +++ b/src/taxonomy/tag-list/tagTree.ts @@ -0,0 +1,217 @@ +import TagTreeError from './tagTreeError'; + +export interface TagData { + childCount: number; + descendantCount: number; + depth: number; + externalId?: string | null; + canChangeTag?: boolean; + canDeleteTag?: boolean; + id: number; + parentValue: string | null; + subTagsUrl: string | null; + value: string; + usageCount?: number; + _id?: string; +} + +export interface TagTreeNode extends TagData { + subRows?: TagTreeNode[]; +} + +/** + * TagTree + * A robust utility class for managing a tree of table rows based on a flat list of TagData. + * + * The tree is designed to be used as row data for tanstack/react-table. + * The focus is on reliability, and it has not been performance-optimized yet. + */ +export class TagTree { + private data: TagData[]; + + private rows: TagTreeNode[]; + + constructor(data: TagData[]) { + this.data = data; + this.rows = []; + this.buildTree(); + } + + getAllFlattenedAsCopy(): TagTreeNode[] { + const flatten = (nodes: TagTreeNode[], accumulator: TagTreeNode[] = []): TagTreeNode[] => { + for (const node of nodes) { + accumulator.push({ ...node, subRows: undefined }); + if (node.subRows) { + flatten(node.subRows, accumulator); + } + } + return accumulator; + }; + return flatten(this.rows); + } + + getAllAsDeepCopy(): TagTreeNode[] { + return JSON.parse(JSON.stringify(this.rows)); + } + + private validateNoDuplicateValues(items: TagData[]) { + // this should be case-sensitive to account for conceivable duplicates that have different cases in the backend. + const seenValues = new Set(); + for (const item of items) { + if (seenValues.has(item.value)) { + throw new TagTreeError(`Duplicate tag value found: ${item.value}`); + } + seenValues.add(item.value); + } + } + + private validateNoCycles(items: TagData[]) { + const parentByValue: { [key: string]: string | null } = {}; + for (const item of items) { + parentByValue[item.value] = item.parentValue; + } + + const visitStatus: { [key: string]: number } = {}; + + const detectCycle = (value: string): boolean => { + const status = visitStatus[value] || 0; + if (status === 1) { + return true; + } + if (status === 2) { + return false; + } + + visitStatus[value] = 1; + const parentValue = parentByValue[value]; + if (parentValue !== null && Object.prototype.hasOwnProperty.call(parentByValue, parentValue)) { + if (detectCycle(parentValue)) { + return true; + } + } + visitStatus[value] = 2; + return false; + }; + + for (const item of items) { + if (detectCycle(item.value)) { + throw new TagTreeError('Cycle detected in tag hierarchy.'); + } + } + } + + buildTree() { + if (!this.data) { + this.rows = []; + return; + } + + this.validateNoDuplicateValues(this.data); + this.validateNoCycles(this.data); + + const treeChildren: TagTreeNode[] = []; + const lookup: { [key: string]: TagTreeNode } = {}; + + // Step 1: Create a lookup map of all items using 'value' as the key. + // We use the spread operator (...) to create a shallow copy so we + // don't mutate the original data array. + for (const item of this.data) { + lookup[item.value] = { ...item }; + } + + // Step 2: Iterate through the data again to link children to their parents. + for (const item of this.data) { + // Get the reference to the newly copied object in our lookup map + const currentNode = lookup[item.value]; + const parentValue = currentNode?.parentValue; + + if (parentValue !== null && lookup[parentValue]) { + // If the node has a parent, initialize the subRows array (if needed) and push it + const parentNode = lookup[parentValue]; + if (!parentNode.subRows) { + parentNode.subRows = []; + } + parentNode.subRows.push(currentNode); + } else { + // If there is no parentValue (or it equals null), it is a root node + treeChildren.push(currentNode); + } + } + + this.rows = treeChildren; + } + + private findNodeByValueRecursive(nodes: TagTreeNode[], value: string): TagTreeNode | null { + for (const node of nodes) { + if (node.value === value) { + return node; + } + if (node.subRows) { + const found = this.findNodeByValueRecursive(node.subRows, value); + if (found) { + return found; + } + } + } + return null; + } + + private getNode(value: string): TagTreeNode | null { + return this.findNodeByValueRecursive(this.rows, value); + } + + // We don't want to expose editing the tree nodes directly, so that tree integrity is maintained. + getTagAsDeepCopy(value: string): TagTreeNode | null { + const node = this.getNode(value); + if (node) { + return JSON.parse(JSON.stringify(node)); + } + return null; + } + + // For now, only editing a tag's "value" property is supported. + editTagValue(oldValue: string, newValue: string) { + const node = this.getNode(oldValue); + if (node) { + if (oldValue !== newValue && this.getNode(newValue)) { + throw new TagTreeError(`Cannot change tag value to existing value: ${newValue}`); + } + node.value = newValue; + } + return node; + } + + addNode(newNode: TagTreeNode, parentValue: string | null = null) { + if (this.getNode(newNode.value)) { + throw new TagTreeError(`Cannot add duplicate tag value: ${newNode.value}`); + } + + if (parentValue) { + const parentNode = this.getNode(parentValue); + if (parentNode) { + if (!parentNode.subRows) { + parentNode.subRows = []; + } + parentNode.subRows.unshift(newNode); + } + } else { + this.rows.unshift(newNode); + } + } + + removeNode(value: string, parentValue: string | null = null): TagTreeNode | null { + const node = this.getNode(value); + if (!node) { + return null; + } + if (parentValue) { + const parentNode = this.getNode(parentValue); + if (parentNode && parentNode.subRows) { + parentNode.subRows = parentNode.subRows.filter(subNode => subNode.value !== value); + } + } else { + this.rows = this.rows.filter(rootNode => rootNode.value !== value); + } + return node; + } +} diff --git a/src/taxonomy/tag-list/tagTreeError.ts b/src/taxonomy/tag-list/tagTreeError.ts new file mode 100644 index 0000000000..5e1615f257 --- /dev/null +++ b/src/taxonomy/tag-list/tagTreeError.ts @@ -0,0 +1,6 @@ +export default class TagTreeError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagTreeError'; + } +} diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx index bb9fd89c46..bcf14020f4 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -19,6 +19,7 @@ import { TaxonomyMenu } from '../taxonomy-menu'; import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; import { useTaxonomyDetails } from '../data/apiHooks'; import SystemDefinedBadge from '../system-defined-badge'; +import { TAXONOMY_MAX_DEPTH } from './constants'; const TaxonomyDetailPage = () => { const intl = useIntl(); @@ -88,7 +89,7 @@ const TaxonomyDetailPage = () => { xl={[{ span: 9 }, { span: 3 }]} > - + diff --git a/src/taxonomy/taxonomy-detail/constants.ts b/src/taxonomy/taxonomy-detail/constants.ts new file mode 100644 index 0000000000..ae59021c7a --- /dev/null +++ b/src/taxonomy/taxonomy-detail/constants.ts @@ -0,0 +1,6 @@ +/** + * Warning: This must reflect the `TAXONOMY_MAX_DEPTH` used in the openedx-core backend. + */ +const TAXONOMY_MAX_DEPTH = 3; + +export { TAXONOMY_MAX_DEPTH }; diff --git a/src/taxonomy/tree-table/CreateRow.test.tsx b/src/taxonomy/tree-table/CreateRow.test.tsx new file mode 100644 index 0000000000..dedfb12490 --- /dev/null +++ b/src/taxonomy/tree-table/CreateRow.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { CreateRow } from './CreateRow'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const baseProps = () => ({ + draftError: '', + setDraftError: jest.fn(), + handleCreateRow: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + createRowMutation: { isPending: false }, + columns: [{ id: 'value' }], + validate: jest.fn((value: string) => value.trim().length > 0), +}); + +describe('CreateRow', () => { + it('saves on Enter when value is valid', () => { + const props = baseProps(); + render( +
+ + + +
, + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: ' new tag ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(props.handleCreateRow).toHaveBeenCalledWith('new tag'); + }); + + it('does not save on Enter when mutation is pending', () => { + const props = baseProps(); + props.createRowMutation = { isPending: true }; + + render( + + + + +
, + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'pending tag' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(props.handleCreateRow).not.toHaveBeenCalled(); + }); + + it('cancels on Escape and resets draft state', () => { + const props = baseProps(); + + render( + + + + +
, + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'will cancel' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(props.setDraftError).toHaveBeenCalledWith(''); + expect(props.setIsCreatingTopRow).toHaveBeenCalledWith(false); + expect(props.exitDraftWithoutSave).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/CreateRow.tsx b/src/taxonomy/tree-table/CreateRow.tsx new file mode 100644 index 0000000000..4091db0a56 --- /dev/null +++ b/src/taxonomy/tree-table/CreateRow.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { Button, Spinner } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { EditableCell } from './EditableCell'; +import type { CreateRowMutationState, TreeColumnDef } from './types'; +import messages from './messages'; + +interface DraftRowProps { + draftError: string; + initialValue?: string; + onSave: (value: string) => void; + onCancel: () => void; + mutationState: CreateRowMutationState; + columns: TreeColumnDef[]; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + requireValueChangeToEnableSave?: boolean; + rowTestId?: string; + rowId?: string; +} + +interface CreateRowProps { + draftError: string; + setDraftError: (error: string) => void; + handleCreateRow: (value: string) => void; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + createRowMutation: CreateRowMutationState; + columns: TreeColumnDef[]; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +interface EditRowProps { + draftError: string; + setDraftError: (error: string) => void; + initialValue: string; + handleUpdateRow: (value: string) => void; + cancelEditRow: () => void; + updateRowMutation: CreateRowMutationState; + columns: TreeColumnDef[]; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +const DraftRow: React.FC = ({ + draftError, + initialValue = '', + onSave, + onCancel, + mutationState, + columns, + indent = 0, + validate, + requireValueChangeToEnableSave = false, + rowTestId, + rowId, +}) => { + const [rowValue, setRowValue] = useState(initialValue); + const [saveDisabled, setSaveDisabled] = useState(true); + const intl = useIntl(); + + const updateSaveDisabled = (value: string) => { + const trimmedValue = value.trim(); + const isValid = validate(value, 'soft'); + const isUnchanged = requireValueChangeToEnableSave && trimmedValue === initialValue.trim(); + setSaveDisabled(!isValid || !trimmedValue || isUnchanged || mutationState.isPending || false); + }; + + const handleValueChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setRowValue(value); + updateSaveDisabled(value); + }; + + const handleSave = () => { + onSave(rowValue.trim()); + }; + + const handleValueCellKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !saveDisabled && !draftError) { + e.preventDefault(); + handleSave(); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + + +
+ +
+ + + + + + + + + + {mutationState.isPending && ( + + )} + + + + ); +}; + +const CreateRow: React.FC = ({ + draftError, + setDraftError, + handleCreateRow, + setIsCreatingTopRow, + exitDraftWithoutSave, + createRowMutation, + columns, + indent = 0, + validate, +}) => { + const handleCancel = () => { + setDraftError(''); + setIsCreatingTopRow(false); + exitDraftWithoutSave(); + }; + + return ( + + ); +}; + +const EditRow: React.FC = ({ + draftError, + setDraftError, + initialValue, + handleUpdateRow, + cancelEditRow, + updateRowMutation, + columns, + indent = 0, + validate, +}) => { + const handleCancel = () => { + setDraftError(''); + cancelEditRow(); + }; + + return ( + + ); +}; + +export { CreateRow, EditRow }; diff --git a/src/taxonomy/tree-table/EditableCell.test.tsx b/src/taxonomy/tree-table/EditableCell.test.tsx new file mode 100644 index 0000000000..d09fa09e05 --- /dev/null +++ b/src/taxonomy/tree-table/EditableCell.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { + createEvent, + fireEvent, + render, + screen, +} from '@testing-library/react'; + +import { EditableCell } from './EditableCell'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EditableCell', () => { + it('renders inline validation message when provided by validator', () => { + render( + 'Invalid character in tag name'} + />, + { wrapper }, + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Invalid character in tag name'); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby'); + }); + + it('prioritizes explicit errorMessage over validator message', () => { + render( + 'Inline message'} + />, + { wrapper }, + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Server error'); + }); + + it('propagates onChange updates from input', () => { + const onChange = jest.fn(); + render(, { wrapper }); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'next' } }); + expect(onChange).toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveValue('next'); + }); + + it('prevents input clicks from bubbling to parent rows', () => { + render(, { wrapper }); + const input = screen.getByRole('textbox'); + const clickEvent = createEvent.click(input); + const stopPropagationSpy = jest.spyOn(clickEvent, 'stopPropagation'); + + fireEvent(input, clickEvent); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx new file mode 100644 index 0000000000..507976e826 --- /dev/null +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -0,0 +1,86 @@ +import React, { + useState, + useEffect, + useId, + useRef, +} from 'react'; + +import { Form } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import OptionalExpandLink from '../tag-list/OptionalExpandLink'; + +interface EditableCellProps { + initialValue?: string; + onKeyDown?: (event: React.KeyboardEvent) => void; + onChange?: (event: React.ChangeEvent) => void; + errorMessage?: string; + isSaving?: boolean; + autoFocus?: boolean; + getInlineValidationMessage?: (value: string) => string; +} + +const EditableCell = ({ + initialValue = '', + onKeyDown, + onChange = () => {}, + errorMessage = '', + isSaving = false, + getInlineValidationMessage = () => '', + autoFocus = false, +}: EditableCellProps) => { + const [value, setValue] = useState(initialValue); + const inputId = useId(); + const inputRef = useRef(null); + const intl = useIntl(); + + useEffect(() => { + if (autoFocus) { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + } + }, [autoFocus]); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const validationMessage = getInlineValidationMessage(value); + const effectiveErrorMessage = errorMessage || validationMessage; + const errorMessageId = `${inputId}-error`; + + return ( + + + + + { + setValue(e.target.value); + onChange(e); + }} + size="sm" + onKeyDown={onKeyDown} + onClick={(e) => e.stopPropagation()} + floatingLabel={intl.formatMessage(messages.editTagInputLabel)} + disabled={isSaving} + autoComplete="off" + isInvalid={!!effectiveErrorMessage} + aria-describedby={effectiveErrorMessage ? errorMessageId : undefined} + /> + {effectiveErrorMessage && ( + + )} + + + + ); +}; + +export { EditableCell }; diff --git a/src/taxonomy/tree-table/NestedRows.test.tsx b/src/taxonomy/tree-table/NestedRows.test.tsx new file mode 100644 index 0000000000..0909ad5a86 --- /dev/null +++ b/src/taxonomy/tree-table/NestedRows.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import NestedRows from './NestedRows'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const defaultRequiredProps = { + setIsCreatingTopRow: jest.fn(), + createRowMutation: {}, + updateRowMutation: {}, + handleUpdateRow: jest.fn(), + editingRowId: null, + setEditingRowId: jest.fn(), + exitDraftWithoutSave: jest.fn(), + validate: () => true, +}; + +const makeCell = (id: string, content: string) => ({ + id, + column: { columnDef: { cell: () => content } }, + getContext: () => ({}), +}); + +const makeRow = ({ + id, + value, + expanded = true, + subRows = [], +}: { + id: number; + value: string; + expanded?: boolean; + subRows?: any[]; +}) => ({ + id: String(id), + original: { id, value }, + subRows, + getIsExpanded: () => expanded, + getVisibleCells: () => [makeCell(`${id}-cell`, value)], +}); + +describe('NestedRows', () => { + it('renders nothing when parent row is collapsed', () => { + const parent = makeRow({ id: 1, value: 'parent', expanded: false }); + const { container } = render( + + + + +
, + { wrapper }, + ); + + expect(container.querySelector('tr')).toBeNull(); + }); + + it('resets creating parent and runs cancel callback for nested create row', () => { + const nestedChild = makeRow({ id: 2, value: 'child', expanded: true }); + const parent = makeRow({ + id: 1, + value: 'parent', + expanded: true, + subRows: [nestedChild], + }); + const setCreatingParentId = jest.fn(); + const onCancelCreation = jest.fn(); + + render( + + + + +
, + { wrapper }, + ); + + fireEvent.click(screen.getByText('Cancel')); + + expect(setCreatingParentId).toHaveBeenCalledWith(null); + expect(onCancelCreation).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx new file mode 100644 index 0000000000..7c918ec387 --- /dev/null +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { flexRender } from '@tanstack/react-table'; + +import type { + RowId, + TreeRow, + TreeColumnDef, + CreateRowMutationState, +} from './types'; +import { CreateRow, EditRow } from './CreateRow'; + +interface NestedRowsProps { + parentRow: TreeRow; + parentRowValue: string; + isCreating?: boolean; + onSaveNewChildRow?: (value: string, parentRowValue: string) => void; + onCancelCreation?: () => void; + childRowsData?: TreeRow[]; + depth?: number; + draftError?: string; + setDraftError?: (error: string) => void; + creatingParentId?: RowId | null; + setCreatingParentId?: (value: RowId | null) => void; + setIsCreatingTopRow: (isCreating: boolean) => void; + createRowMutation: CreateRowMutationState; + updateRowMutation: CreateRowMutationState; + handleUpdateRow: (value: string, originalValue: string) => void; + editingRowId: RowId | null; + setEditingRowId: (id: RowId | null) => void; + exitDraftWithoutSave: () => void; + columns?: TreeColumnDef[]; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +const NestedRows = ({ + parentRow, + parentRowValue, + isCreating = false, + onSaveNewChildRow = () => {}, + onCancelCreation = () => {}, + childRowsData = [], + depth = 1, + draftError = '', + setDraftError = () => {}, + creatingParentId = null, + setCreatingParentId = () => {}, + setIsCreatingTopRow, + createRowMutation, + updateRowMutation, + handleUpdateRow, + editingRowId, + setEditingRowId, + exitDraftWithoutSave, + columns = [], + validate, +}: NestedRowsProps) => { + if (!parentRow.getIsExpanded()) { + return null; + } + const indent = Math.max(depth, 1); + + return ( + <> + {isCreating && ( + onSaveNewChildRow(value, parentRowValue)} + setIsCreatingTopRow={setIsCreatingTopRow} + exitDraftWithoutSave={onCancelCreation} + createRowMutation={createRowMutation} + columns={[]} + indent={indent} + validate={validate} + /> + )} + {childRowsData?.map(row => { + const rowData = row.original || row; + return ( + + {editingRowId === `${row.original.id}:${String(row.original.value)}` ? ( + handleUpdateRow(value, String(row.original.value))} + cancelEditRow={() => { + setEditingRowId(null); + exitDraftWithoutSave(); + }} + updateRowMutation={updateRowMutation} + columns={columns} + indent={indent} + validate={validate} + /> + ) : ( + + {row.getVisibleCells() + .map((cell, index) => { + const content = flexRender(cell.column.columnDef.cell, cell.getContext()); + const isFirstColumn = index === 0; + + return ( + + {isFirstColumn ? ( +
{content}
+ ) : ( + content + )} + + ); + })} + + )} + { + setCreatingParentId(null); + onCancelCreation(); + } + } + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={depth + 1} + draftError={draftError} + setDraftError={setDraftError} + setIsCreatingTopRow={setIsCreatingTopRow} + createRowMutation={createRowMutation} + updateRowMutation={updateRowMutation} + handleUpdateRow={handleUpdateRow} + editingRowId={editingRowId} + setEditingRowId={setEditingRowId} + exitDraftWithoutSave={exitDraftWithoutSave} + columns={columns} + validate={validate} + /> +
+ ); + })} + + ); +}; + +export default NestedRows; diff --git a/src/taxonomy/tree-table/TableBody.tsx b/src/taxonomy/tree-table/TableBody.tsx new file mode 100644 index 0000000000..6e1819a289 --- /dev/null +++ b/src/taxonomy/tree-table/TableBody.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { flexRender } from '@tanstack/react-table'; + +import { LoadingSpinner } from '@src/generic/Loading'; +import NestedRows from './NestedRows'; + +import messages from './messages'; + +import type { + CreateRowMutationState, + RowId, + TreeColumnDef, + TreeTable, +} from './types'; +import { CreateRow, EditRow } from './CreateRow'; + +interface TableBodyProps { + columns: TreeColumnDef[]; + isCreatingTopRow: boolean; + draftError: string; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + handleCreateRow: (value: string, parentRowValue?: string) => void; + creatingParentId: RowId | null; + setCreatingParentId: (id: RowId | null) => void; + setDraftError: (error: string) => void; + createRowMutation: CreateRowMutationState; + updateRowMutation: CreateRowMutationState; + table: TreeTable; + isLoading: boolean; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + handleUpdateRow: (value: string, originalValue: string) => void; + editingRowId: RowId | null; + setEditingRowId: (id: RowId | null) => void; +} + +const TableBody = ({ + columns, + isCreatingTopRow, + draftError, + handleCreateRow, + setIsCreatingTopRow, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + createRowMutation, + updateRowMutation, + table, + isLoading, + validate, + handleUpdateRow, + editingRowId, + setEditingRowId, +}: TableBodyProps) => { + const intl = useIntl(); + + if (isLoading) { + return ( + + + + + + + + ); + } + + return ( + + {table.getRowModel().rows.length === 0 && ( + + + {intl.formatMessage(messages.noResultsFoundMessage)} + + + )} + + {isCreatingTopRow && ( + + )} + + {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( + + {editingRowId === `${row.original.id}:${String(row.original.value)}` ? ( + handleUpdateRow(value, String(row.original.value))} + cancelEditRow={() => { + setEditingRowId(null); + exitDraftWithoutSave(); + }} + updateRowMutation={updateRowMutation} + columns={columns} + validate={validate} + /> + ) : ( + + {row.getVisibleCells() + .map((cell, index) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )} + { + setDraftError(''); + setCreatingParentId(null); + exitDraftWithoutSave(); + }} + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={1} + draftError={draftError} + createRowMutation={createRowMutation} + setDraftError={setDraftError} + setIsCreatingTopRow={setIsCreatingTopRow} + validate={validate} + updateRowMutation={updateRowMutation} + handleUpdateRow={handleUpdateRow} + editingRowId={editingRowId} + setEditingRowId={setEditingRowId} + exitDraftWithoutSave={exitDraftWithoutSave} + columns={columns} + /> + + ))} + + ); +}; + +export default TableBody; diff --git a/src/taxonomy/tree-table/TableView.scss b/src/taxonomy/tree-table/TableView.scss new file mode 100644 index 0000000000..19647fadf2 --- /dev/null +++ b/src/taxonomy/tree-table/TableView.scss @@ -0,0 +1,21 @@ +.tree-table-layout-fixed { + table-layout: fixed; +} + +.tree-table-create-row-actions-cell { + overflow-wrap: anywhere; +} + +.tree-table-overflow-anywhere { + overflow-wrap: anywhere; +} + +.tree-table-indent { + padding-inline-start: var(--pgn-spacing-spacer-base); +} + +@for $depth from 2 through 10 { + .tree-table-indent-#{$depth} { + padding-inline-start: calc(var(--pgn-spacing-spacer-base) * #{$depth}); + } +} diff --git a/src/taxonomy/tree-table/TableView.test.tsx b/src/taxonomy/tree-table/TableView.test.tsx new file mode 100644 index 0000000000..f1382eeea5 --- /dev/null +++ b/src/taxonomy/tree-table/TableView.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { TableView } from './TableView'; + +jest.mock('./TableBody', () => { + const MockTableBody = () => ( + + + mock body + + + ); + return MockTableBody; +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const baseProps = () => ({ + treeData: [{ id: 1, value: 'root' }], + columns: [{ accessorKey: 'value', header: 'Tag name', cell: (info: any) => info.getValue() }], + pageCount: 3, + pagination: { pageIndex: 0, pageSize: 10 }, + handlePaginationChange: jest.fn(), + isLoading: false, + isCreatingTopRow: false, + draftError: '', + createRowMutation: { isPending: false, isError: false }, + updateRowMutation: { isPending: false, isError: false }, + toast: { show: false, message: '', variant: 'success' }, + setToast: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + handleCreateRow: jest.fn(), + creatingParentId: null, + setCreatingParentId: jest.fn(), + setDraftError: jest.fn(), + validate: jest.fn(() => true), + handleUpdateRow: jest.fn(), + editingRowId: null, + setEditingRowId: jest.fn(), +}); + +describe('TableView', () => { + it('shows and dismisses save error banner', () => { + const props = baseProps(); + props.createRowMutation = { isPending: false, isError: true }; + + render(, { wrapper }); + + expect(screen.getByText('Error saving changes')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(screen.queryByText('Error saving changes')).not.toBeInTheDocument(); + }); + + it('keeps pagination hidden by default even when multiple pages are reported', () => { + const props = baseProps(); + render(, { wrapper }); + + expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); + }); + + it('renders pagination and updates page selection when explicitly enabled', () => { + const props = baseProps(); + render(, { wrapper }); + + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^page 2$/i })); + expect(props.handlePaginationChange).toHaveBeenCalled(); + }); + + it('hides pagination when there is only one page', () => { + const props = baseProps(); + props.pageCount = 1; + render(, { wrapper }); + + expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); + }); + + it('closes toast by setting show to false', () => { + const props = baseProps(); + props.toast = { show: true, message: 'created', variant: 'success' }; + + render(, { wrapper }); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(props.setToast).toHaveBeenCalled(); + const updater = props.setToast.mock.calls[0][0]; + expect(updater({ show: true, message: 'created', variant: 'success' })).toEqual({ + show: false, + message: 'created', + variant: 'success', + }); + }); +}); diff --git a/src/taxonomy/tree-table/TableView.tsx b/src/taxonomy/tree-table/TableView.tsx new file mode 100644 index 0000000000..79aa625259 --- /dev/null +++ b/src/taxonomy/tree-table/TableView.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { + Button, + Toast, + Card, + ActionRow, + Pagination, + Alert, + Icon, +} from '@openedx/paragon'; + +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + flexRender, + type OnChangeFn, + type PaginationState, +} from '@tanstack/react-table'; + +import { ArrowDropUpDown, Info } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import TableBody from './TableBody'; +import './TableView.scss'; +import type { + CreateRowMutationState, + RowId, + ToastState, + TreeColumnDef, + TreeRowData, +} from './types'; +import messages from './messages'; + +interface TableViewProps { + treeData: TreeRowData[]; + columns: TreeColumnDef[]; + pageCount: number; + enablePagination?: boolean; + pagination: PaginationState; + handlePaginationChange: OnChangeFn; + isLoading: boolean; + isCreatingTopRow: boolean; + draftError: string; + createRowMutation: CreateRowMutationState; + updateRowMutation: CreateRowMutationState; + toast: ToastState; + setToast: React.Dispatch>; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + handleCreateRow: (value: string, parentRowValue?: string) => void; + creatingParentId: RowId | null; + setCreatingParentId: (id: RowId | null) => void; + setDraftError: (error: string) => void; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + handleUpdateRow: (value: string, originalValue: string) => void; + editingRowId: RowId | null; + setEditingRowId: (id: RowId | null) => void; +} + +const TableView = ({ + treeData, + columns, + pageCount, + enablePagination = false, + pagination, + handlePaginationChange, + isLoading, + isCreatingTopRow, + draftError, + createRowMutation, + updateRowMutation, + handleCreateRow, + toast, + setToast, + setIsCreatingTopRow, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + validate, + handleUpdateRow, + editingRowId, + setEditingRowId, +}: TableViewProps) => { + const intl = useIntl(); + + const table = useReactTable({ + data: treeData, + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + manualPagination: true, + pageCount: pageCount ?? -1, + state: { + pagination, + }, + onPaginationChange: handlePaginationChange, + getSubRows: (row) => row?.subRows || undefined, + }); + + const currentPageIndex = table.getState().pagination.pageIndex + 1; + + const { isError } = createRowMutation; + const { isError: isUpdateError } = updateRowMutation; + const [showError, setShowError] = React.useState(true); + + return ( + <> + {(isError || isUpdateError) && showError && ( + setShowError(false)}> + + {intl.formatMessage(messages.errorSavingTitle)} + + {intl.formatMessage(messages.errorSavingMessage, { errorMessage: draftError || intl.formatMessage(messages.errorSavingMessage, { errorMessage: '' }) })} + + )} + + +
+ {/* TODO: Implement search functionality */} + + + +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map((header, index) => ( + + ))} + + ))} + + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+
+ + {enablePagination && pageCount > 1 && ( +
+ + {intl.formatMessage(messages.tablePaginationPageStatus, { + currentPage: currentPageIndex, + pageCount, + })} + + { + table.setPageIndex(page - 1); + }} + /> +
+ )} + { + setToast((prevToast) => ({ ...prevToast, show: false })); + }} + delay={15000} + > + {toast.message} + +
+ + ); +}; + +export { TableView }; diff --git a/src/taxonomy/tree-table/index.ts b/src/taxonomy/tree-table/index.ts new file mode 100644 index 0000000000..33e31066d9 --- /dev/null +++ b/src/taxonomy/tree-table/index.ts @@ -0,0 +1,2 @@ +export { TableView } from './TableView'; +export { EditableCell } from './EditableCell'; diff --git a/src/taxonomy/tree-table/messages.ts b/src/taxonomy/tree-table/messages.ts new file mode 100644 index 0000000000..e3bea741e1 --- /dev/null +++ b/src/taxonomy/tree-table/messages.ts @@ -0,0 +1,54 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + errorSavingTitle: { + id: 'course-authoring.tree-table.error-saving.title', + defaultMessage: 'Error saving changes', + }, + errorSavingMessage: { + id: 'course-authoring.tree-table.error-saving.message', + defaultMessage: '{errorMessage}. Please try again.', + }, + expandAll: { + id: 'course-authoring.tree-table.expand-all', + defaultMessage: 'Expand All', + }, + collapseAll: { + id: 'course-authoring.tree-table.collapse-all', + defaultMessage: 'Collapse All', + }, + noResultsFoundMessage: { + id: 'course-authoring.tree-table.no-results-found.message', + defaultMessage: 'No results found', + }, + searchPlaceholder: { + id: 'course-authoring.tree-table.search.placeholder', + defaultMessage: 'Search...', + }, + editTagInputLabel: { + id: 'course-authoring.tree-table.edit-tag-input.label', + defaultMessage: 'Type tag name', + }, + cancelButtonLabel: { + id: 'course-authoring.tree-table.cancel.button-label', + defaultMessage: 'Cancel', + }, + saveButtonLabel: { + id: 'course-authoring.tree-table.save.button-label', + defaultMessage: 'Save', + }, + savingSpinnerScreenReaderText: { + id: 'course-authoring.tree-table.saving-spinner.screen-reader-text', + defaultMessage: 'Saving...', + }, + tablePaginationLabel: { + id: 'course-authoring.tree-table.pagination.label', + defaultMessage: 'table pagination', + }, + tablePaginationPageStatus: { + id: 'course-authoring.tree-table.pagination.page-status', + defaultMessage: 'Page {currentPage} of {pageCount}', + }, +}); + +export default messages; diff --git a/src/taxonomy/tree-table/types.ts b/src/taxonomy/tree-table/types.ts new file mode 100644 index 0000000000..8dabb408e0 --- /dev/null +++ b/src/taxonomy/tree-table/types.ts @@ -0,0 +1,31 @@ +import type { + ColumnDef, + Row, + Table, +} from '@tanstack/react-table'; + +export type RowId = string | number; + +export interface TreeRowData { + id: RowId; + value: string; + subRows?: TreeRowData[]; + depth?: number; + [key: string]: unknown; +} + +export type TreeRow = Row; +export type TreeTable = Table; +export type TreeColumnDef = ColumnDef; + +export interface CreateRowMutationState { + isPending?: boolean; + isError?: boolean; + error?: unknown; +} + +export interface ToastState { + show: boolean; + message: string; + variant: string; +}