diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index eff6bfea75..be55bfaacb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -33,7 +33,7 @@ jobs: path: coverage merge-multiple: true - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 7d97aa32a8..8164581eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.95.2", "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", @@ -96,7 +96,7 @@ "jest-canvas-mock": "^2.5.2", "jest-expect-message": "^1.1.3", "oxlint": "^1.42.0", - "oxlint-tsgolint": "^0.16.0", + "oxlint-tsgolint": "^0.17.0", "react-test-renderer": "^18.3.1", "redux-mock-store": "^1.5.4" } @@ -178,7 +178,6 @@ "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", @@ -2055,12 +2054,12 @@ } }, "node_modules/@bundled-es-modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2329,21 +2328,21 @@ } }, "node_modules/@codemirror/state": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", - "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.39.16", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", - "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.5.0", + "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" @@ -2396,7 +2395,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2419,7 +2417,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -2496,7 +2493,6 @@ "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", @@ -2674,7 +2670,6 @@ "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", @@ -3446,7 +3441,6 @@ "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" }, @@ -5208,9 +5202,9 @@ "link": true }, "node_modules/@openedx/frontend-build": { - "version": "14.6.2", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.2.tgz", - "integrity": "sha512-Iu4/GPq90Xr/MSWnonn2qX8VDhI89HN7KOYBZ0/sxmAQgvXXNc7OYNC7kumvzbYzKueJQTyZoUYS7UjKB/n1WA==", + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.3.tgz", + "integrity": "sha512-6TVe8WWRuakErz/5wwN+CbaE2MItp8pKiJc2rB+3J0azRIjbWiEK40Vk0SKJVkdnZBlp0VlSSSQGZnlwbFF60g==", "license": "AGPL-3.0", "dependencies": { "@babel/cli": "7.24.8", @@ -5231,7 +5225,7 @@ "@types/jest": "29.5.12", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", - "autoprefixer": "10.4.20", + "autoprefixer": "10.4.27", "babel-jest": "29.7.0", "babel-loader": "9.2.1", "babel-plugin-formatjs": "^10.4.0", @@ -5412,11 +5406,10 @@ } }, "node_modules/@openedx/paragon": { - "version": "23.19.1", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.1.tgz", - "integrity": "sha512-c/cWnvZsGS7xyq0tJpssmv2oyfYG6Fuawy6EzWy8CYiQ4oD67EVuSwBInCfSJoNZhvvkUE+4B/YhDIRGUVDz5w==", + "version": "23.19.2", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.2.tgz", + "integrity": "sha512-4umD73Ujknvo4Bt1dr5X9QvwR1vlSkdoG/s6SaKmBlUa1eP1CicpIBqZiMC5/z3BUPcorxWedXQKp1Rdlv+73Q==", "license": "Apache-2.0", - "peer": true, "workspaces": [ "example", "component-generator", @@ -5480,9 +5473,9 @@ } }, "node_modules/@openedx/paragon/node_modules/axios": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.2.tgz", - "integrity": "sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==", + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.3.tgz", + "integrity": "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.4", @@ -5491,9 +5484,9 @@ } }, "node_modules/@openedx/paragon/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5512,7 +5505,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5529,9 +5522,9 @@ } }, "node_modules/@openedx/paragon/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -5582,9 +5575,9 @@ } }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz", - "integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.17.2.tgz", + "integrity": "sha512-1/QmWTRB8g5273wUnmmQxQz+kEFLJq8MYS82uFdxulUa2sdggWEQphnRhDRAzcbOCtrsya8+xGohv41dqRM/hQ==", "cpu": [ "arm64" ], @@ -5596,9 +5589,9 @@ ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz", - "integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.17.2.tgz", + "integrity": "sha512-GjEvcZPm8e9N2QtRlH5ttRr4II1ph86iR+gj7P7u47NuxKs099GivV0ISAsRlG09uYgRG3lTe2x5JrnMknuI0Q==", "cpu": [ "x64" ], @@ -5610,9 +5603,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz", - "integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.17.2.tgz", + "integrity": "sha512-Ybo4npjDMXQ15MBoftOBut9/gOdHhbnIhRmphx9owBQcZBmwrIy1+PfLqHRBuTCJ8diUmxQxSRkvXrGb+ogGqQ==", "cpu": [ "arm64" ], @@ -5624,9 +5617,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz", - "integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.17.2.tgz", + "integrity": "sha512-bU3A7bg9qa1VeWUwYwbXaAcUCOW+fl+SndNMNoYpm2+nhsAzzr9k9jz5Qr7NeKwbYet3qETjmhCmmfqe1syiPA==", "cpu": [ "x64" ], @@ -5638,9 +5631,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.16.0.tgz", - "integrity": "sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.17.2.tgz", + "integrity": "sha512-MeM1tyeg8J4DoHxAO3geDllM0Zm0tQDieQ701OXiS/vFA4QK+v+qBEJALqUys5obbIlLR2scmhzGor89bOr2ug==", "cpu": [ "arm64" ], @@ -5652,9 +5645,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz", - "integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.17.2.tgz", + "integrity": "sha512-XfmGnyosL9jDGPwZcoDqdYADQNXjzH5hZs0xoZFodBbQhI1oAuItw/XR6tgga6grjusPSMS7j373sSGLLrE3yg==", "cpu": [ "x64" ], @@ -5666,9 +5659,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", - "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.57.0.tgz", + "integrity": "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==", "cpu": [ "arm" ], @@ -5683,9 +5676,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", - "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.57.0.tgz", + "integrity": "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==", "cpu": [ "arm64" ], @@ -5700,9 +5693,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", - "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.57.0.tgz", + "integrity": "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==", "cpu": [ "arm64" ], @@ -5717,9 +5710,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", - "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.57.0.tgz", + "integrity": "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==", "cpu": [ "x64" ], @@ -5734,9 +5727,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", - "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.57.0.tgz", + "integrity": "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==", "cpu": [ "x64" ], @@ -5751,9 +5744,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", - "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.57.0.tgz", + "integrity": "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==", "cpu": [ "arm" ], @@ -5768,9 +5761,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", - "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.57.0.tgz", + "integrity": "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==", "cpu": [ "arm" ], @@ -5785,13 +5778,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", - "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.57.0.tgz", + "integrity": "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5802,13 +5798,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", - "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.57.0.tgz", + "integrity": "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5819,13 +5818,16 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", - "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.57.0.tgz", + "integrity": "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5836,13 +5838,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", - "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.57.0.tgz", + "integrity": "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5853,13 +5858,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", - "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.57.0.tgz", + "integrity": "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5870,13 +5878,16 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", - "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.57.0.tgz", + "integrity": "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5887,13 +5898,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", - "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.57.0.tgz", + "integrity": "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5904,13 +5918,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", - "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.57.0.tgz", + "integrity": "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5921,9 +5938,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", - "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.57.0.tgz", + "integrity": "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==", "cpu": [ "arm64" ], @@ -5938,9 +5955,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", - "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.57.0.tgz", + "integrity": "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==", "cpu": [ "arm64" ], @@ -5955,9 +5972,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", - "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.57.0.tgz", + "integrity": "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==", "cpu": [ "ia32" ], @@ -5972,9 +5989,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", - "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.57.0.tgz", + "integrity": "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==", "cpu": [ "x64" ], @@ -6375,7 +6392,6 @@ "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" @@ -6399,7 +6415,6 @@ "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", @@ -6425,8 +6440,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { "version": "3.1.0", @@ -6674,7 +6688,6 @@ "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", @@ -6774,9 +6787,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", "license": "MIT", "funding": { "type": "github", @@ -6784,12 +6797,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.95.2" }, "funding": { "type": "github", @@ -7000,7 +7013,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -7396,7 +7410,6 @@ "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" @@ -7408,7 +7421,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -7547,7 +7559,6 @@ "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", @@ -7594,7 +7605,6 @@ "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", @@ -8274,7 +8284,6 @@ "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" }, @@ -8364,7 +8373,6 @@ "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", @@ -8795,9 +8803,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "funding": [ { "type": "opencollective", @@ -8814,11 +8822,10 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -8860,7 +8867,6 @@ "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", @@ -9044,7 +9050,6 @@ "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", @@ -9241,9 +9246,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9421,7 +9426,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9625,9 +9629,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001779", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "funding": [ { "type": "opencollective", @@ -9780,8 +9784,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/clean-css": { "version": "5.3.3", @@ -11132,7 +11135,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -11324,7 +11328,6 @@ "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" } @@ -11669,7 +11672,6 @@ "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", @@ -11726,7 +11728,6 @@ "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", @@ -11767,7 +11768,6 @@ "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" }, @@ -12243,6 +12243,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -12252,6 +12253,7 @@ "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" }, @@ -12264,7 +12266,6 @@ "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", @@ -12301,7 +12302,6 @@ "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", @@ -12332,7 +12332,6 @@ "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" }, @@ -13384,7 +13383,6 @@ } ], "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", @@ -13418,15 +13416,15 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -15550,7 +15548,6 @@ "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", @@ -16959,8 +16956,7 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.23", @@ -17491,9 +17487,9 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -17550,7 +17546,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -17796,15 +17791,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -18130,9 +18116,9 @@ } }, "node_modules/oxlint": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", - "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.57.0.tgz", + "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", "dev": true, "license": "MIT", "bin": { @@ -18145,25 +18131,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.56.0", - "@oxlint/binding-android-arm64": "1.56.0", - "@oxlint/binding-darwin-arm64": "1.56.0", - "@oxlint/binding-darwin-x64": "1.56.0", - "@oxlint/binding-freebsd-x64": "1.56.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", - "@oxlint/binding-linux-arm-musleabihf": "1.56.0", - "@oxlint/binding-linux-arm64-gnu": "1.56.0", - "@oxlint/binding-linux-arm64-musl": "1.56.0", - "@oxlint/binding-linux-ppc64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-musl": "1.56.0", - "@oxlint/binding-linux-s390x-gnu": "1.56.0", - "@oxlint/binding-linux-x64-gnu": "1.56.0", - "@oxlint/binding-linux-x64-musl": "1.56.0", - "@oxlint/binding-openharmony-arm64": "1.56.0", - "@oxlint/binding-win32-arm64-msvc": "1.56.0", - "@oxlint/binding-win32-ia32-msvc": "1.56.0", - "@oxlint/binding-win32-x64-msvc": "1.56.0" + "@oxlint/binding-android-arm-eabi": "1.57.0", + "@oxlint/binding-android-arm64": "1.57.0", + "@oxlint/binding-darwin-arm64": "1.57.0", + "@oxlint/binding-darwin-x64": "1.57.0", + "@oxlint/binding-freebsd-x64": "1.57.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", + "@oxlint/binding-linux-arm-musleabihf": "1.57.0", + "@oxlint/binding-linux-arm64-gnu": "1.57.0", + "@oxlint/binding-linux-arm64-musl": "1.57.0", + "@oxlint/binding-linux-ppc64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-musl": "1.57.0", + "@oxlint/binding-linux-s390x-gnu": "1.57.0", + "@oxlint/binding-linux-x64-gnu": "1.57.0", + "@oxlint/binding-linux-x64-musl": "1.57.0", + "@oxlint/binding-openharmony-arm64": "1.57.0", + "@oxlint/binding-win32-arm64-msvc": "1.57.0", + "@oxlint/binding-win32-ia32-msvc": "1.57.0", + "@oxlint/binding-win32-x64-msvc": "1.57.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" @@ -18175,22 +18161,21 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz", - "integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.17.2.tgz", + "integrity": "sha512-W3gmZSOzNFGs9EwU8i3xlDpC0aqynQNtoDnaftdAZ3FE8cR7W625pPRbSmtsUOtTC0MPixx1i08R6uRVLfPp7g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "0.16.0", - "@oxlint-tsgolint/darwin-x64": "0.16.0", - "@oxlint-tsgolint/linux-arm64": "0.16.0", - "@oxlint-tsgolint/linux-x64": "0.16.0", - "@oxlint-tsgolint/win32-arm64": "0.16.0", - "@oxlint-tsgolint/win32-x64": "0.16.0" + "@oxlint-tsgolint/darwin-arm64": "0.17.2", + "@oxlint-tsgolint/darwin-x64": "0.17.2", + "@oxlint-tsgolint/linux-arm64": "0.17.2", + "@oxlint-tsgolint/linux-x64": "0.17.2", + "@oxlint-tsgolint/win32-arm64": "0.17.2", + "@oxlint-tsgolint/win32-x64": "0.17.2" } }, "node_modules/p-limit": { @@ -18794,7 +18779,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -19514,7 +19498,6 @@ "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" @@ -19600,6 +19583,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -19615,6 +19599,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -19627,7 +19612,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process": { "version": "0.11.10", @@ -19662,7 +19648,6 @@ "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", @@ -19804,9 +19789,9 @@ } }, "node_modules/purgecss/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -19936,7 +19921,6 @@ "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" }, @@ -20115,7 +20099,6 @@ "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" @@ -20158,7 +20141,6 @@ "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" }, @@ -20419,7 +20401,6 @@ "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", @@ -20451,7 +20432,6 @@ "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" } @@ -20541,7 +20521,6 @@ "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" @@ -20909,7 +20888,6 @@ "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" } @@ -21387,7 +21365,6 @@ "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", @@ -21519,7 +21496,6 @@ "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", @@ -22527,7 +22503,6 @@ "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", @@ -22622,7 +22597,6 @@ "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", @@ -23249,7 +23223,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23261,8 +23234,7 @@ "version": "5.10.9", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1", - "peer": true + "license": "LGPL-2.1" }, "node_modules/tmp": { "version": "0.2.5", @@ -23404,7 +23376,6 @@ "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", @@ -23569,8 +23540,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsutils": { "version": "3.21.0", @@ -23619,7 +23589,6 @@ "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" }, @@ -23719,7 +23688,6 @@ "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" @@ -23862,7 +23830,6 @@ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -24144,7 +24111,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -24270,7 +24236,6 @@ "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", @@ -24375,7 +24340,6 @@ "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", @@ -24462,7 +24426,6 @@ "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", @@ -24999,7 +24962,6 @@ "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 403c475780..9af1f85eb4 100644 --- a/package.json +++ b/package.json @@ -66,8 +66,8 @@ "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", + "@tanstack/react-query": "5.95.2", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-query": "5.90.21", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", @@ -120,7 +120,7 @@ "jest-canvas-mock": "^2.5.2", "jest-expect-message": "^1.1.3", "oxlint": "^1.42.0", - "oxlint-tsgolint": "^0.16.0", + "oxlint-tsgolint": "^0.17.0", "react-test-renderer": "^18.3.1", "redux-mock-store": "^1.5.4" } diff --git a/src/authz/data/api.ts b/src/authz/data/api.ts index 9801f8be88..6aca5e5307 100644 --- a/src/authz/data/api.ts +++ b/src/authz/data/api.ts @@ -5,7 +5,9 @@ import { PermissionValidationRequestItem, PermissionValidationResponseItem, } from '@src/authz/types'; -import { getApiUrl } from './utils'; +import { getConfig } from '@edx/frontend-platform'; + +export const getAuthzApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}/api/authz/${path || ''}`; export const validateUserPermissions = async ( query: PermissionValidationQuery, @@ -14,7 +16,7 @@ export const validateUserPermissions = async ( const request: PermissionValidationRequestItem[] = Object.values(query); const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post( - getApiUrl('/api/authz/v1/permissions/validate/me'), + getAuthzApiUrl('v1/permissions/validate/me'), request, ); diff --git a/src/authz/data/utils.ts b/src/authz/data/utils.ts deleted file mode 100644 index 8676ba1abd..0000000000 --- a/src/authz/data/utils.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getConfig } from '@edx/frontend-platform'; - -export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`; -export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`; diff --git a/src/constants.ts b/src/constants.ts index 12e65d401d..be5eeca609 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,3 +116,9 @@ export const BROKEN = 'broken'; export const LOCKED = 'locked'; export const MANUAL = 'manual'; + +export enum AgreementGated { + UPLOAD = 'upload', + UPLOAD_VIDEOS = 'upload.videos', + UPLOAD_FILES = 'upload.files', +} diff --git a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx index d42a5cb0d4..5d0234d0df 100644 --- a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx +++ b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx @@ -5,24 +5,21 @@ import { ActionRow, Button, Icon } from '@openedx/paragon'; import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons'; import { getConfig } from '@edx/frontend-platform'; -import { useWaffleFlags } from '../../data/apiHooks'; +import { useWaffleFlags } from '@src/data/apiHooks'; + import messages from './messages'; const getUpdateLinks = (courseId, waffleFlags) => { const baseUrl = getConfig().STUDIO_BASE_URL; - const isLegacyGradingUrl = !waffleFlags.useNewGradingPage; const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage; - const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage; const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage; return { welcomeMessage: `/course/${courseId}/course_info`, - gradingPolicy: isLegacyGradingUrl - ? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`, + gradingPolicy: `/course/${courseId}/settings/grading`, certificate: isLegacyCertificateUrl ? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`, - courseDates: isLegacyCourseDatesUrl - ? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`, + courseDates: `/course/${courseId}/settings/details/#schedule`, proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`, outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`, }; diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx index 2fc73e66d6..d6baed767d 100644 --- a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx +++ b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx @@ -2,8 +2,9 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { initializeMocks, render, screen, within, -} from '../../testUtils'; -import { getApiWaffleFlagsUrl } from '../../data/api'; +} from '@src/testUtils'; +import { getApiWaffleFlagsUrl } from '@src/data/api'; + import { generateCourseLaunchData } from '../factories/mockApiResponses'; import { checklistItems } from './utils/courseChecklistData'; import messages from './messages'; @@ -36,9 +37,7 @@ describe('ChecklistSection', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 95d75627c9..0ae520a447 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -498,6 +498,11 @@ const CourseOutline = () => { isOpen={isConfigureModalOpen} onClose={handleConfigureModalClose} onConfigureSubmit={handleConfigureItemSubmit} + /** + * Only sections need overflow visible (for the Release date datepicker, fixed in #2901); + * enabling it for subsection/unit modals causes the Visibility tab background to clip. + */ + isOverflowVisible={itemCategory === COURSE_BLOCK_NAMES.chapter.id} currentItemData={currentItemData} enableProctoredExams={enableProctoredExams} enableTimedExams={enableTimedExams} diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index e02afd49c3..a7344952ea 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -1,34 +1,36 @@ +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle } from '@openedx/paragon'; +import { + Tab, + Tabs, + useToggle, +} from '@openedx/paragon'; import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { COURSE_PERMISSIONS } from '@src/authz/constants'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { useCourseSettings, useWaffleFlags } from '@src/data/apiHooks'; import { ComponentCountSnippet } from '@src/generic/block-type-utils'; +import { HelpSidebarLink, otherLinkURLParams, messages as helpSidebarMessages } from '@src/generic/help-sidebar'; +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; import { useGetBlockTypes } from '@src/search-manager'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; - import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from '../messages'; -export const CourseInfoSidebar = () => { +const DetailsTab = () => { const intl = useIntl(); - const { courseId } = useCourseAuthoringContext(); - const { data: courseDetails } = useCourseDetails(courseId); + const { courseId } = useCourseAuthoringContext(); const { data: componentData } = useGetBlockTypes( [`context_key = "${courseId}"`], ); - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); return ( -
- + <> { onClose={closeManageTagsDrawer} showSheet={isManageTagsDrawerOpen} /> -
+ + ); +}; + +const SettingsTab = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { data: courseSettingsData } = useCourseSettings(courseId); + + const { + grading, + courseTeam, + advancedSettings, + scheduleAndDetails, + groupConfigurations, + } = otherLinkURLParams; + const waffleFlags = useWaffleFlags(courseId); + + const proctoredExamSettingsUrl = courseSettingsData?.mfeProctoredExamSettingsUrl; + + /* + AuthZ for Course Authoring + If authz.enable_course_authoring flag is enabled, validate permissions using AuthZ API. + */ + const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring; + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }, isAuthzEnabled); + + // If it's still loading, don't show the Advanced Settings link, otherwise, use the permission to decide + const authzCanManageAdvancedSettings = isLoadingUserPermissions + ? false + : !!userPermissions?.canManageAdvancedSettings; + + // When authz is enabled, use permission, otherwise it's always allowed (legacy behavior) + const canManageAdvancedSettings = isAuthzEnabled ? authzCanManageAdvancedSettings : true; + + return ( + + + + + + {canManageAdvancedSettings && ( + + )} + {proctoredExamSettingsUrl && ( + + )} + + ); +}; + +export const CourseInfoSidebar = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + + return ( + <> + + + + + + + + + + ); }; diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx index a8a1d415b8..2d9359046f 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx @@ -1,5 +1,6 @@ import { initializeMocks, render, screen } from '@src/testUtils'; -import { SelectionState } from '@src/data/types'; +import { getCourseSettingsApiUrl } from '@src/data/api'; +import type { SelectionState } from '@src/data/types'; import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { getXBlockApiUrl } from '@src/course-outline/data/api'; import userEvent from '@testing-library/user-event'; @@ -22,10 +23,12 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({ }), })); +const courseId = '5'; + const openPublishModal = jest.fn(); jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ - courseId: 5, + courseId, setCurrentSelection: jest.fn(), openPublishModal, getUnitUrl: jest.fn(), @@ -50,6 +53,32 @@ describe('InfoSidebar component', () => { expect(await screen.findByText('Course name')).toBeInTheDocument(); }); + it('shows the settings link for the course', async () => { + const user = userEvent.setup(); + renderComponent(); + await user.click((await screen.findByRole('tab', { name: 'Settings' }))); + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(5); + expect(links[0]).toHaveTextContent('Schedule & details'); + expect(links[1]).toHaveTextContent('Grading'); + expect(links[2]).toHaveTextContent('Course team'); + expect(links[3]).toHaveTextContent('Group configurations'); + expect(links[4]).toHaveTextContent('Advanced settings'); + }); + + it('shows the proctored exam settings link for the course if it exists', async () => { + const user = userEvent.setup(); + const courseSettingsData = { + mfeProctoredExamSettingsUrl: 'https://example.com/proctored-exam-settings', + }; + axiosMock + .onGet(getCourseSettingsApiUrl(courseId)) + .reply(200, courseSettingsData); + renderComponent(); + await user.click(await screen.findByRole('tab', { name: 'Settings' })); + expect(await screen.findByRole('link', { name: 'Proctored exam settings' })).toBeInTheDocument(); + }); + it('renders InfoSidebar with section info', async () => { const user = userEvent.setup(); selectedContainerState = { diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 7389263044..dad989010e 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -15,6 +15,8 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; import { usePasteFileNotices } from '@src/course-outline/data/apiHooks'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '../../constants'; import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot'; import advancedSettingsMessages from '../../advanced-settings/messages'; import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; @@ -441,6 +443,9 @@ const PageAlerts = ({ {conflictingFilesPasteAlert()} {newFilesPasteAlert()} {renderOutOfSyncAlert()} + ); diff --git a/src/course-outline/status-bar/LegacyStatusBar.tsx b/src/course-outline/status-bar/LegacyStatusBar.tsx index 2673dd2143..5eb5449d14 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.tsx @@ -5,16 +5,16 @@ import { Button, Hyperlink, Form, Stack, useToggle, } from '@openedx/paragon'; import { Link } from 'react-router-dom'; +import { type ReactNode } from 'react'; -import { ReactNode } from 'react'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import TagCount from '@src/generic/tag-count'; import { useHelpUrls } from '@src/help-urls/hooks'; -import { useWaffleFlags } from '@src/data/apiHooks'; import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants'; import { useContentTagsCount } from '@src/generic/data/apiHooks'; import { getVideoSharingOptionText } from '@src/course-outline/utils'; + import messages from './messages'; interface StatusBarItemProps { @@ -47,7 +47,6 @@ export const LegacyStatusBar = ({ handleVideoSharingOptionChange, }: LegacyStatusBarProps) => { const intl = useIntl(); - const waffleFlags = useWaffleFlags(courseId); const { courseReleaseDate, @@ -67,7 +66,6 @@ export const LegacyStatusBar = ({ const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; - const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; const { contentHighlights: contentHighlightsUrl, @@ -88,7 +86,7 @@ export const LegacyStatusBar = ({ {courseReleaseDateObj.isValid() ? ( { - const waffleFlags = useWaffleFlags(courseId); - const { endDate, courseReleaseDate, @@ -189,7 +186,6 @@ export const StatusBar = ({ const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); const endDateObj = moment.utc(endDate); - const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; if (isLoading) { return null; @@ -203,7 +199,7 @@ export const StatusBar = ({ startDate={courseReleaseDateObj} endDate={endDateObj} startDateRaw={courseReleaseDate} - datesLink={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()} + datesLink={`/course/${courseId}/settings/details/#schedule`} /> diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index 32ec0f4c05..9c6d38afef 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -713,15 +713,13 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - await waitFor(async () => { - const problemButton = screen.getByRole('button', { - name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), - hidden: true, - }); - - await user.click(problemButton); + const problemButton = await screen.findByRole('button', { + name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), + hidden: true, }); + await user.click(problemButton); + axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); diff --git a/src/custom-pages/CustomPages.test.tsx b/src/custom-pages/CustomPages.test.tsx index fec0ebc220..f8acd09b1f 100644 --- a/src/custom-pages/CustomPages.test.tsx +++ b/src/custom-pages/CustomPages.test.tsx @@ -62,9 +62,7 @@ describe('CustomPages', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); diff --git a/src/data/api.ts b/src/data/api.ts index 4f74a7196b..9501998bff 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -48,6 +48,8 @@ export const bulkModulestoreMigrateUrl = () => `${getStudioBaseUrl()}/api/module */ export const getPreviewModulestoreMigrationUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/migration_preview/`; +export const getCourseSettingsApiUrl = (courseId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; + export const getApiWaffleFlagsUrl = (courseId?: string): string => { const baseUrl = getStudioBaseUrl(); const apiPath = '/api/contentstore/v1/course_waffle_flags'; @@ -76,9 +78,6 @@ export const waffleFlagDefaults = { enableCourseOptimizerCheckPrevRunLinks: false, useNewHomePage: true, useNewCustomPages: true, - useNewScheduleDetailsPage: true, - useNewAdvancedSettingsPage: true, - useNewGradingPage: true, useNewUpdatesPage: true, useNewImportPage: false, useNewExportPage: true, @@ -86,10 +85,8 @@ export const waffleFlagDefaults = { useNewVideoUploadsPage: true, useNewCourseOutlinePage: true, useNewUnitPage: false, - useNewCourseTeamPage: true, useNewCertificatesPage: true, useNewTextbooksPage: true, - useNewGroupConfigurationsPage: true, useReactMarkdownEditor: true, useVideoGalleryFlow: false, enableAuthzCourseAuthoring: false, @@ -208,3 +205,66 @@ export async function getPreviewModulestoreMigration( const { data } = await client.get(getPreviewModulestoreMigrationUrl(), { params }); return camelCaseObject(data); } + +export const getUserAgreementRecordApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement_record/${agreementType}`; + +export async function getUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export async function updateUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export const getUserAgreementApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement/${agreementType}/`; + +export async function getUserAgreement(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementApi(agreementType)); + return camelCaseObject(data); +} + +export interface CourseSettingsData { + aboutPageEditable: boolean; + canShowCertificateAvailableDateField: boolean; + courseDisplayName: string; + courseDisplayNameWithDefault: string; + creditEligibilityEnabled: boolean; + enableExtendedCourseDetails: boolean; + enrollmentEndEditable: boolean; + isCreditCourse: boolean; + isEntranceExamsEnabled: boolean; + isPrerequisiteCoursesEnabled: boolean; + languageOptions: [string, string][]; + lmsLinkForAboutPage: string; + licensingEnabled: boolean; + marketingEnabled: boolean; + mfeProctoredExamSettingsUrl: string; + platformName: string; + possiblePreRequisiteCourses: { + courseKey: string; + displayName: string; + lmsLink: string; + number: string; + org: string; + rerunLink: string; + run: string; + url: string; + } + shortDescriptionEditable: boolean; + showMinGradeWarning: boolean; + sidebarHtmlEnabled: boolean; + upgradeDeadline: string | null; +} + +/** + * Get course settings. + */ +export async function getCourseSettings(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getCourseSettingsApiUrl(courseId)); + return camelCaseObject(data); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 211b9eede5..1a764e377c 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -1,16 +1,20 @@ -import { - skipToken, useMutation, useQuery, useQueryClient, -} from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { UserAgreement, UserAgreementRecord } from '@src/data/types'; import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks'; import { - getWaffleFlags, - waffleFlagDefaults, - bulkModulestoreMigrate, - getModulestoreMigrationStatus, + skipToken, useMutation, useQueries, useQuery, useQueryClient, UseQueryOptions, +} from '@tanstack/react-query'; +import { BulkMigrateRequestData, + bulkModulestoreMigrate, getCourseDetails, - getPreviewModulestoreMigration, + getModulestoreMigrationStatus, + getPreviewModulestoreMigration, getUserAgreement, + getUserAgreementRecord, + getWaffleFlags, updateUserAgreementRecord, + waffleFlagDefaults, + getCourseSettings, } from './api'; import { RequestStatus, RequestStatusType } from './constants'; @@ -165,3 +169,57 @@ export function createGlobalState( return { data, setData, resetData }; }; } + +export const getGatingAgreementTypes = (gatingTypes: string[]): string[] => ( + [...new Set( + gatingTypes + .flatMap(gatingType => getConfig().AGREEMENT_GATING?.[gatingType]) + .filter(item => Boolean(item)), + )] +); + +export const useUserAgreementRecord = (agreementType:string) => ( + useQuery({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + }) +); + +export const useUserAgreementRecords = (agreementTypes:string[]) => ( + useQueries({ + queries: agreementTypes.map>(agreementType => ({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + })), + }) +); + +export const useUserAgreementRecordUpdater = (agreementType:string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => updateUserAgreementRecord(agreementType), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agreement-record', agreementType] }); + }, + }); +}; + +export const useUserAgreement = (agreementType:string) => ( + useQuery({ + queryKey: ['agreements', agreementType], + queryFn: () => getUserAgreement(agreementType), + retry: false, + }) +); + +/** + * Get the course settings + */ +export const useCourseSettings = (courseId: string) => ( + useQuery({ + queryKey: ['courseSettings', courseId], + queryFn: () => getCourseSettings(courseId), + }) +); diff --git a/src/data/types.ts b/src/data/types.ts index c13205a6a0..7dd267f4f8 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -201,3 +201,19 @@ export type AccessManagedXBlockDataTypes = { onlineProctoringRules?: string; discussionEnabled?: boolean; }; + +export interface UserAgreementRecord { + username: string; + agreementType: string; + acceptedAt: string | null; + isCurrent: boolean; +} + +export interface UserAgreement { + type: string; + name: string; + summary: string; + hasText: boolean; + url: string; + updated: string; +} diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx index d71b90476e..1fc9e885eb 100644 --- a/src/files-and-videos/files-page/CourseFilesTable.tsx +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -1,5 +1,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { CheckboxFilter } from '@openedx/paragon'; +import { AgreementGated, UPLOAD_FILE_MAX_SIZE } from '@src/constants'; import { addAssetFile, deleteAssetFile, @@ -20,13 +21,13 @@ import { FileTable, ThumbnailColumn, } from '@src/files-and-videos/generic'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import { getFileSizeToClosestByte } from '@src/utils'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { UPLOAD_FILE_MAX_SIZE } from '@src/constants'; export const CourseFilesTable = () => { const intl = useIntl(); @@ -159,26 +160,28 @@ export const CourseFilesTable = () => { return null; } return ( - <> - - - + + <> + + + + ); }; diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 41af98f34e..210c43bdc1 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,7 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Container } from '@openedx/paragon'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; @@ -10,6 +10,8 @@ import Placeholder from '@src/editors/Placeholder'; import { RequestStatus } from '@src/data/constants'; import getPageHeadTitle from '@src/generic/utils'; import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '@src/constants'; import { EditFileErrors } from '../generic'; import { fetchAssets, resetErrors } from './data/thunks'; @@ -55,6 +57,9 @@ const FilesPage = () => { updateFileStatus={updateAssetStatus} loadingStatus={loadingStatus} /> +
{intl.formatMessage(messages.heading)} diff --git a/src/files-and-videos/videos-page/CourseVideosTable.tsx b/src/files-and-videos/videos-page/CourseVideosTable.tsx index d1667ef13f..fe14b2645b 100644 --- a/src/files-and-videos/videos-page/CourseVideosTable.tsx +++ b/src/files-and-videos/videos-page/CourseVideosTable.tsx @@ -2,6 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, CheckboxFilter, useToggle, } from '@openedx/paragon'; +import { AgreementGated } from '@src/constants'; import { RequestStatus } from '@src/data/constants'; import { ActiveColumn, @@ -29,6 +30,7 @@ import messages from '@src/files-and-videos/videos-page/messages'; import TranscriptSettings from '@src/files-and-videos/videos-page/transcript-settings'; import UploadModal from '@src/files-and-videos/videos-page/upload-modal'; import VideoThumbnail from '@src/files-and-videos/videos-page/VideoThumbnail'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import React, { useEffect, useRef } from 'react'; @@ -224,23 +226,24 @@ export const CourseVideosTable = () => { ]; return ( - <> - - - {isVideoTranscriptEnabled ? ( - - ) : null} - - { + + <> + + + {isVideoTranscriptEnabled ? ( + + ) : null} + + { loadingStatus !== RequestStatus.FAILED && ( <> {isVideoTranscriptEnabled && ( @@ -275,14 +278,15 @@ export const CourseVideosTable = () => { ) } - - + + + ); }; diff --git a/src/files-and-videos/videos-page/VideosPage.tsx b/src/files-and-videos/videos-page/VideosPage.tsx index 53d32322c8..b2faa26e07 100644 --- a/src/files-and-videos/videos-page/VideosPage.tsx +++ b/src/files-and-videos/videos-page/VideosPage.tsx @@ -1,4 +1,6 @@ -import { useEffect } from 'react'; +import { AgreementGated } from '@src/constants'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import React, { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; @@ -57,6 +59,9 @@ const VideosPage = () => { updateFileStatus={updateVideoStatus} loadingStatus={loadingStatus} /> +

{intl.formatMessage(messages.heading)}

diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx new file mode 100644 index 0000000000..78c525b66f --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx @@ -0,0 +1,142 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementApi, getUserAgreementRecordApi } from '@src/data/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + , + , + ); +} + +describe('AlertAgreementGatedFeature', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementApi('agreement1')).reply(200, { + type: 'agreement1', + name: 'agreement1', + summary: 'summary1', + has_text: true, + url: 'https://example.com/agreement1', + updated: '2023-01-01T00:00:00Z', + }); + axiosMock.onGet(getUserAgreementApi('agreement2')).reply(200, { + type: 'agreement2', + name: 'agreement2', + summary: 'summary2', + has_text: true, + url: 'https://example.com/agreement2', + }); + axiosMock.onGet(getUserAgreementApi('agreement3')).reply(404); + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, {}); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('renders no alerts when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when gatingTypes have no associated agreement', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when associated agreement does not exist', async () => { + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD_FILES]: ['agreement3'], + }, + }); + await renderComponent([AgreementGated.UPLOAD_FILES]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders an alert for each agreement type associated with the gating types', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('renders skips alerts for agreements that have already been accepted', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(1); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.queryByText('agreement2')).not.toBeInTheDocument(); + expect(screen.queryByText('summary2')).not.toBeInTheDocument(); + }); + + it('does not duplicate alert if multiple gating types have the same agreement type', async () => { + const gatingTypes = [AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('posts a request to mark acceptance when user clicks Agree', async () => { + const user = userEvent.setup(); + const gatingTypes = [AgreementGated.UPLOAD_VIDEOS]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + axiosMock.onPost(new RegExp(getUserAgreementRecordApi('*'))).reply(201, {}); + await user.click(screen.getByRole('button', { name: 'Agree' })); + expect(axiosMock.history.post[0].url).toBe(getUserAgreementRecordApi('agreement2')); + }); +}); diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx new file mode 100644 index 0000000000..6df1f30321 --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx @@ -0,0 +1,66 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Alert, Button, Hyperlink } from '@openedx/paragon'; +import { Policy } from '@openedx/paragon/icons'; +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreement, + useUserAgreementRecord, + useUserAgreementRecordUpdater, +} from '@src/data/apiHooks'; +import messages from './messages'; + +const AlertAgreement = ({ agreementType }: { agreementType: string }) => { + const intl = useIntl(); + const { data, isLoading, isError } = useUserAgreement(agreementType); + const mutation = useUserAgreementRecordUpdater(agreementType); + const showAlert = data && !isLoading && !isError; + const handleAcceptAgreement = async () => { + try { + await mutation.mutateAsync(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error accepting agreement', e); + } + }; + if (!showAlert) { return null; } + const { url, name, summary } = data; + return ( + {intl.formatMessage(messages.learnMoreLinkLabel)}, + , + ]} + > + {name} + {summary} + + ); +}; + +const AlertAgreementWrapper = ( + { agreementType }: { agreementType: string }, +) => { + const { data, isLoading, isError } = useUserAgreementRecord(agreementType); + const showAlert = !data?.isCurrent && !isLoading && !isError; + if (!showAlert) { return null; } + return ; +}; + +export const AlertAgreementGatedFeature = ( + { gatingTypes }: { gatingTypes: AgreementGated[] }, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + return ( + <> + {agreementTypes.map((agreementType) => ( + + ))} + + ); +}; diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx new file mode 100644 index 0000000000..bd00233f92 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx @@ -0,0 +1,79 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementRecordApi } from '@src/data/api'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature/GatedComponentWrapper'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + + + , + , + ); +} + +describe('GatedComponentWrapper', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + [AgreementGated.UPLOAD_FILES]: ['agreement1'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('applies no gating when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies no gating when associated agreement has been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_VIDEOS]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies gating when associated agreement has not been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx new file mode 100644 index 0000000000..bddd352e40 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx @@ -0,0 +1,30 @@ +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreementRecords, +} from '@src/data/apiHooks'; + +interface GatedComponentWrapperProps { + gatingTypes: AgreementGated[]; + children: React.ReactElement; +} + +export const GatedComponentWrapper = ( + { gatingTypes, children }: GatedComponentWrapperProps, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + const results = useUserAgreementRecords(agreementTypes); + const isNotGated = results.every((result) => !!result?.data?.isCurrent); + return isNotGated ? children : ( +
+ {children} +
+ ); +}; diff --git a/src/generic/agreement-gated-feature/index.ts b/src/generic/agreement-gated-feature/index.ts new file mode 100644 index 0000000000..9c3265f27e --- /dev/null +++ b/src/generic/agreement-gated-feature/index.ts @@ -0,0 +1,2 @@ +export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; +export { GatedComponentWrapper } from './GatedComponentWrapper'; diff --git a/src/generic/agreement-gated-feature/messages.ts b/src/generic/agreement-gated-feature/messages.ts new file mode 100644 index 0000000000..c17ac47c75 --- /dev/null +++ b/src/generic/agreement-gated-feature/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + agreeButtonLabel: { + id: 'authoring.agreement-gated-feature.agree', + defaultMessage: 'Agree', + description: 'The label for the Agree button on an alert asking users to agree with terms.', + }, + learnMoreLinkLabel: { + id: 'authoring.agreement-gated-feature.learn-more', + defaultMessage: 'Learn more', + description: 'The label for a "learn more" link on an alert asking users to agree with terms.', + }, +}); + +export default messages; diff --git a/src/generic/configure-modal/ConfigureModal.tsx b/src/generic/configure-modal/ConfigureModal.tsx index abf9b101f6..5b2ac38bba 100644 --- a/src/generic/configure-modal/ConfigureModal.tsx +++ b/src/generic/configure-modal/ConfigureModal.tsx @@ -28,6 +28,7 @@ interface Props { currentItemData?: AccessManagedXBlockDataTypes, isXBlockComponent?: boolean, isSelfPaced?: boolean, + isOverflowVisible?: boolean, } const ConfigureModal = ({ @@ -39,6 +40,7 @@ const ConfigureModal = ({ enableTimedExams = false, isXBlockComponent = false, isSelfPaced, + isOverflowVisible = false, }: Props) => { const intl = useIntl(); @@ -298,7 +300,7 @@ const ConfigureModal = ({ onClose={onClose} hasCloseButton isFullscreenOnMobile - isOverflowVisible + isOverflowVisible={isOverflowVisible} >
diff --git a/src/generic/help-sidebar/HelpSidebar.test.jsx b/src/generic/help-sidebar/HelpSidebar.test.tsx similarity index 98% rename from src/generic/help-sidebar/HelpSidebar.test.jsx rename to src/generic/help-sidebar/HelpSidebar.test.tsx index 8f97c4478e..64eb6d9345 100644 --- a/src/generic/help-sidebar/HelpSidebar.test.jsx +++ b/src/generic/help-sidebar/HelpSidebar.test.tsx @@ -1,9 +1,9 @@ -// @ts-check - import { waitFor } from '@testing-library/react'; + import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { useUserPermissions } from '@src/authz/data/apiHooks'; -import { initializeMocks, render } from '../../testUtils'; +import { initializeMocks, render } from '@src/testUtils'; + import messages from './messages'; import { HelpSidebar } from '.'; diff --git a/src/generic/help-sidebar/HelpSidebar.jsx b/src/generic/help-sidebar/HelpSidebar.tsx similarity index 64% rename from src/generic/help-sidebar/HelpSidebar.jsx rename to src/generic/help-sidebar/HelpSidebar.tsx index 5a9745e8a8..074ab955e1 100644 --- a/src/generic/help-sidebar/HelpSidebar.jsx +++ b/src/generic/help-sidebar/HelpSidebar.tsx @@ -1,8 +1,7 @@ -import PropTypes from 'prop-types'; +import { type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; import { useUserPermissions } from '@src/authz/data/apiHooks'; import { COURSE_PERMISSIONS } from '@src/authz/constants'; @@ -11,13 +10,21 @@ import { otherLinkURLParams } from './constants'; import messages from './messages'; import HelpSidebarLink from './HelpSidebarLink'; +interface HelpSidebarProps { + courseId: string; + showOtherSettings?: boolean; + proctoredExamSettingsUrl?: string; + children: ReactNode; + className?: string; +} + const HelpSidebar = ({ courseId, - showOtherSettings, - proctoredExamSettingsUrl, + showOtherSettings = false, + proctoredExamSettingsUrl = '', children, className, -}) => { +}: HelpSidebarProps) => { const intl = useIntl(); const { pathname } = useLocation(); const { @@ -30,16 +37,6 @@ const HelpSidebar = ({ const waffleFlags = useWaffleFlags(courseId); const showOtherLink = (params) => !pathname.includes(params); - const generateLegacyURL = (urlParameter) => { - const referObj = new URL(`${urlParameter}/${courseId}`, getConfig().STUDIO_BASE_URL); - return referObj.href; - }; - - const scheduleAndDetailsDestination = generateLegacyURL(scheduleAndDetails); - const gradingDestination = generateLegacyURL(grading); - const courseTeamDestination = generateLegacyURL(courseTeam); - const advancedSettingsDestination = generateLegacyURL(advancedSettings); - const groupConfigurationsDestination = generateLegacyURL(groupConfigurations); /* AuthZ for Course Authoring @@ -78,46 +75,41 @@ const HelpSidebar = ({
    {showOtherLink(scheduleAndDetails) && ( )} {showOtherLink(grading) && ( )} {showOtherLink(courseTeam) && ( )} {showOtherLink(groupConfigurations) && ( )} {showOtherLink(advancedSettings) && canManageAdvancedSettings && ( )} {proctoredExamSettingsUrl && ( @@ -137,19 +129,4 @@ const HelpSidebar = ({ ); }; -HelpSidebar.defaultProps = { - proctoredExamSettingsUrl: '', - className: undefined, - courseId: undefined, - showOtherSettings: false, -}; - -HelpSidebar.propTypes = { - courseId: PropTypes.string, - showOtherSettings: PropTypes.bool, - proctoredExamSettingsUrl: PropTypes.string, - children: PropTypes.node.isRequired, - className: PropTypes.string, -}; - export default HelpSidebar; diff --git a/src/generic/help-sidebar/HelpSidebarLink.jsx b/src/generic/help-sidebar/HelpSidebarLink.tsx similarity index 64% rename from src/generic/help-sidebar/HelpSidebarLink.jsx rename to src/generic/help-sidebar/HelpSidebarLink.tsx index 4a4ee91ac8..dd853c552f 100644 --- a/src/generic/help-sidebar/HelpSidebarLink.jsx +++ b/src/generic/help-sidebar/HelpSidebarLink.tsx @@ -1,10 +1,21 @@ +import React from 'react'; + import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; import { Hyperlink } from '@openedx/paragon'; +interface HelpSidebarLinkProps { + as?: React.ElementType; + isNewPage?: boolean; + pathToPage: string; + title: string; +} + const HelpSidebarLink = ({ - as, pathToPage, title, isNewPage, -}) => { + as = 'li', + isNewPage = true, + pathToPage, + title, +}: HelpSidebarLinkProps) => { const TagElement = as; if (isNewPage) { return ( @@ -29,16 +40,4 @@ const HelpSidebarLink = ({ ); }; -HelpSidebarLink.propTypes = { - isNewPage: PropTypes.bool, - pathToPage: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - as: PropTypes.string, -}; - -HelpSidebarLink.defaultProps = { - as: 'li', - isNewPage: true, -}; - export default HelpSidebarLink; diff --git a/src/generic/help-sidebar/index.ts b/src/generic/help-sidebar/index.ts index 7f0fc8374a..3b5b9d2463 100644 --- a/src/generic/help-sidebar/index.ts +++ b/src/generic/help-sidebar/index.ts @@ -1,2 +1,4 @@ export { default as HelpSidebar } from './HelpSidebar'; export { default as HelpSidebarLink } from './HelpSidebarLink'; +export { otherLinkURLParams } from './constants'; +export { default as messages } from './messages'; diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 56c0da6620..a359087db4 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -3,22 +3,23 @@ import { Button, Container, Layout, StatefulButton, } from '@openedx/paragon'; import { Add as IconAdd, CheckCircle, Warning } from '@openedx/paragon/icons'; -import { - useCourseSettings, - useGradingSettings, - useGradingSettingUpdater, -} from 'CourseAuthoring/grading-settings/data/apiHooks'; import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; + import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { STATEFUL_BUTTON_STATES } from '../constants'; -import AlertMessage from '../generic/alert-message'; -import InternetConnectionAlert from '../generic/internet-connection-alert'; +import { STATEFUL_BUTTON_STATES } from '@src/constants'; +import { useCourseSettings } from '@src/data/apiHooks'; +import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; +import SectionSubHeader from '@src/generic/section-sub-header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import AlertMessage from '@src/generic/alert-message'; +import InternetConnectionAlert from '@src/generic/internet-connection-alert'; +import getPageHeadTitle from '@src/generic/utils'; -import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; -import SectionSubHeader from '../generic/section-sub-header'; -import SubHeader from '../generic/sub-header/SubHeader'; -import getPageHeadTitle from '../generic/utils'; +import { + useGradingSettings, + useGradingSettingUpdater, +} from './data/apiHooks'; import AssignmentSection from './assignment-section'; import CreditSection from './credit-section'; import DeadlineSection from './deadline-section'; diff --git a/src/grading-settings/GradingSettings.test.jsx b/src/grading-settings/GradingSettings.test.jsx index c524845acd..76a667de4c 100644 --- a/src/grading-settings/GradingSettings.test.jsx +++ b/src/grading-settings/GradingSettings.test.jsx @@ -2,9 +2,10 @@ import { act, fireEvent, render, screen, initializeMocks, } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { getCourseSettingsApiUrl } from '@src/data/api'; import gradingSettings from './__mocks__/gradingSettings'; -import { getCourseSettingsApiUrl, getGradingSettingsApiUrl } from './data/api'; +import { getGradingSettingsApiUrl } from './data/api'; import * as apiHooks from './data/apiHooks'; import GradingSettings from './GradingSettings'; import messages from './messages'; diff --git a/src/grading-settings/data/api.js b/src/grading-settings/data/api.js index 8e73c720bb..d21b128f64 100644 --- a/src/grading-settings/data/api.js +++ b/src/grading-settings/data/api.js @@ -5,7 +5,6 @@ import { deepConvertingKeysToCamelCase, deepConvertingKeysToSnakeCase } from '.. const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getGradingSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_grading/${courseId}`; -export const getCourseSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; /** * Get's grading setting for a course. @@ -29,14 +28,3 @@ export async function sendGradingSettings(courseId, settings) { .post(getGradingSettingsApiUrl(courseId), deepConvertingKeysToSnakeCase(settings)); return camelCaseObject(data); } - -/** - * Get course settings. - * @param {string} courseId - * @returns {Promise} - */ -export async function getCourseSettings(courseId) { - const { data } = await getAuthenticatedHttpClient() - .get(getCourseSettingsApiUrl(courseId)); - return camelCaseObject(data); -} diff --git a/src/grading-settings/data/apiHooks.ts b/src/grading-settings/data/apiHooks.ts index c963575b8a..8d12f172f4 100644 --- a/src/grading-settings/data/apiHooks.ts +++ b/src/grading-settings/data/apiHooks.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getCourseSettings, getGradingSettings, sendGradingSettings } from './api'; +import { getGradingSettings, sendGradingSettings } from './api'; export const useGradingSettings = (courseId: string) => ( useQuery({ @@ -8,13 +8,6 @@ export const useGradingSettings = (courseId: string) => ( }) ); -export const useCourseSettings = (courseId: string) => ( - useQuery({ - queryKey: ['courseSettings', courseId], - queryFn: () => getCourseSettings(courseId), - }) -); - export const useGradingSettingUpdater = (courseId: string) => { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx index 5c12bcfe9f..6b8112b175 100644 --- a/src/header/hooks.tsx +++ b/src/header/hooks.tsx @@ -57,7 +57,6 @@ export const useContentMenuItems = (courseId: string) => { export const useSettingMenuItems = (courseId: string) => { const intl = useIntl(); - const studioBaseUrl = getConfig().STUDIO_BASE_URL; const { canAccessAdvancedSettings: legacyCanAccessAdvancedSettings } = useSelector(getStudioHomeData); const waffleFlags = useWaffleFlags(courseId); @@ -84,24 +83,24 @@ export const useSettingMenuItems = (courseId: string) => { const items = [ { - href: waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details` : `${studioBaseUrl}/settings/details/${courseId}`, + href: `/course/${courseId}/settings/details`, title: intl.formatMessage(messages['header.links.scheduleAndDetails']), }, { - href: waffleFlags.useNewGradingPage ? `/course/${courseId}/settings/grading` : `${studioBaseUrl}/settings/grading/${courseId}`, + href: `/course/${courseId}/settings/grading`, title: intl.formatMessage(messages['header.links.grading']), }, { - href: waffleFlags.useNewCourseTeamPage ? `/course/${courseId}/course_team` : `${studioBaseUrl}/course_team/${courseId}`, + href: `/course/${courseId}/course_team`, title: intl.formatMessage(messages['header.links.courseTeam']), }, { - href: waffleFlags.useNewGroupConfigurationsPage ? `/course/${courseId}/group_configurations` : `${studioBaseUrl}/group_configurations/${courseId}`, + href: `/course/${courseId}/group_configurations`, title: intl.formatMessage(messages['header.links.groupConfigurations']), }, ...(canAccessAdvancedSettings ? [{ - href: waffleFlags.useNewAdvancedSettingsPage ? `/course/${courseId}/settings/advanced` : `${studioBaseUrl}/settings/advanced/${courseId}`, + href: `/course/${courseId}/settings/advanced`, title: intl.formatMessage(messages['header.links.advancedSettings']), }] : [] ), diff --git a/src/library-authoring/components/PublicReadToggle.test.tsx b/src/library-authoring/components/PublicReadToggle.test.tsx new file mode 100644 index 0000000000..e3dd9d8fbf --- /dev/null +++ b/src/library-authoring/components/PublicReadToggle.test.tsx @@ -0,0 +1,117 @@ +import userEvent from '@testing-library/user-event'; +import { initializeMocks, render, screen } from '@src/testUtils'; +import PublicReadToggle from './PublicReadToggle'; +import messages from './messages'; + +jest.mock('../data/apiHooks', () => ({ + useContentLibrary: jest.fn(), + useUpdateLibraryMetadata: jest.fn(), +})); + +const mockUseContentLibrary = require('../data/apiHooks').useContentLibrary; +const mockUseUpdateLibraryMetadata = require('../data/apiHooks').useUpdateLibraryMetadata; + +let mockShowToast; + +describe('PublicReadToggle', () => { + beforeEach(() => { + const mocks = initializeMocks(); + mockShowToast = mocks.mockShowToast; + }); + + it('renders toggle when allowPublicRead is true and canEditToggle is true', () => { + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: true } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: jest.fn(), isPending: false }); + + render( + , + ); + expect(screen.getByText(messages.publicReadToggleLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.publicReadToggleSubtext.defaultMessage)).toBeInTheDocument(); + }); + + it('toggle is disabled when canEditToggle is false', () => { + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: true } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: jest.fn(), isPending: false }); + + render( + , + ); + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('calls updateLibrary when toggle is changed', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn().mockImplementation(() => Promise.resolve()); + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + await user.click(screen.getByRole('switch')); + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + }); + + it('shows error toast when updateLibrary fails', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn(); + + const error = { + customAttributes: { + httpErrorStatus: 500, + }, + }; + + mockMutateAsync.mockImplementation((_, options) => { + if (options?.onError) { + options.onError(error); + } + return Promise.reject(error); + }); + + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + + expect(mockShowToast).toHaveBeenCalledWith(messages.publicReadToggleDefaultError.defaultMessage); + }); + + it('shows error toast when updateLibrary promise is rejected', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn().mockRejectedValue(new Error('Network error')); + + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + + await user.click(screen.getByRole('switch')); + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + expect(mockShowToast).toHaveBeenCalledWith(messages.publicReadToggleDefaultError.defaultMessage); + }); +}); diff --git a/src/library-authoring/components/PublicReadToggle.tsx b/src/library-authoring/components/PublicReadToggle.tsx new file mode 100644 index 0000000000..635a0a0902 --- /dev/null +++ b/src/library-authoring/components/PublicReadToggle.tsx @@ -0,0 +1,42 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; +import { ToastContext } from '@src/generic/toast-context'; +import { useContext } from 'react'; +import messages from './messages'; +import { useContentLibrary, useUpdateLibraryMetadata } from '../data/apiHooks'; + +type PublicReadToggleProps = { + libraryId: string; + canEditToggle: boolean; +}; + +const PublicReadToggle = ({ libraryId, canEditToggle }: PublicReadToggleProps) => { + const { formatMessage } = useIntl(); + const { data: library } = useContentLibrary(libraryId); + const { mutateAsync: updateLibrary, isPending } = useUpdateLibraryMetadata(); + const { showToast } = useContext(ToastContext); + + const onChangeToggle = async () => { + await updateLibrary({ + id: libraryId, + allow_public_read: !library?.allowPublicRead, + }).catch(() => { + showToast(formatMessage(messages.publicReadToggleDefaultError)); + }); + }; + + return ( + {formatMessage(messages.publicReadToggleSubtext)} + } + > + {formatMessage(messages.publicReadToggleLabel)} + + ); +}; + +export default PublicReadToggle; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index f1e3b89d71..baf0c6b0f0 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -246,5 +246,20 @@ const messages = defineMessages({ defaultMessage: 'Remove', description: 'Button to confirm removal of a container from its parent', }, + publicReadToggleLabel: { + id: 'course-authoring.library-authoring.public.read.toggle.label', + defaultMessage: 'Allow public read', + description: 'Library label toggle to allow public read', + }, + publicReadToggleSubtext: { + id: 'course-authoring.library-authoring.public.read.toggle.subtext', + defaultMessage: 'Allows reuse of library content in courses.', + description: 'Library description toggle to allow public read', + }, + publicReadToggleDefaultError: { + id: 'course-authoring.library-authoring.public.read.toggle.default.error.message', + defaultMessage: 'Something went wrong on our end. Please try again later.', + description: 'Public read toggle default error message', + }, }); export default messages; diff --git a/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx index 4a60d7e35c..260cab0cee 100644 --- a/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx +++ b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx @@ -2,14 +2,16 @@ import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { + Alert, Container, Form, Button, StatefulButton, ActionRow, } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; import { Formik } from 'formik'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import * as Yup from 'yup'; import classNames from 'classnames'; @@ -100,6 +102,20 @@ export const CreateLegacyLibrary = ({ title={intl.formatMessage(legacyMessages.createLibrary)} /> )} + + {intl.formatMessage(legacyMessages.warningTitle)} + {intl.formatMessage(legacyMessages.warningBody, { + libraryLink: ( + + {intl.formatMessage(legacyMessages.warningLibraryFeature)} + + ), + })} + ', () => { mockShowToast = mocks.mockShowToast; validateUserPermissionsMock = mocks.validateUserPermissionsMock; - validateUserPermissionsMock.mockResolvedValue({ canPublish: true }); + validateUserPermissionsMock.mockResolvedValue({ canPublish: true, canManageTeam: true }); }); afterEach(() => { @@ -285,4 +285,29 @@ describe('', () => { expect(manageTeam).toBeInTheDocument(); expect(manageTeam).toHaveAttribute('href', `${ADMIN_CONSOLE_URL}/authz/libraries/${libraryData.id}`); }); + + it('renders settings section title', () => { + render(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders PublicReadToggle when user can manage team', async () => { + render(); + const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); + expect(allowSwitch).toBeInTheDocument(); + await waitFor(() => { + expect(allowSwitch).toBeEnabled(); + }); + }); + + it('renders PublicReadToggle in disabled mode when user can not manage team', async () => { + validateUserPermissionsMock.mockResolvedValue({ canPublish: true, canManageTeam: false }); + + render(); + const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); + expect(allowSwitch).toBeInTheDocument(); + await waitFor(() => { + expect(allowSwitch).toBeDisabled(); + }); + }); }); diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 562e511e72..6337f7389e 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -3,15 +3,24 @@ import { Button, Hyperlink, Stack } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; import messages from './messages'; import LibraryPublishStatus from './LibraryPublishStatus'; import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; +import PublicReadToggle from '../components/PublicReadToggle'; const LibraryInfo = () => { const intl = useIntl(); const { libraryId, libraryData, readOnly } = useLibraryContext(); const { setSidebarAction } = useSidebarContext(); + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageTeam: { + action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, + scope: libraryId, + }, + }, typeof libraryId !== 'undefined'); const adminConsoleUrl = getConfig().ADMIN_CONSOLE_URL; // always show link to admin console MFE if it is being used @@ -28,6 +37,13 @@ const LibraryInfo = () => { + + {intl.formatMessage(messages.settingsSectionTitle)} + + {intl.formatMessage(messages.organizationSectionTitle)} diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index 75e3b46f4b..9633de1279 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Organization', description: 'Title for Organization section in Library info sidebar.', }, + settingsSectionTitle: { + id: 'course-authoring.library-authoring.sidebar.info.settings.title', + defaultMessage: 'Settings', + description: 'Title for Settings section in Library info sidebar.', + }, libraryTeamButtonTitle: { id: 'course-authoring.library-authoring.sidebar.info.library-team.button.title', defaultMessage: 'Library Team', diff --git a/src/pages-and-resources/pages/PageCard.test.jsx b/src/pages-and-resources/pages/PageCard.test.jsx index c5d641789e..19c6247f58 100644 --- a/src/pages-and-resources/pages/PageCard.test.jsx +++ b/src/pages-and-resources/pages/PageCard.test.jsx @@ -1,13 +1,14 @@ import { getConfig } from '@edx/frontend-platform'; +import { getApiWaffleFlagsUrl } from '@src/data/api'; import { initializeMocks, screen, render, waitFor, -} from '../../testUtils'; +} from '@src/testUtils'; + import PageGrid from './PageGrid'; -import { getApiWaffleFlagsUrl } from '../../data/api'; import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; @@ -49,9 +50,7 @@ describe('LiveSettings', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md index 6d6603dafd..9651d829eb 100644 --- a/src/plugin-slots/README.md +++ b/src/plugin-slots/README.md @@ -8,7 +8,7 @@ ## Course Unit page * [`org.openedx.frontend.authoring.course_unit_header_actions.v1`](./CourseUnitHeaderActionsSlot/) -* [`org.openedx.frontend.authoring.course_unit_sidebar.v1`](./CourseAuthoringUnitSidebarSlot/) +* [`org.openedx.frontend.authoring.course_unit_sidebar.v2`](./CourseAuthoringUnitSidebarSlot/) ## Other Slots * [`org.openedx.frontend.authoring.additional_course_content_plugin.v1`](./AdditionalCourseContentPluginSlot/) diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index 77c0192e9c..f0990358aa 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -9,10 +9,11 @@ import { import { executeThunk } from '@src/utils'; import genericMessages from '@src/generic/help-sidebar/messages'; import { DATE_FORMAT } from '@src/constants'; +import { getCourseSettingsApiUrl } from '@src/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { courseDetailsMock, courseSettingsMock } from './__mocks__'; -import { getCourseDetailsApiUrl, getCourseSettingsApiUrl } from './data/api'; +import { getCourseDetailsApiUrl } from './data/api'; import { updateCourseDetailsQuery } from './data/thunks'; import creditMessages from './credit-section/messages'; import pacingMessages from './pacing-section/messages'; diff --git a/src/schedule-and-details/data/api.js b/src/schedule-and-details/data/api.js index 23f83db79b..a6202f0091 100644 --- a/src/schedule-and-details/data/api.js +++ b/src/schedule-and-details/data/api.js @@ -4,7 +4,6 @@ import { convertObjectToSnakeCase } from '../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`; -export const getCourseSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; export const getUploadAssetsUrl = (courseId) => `${getApiBaseUrl()}/assets/${courseId}/`; /** @@ -32,15 +31,3 @@ export async function updateCourseDetails(courseId, details) { ); return camelCaseObject(data); } - -/** - * Get course settings. - * @param {string} courseId - * @returns {Promise} - */ -export async function getCourseSettings(courseId) { - const { data } = await getAuthenticatedHttpClient().get( - `${getCourseSettingsApiUrl(courseId)}`, - ); - return camelCaseObject(data); -} diff --git a/src/schedule-and-details/data/thunks.js b/src/schedule-and-details/data/thunks.js index bc2be6dc41..fe14d29260 100644 --- a/src/schedule-and-details/data/thunks.js +++ b/src/schedule-and-details/data/thunks.js @@ -1,8 +1,11 @@ -import { RequestStatus } from '../../data/constants'; +import { + getCourseSettings, +} from '@src/data/api'; +import { RequestStatus } from '@src/data/constants'; + import { getCourseDetails, updateCourseDetails, - getCourseSettings, } from './api'; import { updateSavingStatus, diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx index 590c870c42..8a4a5d6dfb 100644 --- a/src/studio-home/tabs-section/TabsSection.test.tsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -33,9 +33,6 @@ let store; const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; const libraryApiLink = `${getStudioHomeApiUrl()}/libraries`; -// The Libraries v2 tab title contains a badge, so we need to use regex to match its tab text. -const librariesBetaTabTitle = /Libraries Beta/; - const tabSectionComponent = (overrideProps) => ( ', () => { expect(screen.getByRole('tab', { name: tabMessages.coursesTabTitle.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: librariesBetaTabTitle })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: tabMessages.legacyLibrariesTabTitle.defaultMessage })).toBeInTheDocument(); }); @@ -120,7 +117,7 @@ describe('', () => { await executeThunk(fetchStudioHomeData(), store.dispatch); expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - const librariesTab = screen.getByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); expect(librariesTab).toBeInTheDocument(); // Check Tab.eventKey expect(librariesTab).toHaveAttribute('data-rb-event-key', 'libraries'); @@ -352,7 +349,7 @@ describe('', () => { await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -377,9 +374,6 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - // Libraries v2 tab should not be shown - expect(screen.queryByRole('tab', { name: librariesBetaTabTitle })).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); @@ -399,9 +393,6 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - // Libraries v2 tab should not be shown - expect(screen.queryByRole('tab', { name: librariesBetaTabTitle })).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); @@ -430,7 +421,7 @@ describe('', () => { render(); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -453,7 +444,7 @@ describe('', () => { // Libraries v1 tab should not be shown expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -478,7 +469,7 @@ describe('', () => { await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -522,7 +513,7 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -546,7 +537,7 @@ describe('', () => { render(); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx index 9a07c7c2c3..e4eb63223f 100644 --- a/src/studio-home/tabs-section/index.tsx +++ b/src/studio-home/tabs-section/index.tsx @@ -1,7 +1,6 @@ import { useMemo, useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { - Badge, Stack, Tab, Tabs, @@ -91,7 +90,6 @@ const TabsSection = ({ title={( {intl.formatMessage(messages.librariesTabTitle)} - {intl.formatMessage(messages.librariesV2TabBetaBadge)} )} > diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index 763a384187..723a202102 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -50,19 +50,6 @@ const messages = defineMessages({ defaultMessage: 'Taxonomies', description: 'Title of Taxonomies tab on the home page', }, - libraryV2PlaceholderTitle: { - id: 'course-authoring.studio-home.libraries.placeholder.title', - defaultMessage: 'Library V2 Placeholder', - }, - libraryV2PlaceholderBody: { - id: 'course-authoring.studio-home.libraries.placeholder.body', - defaultMessage: 'This is a placeholder page, as the Library Authoring MFE is not enabled.', - }, - librariesV2TabBetaBadge: { - id: 'course-authoring.studio-home.libraries.tab.library.beta-badge', - defaultMessage: 'Beta', - description: 'Text used to mark the Libraries v2 feature as "in beta"', - }, librariesV2TabLibrarySearchPlaceholder: { id: 'course-authoring.studio-home.libraries.tab.library.search-placeholder', defaultMessage: 'Search', diff --git a/src/taxonomy/data/api.test.ts b/src/taxonomy/data/api.test.ts index ed6ef8cdce..1a0cbf2df3 100644 --- a/src/taxonomy/data/api.test.ts +++ b/src/taxonomy/data/api.test.ts @@ -7,6 +7,7 @@ import { getTaxonomyListData, getTaxonomy, deleteTaxonomy, + getApiErrorMessage, } from './api'; describe('taxonomy api calls', () => { @@ -57,4 +58,82 @@ describe('taxonomy api calls', () => { // Restore the location object of window: window.location = origLocation; }); + + describe('getApiErrorMessage', () => { + it('returns first non-empty string when response data is an array', () => { + const err = { + response: { + data: ['', 'Array error message', 'Another message'], + }, + }; + + expect(getApiErrorMessage(err)).toEqual('Array error message'); + }); + + it('returns response data when it is a non-empty string', () => { + const err = { + response: { + data: 'String error message', + }, + }; + + expect(getApiErrorMessage(err)).toEqual('String error message'); + }); + + it('prefers object.error over detail and message fields', () => { + const err = { + response: { + data: { + error: 'Error field message', + detail: 'Detail field message', + message: 'Message field message', + }, + }, + }; + + expect(getApiErrorMessage(err)).toEqual('Error field message'); + }); + + it('falls back to object.message then object.detail when needed', () => { + const messageErr = { + response: { + data: { + detail: 'Detail field message', + message: 'Message field message', + }, + }, + }; + const detailErr = { + response: { + data: { + detail: 'Detail field message', + }, + }, + }; + + expect(getApiErrorMessage(messageErr)).toEqual('Message field message'); + expect(getApiErrorMessage(detailErr)).toEqual('Detail field message'); + }); + + it('falls back to top-level error message when response data is unparseable', () => { + const err = { + message: 'Top level error message', + response: { + data: [null, {}, ' '], + }, + }; + + expect(getApiErrorMessage(err)).toEqual('Top level error message'); + }); + + it('returns default message when no message is available', () => { + const err = { + response: { + data: null, + }, + }; + + expect(getApiErrorMessage(err)).toEqual('Unknown error'); + }); + }); }); diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 4d1f473e66..eb50241106 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -1,6 +1,8 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import type { TaxonomyData, TaxonomyListData } from './types'; +import { MAX_TAXONOMY_ITEMS } from './constants'; +import messages from '../messages'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href; @@ -53,18 +55,25 @@ export const apiUrls = { /** Get the URL for a Taxonomy */ taxonomy: (taxonomyId: number) => makeUrl(`${taxonomyId}/`), /** - * Get the URL for listing the tags of a taxonomy + * Get the URL for listing the tags of a taxonomy. + * The max response size is 10,000 items, as set in the `MAX_TAXONOMY_ITEMS` constant. + * The backend does not support larger responses. * @param pageIndex Zero-indexed page number * @param pageSize How many tags per page to load + * @param fullDepth Whether to return max levels of child tags, + * with results limited by the MAX_TAXONOMY_ITEMS constant. */ - tagList: (taxonomyId: number, pageIndex: number | null, pageSize: number | null, fullDepthThreshold?: number) => { - if (pageIndex === null) { - return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepthThreshold || 0 }); + tagList: (taxonomyId: number, { + pageIndex, pageSize, fullDepth, disablePagination, + }: { pageIndex: number | null; pageSize: number | null; fullDepth?: boolean; disablePagination?: boolean }) => { + if (disablePagination) { + return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, include_counts: 'true' }); } return makeUrl(`${taxonomyId}/tags/`, { page: (pageIndex ?? 0) + 1, page_size: pageSize ?? 10, - full_depth_threshold: fullDepthThreshold || 0, + full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, + include_counts: 'true', }); }, /** @@ -72,7 +81,7 @@ export const apiUrls = { */ allSubtagsOf: (taxonomyId: number, parentTagValue: string) => makeUrl(`${taxonomyId}/tags/`, { // Load as deeply as we can - full_depth_threshold: 10000, + full_depth_threshold: MAX_TAXONOMY_ITEMS, parent_tag: parentTagValue, }), /** URL to create a new taxonomy from an import file. */ @@ -117,3 +126,46 @@ export async function getTaxonomy(taxonomyId: number): Promise { export function getTaxonomyExportFile(taxonomyId: number, format: 'json' | 'csv'): void { window.location.href = apiUrls.exportTaxonomy(taxonomyId, format); } + +/** + * Extracts a human-readable error message from the API response. + * + * While most endpoints return an object (e.g., `{ error: "msg" }`), this specific + * backend call may return a raw array of strings: `["error1", "error2"]`. This function normalizes those + * edge cases by returning the first available error message. + * @param {unknown} err - The caught error object from the API. + * @param {Object} intl - The internationalization object to format default messages. + * @returns {string} The first detected error string or a default message if unparseable. + */ +export const getApiErrorMessage = (err: unknown, intl?: any): string => { + const error = err as { message?: string; response?: { data?: unknown } }; + const responseData = error?.response?.data; + + // `POST /api/content_tagging/v1/taxonomies/:id/tags/ with a duplicate tag name returns + // `["Tag with value 'abblue' already exists for taxonomy."]` as response body. + 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.message === 'string' && objectData.message.trim().length > 0) { + return objectData.message; + } + if (typeof objectData.detail === 'string' && objectData.detail.trim().length > 0) { + return objectData.detail; + } + } + + return error?.message || (intl ? intl.formatMessage(messages.unknownErrorMessage) : 'Unknown error'); +}; diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index c4454acc49..1f012ee946 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -3,6 +3,7 @@ import React from 'react'; // Required to use JSX syntax without type errors import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -29,7 +30,9 @@ const queryClient = new QueryClient({ const wrapper = ({ children }) => ( - {children} + + {children} + ); diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 5178ecbf24..3207f5665a 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -13,10 +13,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { apiUrls, ALL_TAXONOMIES } from './api'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } 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 @@ -66,37 +66,6 @@ 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 @@ -128,6 +97,7 @@ export const useDeleteTaxonomy = () => { export const useTaxonomyDetails = (taxonomyId: number) => useQuery({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), queryFn: () => api.getTaxonomy(taxonomyId), + refetchOnMount: 'always', }); /** @@ -212,17 +182,20 @@ 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, enabled = true } = options; // eslint-disable-line + const { pageIndex, pageSize, enabled = true, disablePagination = false } = options; // eslint-disable-line return useQuery({ // 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, null, null, EXPECTED_MAX_TAXONOMY_ITEMS), + apiUrls.tagList(taxonomyId, { + pageIndex, pageSize, fullDepth: true, disablePagination, + }), ); return camelCaseObject(data) as TagListData; }, enabled, + refetchOnMount: 'always', }); }; @@ -241,6 +214,7 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue export const useCreateTag = (taxonomyId: number) => { const queryClient = useQueryClient(); + const intl = useIntl(); return useMutation({ mutationFn: async ({ value, parentTagValue }: { value: string, parentTagValue?: string }) => { @@ -250,15 +224,19 @@ export const useCreateTag = (taxonomyId: number) => { { tag: value, parent_tag_value: parentTagValue }, ); } catch (err) { - throw new Error(getApiErrorMessage(err)); + throw new Error(getApiErrorMessage(err, intl)); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + refetchType: 'none', }); // In the metadata, 'tagsCount' (and possibly other fields) will have changed: - queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), + refetchType: 'none', + }); }, }); }; diff --git a/src/taxonomy/data/constants.ts b/src/taxonomy/data/constants.ts index 399418fbb8..dc205768a2 100644 --- a/src/taxonomy/data/constants.ts +++ b/src/taxonomy/data/constants.ts @@ -1,8 +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. + * Used to ensure that we load all nested subtags. + * This is set to the maximum value allowed by the backend. + * However, if the taxonomy size exceeds this value, the results + * will be incomplete because the backend only supports a taxonomy size of 10,000 items or fewer. */ -export const EXPECTED_MAX_TAXONOMY_ITEMS = 10000; +export const MAX_TAXONOMY_ITEMS = 10000; diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index d0e0192d36..d8ca63d1e7 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -33,6 +33,7 @@ export interface QueryOptions { pageIndex: number; pageSize: number; enabled?: boolean; + disablePagination?: boolean; } export interface TagData { @@ -43,6 +44,8 @@ export interface TagData { id: number; parentValue: string | null; subTagsUrl: string | null; + canChangeTag?: boolean; + canDeleteTag?: boolean; /** Unique ID for this tag, also its display text */ value: string; usageCount?: number; diff --git a/src/taxonomy/messages.ts b/src/taxonomy/messages.ts index c904346dd3..0fca895539 100644 --- a/src/taxonomy/messages.ts +++ b/src/taxonomy/messages.ts @@ -50,6 +50,10 @@ const messages = defineMessages({ defaultMessage: 'Please keep this window open. We\'ll let you know when it\'s done.', description: 'Alert message when the taxonomy import is in progress.', }, + unknownErrorMessage: { + id: 'course-authoring.taxonomy-list.error.unknown', + defaultMessage: 'Unknown error', + }, }); export default messages; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 9a5dde7a3c..6074dc1e23 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -45,7 +45,7 @@ RootWrapper.propTypes = { maxDepth: PropTypes.number, }; -const tagDefaults = { depth: 0, external_id: null, parent_value: null }; +const tagDefaults = { depth: 0, external_id: '', parent_value: null }; const mockTagsResponse = { next: null, previous: null, @@ -61,6 +61,7 @@ const mockTagsResponse = { descendant_count: 14, _id: 1001, sub_tags_url: '/request/to/load/subtags/1', + usage_count: 1, }, { ...tagDefaults, @@ -69,6 +70,7 @@ const mockTagsResponse = { descendant_count: 10, _id: 1002, sub_tags_url: '/request/to/load/subtags/2', + usage_count: 0, }, { ...tagDefaults, @@ -77,6 +79,7 @@ const mockTagsResponse = { descendant_count: 5, _id: 1003, sub_tags_url: '/request/to/load/subtags/3', + usage_count: 3, }, { ...tagDefaults, @@ -86,6 +89,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'root tag 1', + usage_count: 1, }, { ...tagDefaults, @@ -95,6 +99,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'the child tag', + usage_count: null, }, ], }; @@ -107,7 +112,7 @@ const mockTagsPaginationResponse = { start: 0, results: [], }; -const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000'; +const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&include_counts=true'; const subTagsResponse = { next: null, previous: null, @@ -217,6 +222,13 @@ describe('', () => { 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'); + expect(within(rows[0]).getAllByRole('columnheader')[1].textContent).toEqual('Usage Count'); + }); + + it('should render usage count correctly for root tag', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[1]).getAllByRole('cell')[1].textContent).toEqual('1'); }); it('should render page correctly with subtags', async () => { @@ -226,6 +238,36 @@ describe('', () => { expect(childTag).toBeInTheDocument(); }); + it('should render usage count correctly for sub tag', async () => { + // Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual('1'); + }); + + it('should render usage count as empty/no content when usage count is "0"', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual(''); + }); + + it('should render usage count as empty/no when usage count is "null"', async () => { + // Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[4]).getAllByRole('cell')[1].textContent).toEqual(''); + }); + it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); renderTagListTable(); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 22575736f2..01bfdbfe11 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useEffect, } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; import type { PaginationState } from '@tanstack/react-table'; import { useTagListData, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; @@ -24,6 +23,9 @@ interface TagListTableProps { maxDepth: number; } +// TODO: Fix and enable pagination on backend and frontend.For now, disable pagination by showing all tags on one page. +const DISABLE_PAGINATION = true; + 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. @@ -33,10 +35,15 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // 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(); + // + // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: + // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. + // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); + + // TODO: change to use the global ToastContext (waiting for UX refinement on that). const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); const [tagTree, setTagTree] = useState(null); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); @@ -51,10 +58,10 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } = useTableModes(); // PAGINATION - // TODO: Fix and enable pagination. For now, disable pagination on the api hook side. + // TODO: Fix and enable pagination. For now, disable pagination. const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, - pageSize: 100, + pageSize: 50, }); const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); const handlePaginationChange = (updater: React.SetStateAction) => { @@ -67,11 +74,14 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // API HOOKS const { isLoading, data: tagList } = useTagListData(taxonomyId, { ...pagination, + disablePagination: DISABLE_PAGINATION, enabled: tableMode === TABLE_MODES.VIEW, }); const createTagMutation = useCreateTag(taxonomyId); const pageCount = tagList?.numPages ?? -1; + // TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props. + // 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({ @@ -80,7 +90,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { createTagMutation, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, exitDraftWithoutSave, @@ -89,7 +98,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { const columns = useMemo( () => getColumns({ - intl, setIsCreatingTopTag, setCreatingParentId, handleUpdateTag, @@ -104,7 +112,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { creatingParentId, }), [ - intl, isCreatingTopTag, editingRowId, tableMode, diff --git a/src/taxonomy/tag-list/constants.ts b/src/taxonomy/tag-list/constants.ts index 5ac09eb66b..33b0dc058d 100644 --- a/src/taxonomy/tag-list/constants.ts +++ b/src/taxonomy/tag-list/constants.ts @@ -1,15 +1,22 @@ +/** Tag list table modes - see explanation in `` component (`src/taxonomy/tag-list/TagListTable.tsx`) */ const TABLE_MODES = { VIEW: 'view', DRAFT: 'draft', PREVIEW: 'preview', }; +/** Allowed transitions for table mode. + * An invalid transition is mainly an illegal switch from DRAFT mode to VIEW mode, + * which would refresh data and suddenly reorder the table and disrupt the user's workflow. + * Refreshing data is only allowed in VIEW mode. + */ 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], }; +/** Table mode action types for the React's `useReducer` hook */ const TABLE_MODE_ACTIONS = { TRANSITION: 'transition', }; diff --git a/src/taxonomy/tag-list/errors.ts b/src/taxonomy/tag-list/errors.ts new file mode 100644 index 0000000000..389621388c --- /dev/null +++ b/src/taxonomy/tag-list/errors.ts @@ -0,0 +1,16 @@ +/** Custom error classes for the Tag List feature. */ +/* eslint-disable max-classes-per-file */ + +export class TagTreeError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagTreeError'; + } +} + +export class TagListTableError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagListTableError'; + } +} diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx index dbb76b3f8f..7d799afceb 100644 --- a/src/taxonomy/tag-list/hooks.test.tsx +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { IntlProvider, useIntl } from '@edx/frontend-platform/i18n'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { act, renderHook } from '@testing-library/react'; import { TagTree } from './tagTree'; @@ -9,11 +9,6 @@ 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()); @@ -48,7 +43,6 @@ describe('useTableModes', () => { describe('useEditActions', () => { const buildActions = (overrides = {}) => { - const intl = getIntl(); const createTagMutation = { mutateAsync: jest.fn() }; const setTagTree = jest.fn(); const setDraftError = jest.fn(); @@ -59,22 +53,23 @@ describe('useEditActions', () => { const exitDraftWithoutSave = jest.fn(); const setEditingRowId = jest.fn(); - const actions = useEditActions({ // eslint-disable-line react-hooks/rules-of-hooks + const params = { setTagTree, setDraftError, createTagMutation: createTagMutation as any, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, exitDraftWithoutSave, setEditingRowId, ...(overrides as any), - }); + }; + + const { result } = renderHook(() => useEditActions(params), { wrapper }); return { - actions, + actions: result.current, createTagMutation, setTagTree, setDraftError, @@ -108,7 +103,9 @@ describe('useEditActions', () => { }); const { actions } = buildActions({ setTagTree }); - actions.updateTableWithoutDataReload('brand new root'); + act(() => { + actions.updateTableWithoutDataReload('brand new root'); + }); expect(updatedTree.getTagAsDeepCopy('brand new root')).not.toBeNull(); }); @@ -121,7 +118,9 @@ describe('useEditActions', () => { setEditingRowId, } = buildActions(); - await actions.handleUpdateTag(' same value ', 'same value'); + await act(async () => { + await actions.handleUpdateTag(' same value ', 'same value'); + }); expect(enterPreviewMode).not.toHaveBeenCalled(); expect(setToast).not.toHaveBeenCalled(); @@ -136,13 +135,14 @@ describe('useEditActions', () => { setEditingRowId, } = buildActions(); - await actions.handleUpdateTag('updated', 'original'); + await act(async () => { + await actions.handleUpdateTag('updated', 'original'); + }); expect(enterPreviewMode).toHaveBeenCalled(); expect(setToast).toHaveBeenCalledWith({ show: true, message: 'Tag "updated" updated successfully', - variant: 'success', }); expect(setEditingRowId).toHaveBeenCalledWith(null); }); @@ -156,13 +156,14 @@ describe('useEditActions', () => { } = buildActions(); createTagMutation.mutateAsync.mockRejectedValue(new Error('server failed')); - await actions.handleCreateTag('new tag'); + await act(async () => { + 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 index b90b4d1e50..a350e3eabe 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -3,6 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; +import { TagListTableError } from './errors'; import type { RowId } from '../tree-table/types'; import { TABLE_MODES, @@ -13,6 +14,14 @@ import { import messages from './messages'; +/** Interface for table mode actions for React's `useReducer` hook. + * + * `type`: Action type. + * `targetMode`: The table mode to transition to. Must be one of the allowed transitions defined in `TRANSITION_TABLE`. + * An invalid transition (e.g. from DRAFT to VIEW) will throw an error to prevent disruptive data refreshes. + * + * For examples, see: https://react.dev/learn/extracting-state-logic-into-a-reducer#writing-reducers-well +*/ export interface TableModeAction { type: string; targetMode: string; @@ -31,8 +40,7 @@ interface UseEditActionsParams { setDraftError: React.Dispatch>; createTagMutation: ReturnType; enterPreviewMode: () => void; - setToast: React.Dispatch>; - intl: ReturnType; + setToast: React.Dispatch>; setIsCreatingTopTag: React.Dispatch>; setCreatingParentId: React.Dispatch>; exitDraftWithoutSave: () => void; @@ -50,9 +58,17 @@ const getInlineValidationMessage = (value: string, intl: ReturnType { if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { - throw new Error(`Unknown table mode action: ${action?.type}`); + throw new TagListTableError(`Unknown table mode action: ${action?.type}`); } const { targetMode } = action; @@ -60,9 +76,16 @@ const tableModeReducer = (currentMode: string, action: TableModeAction): string return targetMode; } - throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); + throw new TagListTableError(`Invalid table mode transition from ${currentMode} to ${targetMode}`); }; +/** Simple custom hook providing table modes. + * The main purpose of this hook is to manage allowed transitions between table modes + * to prevent disruptive data refreshes. + * This allows a component to check the current mode and switch to a different mode without risking invalid transitions. + * Transitions are defined separately in the `TRANSITION_TABLE` constant, + * which makes it easy to understand and update allowed transitions in one place. + */ const useTableModes = (): UseTableModesReturn => { const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); @@ -86,11 +109,11 @@ const useEditActions = ({ createTagMutation, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, setEditingRowId, }: UseEditActionsParams) => { + const intl = useIntl(); const updateTableWithoutDataReload = (value: string, parentTagValue: string | null = null) => { setTagTree((currentTagTree) => { const nextTree = currentTagTree || new TagTree([]); @@ -104,13 +127,17 @@ const useEditActions = ({ childCount: 0, descendantCount: 0, subTagsUrl: null, - externalId: null, + externalId: '', }, parentTagValue); return nextTree; }); }; + /** Validates a tag value and sets a draft error message if invalid. + * In 'hard' mode, it will throw an error instead of setting a draft error message; + * in 'soft' mode, it will set a draft error message and return false. + */ const validate = (value: string, mode: 'soft' | 'hard' = 'hard'): boolean => { const validationError = getInlineValidationMessage(value, intl); if (validationError) { @@ -140,14 +167,13 @@ const useEditActions = ({ 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' }); + setToast({ show: true, message }); } }; @@ -158,7 +184,6 @@ const useEditActions = ({ setToast({ show: true, message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }), - variant: 'success', }); } setEditingRowId(null); diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 2e9b7ecadb..b85c8a4696 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -5,6 +5,10 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.column.value.header', defaultMessage: 'Tag name', }, + tagListColumnCountHeader: { + id: 'course-authoring.tag-list.column.count.header', + defaultMessage: 'Usage Count', + }, tagListError: { id: 'course-authoring.tag-list.error', defaultMessage: 'Error: unable to load child tags', diff --git a/src/taxonomy/tag-list/mockData.ts b/src/taxonomy/tag-list/mockData.ts index 695bee619b..77d88cb60a 100644 --- a/src/taxonomy/tag-list/mockData.ts +++ b/src/taxonomy/tag-list/mockData.ts @@ -1,9 +1,10 @@ -import { TagData, TagTreeNode } from './tagTree'; +import { TagTreeNode } from './tagTree'; +import { TagData } from '../data/types'; export const rawData: TagData[] = [ { value: 'ab', - externalId: null, + externalId: 'some-external-id', childCount: 2, descendantCount: 4, depth: 0, @@ -15,7 +16,7 @@ export const rawData: TagData[] = [ }, { value: 'aaa', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -27,7 +28,7 @@ export const rawData: TagData[] = [ }, { value: 'aa', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -39,7 +40,7 @@ export const rawData: TagData[] = [ }, { value: 'ab2', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -51,7 +52,7 @@ export const rawData: TagData[] = [ }, { value: 'S3', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -63,7 +64,7 @@ export const rawData: TagData[] = [ }, { value: 'Brass2', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -75,7 +76,7 @@ export const rawData: TagData[] = [ }, { value: 'Celli', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -87,7 +88,7 @@ export const rawData: TagData[] = [ }, { value: 'ViolaDaGamba', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -99,7 +100,7 @@ export const rawData: TagData[] = [ }, { value: 'Soprano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -111,7 +112,7 @@ export const rawData: TagData[] = [ }, { value: 'Contrabass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -123,7 +124,7 @@ export const rawData: TagData[] = [ }, { value: 'Electrodrum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -171,7 +172,7 @@ export const rawData: TagData[] = [ }, { value: 'Fiddle', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -183,7 +184,7 @@ export const rawData: TagData[] = [ }, { value: 'grand piano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -195,7 +196,7 @@ export const rawData: TagData[] = [ }, { value: 'Horns', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -207,7 +208,7 @@ export const rawData: TagData[] = [ }, { value: 'English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -219,7 +220,7 @@ export const rawData: TagData[] = [ }, { value: 'Small English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -231,7 +232,7 @@ export const rawData: TagData[] = [ }, { value: 'Keyboard', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -243,7 +244,7 @@ export const rawData: TagData[] = [ }, { value: 'Kid drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -255,7 +256,7 @@ export const rawData: TagData[] = [ }, { value: 'Mezzosopranocello', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -267,7 +268,7 @@ export const rawData: TagData[] = [ }, { value: 'Oriental', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -315,7 +316,7 @@ export const rawData: TagData[] = [ }, { value: 'Drum', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -327,7 +328,7 @@ export const rawData: TagData[] = [ }, { value: 'bass drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -411,7 +412,7 @@ export const rawData: TagData[] = [ }, { value: 'Recorder', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -459,7 +460,7 @@ export const rawData: TagData[] = [ }, { value: 'Viola', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -483,7 +484,7 @@ export const rawData: TagData[] = [ }, { value: 'Other strings', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 1, @@ -543,7 +544,7 @@ export const rawData: TagData[] = [ }, { value: 'Subbass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -555,7 +556,7 @@ export const rawData: TagData[] = [ }, { value: 'Trumpets', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -663,7 +664,7 @@ export const rawData: TagData[] = [ }, { value: 'Xyllophones', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -678,7 +679,7 @@ export const rawData: TagData[] = [ export const treeRowData: TagTreeNode[] = [ { value: 'ab', - externalId: null, + externalId: 'some-external-id', childCount: 2, descendantCount: 4, depth: 0, @@ -690,7 +691,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'aaa', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -702,7 +703,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'aa', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -716,7 +717,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'ab2', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -728,7 +729,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'S3', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -744,7 +745,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Brass2', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -756,7 +757,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Celli', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -768,7 +769,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'ViolaDaGamba', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -780,7 +781,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'Soprano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -796,7 +797,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Contrabass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -808,7 +809,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Electrodrum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -858,7 +859,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Fiddle', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -870,7 +871,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'grand piano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -882,7 +883,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Horns', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -894,7 +895,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -906,7 +907,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'Small English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -922,7 +923,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Keyboard', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -934,7 +935,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Kid drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -946,7 +947,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Mezzosopranocello', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -958,7 +959,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Oriental', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1008,7 +1009,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Drum', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -1020,7 +1021,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'bass drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -1112,7 +1113,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Recorder', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1160,7 +1161,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Viola', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -1186,7 +1187,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Other strings', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 1, @@ -1250,7 +1251,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Subbass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1262,7 +1263,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Trumpets', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1376,7 +1377,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Xyllophones', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index 01fbcfc860..2fad2e25c6 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -1,4 +1,5 @@ import { + Bubble, Button, Icon, IconButton, @@ -9,8 +10,8 @@ import { AddCircle, MoreVert, } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import type { Row } from '@tanstack/react-table'; -import type { IntlShape } from 'react-intl'; import messages from './messages'; import type { @@ -24,6 +25,7 @@ interface TagListRowData extends TreeRowData { depth: number; childCount: number; descendantCount: number; + usageCount?: number; isNew?: boolean; isEditing?: boolean; } @@ -33,7 +35,6 @@ const asTagListRowData = (row: Row): TagListRowData => ( ); interface GetColumnsArgs { - intl: IntlShape; setIsCreatingTopTag: (isCreating: boolean) => void; setCreatingParentId: (id: RowId | null) => void; handleUpdateTag: (value: string, originalValue: string) => void; @@ -48,8 +49,93 @@ interface GetColumnsArgs { creatingParentId: RowId | null; } +const UsageCountDisplay = ({ row }: { row: Row }) => { + const count = asTagListRowData(row).usageCount ?? 0; + return ( + count > 0 && ( + + {count} + + ) + ); +}; + +interface ActionsHeaderProps { + onStartDraft: () => void; + setDraftError: (error: string) => void; + setIsCreatingTopTag: (isCreating: boolean) => void; + setEditingRowId: (id: RowId | null) => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftInProgressHintId: string; +} + +const ActionsHeader = ({ + onStartDraft, + setDraftError, + setIsCreatingTopTag, + setEditingRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftInProgressHintId, +}: ActionsHeaderProps) => { + const intl = useIntl(); + return ( +
    + {intl.formatMessage(messages.createNewTagTooltip)}
    } + src={AddCircle} + alt={intl.formatMessage(messages.createTagButtonLabel)} + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopTag(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft} + aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} + /> + + ); +}; + +interface ActionsMenuProps { + rowData: TagListRowData; + startSubtagDraft: () => void; + disableAddSubtag: boolean; +} + +const ActionsMenu = ({ rowData, startSubtagDraft, disableAddSubtag }: ActionsMenuProps) => { + const intl = useIntl(); + + return ( + + + + + {intl.formatMessage(messages.addSubtag)} + + + + ); +}; + function getColumns({ - intl, setIsCreatingTopTag, setCreatingParentId, setEditingRowId, @@ -62,10 +148,12 @@ function getColumns({ }: GetColumnsArgs): TreeColumnDef[] { const canAddSubtag = (row: Row) => row.depth < maxDepth; const draftInProgressHintId = 'tag-list-draft-in-progress-hint'; + const intl = useIntl(); return [ { - header: intl.formatMessage(messages.tagListColumnValueHeader), + id: 'valueColumn', + header: () => , cell: ({ row }) => { const { value, @@ -79,27 +167,23 @@ function getColumns({ ); }, }, + { + id: 'count', + header: intl.formatMessage(messages.tagListColumnCountHeader), + cell: UsageCountDisplay, + }, { 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} - aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} - /> - + ), cell: ({ row }) => { const rowData = asTagListRowData(row); @@ -121,26 +205,7 @@ function getColumns({ return (
    - - - - - {intl.formatMessage(messages.addSubtag)} - - - +
    ); }, diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts index f19885faf1..068005adb5 100644 --- a/src/taxonomy/tag-list/tagTree.test.ts +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -1,10 +1,12 @@ import { rawData, treeRowData } from './mockData'; import { TagTree } from './tagTree'; -import TagTreeError from './tagTreeError'; +import { TagTreeError } from './errors'; +import { TagData } from '../data/types'; -const newSubtagChildRow = { +// For testing purposes, we define a new child node that can be added to the tree in various test cases. +const newChildNode: TagData = { value: 'newChild', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 8, @@ -42,13 +44,13 @@ describe('TagTree', () => { 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(); + expect(node?.externalId).toBe('some-external-id'); if (node) { node.externalId = 'modified'; } const originalNode = tree.getTagAsDeepCopy('ab'); - expect(originalNode?.externalId).toBeNull(); + expect(originalNode?.externalId).toBe('some-external-id'); }); it('returns null for non-existent node', () => { @@ -59,9 +61,9 @@ describe('TagTree', () => { it('creates a new top-level row', () => { const tree = new TagTree(rawData); - const newRow = { + const newRow: TagData = { value: 'newTopLevel', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 7, @@ -77,24 +79,24 @@ describe('TagTree', () => { it('creates a new child row', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); const parentNode = tree.getTagAsDeepCopy('ab'); - expect(parentNode?.subRows).toContainEqual(newSubtagChildRow); + expect(parentNode?.subRows).toContainEqual(newChildNode); }); it('edits a node value', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, '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); + expect(tree.getTagAsDeepCopy('editedAb')?.subRows).toContainEqual(newChildNode); }); it('deletes a top-level node and its children', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); tree.removeNode('ab'); expect(tree.getTagAsDeepCopy('ab')).toBeNull(); expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); @@ -102,10 +104,10 @@ describe('TagTree', () => { it('deletes a child node', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); tree.removeNode('newChild', 'ab'); const parentNode = tree.getTagAsDeepCopy('ab'); - expect(parentNode?.subRows).not.toContainEqual(newSubtagChildRow); + expect(parentNode?.subRows).not.toContainEqual(newChildNode); }); it('returns null and leaves tree unchanged when removing a non-existent node', () => { @@ -132,7 +134,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const rowCountBefore = tree.getAllAsDeepCopy().length; - tree.addNode(newSubtagChildRow, 'missing-parent'); + tree.addNode(newChildNode, 'missing-parent'); expect(tree.getAllAsDeepCopy()).toHaveLength(rowCountBefore); expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); @@ -142,7 +144,7 @@ describe('TagTree', () => { const orphanData = [ { value: 'orphan', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 900, @@ -164,7 +166,7 @@ describe('TagTree', () => { const duplicateValueData = [ { value: 'dup', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1001, @@ -176,7 +178,7 @@ describe('TagTree', () => { }, { value: 'dup', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1002, @@ -195,7 +197,7 @@ describe('TagTree', () => { const cyclicData = [ { value: 'a', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1101, @@ -207,7 +209,7 @@ describe('TagTree', () => { }, { value: 'b', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1102, @@ -232,7 +234,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newNode = { value: 'ab', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 999, @@ -250,7 +252,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newNode = { value: 'new row', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1000, @@ -266,7 +268,7 @@ describe('TagTree', () => { expect(tree.getAllAsDeepCopy()[0]).toEqual(newNode); const nextNewNode = { value: 'another new row', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1001, @@ -285,7 +287,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newChild = { value: 'new child', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1002, @@ -303,7 +305,7 @@ describe('TagTree', () => { const nextNewChild = { value: 'another new child', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1003, diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index 992a5f2503..881e3dc623 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -1,19 +1,5 @@ -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; -} +import { TagTreeError } from './errors'; +import type { TagData } from '../data/types'; export interface TagTreeNode extends TagData { subRows?: TagTreeNode[]; @@ -37,10 +23,41 @@ export class TagTree { this.buildTree(); } - getAllFlattenedAsCopy(): TagTreeNode[] { - const flatten = (nodes: TagTreeNode[], accumulator: TagTreeNode[] = []): TagTreeNode[] => { + /** Returns a flattened copy of all nodes in the tree. + * For example, this array is not nested even though it contains a parent and a child tag: + * [ + * { + * value: 'parent tag name', + * externalId: null, + * childCount: 2, + * descendantCount: 4, + * depth: 0, + * parentValue: null, + * id: 1, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * { + * value: 'child tag name', + * externalId: null, + * childCount: 0, + * descendantCount: 0, + * depth: 1, + * parentValue: 'parent tag name', + * id: 2, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * // ... more tags + * ] + */ + getAllFlattenedAsCopy(): TagData[] { + const flatten = (nodes: TagTreeNode[], accumulator: TagData[] = []): TagData[] => { for (const node of nodes) { - accumulator.push({ ...node, subRows: undefined }); + const { subRows, ...tagData } = node; + accumulator.push({ ...tagData }); // Create a shallow copy of the tag data without subRows if (node.subRows) { flatten(node.subRows, accumulator); } @@ -54,21 +71,25 @@ export class TagTree { return JSON.parse(JSON.stringify(this.rows)); } + /** For extra robustness, we verify that there are no duplicate values + * in the data. (The backend also guarantees this.) + */ 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}`); + const lowerCaseValue = item.value.toLowerCase(); + if (seenValues.has(lowerCaseValue)) { + throw new TagTreeError(`Duplicate tag value found: ${lowerCaseValue}`); } - seenValues.add(item.value); + seenValues.add(lowerCaseValue); } } + /** For extra robustness, we verify that there are no cycles in the data. (The backend also guarantees this.) */ private validateNoCycles(items: TagData[]) { const parentByValue: { [key: string]: string | null } = {}; for (const item of items) { - parentByValue[item.value] = item.parentValue; + parentByValue[item.value.toLowerCase()] = item.parentValue ? item.parentValue.toLowerCase() : null; } const visitStatus: { [key: string]: number } = {}; @@ -94,7 +115,7 @@ export class TagTree { }; for (const item of items) { - if (detectCycle(item.value)) { + if (detectCycle(item.value.toLowerCase())) { throw new TagTreeError('Cycle detected in tag hierarchy.'); } } diff --git a/src/taxonomy/tag-list/tagTreeError.ts b/src/taxonomy/tag-list/tagTreeError.ts deleted file mode 100644 index 5e1615f257..0000000000 --- a/src/taxonomy/tag-list/tagTreeError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default class TagTreeError extends Error { - constructor(message: string) { - super(message); - this.name = 'TagTreeError'; - } -} diff --git a/src/taxonomy/taxonomy-detail/constants.ts b/src/taxonomy/taxonomy-detail/constants.ts index ae59021c7a..442573c7c3 100644 --- a/src/taxonomy/taxonomy-detail/constants.ts +++ b/src/taxonomy/taxonomy-detail/constants.ts @@ -1,5 +1,10 @@ /** - * Warning: This must reflect the `TAXONOMY_MAX_DEPTH` used in the openedx-core backend. + * The maximum allowable depth for any tag in the taxonomy (0-indexed). + * * **Constraint**: A value of 3 allows levels 0, 1, 2, and 3. Creation of new subtags + * is disabled for any tag already at this depth to prevent exceeding the limit. + * * **Data Handling**: This is a UI safety gate, not a filter. If the backend returns + * tags exceeding this depth, they will still be rendered, but further nesting will be blocked. + * * **Sync Required**: This must match `TAXONOMY_MAX_DEPTH` in the openedx-core backend. */ const TAXONOMY_MAX_DEPTH = 3; diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx index 507976e826..09a660c159 100644 --- a/src/taxonomy/tree-table/EditableCell.tsx +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -10,13 +10,23 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import OptionalExpandLink from '../tag-list/OptionalExpandLink'; +/** + * Props for the EditableCell component. + */ interface EditableCellProps { + /** The initial value to display in the cell */ initialValue?: string; + /** Callback function triggered on keyboard events */ onKeyDown?: (event: React.KeyboardEvent) => void; + /** Callback function triggered when the input value changes */ onChange?: (event: React.ChangeEvent) => void; + /** Error message to display if validation fails */ errorMessage?: string; + /** Indicates whether the cell value is currently being saved to the server */ isSaving?: boolean; + /** If true, the input field will automatically receive focus when the cell enters edit mode */ autoFocus?: boolean; + /** Function that returns a validation message to be displayed based on the current input value. */ getInlineValidationMessage?: (value: string) => string; } @@ -30,24 +40,23 @@ const EditableCell = ({ autoFocus = false, }: EditableCellProps) => { const [value, setValue] = useState(initialValue); + const [validationMessage, setValidationMessage] = useState(''); const inputId = useId(); const inputRef = useRef(null); const intl = useIntl(); useEffect(() => { - if (autoFocus) { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); } - }, [autoFocus]); + }, [inputRef.current]); // autoFocus explicitly not a dependency, to avoid unexpected focus change. useEffect(() => { setValue(initialValue); - }, [initialValue]); + setValidationMessage(getInlineValidationMessage(initialValue)); + }, []); // initialValue explicitly not a dependency, to avoid overwriting user input. - const validationMessage = getInlineValidationMessage(value); const effectiveErrorMessage = errorMessage || validationMessage; const errorMessageId = `${inputId}-error`; @@ -61,6 +70,7 @@ const EditableCell = ({ value={value} onChange={(e) => { setValue(e.target.value); + setValidationMessage(getInlineValidationMessage(e.target.value)); onChange(e); }} size="sm" diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx index bed055302e..8facb825b7 100644 --- a/src/taxonomy/tree-table/NestedRows.tsx +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -9,22 +9,50 @@ import type { import { CreateRow } from './CreateRow'; interface NestedRowsProps { + /** The parent row object from TanStack React Table */ parentRow: TreeRow; + /** The value identifier of the parent row */ parentRowValue: string; + /** Whether a new child row is currently being created for this parent */ isCreating?: boolean; + /** Callback when a new child row is saved (receives value and parentRowValue) */ onSaveNewChildRow?: (value: string, parentRowValue: string) => void; + /** Callback when child row creation is cancelled */ onCancelCreation?: () => void; + /** Array of child row objects to render */ childRowsData?: TreeRow[]; + /** Current nesting depth level (used for indentation calculation) */ depth?: number; + /** Error message to display in draft creation form */ draftError?: string; + /** Setter function for draft error state */ setDraftError?: (error: string) => void; + /** ID of the row currently in creation mode */ creatingParentId?: RowId | null; + /** Setter function for which row is in creation mode */ setCreatingParentId?: (value: RowId | null) => void; + /** Callback to set whether top-level row creation is active */ setIsCreatingTopRow: (isCreating: boolean) => void; + /** State object for the row creation mutation (isPending, isError, error) */ createRowMutation: CreateRowMutationState; + /** Validation function for new row values (receives value and optional 'soft' or 'hard' mode; + * in 'hard' mode an exception is thrown on validation failure) */ validate: (value: string, mode?: 'soft' | 'hard') => boolean; } +/** + * NestedRows + * + * Recursively renders nested child rows within a tree table structure. This component handles: + * - Display of child rows when a parent row is expanded + * - Indentation based on nesting depth + * - Creation of new child rows with validation + * - Management of draft state during row creation + * - Recursive rendering of grandchild rows and deeper levels + * + * The component uses the TanStack React Table library to render table cells and manages + * the creation flow by displaying a CreateRow form when a parent is in creation mode. + */ const NestedRows = ({ parentRow, parentRowValue,