diff --git a/package-lock.json b/package-lock.json index c3f55e5ca9..90c5f0bd7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", - "@openedx/frontend-build": "^14.6.6", + "@openedx/frontend-build": "^14.6.17", "@openedx/frontend-plugin-framework": "^1.8.0", "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", @@ -48,7 +48,7 @@ "classnames": "2.5.1", "codemirror": "^6.0.0", "email-validator": "2.0.4", - "fast-xml-parser": "5.3.7", + "fast-xml-parser": "5.11.1", "file-saver": "^2.0.5", "formik": "2.4.9", "frontend-components-tinymce-advanced-plugins": "^1.0.3", @@ -3933,9 +3933,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -3945,19 +3945,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -3967,19 +3967,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -3993,9 +4012,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -4009,9 +4028,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -4028,9 +4047,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -4047,9 +4066,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -4066,9 +4085,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -4085,9 +4104,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -4104,9 +4123,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -4123,9 +4142,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -4142,9 +4161,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -4161,9 +4180,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -4176,19 +4195,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -4201,19 +4220,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -4226,19 +4245,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -4251,19 +4270,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -4276,19 +4295,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -4301,19 +4320,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -4326,19 +4345,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -4351,38 +4370,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -4392,16 +4427,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -4411,16 +4446,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -4430,7 +4465,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -5602,6 +5637,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5682,9 +5729,9 @@ "link": true }, "node_modules/@openedx/frontend-build": { - "version": "14.6.13", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.13.tgz", - "integrity": "sha512-dh+AiJOeu4TkaDUXd5eyU1Fg1eHUDO7UDya0xGnh5vluN7wcMdC8M8nN1UaFEB43ZwJoqcIxcA11sb+GGkC2Bg==", + "version": "14.6.17", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.17.tgz", + "integrity": "sha512-3qOS3deYwvFN/81SKyND5U4CAptmm3Lo6UgLcE43gzoHxNS/XUZbOhPq0jqlC14B/O3vWqovQ3Phm38fhvh8iA==", "license": "AGPL-3.0", "dependencies": { "@babel/cli": "^7.24.8", @@ -5744,7 +5791,7 @@ "resolve-url-loader": "^5.0.0", "sass": "^1.85.1", "sass-loader": "^13.3.3", - "sharp": "^0.34.3", + "sharp": "^0.35.0", "source-map-loader": "^4.0.2", "style-loader": "^3.3.4", "ts-jest": "^29.1.4", @@ -9535,9 +9582,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", - "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9918,9 +9965,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -10274,9 +10321,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "license": "MIT" }, "node_modules/colorette": { @@ -11493,9 +11540,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -13078,9 +13125,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -13093,10 +13140,26 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", "funding": [ { "type": "github", @@ -13105,7 +13168,12 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -15653,6 +15721,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-valid-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", @@ -18834,6 +18914,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -22179,47 +22274,52 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { @@ -22914,9 +23014,9 @@ } }, "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", "funding": [ { "type": "github", @@ -23289,9 +23389,9 @@ "dev": true }, "node_modules/svgo": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz", - "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -25222,6 +25322,21 @@ "node": ">=12" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 2704b17a69..21fe4a5365 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams", "@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary", - "@openedx/frontend-build": "^14.6.6", + "@openedx/frontend-build": "^14.6.17", "@openedx/frontend-plugin-framework": "^1.8.0", "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", @@ -72,7 +72,7 @@ "classnames": "2.5.1", "codemirror": "^6.0.0", "email-validator": "2.0.4", - "fast-xml-parser": "5.3.7", + "fast-xml-parser": "5.11.1", "file-saver": "^2.0.5", "formik": "2.4.9", "frontend-components-tinymce-advanced-plugins": "^1.0.3", diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 6a07c7143c..4d137efc98 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -470,10 +470,13 @@ describe('ProctoredExamSettings', () => { await waitFor(() => { screen.getByDisplayValue('mockproc'); }); - // (1) for studio settings - // (2) for course details - expect(axiosMock.history.get.length).toBe(2); - expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); + // With no exam service URL configured, no request should be made to the exams + // service for provider options. (Total GET count is not asserted because the + // CourseAuthoringProvider also fetches course details and waffle flags.) + const examProvidersRequested = axiosMock.history.get.some((req) => req.url.includes('/api/v1/providers')); + expect(examProvidersRequested).toBe(false); + const studioSettingsRequested = axiosMock.history.get.some((req) => req.url.includes('proctored_exam_settings')); + expect(studioSettingsRequested).toBe(true); }); it('Selected LTI proctoring provider is shown on page load', async () => { diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index bc444bd7cb..b62f6dbbeb 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -10,6 +10,8 @@ import { type UnitXBlock, type XBlock } from '@src/data/types'; import { CourseDetailsData } from './data/api'; import { useCourseDetails } from './data/apiHooks'; import { RequestStatusType } from './data/constants'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getCourseOutlinePermissions } from '@src/authz/permissionHelpers'; export type ModalState = { value?: XBlock | UnitXBlock; @@ -29,6 +31,9 @@ export type CourseAuthoringContextData = { currentUnlinkModalData?: ModalState; openUnlinkModal: (value: ModalState) => void; closeUnlinkModal: () => void; + isLoadingPermissions: boolean; + canEditCourseContent: boolean; + canPublishCourseContent: boolean; }; /** @@ -58,6 +63,12 @@ export const CourseAuthoringProvider = ({ closeUnlinkModal, ] = useToggleWithValue(); + const { + canEditCourseContent, + canPublishCourseContent, + isLoading: isLoadingUserPermissions, + } = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId)); + const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`; /** @@ -78,6 +89,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + isLoadingPermissions: isLoadingUserPermissions, + canEditCourseContent, + canPublishCourseContent, }), [ courseId, courseDetails, @@ -89,6 +103,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + canEditCourseContent, + canPublishCourseContent, + isLoadingUserPermissions, ]); return ( diff --git a/src/advanced-settings/AdvancedSettings.test.tsx b/src/advanced-settings/AdvancedSettings.test.tsx index 9ed3f144aa..0332aafa1f 100644 --- a/src/advanced-settings/AdvancedSettings.test.tsx +++ b/src/advanced-settings/AdvancedSettings.test.tsx @@ -52,6 +52,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: false, + canViewAdvancedSettings: true, canManageAdvancedSettings: true, } as ReturnType); }); @@ -175,6 +176,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, + canViewAdvancedSettings: true, canManageAdvancedSettings: true, } as ReturnType); render(); @@ -192,7 +194,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, } as ReturnType); render(); expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); @@ -203,7 +205,7 @@ describe('', () => { jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, - canManageAdvancedSettings: false, + canViewAdvancedSettings: false, } as ReturnType); axiosMock .onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`) @@ -212,4 +214,21 @@ describe('', () => { expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); expect(screen.queryByText(/Under Construction/i)).not.toBeInTheDocument(); }); + + it('should show view-only alert and disable editing when user has view but not manage permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewAdvancedSettings: true, + canManageAdvancedSettings: false, + } as ReturnType); + render(); + expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + const textarea = screen.getByLabelText(/Advanced Module List/i); + expect(textarea).toBeDisabled(); + expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(messages.buttonCancelText.defaultMessage)).not.toBeInTheDocument(); + }); }); diff --git a/src/advanced-settings/AdvancedSettings.tsx b/src/advanced-settings/AdvancedSettings.tsx index 997d6ffaee..3eae6ead40 100644 --- a/src/advanced-settings/AdvancedSettings.tsx +++ b/src/advanced-settings/AdvancedSettings.tsx @@ -13,6 +13,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useCourseUserPermissions } from '@src/authz/hooks'; import { getAdvancedSettingsPermissions } from '@src/authz/permissionHelpers'; import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import AlertProctoringError from '@src/generic/AlertProctoringError'; import { LoadingSpinner } from '@src/generic/Loading'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; @@ -45,6 +46,7 @@ const AdvancedSettings = () => { const { isLoading: isLoadingUserPermissions, + canViewAdvancedSettings, canManageAdvancedSettings, } = useCourseUserPermissions(courseId, getAdvancedSettingsPermissions(courseId)); @@ -102,7 +104,7 @@ const AdvancedSettings = () => { ); } - if (!canManageAdvancedSettings) { + if (!canViewAdvancedSettings) { return ; } @@ -201,6 +203,7 @@ const AdvancedSettings = () => { subtitle={intl.formatMessage(messages.headingSubtitle)} title={intl.formatMessage(messages.headingTitle)} contentTitle={intl.formatMessage(messages.policy)} + banner={!canManageAdvancedSettings ? : null} />
@@ -246,6 +249,7 @@ const AdvancedSettings = () => { handleBlur={handleSettingBlur} isEditableState={isEditableState} setIsEditableState={setIsEditableState} + disabled={!canManageAdvancedSettings} /> ); })} diff --git a/src/advanced-settings/setting-card/SettingCard.test.jsx b/src/advanced-settings/setting-card/SettingCard.test.tsx similarity index 51% rename from src/advanced-settings/setting-card/SettingCard.test.jsx rename to src/advanced-settings/setting-card/SettingCard.test.tsx index cb2fd25aba..78316e6156 100644 --- a/src/advanced-settings/setting-card/SettingCard.test.jsx +++ b/src/advanced-settings/setting-card/SettingCard.test.tsx @@ -1,8 +1,13 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { + fireEvent, + initializeMocks, + render, + screen, + userEvent, + waitFor, +} from '@src/testUtils'; -import SettingCard from './SettingCard'; +import SettingCard, { type SettingCardProps } from './SettingCard'; import messages from './messages'; const setEdited = jest.fn(); @@ -25,10 +30,9 @@ jest.mock('react-textarea-autosize', () => /> ))); -const RootWrapper = () => ( - +const renderComponent = (props: Partial = {}) => + render( ( handleBlur={handleBlur} isEditableState saveSettingsPrompt={false} - /> - -); + {...props} + />, + ); describe('', () => { - afterEach(() => jest.clearAllMocks()); + beforeEach(() => { + initializeMocks(); + }); + it('renders the setting card with the provided data', () => { - const { getByText, getByLabelText } = render(); - const cardTitle = getByText(/Setting Name/i); - const input = getByLabelText(/Setting Name/i); + renderComponent(); + const cardTitle = screen.getByText(/Setting Name/i); + const input = screen.getByLabelText(/Setting Name/i); expect(cardTitle).toBeInTheDocument(); expect(input).toBeInTheDocument(); - expect(input.value).toBe(JSON.stringify(settingData.value, null, 4)); + expect(input).toHaveValue(JSON.stringify(settingData.value, null, 4)); }); + it('displays the deprecated status when the setting is deprecated', () => { - const deprecatedSettingData = { ...settingData, deprecated: true }; - const { getByText } = render( - - - , - ); - const deprecatedStatus = getByText(messages.deprecated.defaultMessage); - expect(deprecatedStatus).toBeInTheDocument(); + renderComponent({ + settingData: { ...settingData, deprecated: true }, + isEditableState: false, + saveSettingsPrompt: true, + }); + expect(screen.getByText(messages.deprecated.defaultMessage)).toBeInTheDocument(); }); + it('does not display the deprecated status when the setting is not deprecated', () => { - const { queryByText } = render(); - expect(queryByText(messages.deprecated.defaultMessage)).toBeNull(); + renderComponent(); + expect(screen.queryByText(messages.deprecated.defaultMessage)).toBeNull(); }); + it('calls setEdited on blur', async () => { const user = userEvent.setup(); - const { getByLabelText } = render(); - const inputBox = getByLabelText(/Setting Name/i); + renderComponent(); + const inputBox = screen.getByLabelText(/Setting Name/i); fireEvent.focus(inputBox); await user.clear(inputBox); await user.type(inputBox, '3, 2, 1'); @@ -91,4 +89,9 @@ describe('', () => { expect(handleBlur).toHaveBeenCalled(); }); }); + + it('disables the setting input when `disabled` is true', () => { + renderComponent({ disabled: true }); + expect(screen.getByLabelText(/Setting Name/i)).toBeDisabled(); + }); }); diff --git a/src/advanced-settings/setting-card/SettingCard.tsx b/src/advanced-settings/setting-card/SettingCard.tsx index e7dedd7df4..3d93cde881 100644 --- a/src/advanced-settings/setting-card/SettingCard.tsx +++ b/src/advanced-settings/setting-card/SettingCard.tsx @@ -9,13 +9,29 @@ import { useToggle, } from '@openedx/paragon'; import { InfoOutline, Warning } from '@openedx/paragon/icons'; -import PropTypes from 'prop-types'; import { capitalize } from 'lodash'; import { useIntl } from '@edx/frontend-platform/i18n'; import TextareaAutosize from 'react-textarea-autosize'; import messages from './messages'; +export interface SettingCardProps { + name: string; + settingData: { + deprecated?: boolean; + help?: string; + displayName?: string; + value?: unknown; + }; + handleBlur: () => void; + setEdited: React.Dispatch>>; + showSaveSettingsPrompt: (show: boolean) => void; + saveSettingsPrompt: boolean; + isEditableState: boolean; + setIsEditableState: (isEditable: boolean) => void; + disabled?: boolean; +} + const SettingCard = ({ name, settingData, @@ -25,7 +41,8 @@ const SettingCard = ({ saveSettingsPrompt, isEditableState, setIsEditableState, -}) => { + disabled = false, +}: SettingCardProps) => { const intl = useIntl(); const { deprecated, help, displayName } = settingData; const initialValue = JSON.stringify(settingData.value, null, 4); @@ -83,7 +100,7 @@ const SettingCard = ({
@@ -99,6 +116,7 @@ const SettingCard = ({ onChange={handleSettingChange} aria-label={displayName} onBlur={handleCardBlur} + disabled={disabled} /> @@ -113,26 +131,4 @@ const SettingCard = ({ ); }; -SettingCard.propTypes = { - settingData: PropTypes.shape({ - deprecated: PropTypes.bool, - help: PropTypes.string, - displayName: PropTypes.string, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.number, - PropTypes.object, - PropTypes.array, - ]), - }).isRequired, - setEdited: PropTypes.func.isRequired, - showSaveSettingsPrompt: PropTypes.func.isRequired, - name: PropTypes.string.isRequired, - handleBlur: PropTypes.func.isRequired, - saveSettingsPrompt: PropTypes.bool.isRequired, - isEditableState: PropTypes.bool.isRequired, - setIsEditableState: PropTypes.func.isRequired, -}; - export default SettingCard; diff --git a/src/authz/constants.ts b/src/authz/constants.ts index 028a395c3d..f1b0db0b80 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -17,8 +17,11 @@ export const CONTENT_LIBRARY_PERMISSIONS = { export const COURSE_PERMISSIONS = { VIEW_COURSE: 'courses.view_course', + CREATE_COURSE: 'courses.create_course', EDIT_COURSE_CONTENT: 'courses.edit_course_content', + PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', + VIEW_ADVANCED_SETTINGS: 'courses.view_advanced_settings', MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', VIEW_GRADING_SETTINGS: 'courses.view_grading_settings', @@ -41,7 +44,10 @@ export const COURSE_PERMISSIONS = { VIEW_COURSE_TEAM: 'courses.view_course_team', + VIEW_GROUP_CONFIGURATIONS: 'courses.view_group_configurations', MANAGE_GROUP_CONFIGURATIONS: 'courses.manage_group_configurations', + + VIEW_CERTIFICATES: 'courses.view_certificates', MANAGE_CERTIFICATES: 'courses.manage_certificates', VIEW_CHECKLISTS: 'courses.view_checklists', diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts index 7798b9d5ca..45b00e370f 100644 --- a/src/authz/permissionHelpers.test.ts +++ b/src/authz/permissionHelpers.test.ts @@ -93,17 +93,26 @@ describe('permissionHelpers', () => { }); describe('getAdvancedSettingsPermissions', () => { - it('returns MANAGE permission with the correct action and scope', () => { + it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { const result = getAdvancedSettingsPermissions(courseId); - expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS); - expect(result.canManageAdvancedSettings.scope).toBe(courseId); + expect(result).toEqual({ + canViewAdvancedSettings: { + action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS, + scope: courseId, + }, + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }); }); it('uses the provided courseId as scope', () => { const otherId = 'course-v1:another+test+run'; const result = getAdvancedSettingsPermissions(otherId); + expect(result.canViewAdvancedSettings.scope).toBe(otherId); expect(result.canManageAdvancedSettings.scope).toBe(otherId); }); }); @@ -164,6 +173,10 @@ describe('permissionHelpers', () => { action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, scope: courseId, }, + canPublishCourseContent: { + action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + scope: courseId, + }, }); }); }); @@ -195,10 +208,14 @@ describe('permissionHelpers', () => { }); describe('getGroupConfigurationsPermissions', () => { - it('returns MANAGE_GROUP_CONFIGURATIONS permission with the correct action and scope', () => { + it('returns VIEW_GROUP_CONFIGURATIONS and MANAGE_GROUP_CONFIGURATIONS permissions with the correct actions and scope', () => { const result = getGroupConfigurationsPermissions(courseId); expect(result).toEqual({ + canViewGroupConfigurations: { + action: COURSE_PERMISSIONS.VIEW_GROUP_CONFIGURATIONS, + scope: courseId, + }, canManageGroupConfigurations: { action: COURSE_PERMISSIONS.MANAGE_GROUP_CONFIGURATIONS, scope: courseId, @@ -208,10 +225,14 @@ describe('permissionHelpers', () => { }); describe('getCertificatesPermissions', () => { - it('returns MANAGE_CERTIFICATES permission with the correct action and scope', () => { + it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { const result = getCertificatesPermissions(courseId); expect(result).toEqual({ + canViewCertificates: { + action: COURSE_PERMISSIONS.VIEW_CERTIFICATES, + scope: courseId, + }, canManageCertificates: { action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES, scope: courseId, diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index 3520e80f10..1a1cb1878a 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -61,6 +61,10 @@ export const getPagesAndResourcesPermissions = (courseId: string) => ({ }); export const getAdvancedSettingsPermissions = (courseId: string) => ({ + canViewAdvancedSettings: { + action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS, + scope: courseId, + }, canManageAdvancedSettings: { action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, scope: courseId, @@ -75,6 +79,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({ action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, scope: courseId, }, + canPublishCourseContent: { + action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + scope: courseId, + }, }); export const getLibraryUpdatesPermissions = (courseId: string) => ({ @@ -92,6 +100,10 @@ export const getCourseTeamPermissions = (courseId: string) => ({ }); export const getGroupConfigurationsPermissions = (courseId: string) => ({ + canViewGroupConfigurations: { + action: COURSE_PERMISSIONS.VIEW_GROUP_CONFIGURATIONS, + scope: courseId, + }, canManageGroupConfigurations: { action: COURSE_PERMISSIONS.MANAGE_GROUP_CONFIGURATIONS, scope: courseId, @@ -99,6 +111,10 @@ export const getGroupConfigurationsPermissions = (courseId: string) => ({ }); export const getCertificatesPermissions = (courseId: string) => ({ + canViewCertificates: { + action: COURSE_PERMISSIONS.VIEW_CERTIFICATES, + scope: courseId, + }, canManageCertificates: { action: COURSE_PERMISSIONS.MANAGE_CERTIFICATES, scope: courseId, diff --git a/src/certificates/Certificates.test.tsx b/src/certificates/Certificates.test.tsx index 5c7a622635..f4ac8dd7d0 100644 --- a/src/certificates/Certificates.test.tsx +++ b/src/certificates/Certificates.test.tsx @@ -18,6 +18,7 @@ const mockPermissions = (overrides = {}) => jest.mocked(useCourseUserPermissions).mockReturnValue({ isLoading: false, isAuthzEnabled: true, + canViewCertificates: true, canManageCertificates: true, ...overrides, } as ReturnType); @@ -38,7 +39,7 @@ describe('Certificates', () => { }); it('shows PermissionDeniedAlert when user lacks manage certificates permission', async () => { - mockPermissions({ canManageCertificates: false }); + mockPermissions({ canViewCertificates: false }); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -47,6 +48,16 @@ describe('Certificates', () => { expect(screen.queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument(); }); + it('renders content in view-only mode when user can view but not manage', async () => { + mockPermissions({ canViewCertificates: true, canManageCertificates: false }); + axiosMock + .onGet(getCertificatesApiUrl(courseId)) + .reply(200, certificatesDataMock); + renderComponent(); + expect(await screen.findByTestId('certificates-list')).toBeInTheDocument(); + expect(screen.getByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument(); + }); + it('renders WithoutModes when there are certificates but no certificate modes', async () => { const noModesMock = { ...certificatesDataMock, diff --git a/src/certificates/Certificates.tsx b/src/certificates/Certificates.tsx index 1d5a16642a..a8ddef281a 100644 --- a/src/certificates/Certificates.tsx +++ b/src/certificates/Certificates.tsx @@ -1,8 +1,5 @@ import { Helmet } from 'react-helmet'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useCourseUserPermissions } from '@src/authz/hooks'; -import { getCertificatesPermissions } from '@src/authz/permissionHelpers'; import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import Placeholder from '../editors/Placeholder'; import Loading from '../generic/Loading'; @@ -14,6 +11,7 @@ import CertificateCreateForm from './certificate-create-form/CertificateCreateFo import CertificateEditForm from './certificate-edit-form/CertificateEditForm'; import { MODE_STATES } from './data/constants'; import MainLayout from './layout/MainLayout'; +import { useCertificatesContext } from './context'; const MODE_COMPONENTS = { [MODE_STATES.noModes]: CertificateWithoutModes, @@ -24,7 +22,6 @@ const MODE_COMPONENTS = { }; const Certificates = () => { - const { courseId } = useCourseAuthoringContext(); const { certificates, componentMode, @@ -34,12 +31,9 @@ const Certificates = () => { hasCertificateModes, } = useCertificatesData(); - const { - isLoading: isLoadingUserPermissions, - canManageCertificates, - } = useCourseUserPermissions(courseId, getCertificatesPermissions(courseId)); + const { canViewCertificates } = useCertificatesContext(); - if (!isLoadingUserPermissions && !canManageCertificates) { + if (!canViewCertificates) { return ; } diff --git a/src/certificates/certificate-details/CertificateDetails.test.tsx b/src/certificates/certificate-details/CertificateDetails.test.tsx index 281d4007b4..70cc773332 100644 --- a/src/certificates/certificate-details/CertificateDetails.test.tsx +++ b/src/certificates/certificate-details/CertificateDetails.test.tsx @@ -8,6 +8,8 @@ import { } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api'; import { certificatesDataMock } from '../__mocks__'; @@ -25,6 +27,19 @@ const defaultProps: CertificateDetialsProps = { detailsCourseNumber: certificatesDataMock.courseNumber, }; +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = (props = defaultProps) => render( @@ -37,6 +52,8 @@ const renderComponent = (props = defaultProps) => describe('CertificateDetails', () => { beforeEach(() => { ({ axiosMock } = initializeMocks()); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -88,4 +105,15 @@ describe('CertificateDetails', () => { await screen.findByText(messages.detailsSectionTitle.defaultMessage); expect(screen.getByText(courseTitleOverride)).toBeInTheDocument(); }); + + it('hides edit and delete buttons in view-only mode', async () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + await screen.findByText(messages.detailsSectionTitle.defaultMessage); + expect(screen.queryByRole('button', { name: commonMessages.editTooltip.defaultMessage })).not + .toBeInTheDocument(); + expect(screen.queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/certificate-details/CertificateDetails.tsx b/src/certificates/certificate-details/CertificateDetails.tsx index 5a68dbdb83..03cd9e95d2 100644 --- a/src/certificates/certificate-details/CertificateDetails.tsx +++ b/src/certificates/certificate-details/CertificateDetails.tsx @@ -14,6 +14,7 @@ import ModalNotification from '../../generic/modal-notification'; import commonMessages from '../messages'; import messages from './messages'; import useCertificateDetails from './hooks/useCertificateDetails'; +import { useCertificatesContext } from '@src/certificates/context'; export interface CertificateDetialsProps { certificateId: number; @@ -31,6 +32,7 @@ const CertificateDetails = ({ courseNumberOverride, }: CertificateDetialsProps) => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); const { isConfirmOpen, confirmOpen, @@ -48,24 +50,26 @@ const CertificateDetails = ({ title={intl.formatMessage(messages.detailsSectionTitle)} className="certificate-details" data-testid="certificate-details" - actions={ - - {intl.formatMessage(commonMessages.editTooltip)}
} - alt={intl.formatMessage(commonMessages.editTooltip)} - onClick={isCertificateActive ? editModalOpen : handleEditAll} - /> - {intl.formatMessage(commonMessages.deleteTooltip)}
} - alt={intl.formatMessage(commonMessages.deleteTooltip)} - onClick={confirmOpen} - /> - - } + actions={canManageCertificates ? + ( + + {intl.formatMessage(commonMessages.editTooltip)}} + alt={intl.formatMessage(commonMessages.editTooltip)} + onClick={isCertificateActive ? editModalOpen : handleEditAll} + /> + {intl.formatMessage(commonMessages.deleteTooltip)}} + alt={intl.formatMessage(commonMessages.deleteTooltip)} + onClick={confirmOpen} + /> + + ) : + undefined} > diff --git a/src/certificates/certificate-signatories/CertificateSignatories.test.tsx b/src/certificates/certificate-signatories/CertificateSignatories.test.tsx index 6fab2b4a26..965769380b 100644 --- a/src/certificates/certificate-signatories/CertificateSignatories.test.tsx +++ b/src/certificates/certificate-signatories/CertificateSignatories.test.tsx @@ -1,4 +1,7 @@ import { initializeMocks, render, screen, within, userEvent } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { signatoriesMock } from '../__mocks__'; import commonMessages from '../messages'; @@ -20,7 +23,11 @@ const mockArrayHelpers = { const renderComponent = (props) => render( - , + + + + + , ); const defaultProps = { @@ -38,6 +45,7 @@ const defaultProps = { describe('CertificateSignatories', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); mockUseEditSignatory.mockReturnValue({ toggleEditSignatory: jest.fn(), @@ -72,6 +80,7 @@ describe('CertificateSignatories', () => { describe('CertificateSignatories - real useEditSignatory', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); // Use the real implementation so handleDeleteSignatory actually calls arrayHelpers.remove const realUseEditSignatory = jest.requireActual('./hooks/useEditSignatory').default; diff --git a/src/certificates/certificate-signatories/signatory/Signatory.test.tsx b/src/certificates/certificate-signatories/signatory/Signatory.test.tsx index 9df5c3e4d4..1264f03aa4 100644 --- a/src/certificates/certificate-signatories/signatory/Signatory.test.tsx +++ b/src/certificates/certificate-signatories/signatory/Signatory.test.tsx @@ -1,4 +1,8 @@ import { initializeMocks, render, screen, userEvent } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { CertificatesProvider } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { signatoriesMock } from '../../__mocks__'; import commonMessages from '../../messages'; @@ -7,9 +11,26 @@ import Signatory from './Signatory'; const mockHandleEdit = jest.fn(); +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderSignatory = (props) => render( - , + + + + + , ); const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: 0 }; @@ -17,6 +38,8 @@ const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: describe('Signatory Component', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); }); it('renders signatory data in view mode', () => { @@ -45,4 +68,12 @@ describe('Signatory Component', () => { expect(mockHandleEdit).toHaveBeenCalled(); }); + + it('hides edit button in view-only mode', () => { + mockPermissions({ canManageCertificates: false }); + renderSignatory(defaultProps); + + expect(screen.queryByRole('button', { name: commonMessages.editTooltip.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/certificate-signatories/signatory/Signatory.tsx b/src/certificates/certificate-signatories/signatory/Signatory.tsx index 7a26dd7bed..c98e363b1b 100644 --- a/src/certificates/certificate-signatories/signatory/Signatory.tsx +++ b/src/certificates/certificate-signatories/signatory/Signatory.tsx @@ -12,6 +12,7 @@ import { getConfig } from '@edx/frontend-platform'; import commonMessages from '../../messages'; import messages from '../messages'; +import { useCertificatesContext } from '@src/certificates/context'; interface SignatoryProps { name: string; @@ -31,6 +32,7 @@ const Signatory = ({ handleEdit, }: SignatoryProps) => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); return (
@@ -49,14 +51,16 @@ const Signatory = ({ - {intl.formatMessage(commonMessages.editTooltip)}
} - onClick={handleEdit} - /> + {canManageCertificates && ( + {intl.formatMessage(commonMessages.editTooltip)}} + onClick={handleEdit} + /> + )}
{signatureImagePath && ( void; savingIsSuccess: boolean; savingErrorMessage?: string; + canViewCertificates: boolean; + canManageCertificates: boolean; activationStatusMutation: UseMutationResult; updateCertificateMutation: UseMutationResult; deleteCertificateMutation: UseMutationResult; @@ -30,6 +34,12 @@ const CertificatesContext = createContext(u export const CertificatesProvider = ({ children }) => { const { courseId } = useCourseAuthoringContext(); const [componentMode, setComponentMode] = useState(MODE_STATES.noModes); + + const { + canViewCertificates, + canManageCertificates, + } = useCourseUserPermissions(courseId, getCertificatesPermissions(courseId)); + const activationStatusMutation = useUpdateCertificateActiveStatus(courseId); const updateCertificateMutation = useUpdateCertificate(courseId); const deleteCertificateMutation = useDeleteCertificate(courseId); @@ -49,6 +59,8 @@ export const CertificatesProvider = ({ children }) => { const contextValue = { componentMode, setComponentMode, + canViewCertificates, + canManageCertificates, activationStatusMutation, updateCertificateMutation, deleteCertificateMutation, @@ -61,6 +73,8 @@ export const CertificatesProvider = ({ children }) => { }, [ componentMode, setComponentMode, + canViewCertificates, + canManageCertificates, activationStatusMutation, updateCertificateMutation, ]); diff --git a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx index e8a332b0f8..dd5f2b006a 100644 --- a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx +++ b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.test.tsx @@ -5,6 +5,8 @@ import { userEvent, } from '@src/testUtils'; import { CertificatesProvider, useCertificatesContext } from '@src/certificates/context'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { MODE_STATES } from '../data/constants'; import messages from '../messages'; import EmptyCertificatesWithModes from './EmptyCertificatesWithModes'; @@ -15,6 +17,19 @@ const ComponentModeDisplay = () => { return
{componentMode}
; }; +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = () => render( @@ -28,6 +43,8 @@ const renderComponent = () => describe('EmptyCertificatesWithModes', () => { beforeEach(() => { initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); }); it('renders correctly', () => { @@ -47,4 +64,13 @@ describe('EmptyCertificatesWithModes', () => { expect(screen.getByTestId('component-mode')).toHaveTextContent(MODE_STATES.create); }); + + it('hides add button in view-only mode', () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + expect(screen.getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.setupCertificateBtn.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx index e748ee25df..f238d542b7 100644 --- a/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx +++ b/src/certificates/empty-certificates-with-modes/EmptyCertificatesWithModes.tsx @@ -8,7 +8,7 @@ import { useCertificatesContext } from '../context'; const EmptyCertificatesWithModes = () => { const intl = useIntl(); - const { setComponentMode } = useCertificatesContext(); + const { setComponentMode, canManageCertificates } = useCertificatesContext(); const handleCreateMode = () => { setComponentMode(MODE_STATES.create); }; @@ -19,12 +19,14 @@ const EmptyCertificatesWithModes = () => { {intl.formatMessage(messages.noCertificatesText)} - + {canManageCertificates && ( + + )} diff --git a/src/certificates/layout/MainLayout.tsx b/src/certificates/layout/MainLayout.tsx index a6c4563075..227fae49e9 100644 --- a/src/certificates/layout/MainLayout.tsx +++ b/src/certificates/layout/MainLayout.tsx @@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { SavingErrorAlert } from '@src/generic/saving-error-alert'; import SubHeader from '@src/generic/sub-header/SubHeader'; +import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert'; import messages from '../messages'; import CertificatesSidebar from './certificates-sidebar/CertificatesSidebar'; import HeaderButtons from './header-buttons/HeaderButtons'; @@ -20,6 +21,7 @@ const MainLayout = ({ showHeaderButtons = false, children }: MainLayoutProps) => const { savingIsSuccess, savingErrorMessage, + canManageCertificates, } = useCertificatesContext(); useEffect(() => { @@ -39,6 +41,7 @@ const MainLayout = ({ showHeaderButtons = false, children }: MainLayoutProps) => headerActions={showHeaderButtons ? : null} + banner={!canManageCertificates ? : null} />
({ + useCourseUserPermissions: jest.fn(), +})); + +const mockPermissions = (overrides = {}) => + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canViewCertificates: true, + canManageCertificates: true, + ...overrides, + } as ReturnType); + const renderComponent = () => render( @@ -29,6 +44,8 @@ describe('HeaderButtons Component', () => { beforeEach(() => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mockPermissions(); axiosMock .onGet(getCertificatesApiUrl(courseId)) .reply(200, certificatesDataMock); @@ -106,4 +123,16 @@ describe('HeaderButtons Component', () => { expect(screen.queryByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).not .toBeInTheDocument(); }); + + it('hides activate button in view-only mode', async () => { + mockPermissions({ canManageCertificates: false }); + renderComponent(); + + await waitFor(() => screen.getByRole('link', { name: messages.headingActionsPreview.defaultMessage })); + + expect(screen.queryByRole('button', { name: messages.headingActionsActivate.defaultMessage })).not + .toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.headingActionsDeactivate.defaultMessage })).not + .toBeInTheDocument(); + }); }); diff --git a/src/certificates/layout/header-buttons/HeaderButtons.tsx b/src/certificates/layout/header-buttons/HeaderButtons.tsx index 6dd15b15ad..2930486444 100644 --- a/src/certificates/layout/header-buttons/HeaderButtons.tsx +++ b/src/certificates/layout/header-buttons/HeaderButtons.tsx @@ -8,16 +8,18 @@ import { import messages from '../../messages'; import useHeaderButtons from './hooks/useHeaderButtons'; +import { useCertificatesContext } from '@src/certificates/context'; const HeaderButtons = () => { const intl = useIntl(); + const { canManageCertificates } = useCertificatesContext(); const { previewUrl, courseModes, dropdowmItem, - isCertificateActive, setDropdowmItem, handleActivationStatus, + isCertificateActive, } = useHeaderButtons(); return ( @@ -34,14 +36,16 @@ const HeaderButtons = () => { > {intl.formatMessage(messages.headingActionsPreview)} - + {canManageCertificates && ( + + )} ); }; diff --git a/src/container-comparison/CompareContainersWidget.test.tsx b/src/container-comparison/CompareContainersWidget.test.tsx index 347bd3aa72..0ac83cde0e 100644 --- a/src/container-comparison/CompareContainersWidget.test.tsx +++ b/src/container-comparison/CompareContainersWidget.test.tsx @@ -16,7 +16,7 @@ describe('CompareContainersWidget', () => { }); test('renders the component with a title', async () => { - const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId); + const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId, mockGetCourseContainerChildren.sectionId); axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' }); render( { test('calls onRowClick when a row is clicked and updates diff view', async () => { // mocks title - axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { - publishedDisplayName: 'Test Title', - }); axiosMock.onGet( - getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'), + getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId, mockGetCourseContainerChildren.sectionId), + ).reply(200, { publishedDisplayName: 'Test Title' }); + axiosMock.onGet( + getLibraryContainerApiUrl( + 'lct:org1:Demo_course_generated:subsection:subsection-0', + mockGetCourseContainerChildren.subsectionId, + ), ).reply(200, { publishedDisplayName: 'subsection block 0' }); const user = userEvent.setup(); @@ -97,11 +100,14 @@ describe('CompareContainersWidget', () => { test('should show removed container diff state', async () => { // mocks title - axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { - publishedDisplayName: 'Test Title', - }); axiosMock.onGet( - getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'), + getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId, mockGetCourseContainerChildren.sectionId), + ).reply(200, { publishedDisplayName: 'Test Title' }); + axiosMock.onGet( + getLibraryContainerApiUrl( + 'lct:org1:Demo_course_generated:subsection:subsection-0', + mockGetCourseContainerChildren.subsectionId, + ), ).reply(200, { publishedDisplayName: 'subsection block 0' }); const user = userEvent.setup(); @@ -124,9 +130,12 @@ describe('CompareContainersWidget', () => { test('should show new added container diff state', async () => { // mocks title - axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { - publishedDisplayName: 'Test Title', - }); + axiosMock.onGet( + getLibraryContainerApiUrl( + mockGetContainerMetadata.sectionId, + 'block-v1:UNIX+UX1+2025_T3+type@section+block@0-new', + ), + ).reply(200, { publishedDisplayName: 'Test Title' }); axiosMock.onGet( getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'), ).reply(200, { publishedDisplayName: 'subsection block 0' }); @@ -145,7 +154,10 @@ describe('CompareContainersWidget', () => { }); test('should show alert if the only change is a single text component with local overrides', async () => { - const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId); + const url = getLibraryContainerApiUrl( + mockGetContainerMetadata.sectionId, + mockGetCourseContainerChildren.sectionShowsAlertSingleText, + ); axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' }); render( { }); test('should show alert if the only changes is multiple text components with local overrides', async () => { - const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId); + const url = getLibraryContainerApiUrl( + mockGetContainerMetadata.sectionId, + mockGetCourseContainerChildren.sectionShowsAlertMultipleText, + ); axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' }); render( (state === 'removed' ? undefined : upstreamBlockId, true); + } = useContainerChildren( + state === 'removed' ? undefined : upstreamBlockId, + true, + downstreamBlockId, + ); const { data: containerData, isError: isContainerTitleError, error: containerTitleError, - } = useContainer(upstreamBlockId); + } = useContainer(upstreamBlockId, downstreamBlockId); const result = useMemo(() => { if ((!data || !libData) && !['added', 'removed'].includes(state || '')) { diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index bc97222ff7..55e28655e0 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -9,6 +9,7 @@ import { getClipboardUrl } from '@src/generic/data/api'; import { ContainerType } from '@src/generic/key-utils'; import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { act, fireEvent, @@ -552,6 +553,11 @@ const renderComponent = () => describe('', () => { beforeEach(async () => { const mocks = initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mocks.validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: true, + canPublishCourseContent: true, + }); selectedContainerId = undefined; // restore index mock — use reorder outline spec (section[0] has 2 subsections for configure/drag tests) courseOutlineIndexMock = buildTestOutline({ diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 1bc151a171..6174941c6f 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -51,6 +51,8 @@ const CourseOutline = () => { const location = useLocation(); const { courseId, + canEditCourseContent, + isLoadingPermissions, } = useCourseAuthoringContext(); const { courseUsageKey, @@ -94,7 +96,14 @@ const CourseOutline = () => { const [showSuccessAlert, setShowSuccessAlert] = useState(false); const isInternetConnectionAlertFailed = savingStatus === RequestStatus.FAILED; - const isReIndexShow = Boolean(reindexLink); + const isReIndexShow = canEditCourseContent && Boolean(reindexLink); + + // The header's "+ Add" button creates course content, so gate its visibility behind the + // edit permission. This is scoped to the header actions and leaves the outline tree unaffected. + const headerCourseActions = useMemo( + () => ({ ...courseActions, childAddable: canEditCourseContent && courseActions.childAddable }), + [courseActions, canEditCourseContent], + ); const handleAddBlock = useCreateCourseBlock(courseId); const pasteMutation = usePasteItem(courseId); @@ -169,7 +178,7 @@ const CourseOutline = () => { } }, [location, courseId, courseName]); - if (isLoading) { + if (isLoading || isLoadingPermissions) { // eslint-disable-next-line react/jsx-no-useless-fragment return ( @@ -249,7 +258,7 @@ const CourseOutline = () => { headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} hasSections={Boolean(sections.length)} - courseActions={courseActions} + courseActions={headerCourseActions} errors={errors} sections={sections} /> diff --git a/src/course-outline/OutlineNode.tsx b/src/course-outline/OutlineNode.tsx index 5881792589..934b0cdf0a 100644 --- a/src/course-outline/OutlineNode.tsx +++ b/src/course-outline/OutlineNode.tsx @@ -105,7 +105,7 @@ const OutlineNode = ({ const { activeId, overId } = useContext(DragContext); const { selectedContainerState, openContainerSidebar, setSelectedContainerState } = useOutlineSidebarContext(); - const { courseId, openUnlinkModal, getUnitUrl } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, getUnitUrl, canEditCourseContent } = useCourseAuthoringContext(); const duplicateMutation = useDuplicateItem(courseId); const { openPublishModal } = useCourseOutlineContext(); const queryClient = useQueryClient(); @@ -236,7 +236,7 @@ const OutlineNode = ({ else { onOrderChange(effectiveSection, getPossibleMoves!(index, 1)); } }; - const isDraggable = model.isDraggable(actions, isHeaderVisible); + const isDraggable = canEditCourseContent && model.isDraggable(actions, isHeaderVisible); const titleComponent = depth < 2 ? ( @@ -335,7 +335,6 @@ const OutlineNode = ({ {...(depth === 2 ? { isVertical: true, - enableCopyPasteUnits: blk.enableCopyPasteUnits ?? false, onClickCopy: () => copyToClipboard(blk.id), discussionEnabled: blk.discussionEnabled, discussionsSettings, @@ -348,11 +347,16 @@ const OutlineNode = ({ readyToSync={blk.upstreamInfo?.readyToSync} />
onClickCard(e, false)} > - {depth === 0 && onOpenHighlightsModal && ( + {canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
} - iconAs={EditIcon} - onClick={onEditClick} - disabled={editMutation.isPending} - /> + {canEditCourseContent && + ( + {intl.formatMessage(messages.altButtonRename)}
} + iconAs={EditIcon} + onClick={onEditClick} + disabled={editMutation.isPending} + /> + )} )}
@@ -313,105 +314,110 @@ const CardHeader = ({ onClick={onClickSync} /> )} - - - - {isSequential && proctoringExamConfigurationLink && ( - - {intl.formatMessage(messages.menuProctoringLinkText)} - - )} - - {intl.formatMessage(messages.menuPublish)} - - - {intl.formatMessage(messages.menuConfigure)} - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + {canEditCourseContent && ( + + + + {isSequential && proctoringExamConfigurationLink && ( + + {intl.formatMessage(messages.menuProctoringLinkText)} + + )} + {canPublishCourseContent && + ( + + {intl.formatMessage(messages.menuPublish)} + + )} - {intl.formatMessage(messages.menuManageTags)} + {intl.formatMessage(messages.menuConfigure)} - )} + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + {intl.formatMessage(messages.menuManageTags)} + + )} - {isVertical && enableCopyPasteUnits && ( - - {intl.formatMessage(messages.menuCopy)} - - )} - {actions.duplicable && ( - - {intl.formatMessage(messages.menuDuplicate)} - - )} - {actions.draggable && ( - <> + {isVertical && ( + + {intl.formatMessage(messages.menuCopy)} + + )} + {actions.duplicable && ( - {intl.formatMessage(messages.menuMoveUp)} + {intl.formatMessage(messages.menuDuplicate)} + )} + {actions.draggable && ( + <> + + {intl.formatMessage(messages.menuMoveUp)} + + + {intl.formatMessage(messages.menuMoveDown)} + + + )} + {((actions.unlinkable ?? null) !== null || actions.deletable) && } + {(actions.unlinkable ?? null) !== null && ( - {intl.formatMessage(messages.menuMoveDown)} + {intl.formatMessage(messages.menuUnlink)} - - )} - {((actions.unlinkable ?? null) !== null || actions.deletable) && } - {(actions.unlinkable ?? null) !== null && ( - - {intl.formatMessage(messages.menuUnlink)} - - )} - {actions.deletable && ( - - {intl.formatMessage(messages.menuDelete)} - - )} - - + )} + {actions.deletable && ( + + + + )} + + + )}
diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index 4a95b4d19b..992375b00a 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -19,7 +19,8 @@ export const getCourseOutlineIndexApiUrl = ( courseId: string, ) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; -export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`; +export const getCourseDetailsApiUrl = (courseId) => + `${getApiBaseUrl()}/api/contentstore/v3/course_details/${courseId}/`; export const getCourseBestPracticesApiUrl = ({ courseId, diff --git a/src/course-outline/outline-level.ts b/src/course-outline/outline-level.ts index aea3ad9e0a..15ae1587fc 100644 --- a/src/course-outline/outline-level.ts +++ b/src/course-outline/outline-level.ts @@ -89,7 +89,7 @@ export const LEVEL_CONFIG: Record = { }, 1: { name: 'subsection', - contentClass: 'subsection-card__content item-children', + contentClass: 'subsection-card__content', contentTestId: 'subsection-card__content', childContainerClass: 'subsection-card__units', childContainerTestId: 'subsection-card__units', @@ -99,7 +99,7 @@ export const LEVEL_CONFIG: Record = { }, 2: { name: 'unit', - contentClass: 'unit-card__content item-children', + contentClass: 'unit-card__content', contentTestId: 'unit-card__content', iconSize: 'xs', background: { background: '#fdfdfd' }, diff --git a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx index e95adfce4f..7f35350e83 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx @@ -14,25 +14,28 @@ import { OutlineAlignSidebar } from './OutlineAlignSidebar'; import OutlineHelpSidebar from './OutlineHelpSidebar'; import { InfoSidebar } from './info-sidebar/InfoSidebar'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type OutlineSidebarPages = { info: SidebarPage; help: SidebarPage; - add: SidebarPage; + add?: SidebarPage; align?: SidebarPage; }; -const getOutlineSidebarPages = () => ({ +const getOutlineSidebarPages = (canEditCourseContent: boolean = false) => ({ info: { component: InfoSidebar, icon: Info, title: messages.sidebarButtonInfo, }, - add: { - component: AddSidebar, - icon: Plus, - title: messages.sidebarButtonAdd, - }, + ...(canEditCourseContent && { + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + }, + }), ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && { align: { component: OutlineAlignSidebar, @@ -84,9 +87,11 @@ type OutlineSidebarPagesProviderProps = { }; export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesProviderProps) => { + const { canEditCourseContent } = useCourseAuthoringContext(); + // align page is sometimes not added when getOutlineSidebarPages() is called at the top level. // So if we call it inside the hook, getConfig has updated values and align page is added. - const sidebarPages = useMemo(getOutlineSidebarPages, []); + const sidebarPages = useMemo(() => getOutlineSidebarPages(canEditCourseContent), [canEditCourseContent]); return ( diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index 9bcb1b3c3d..a5557c6cc4 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -133,7 +133,7 @@ const SettingsTab = () => { /> ) )} - {perms.canManageGroupConfigurations && ( + {perms.canViewGroupConfigurations && ( { isNewPage /> )} - {perms.canManageAdvancedSettings && ( + {perms.canViewAdvancedSettings && ( ({ courseId, openUnlinkModal, getUnitUrl: jest.fn(), + canEditCourseContent: true, + canPublishCourseContent: true, }), })); diff --git a/src/course-outline/outline-sidebar/info-sidebar/PublishButon.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.test.tsx new file mode 100644 index 0000000000..d9f81857a8 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.test.tsx @@ -0,0 +1,56 @@ +import { + initializeMocks, + render, + screen, + userEvent, +} from '@src/testUtils'; + +import messages from '../messages'; +import { PublishButon } from './PublishButon'; + +const mockCourseAuthoringContext = { + canPublishCourseContent: true, +}; + +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => mockCourseAuthoringContext, +})); + +const onClickMock = jest.fn(); + +// The button's accessible name is the publish label plus the draft status, +// e.g. "Publish Changes (Draft)". +const publishButtonName = new RegExp(messages.publishContainerButton.defaultMessage, 'i'); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockCourseAuthoringContext.canPublishCourseContent = true; + onClickMock.mockClear(); + }); + + it('renders the publish button when the user can publish course content', async () => { + render(); + + expect( + await screen.findByRole('button', { name: publishButtonName }), + ).toBeInTheDocument(); + }); + + it('calls onClick when the button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: publishButtonName })); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + + it('does not render the button when the user cannot publish course content', () => { + mockCourseAuthoringContext.canPublishCourseContent = false; + render(); + + expect( + screen.queryByRole('button', { name: publishButtonName }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx index c932a577d1..eaab37d8dc 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx @@ -1,22 +1,29 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import messages from '../messages'; interface Props { onClick: () => void; } -export const PublishButon = ({ onClick }: Props) => ( - -); +export const PublishButon = ({ onClick }: Props) => { + const { canPublishCourseContent } = useCourseAuthoringContext(); + + if (!canPublishCourseContent) { + return null; + } + + return ( + + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx index 3ec946a0cc..9607073fef 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx @@ -7,7 +7,9 @@ import { import userEvent from '@testing-library/user-event'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { VisibilityTypes } from '@src/data/constants'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { VisibilitySection } from './VisibilitySection'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; jest.mock('@src/course-outline/data/apiHooks', () => ({ ...jest.requireActual('@src/course-outline/data/apiHooks'), @@ -22,14 +24,30 @@ const defaultProps = { onChange: jest.fn(), }; +const WrapperProvider = ({ children }) => ( + {children} +); +const renderWithWrapper = (children) => { + render(children, { + extraWrapper: WrapperProvider, + }); +}; + +let validateUserPermissionsMock; + describe('VisibilitySection component', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); mockUseCourseItemData.mockReturnValue({ data: undefined }); + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + validateUserPermissionsMock = mocks.validateUserPermissionsMock; + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: true, + }); }); it('renders title and buttons', async () => { - render(); + renderWithWrapper(); expect(await screen.findByText('Visibility')).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeInTheDocument(); @@ -38,7 +56,7 @@ describe('VisibilitySection component', () => { it('clicking staff only calls onChange with staff and hideAfterDue false', async () => { const user = userEvent.setup(); const onChange = jest.fn(); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -50,7 +68,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Student Visible' })); await waitFor(async () => { @@ -63,7 +81,7 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // initial data not staff only mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); - render(); + renderWithWrapper(); const checkbox = await screen.findByRole('checkbox'); await user.click(checkbox); @@ -76,7 +94,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -88,7 +106,27 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // when item is staff only, checkbox should not be present mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); + + it('disables both visibility buttons when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + renderWithWrapper(); + + expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeDisabled(); + expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeDisabled(); + }); + + it('disables the hide-after-due checkbox when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); + renderWithWrapper(); + + expect(await screen.findByRole('checkbox')).toBeDisabled(); + }); }); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx index 75793104fb..fc69365bd8 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx @@ -7,6 +7,7 @@ import { SidebarSection } from '@src/generic/sidebar'; import { useFieldDraft } from '@src/hooks/useFieldDraft'; import { useMemo } from 'react'; import messages from '../messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props> { itemId: string; @@ -22,6 +23,7 @@ interface State { export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => { const intl = useIntl(); const { data: itemData } = useCourseItemData(itemId); + const { canEditCourseContent } = useCourseAuthoringContext(); const serverState = useMemo(() => ({ isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY, @@ -42,12 +44,14 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => > - ); } + if (!canEditCourseContent) { + return null; + } + return ( + + ); }; const VideoSharingDropdown = ({ handleVideoSharingOptionChange, videoSharingOptions }: { diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index b8fe42485b..e8f6332b7e 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -1,5 +1,7 @@ import fetchMock from 'fetch-mock-jest'; import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { camelCaseObject, getConfig, @@ -57,7 +59,7 @@ import { clipboardMockResponse, courseOutlineInfoMock, } from './__mocks__'; -import { clipboardUnit } from '../__mocks__'; +import { clipboardUnit, clipboardXBlock } from '../__mocks__'; import { executeThunk } from '../utils'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerTitleMessages from './header-title/messages'; @@ -84,6 +86,7 @@ let axiosMock; let store; let mockShowToast; let mockCloseToast; +let validateUserPermissionsMock; const courseId = '123'; const blockId = courseSectionVerticalMock.xblock_info.id; const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; @@ -156,6 +159,14 @@ describe('', () => { mockShowToast = mocks.mockShowToast; mockCloseToast = mocks.mockCloseToast; axiosMock = mocks.axiosMock; + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + validateUserPermissionsMock = mocks.validateUserPermissionsMock; + validateUserPermissionsMock.mockResolvedValue({ + isLoading: false, + canViewCourse: true, + canEditCourseContent: true, + canPublishCourseContent: true, + }); axiosMock .onGet(getClipboardUrl()) .reply(200, clipboardUnit); @@ -939,7 +950,7 @@ describe('', () => { const unitHeaderTitle = screen.getByTestId('unit-header-title'); - const editTitleButton = within(unitHeaderTitle).getByRole('button', { + const editTitleButton = await within(unitHeaderTitle).findByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage, }); await user.click(editTitleButton); @@ -1589,7 +1600,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await user.click( - screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), + await screen.findByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), ); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); @@ -1650,7 +1661,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await user.click( - screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), + await screen.findByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), ); await waitFor(() => { @@ -1716,7 +1727,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await user.click( - screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), + await screen.findByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), ); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); @@ -1777,7 +1788,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await user.click( - screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), + await screen.findByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), ); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); @@ -1840,7 +1851,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await user.click( - screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), + await screen.findByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }), ); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); @@ -2573,6 +2584,92 @@ describe('', () => { }); }); + describe('Add component authorization (authz)', () => { + let waffleFlagsSpy; + + beforeEach(() => { + // Enable authz so `canEditCourseContent` is driven by the mocked permissions API + // rather than falling back to `true`. + waffleFlagsSpy = mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + }); + + afterEach(() => { + // Restore the real `useWaffleFlags` so the spy doesn't leak into later tests + // (jest is not configured to restore mocks automatically). + waffleFlagsSpy?.mockRestore(); + }); + + it('renders the add-component strip when authz grants edit permission', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canViewCourse: true, + canEditCourseContent: true, + canPublishCourseContent: true, + }); + + render(); + + expect( + await screen.findByRole('heading', { name: addComponentMessages.title.defaultMessage }), + ).toBeInTheDocument(); + }); + + it('hides the add-component strip when authz denies edit permission', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canViewCourse: true, + canEditCourseContent: false, + canPublishCourseContent: false, + }); + + render(); + + // Wait for the unit to finish rendering (the xblock iframe is always present)... + await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + // ...then confirm the add-component strip is gated out by the missing permission. + expect( + screen.queryByRole('heading', { name: addComponentMessages.title.defaultMessage }), + ).not.toBeInTheDocument(); + }); + + it('renders the paste-component button when authz grants edit permission', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canViewCourse: true, + canEditCourseContent: true, + canPublishCourseContent: true, + }); + // Put a component (a non-structural block) on the clipboard so the paste button can appear. + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getClipboardUrl()).reply(200, clipboardXBlock); + axiosMock.onGet(getCourseSectionVerticalApiUrl(blockId)).reply(200, courseSectionVerticalMock); + axiosMock.onGet(getCourseVerticalChildrenApiUrl(blockId)).reply(200, courseVerticalChildrenMock); + + render(); + + expect( + await screen.findByRole('button', { name: messages.pasteButtonText.defaultMessage }), + ).toBeInTheDocument(); + }); + + it('hides the paste-component button when authz denies edit permission', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canViewCourse: true, + canEditCourseContent: false, + canPublishCourseContent: false, + }); + // Same clipboard setup as the granted case, so only the missing permission can hide the button. + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getClipboardUrl()).reply(200, clipboardXBlock); + axiosMock.onGet(getCourseSectionVerticalApiUrl(blockId)).reply(200, courseSectionVerticalMock); + axiosMock.onGet(getCourseVerticalChildrenApiUrl(blockId)).reply(200, courseVerticalChildrenMock); + + render(); + + await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect( + screen.queryByRole('button', { name: messages.pasteButtonText.defaultMessage }), + ).not.toBeInTheDocument(); + }); + }); + it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); // Convert the second child from drag and drop to HTML: @@ -2636,7 +2733,7 @@ describe('', () => { // Edit button should be enabled even for library imported units const unitHeaderTitle = screen.getByTestId('unit-header-title'); - const editButton = within(unitHeaderTitle).getByRole( + const editButton = await within(unitHeaderTitle).findByRole( 'button', { name: 'Edit' }, ); diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index 6a558de793..c162a3c0f6 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -164,7 +164,7 @@ const StatusBar = ({ courseUnit }: { courseUnit: any; }) => { const CourseUnit = () => { const intl = useIntl(); const { blockId } = useParams(); - const { courseId } = useCourseAuthoringContext(); + const { courseId, canEditCourseContent } = useCourseAuthoringContext(); const urls = useHelpUrls(['syncLibraryUpdates']); if (courseId === undefined) { @@ -359,7 +359,8 @@ const CourseUnit = () => { courseVerticalChildren={courseVerticalChildren.children} /> )} - {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData + {canEditCourseContent && !readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && + sharedClipboardData && /* istanbul ignore next */ ( { text={intl.formatMessage(messages.pasteButtonText)} /> )} - {!readOnly && blockId && ( + {canEditCourseContent && !readOnly && blockId && ( { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); return ( @@ -34,22 +36,25 @@ const SequenceNavigationDropdown = ({ unitId={buttonUnitId} /> ))} - - {showPasteUnit && ( + {canEditCourseContent && + ( + + )} + {canEditCourseContent && showPasteUnit && ( )} diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 0f4ca66bff..b57deff504 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -23,7 +23,7 @@ const SequenceNavigationTabs = ({ const dispatch = useDispatch(); const navigate = useNavigate(); const sequenceId = useSelector(getSequenceId); - const { courseId } = useCourseAuthoringContext(); + const { courseId, canEditCourseContent } = useCourseAuthoringContext(); const courseUnit = useSelector(getCourseUnitData); const sequenceChildAddable = courseUnit?.ancestorInfo?.ancestors?.[0]?.actions?.childAddable; @@ -67,7 +67,7 @@ const SequenceNavigationTabs = ({ isActive={unitId === buttonUnitId} /> ))} - {sequenceChildAddable && ( + {canEditCourseContent && sequenceChildAddable && ( - {!readOnly && ( + {canEditCourseContent && !readOnly && ( + ) : + label} ); }; @@ -32,6 +41,7 @@ TranscriptColumn.propTypes = { transcriptionStatus: PropTypes.string.isRequired, }.isRequired, }.isRequired, + handleOpenFileInfo: PropTypes.func, }; export default TranscriptColumn; diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.test.tsx b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.test.tsx new file mode 100644 index 0000000000..165c0c0857 --- /dev/null +++ b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.test.tsx @@ -0,0 +1,42 @@ +import { + fireEvent, + initializeMocks, + render, + screen, +} from '@src/testUtils'; +import TranscriptColumn from './TranscriptColumn'; + +const rowWith = (transcripts: string[], transcriptionStatus = '') => ({ + original: { transcripts, transcriptionStatus }, +}); + +describe('TranscriptColumn', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('pluralizes the transcript count', () => { + const { rerender } = render(); + expect(screen.getByText('1 transcript available')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('3 transcripts available')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('No transcripts available')).toBeInTheDocument(); + }); + + it('renders plain text when no handler is provided', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('opens the file info from the count link, including when there are no transcripts yet', () => { + const handleOpenFileInfo = jest.fn(); + const row = rowWith([]); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'No transcripts available' })); + expect(handleOpenFileInfo).toHaveBeenCalledWith(row.original); + }); +}); diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss index ee5c3d58c8..bd8b97c9ad 100644 --- a/src/files-and-videos/index.scss +++ b/src/files-and-videos/index.scss @@ -79,3 +79,13 @@ .video-upload-warning-text { font-size: 18px; } + +.transcript-upload-button { + border-radius: 5px; + border: 2px solid; + + &:focus::before, + &:focus-visible::before { + display: none !important; + } +} diff --git a/src/files-and-videos/videos-page/CourseVideosTable.tsx b/src/files-and-videos/videos-page/CourseVideosTable.tsx index 1aeb4c69a1..0840505754 100644 --- a/src/files-and-videos/videos-page/CourseVideosTable.tsx +++ b/src/files-and-videos/videos-page/CourseVideosTable.tsx @@ -29,6 +29,7 @@ import { } from '@src/files-and-videos/videos-page/data/thunks'; import { getFormattedDuration, resampleFile } from '@src/files-and-videos/videos-page/data/utils'; import VideoInfoModalSidebar from '@src/files-and-videos/videos-page/info-sidebar'; +import InfoTab from '@src/files-and-videos/videos-page/info-sidebar/InfoTab'; 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'; @@ -159,9 +160,8 @@ export const CourseVideosTable = () => { handleAddThumbnail, videoImageSettings, }); - const infoModalSidebar = (video, activeTab, setActiveTab) => ( - - ); + const infoModalSidebar = (video) => ; + const infoModalContentUnderPreview = (video) => ; const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { id: 'transcriptStatus', @@ -279,6 +279,7 @@ export const CourseVideosTable = () => { maxFileSize, thumbnailPreview, infoModalSidebar, + infoModalContentUnderPreview, files: videos, }} /> diff --git a/src/files-and-videos/videos-page/VideosPage.test.tsx b/src/files-and-videos/videos-page/VideosPage.test.tsx index 4a39689213..91e2ddb042 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.tsx +++ b/src/files-and-videos/videos-page/VideosPage.test.tsx @@ -502,10 +502,10 @@ describe('Videos page', () => { }); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + fireEvent.click(screen.getByText(messages.infoAndTranscriptsTitle.defaultMessage)); await waitFor(() => { - expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); + expect(screen.getByText(messages.usageTitle.defaultMessage)).toBeVisible(); }); const { usageStatus } = store.getState().videos; @@ -521,15 +521,12 @@ describe('Videos page', () => { axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + fireEvent.click(screen.getByText(messages.infoAndTranscriptsTitle.defaultMessage)); await waitFor(() => { expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); }); - const infoTab = screen.getAllByRole('tab')[0]; - expect(infoTab).toBeVisible(); - - expect(infoTab).toHaveClass('active'); + expect(screen.getByText(messages.usageTitle.defaultMessage)).toBeVisible(); }); it('should open video info modal and show transcript tab', async () => { @@ -538,16 +535,12 @@ describe('Videos page', () => { axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + fireEvent.click(screen.getByText(messages.infoAndTranscriptsTitle.defaultMessage)); await waitFor(() => { expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); }); - const transcriptTab = screen.getAllByRole('tab')[1]; - fireEvent.click(transcriptTab); - expect(transcriptTab).toBeVisible(); - - expect(transcriptTab).toHaveClass('active'); + expect(screen.getByText('Transcript (0)')).toBeVisible(); }); it('should show transcript error', async () => { @@ -556,12 +549,11 @@ describe('Videos page', () => { axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(201, { usageLocations: [] }); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); - - const transcriptTab = screen.getAllByRole('tab')[1]; - fireEvent.click(transcriptTab); + fireEvent.click(screen.getByText(messages.infoAndTranscriptsTitle.defaultMessage)); - expect(screen.getByText('Transcript (1)')).toBeVisible(); + await waitFor(() => { + expect(screen.getByText('Transcript (1)')).toBeVisible(); + }); }); }); @@ -712,7 +704,7 @@ describe('Videos page', () => { axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(404); fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + fireEvent.click(screen.getByText(messages.infoAndTranscriptsTitle.defaultMessage)); await executeThunk( getUsagePaths({ courseId, diff --git a/src/files-and-videos/videos-page/data/api.js b/src/files-and-videos/videos-page/data/api.js index b6d1f41d82..655c27aea5 100644 --- a/src/files-and-videos/videos-page/data/api.js +++ b/src/files-and-videos/videos-page/data/api.js @@ -75,12 +75,21 @@ export async function downloadTranscript({ apiUrl, filename, }) { - const { data } = await getAuthenticatedHttpClient() - .get(`${getApiBaseUrl()}${apiUrl}?edx_video_id=${videoId}&language_code=${language}`); + const data = await fetchTranscriptContent({ videoId, language, apiUrl }); const file = new Blob([data], { type: 'text/plain;charset=utf-8' }); saveAs(file, filename); } +export async function fetchTranscriptContent({ + videoId, + language, + apiUrl, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getApiBaseUrl()}${apiUrl}?edx_video_id=${videoId}&language_code=${language}`); + return data; +} + export async function uploadTranscript({ videoId, newLanguage, diff --git a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx index c46fe8a305..25fe9a3c4f 100644 --- a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx @@ -1,14 +1,35 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { + useEffect, + useMemo, + useState, + useRef, +} from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { isEmpty } from 'lodash'; -import { Button, Stack } from '@openedx/paragon'; -import { Add } from '@openedx/paragon/icons'; +import { + Button, + Icon, + IconButton, + Spinner, + Stack, + Toast, +} from '@openedx/paragon'; +import { + Add, + CheckCircle, + Delete, + Error as ErrorIcon, + FileUpload, + Article, +} from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import ErrorAlert from '../../../editors/sharedComponents/ErrorAlerts/ErrorAlert'; import { getLanguages, getSortedTranscripts } from '../data/utils'; import Transcript from './transcript-item'; +import LanguageSelect from './transcript-item/LanguageSelect'; +import { FileInput, useFileInput } from '../../generic'; import { deleteVideoTranscript, downloadVideoTranscript, @@ -17,6 +38,7 @@ import { } from '../data/thunks'; import { RequestStatus } from '../../../data/constants'; import messages from './messages'; +import { isValidSrt } from '../transcript-editor/srtUtils'; const TranscriptTab = ({ video, @@ -35,9 +57,44 @@ const TranscriptTab = ({ transcriptDownloadHandlerUrl, } = videoTranscriptSettings; const { transcripts, id, displayName } = video; - const languages = getLanguages(transcriptAvailableLanguages); + const languages = useMemo( + () => getLanguages(transcriptAvailableLanguages), + [transcriptAvailableLanguages], + ); let sortedTranscripts = getSortedTranscripts(languages, transcripts); const [previousSelection, setPreviousSelection] = useState(sortedTranscripts); + const [isAddingTranscript, setIsAddingTranscript] = useState(false); + const [newLanguage, setNewLanguage] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [pendingFileName, setPendingFileName] = useState(''); + const [isSubmittingNewTranscript, setIsSubmittingNewTranscript] = useState(false); + const [isSubmittingReplace, setIsSubmittingReplace] = useState(false); + const [showAddTranscriptError, setShowAddTranscriptError] = useState(false); + const [showAddedToast, setShowAddedToast] = useState(false); + const [invalidSrtFile, setInvalidSrtFile] = useState(false); + + const addTranscriptFileInput = useFileInput({ + onAddFile: (files) => { + const [file] = files; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + if (!isValidSrt(e.target.result)) { + setInvalidSrtFile(true); + setSelectedFile(null); + } else { + setInvalidSrtFile(false); + setSelectedFile(file); + setShowAddTranscriptError(false); + } + }; + reader.readAsText(file); + }, + setSelectedRows: () => {}, + setAddOpen: () => {}, + }); useEffect(() => { dispatch(resetErrors({ errorType: 'transcript' })); @@ -45,22 +102,59 @@ const TranscriptTab = ({ setPreviousSelection(sortedTranscripts); }, [transcripts]); - const handleAddEmptyTranscript = () => { - setPreviousSelection(['', ...previousSelection]); - if (divRef?.current?.scrollTo) { - divRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + useEffect(() => { + if (isAddingTranscript) { + const firstAvailableLanguage = Object.keys(languages).find((lang) => !previousSelection.includes(lang)); + setNewLanguage(firstAvailableLanguage || ''); + setSelectedFile(null); } - }; + }, [isAddingTranscript]); + + useEffect(() => { + if (!isAddingTranscript || !isSubmittingNewTranscript) { + return; + } + + if (transcriptStatus === RequestStatus.SUCCESSFUL) { + setIsSubmittingNewTranscript(false); + setPendingFileName(''); + setIsAddingTranscript(false); + setSelectedFile(null); + setShowAddTranscriptError(false); + setShowAddedToast(true); + } + + if (transcriptStatus === RequestStatus.FAILED) { + setIsSubmittingNewTranscript(false); + setPendingFileName(''); + setSelectedFile(null); + setShowAddTranscriptError(true); + } + }, [isAddingTranscript, isSubmittingNewTranscript, transcriptStatus]); + + useEffect(() => { + if (!isSubmittingReplace) { + return; + } + if (transcriptStatus === RequestStatus.SUCCESSFUL) { + setIsSubmittingReplace(false); + setShowAddedToast(true); + } + if (transcriptStatus === RequestStatus.FAILED) { + setIsSubmittingReplace(false); + } + }, [isSubmittingReplace, transcriptStatus]); const handleTranscript = (data, actionType) => { const { language, - newLanguage, + newLanguage: transcriptNewLanguage, file, } = data; dispatch(resetErrors({ errorType: 'transcript' })); switch (actionType) { case 'delete': + /* istanbul ignore if -- legacy empty-row path; the add form no longer creates empty rows */ if (isEmpty(language)) { const updatedSelection = previousSelection; updatedSelection.shift(); @@ -83,26 +177,48 @@ const TranscriptTab = ({ })); break; case 'upload': + if (!isEmpty(language)) { + setIsSubmittingReplace(true); + } dispatch(uploadVideoTranscript({ language, videoId: id, apiUrl: transcriptUploadHandlerUrl, - newLanguage, + newLanguage: transcriptNewLanguage, file, transcripts, })); break; + /* istanbul ignore next */ default: break; } }; + const availableLanguages = Object.entries(languages) + .filter(([lang]) => !previousSelection.includes(lang)); + + const handleSubmitNewTranscript = () => { + /* istanbul ignore if -- the submit button is disabled in this state */ + if (!newLanguage || !selectedFile) { + return; + } + setShowAddTranscriptError(false); + setIsSubmittingNewTranscript(true); + setPendingFileName(selectedFile.name); + handleTranscript({ + language: '', + newLanguage, + file: selectedFile, + }, 'upload'); + }; + return ( -
+
    {errors.transcript.map(message => ( @@ -112,30 +228,157 @@ const TranscriptTab = ({ ))}
- {previousSelection.map((transcript, idx) => ( - - ))} -
-
- + {isAddingTranscript ? + ( + +
{intl.formatMessage(messages.newTranscriptTitle)}
+ +
+ { + setNewLanguage(lang); + setShowAddTranscriptError(false); + }} + /> +
+ + {!selectedFile && !isSubmittingNewTranscript && ( + + )} + + {isSubmittingNewTranscript ? + ( + + + {pendingFileName} + + ) : + null} + + {selectedFile && !isSubmittingNewTranscript ? + ( + + + + {selectedFile.name} + + setSelectedFile(null)} + className="flex-shrink-0" + /> + + ) : + null} + + {showAddTranscriptError && ( + + + {intl.formatMessage(messages.addTranscriptFailedLabel)} + + )} + + {invalidSrtFile && ( + + + {intl.formatMessage(messages.invalidSrtFormat)} + + )} + + {!selectedFile &&
{intl.formatMessage(messages.uploadHelpText)} +
} + + + + + + + +
+ ) : + ( + <> + {previousSelection.map((transcript, idx) => ( + + ))} + + )}
+ {!isAddingTranscript && ( +
+ +
+ )} + setShowAddedToast(false)}> + + + {intl.formatMessage(messages.newTranscriptAddedLabel)} + + ); }; diff --git a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx index 656214024d..3b9faa735b 100644 --- a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx @@ -4,7 +4,9 @@ import { fireEvent, screen, waitFor, + within, } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import userEvent from '@testing-library/user-event'; import ReactDOM from 'react-dom'; @@ -27,8 +29,8 @@ import { import { getApiBaseUrl } from '../data/api'; import messages from './messages'; -import genericMessages from '../../generic/messages'; import transcriptRowMessages from './transcript-item/messages'; +import editorMessages from '../transcript-editor/messages'; import VideosPageProvider from '../VideosPageProvider'; import { deleteVideoTranscript } from '../data/thunks'; @@ -51,20 +53,42 @@ const defaultProps = { let axiosMock; let store; +let queryClient; jest.mock('file-saver'); const renderComponent = (props) => { render( - - - - - - - , + + + + + + + + + , ); }; +const openAddForm = async () => { + const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(addButton); + }); +}; + +const selectLanguage = async (languageText, dropdownEl = null) => { + const dropdown = dropdownEl || screen.getByTestId('language-select-dropdown'); + await act(async () => { + fireEvent.click(dropdown); + }); + const allMatches = screen.getAllByText(languageText); + const option = allMatches.find(el => el.closest('.pgn__menu')) || allMatches[allMatches.length - 1]; + await act(async () => { + fireEvent.click(option); + }); +}; + describe('TranscriptTab', () => { beforeEach(async () => { initializeMockApp({ @@ -77,97 +101,92 @@ describe('TranscriptTab', () => { }); store = initializeStore(initialState); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + axiosMock.onGet(/course_waffle_flags/).reply(200, {}); }); describe('with no transcripts preloaded', () => { it('should have add transcript button', async () => { renderComponent(defaultProps); const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - const transcriptRow = screen.queryByTestId('transcript', { exact: false }); + const transcriptRow = screen.queryByTestId('transcript-', { exact: false }); expect(addButton).toBeInTheDocument(); expect(transcriptRow).toBeNull(); }); - it('should delete empty transcript row', async () => { + it('should open and cancel new transcript form', async () => { renderComponent(defaultProps); - const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - await act(async () => { - fireEvent.click(addButton); - }); - - const deleteButton = screen.getByLabelText('delete empty transcript'); - await act(async () => { - fireEvent.click(deleteButton); - }); + await openAddForm(); - expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + expect(screen.getByText(messages.newTranscriptTitle.defaultMessage)).toBeVisible(); - const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); + const cancelButton = screen.getByText(messages.cancelButtonLabel.defaultMessage); await act(async () => { - fireEvent.click(confirmButton); + fireEvent.click(cancelButton); }); - expect(screen.queryByTestId('transcript-')).toBeNull(); + expect(screen.queryByText(messages.newTranscriptTitle.defaultMessage)).toBeNull(); }); describe('uploadVideoTranscript as add function', () => { - let addButton; - const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); - beforeEach(async () => { - renderComponent(defaultProps); - addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - - await act(async () => { - fireEvent.click(addButton); - }); - }); + const srtContent = '1\n00:00:00,000 --> 00:00:01,000\nTest\n'; + const file = new File([srtContent], 'download.srt', { type: 'text/srt' }); it('should upload new transcript', async () => { const user = userEvent.setup(); + renderComponent(defaultProps); axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + + await openAddForm(); + await selectLanguage('Arabic'); + await act(async () => { - const addFileInput = screen.getByLabelText(genericMessages.fileInputAriaLabel.defaultMessage); - expect(addFileInput).toBeInTheDocument(); + fireEvent.click(screen.getByText(messages.uploadFileLabel.defaultMessage)); + }); + const fileInputs = document.querySelectorAll('input[type="file"]'); + await act(async () => { + await user.upload(fileInputs[fileInputs.length - 1], file); + }); - await user.upload(addFileInput, file); + const submitButton = screen.getByText(messages.addTranscriptButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(submitButton); }); - const addStatus = store.getState().videos.transcriptStatus; + const addStatus = store.getState().videos.transcriptStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); - it('should show default error message', async () => { + it('should show default error message on upload failure', async () => { const user = userEvent.setup(); + renderComponent(defaultProps); axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404); - await act(async () => { - const addFileInput = screen.getByLabelText(genericMessages.fileInputAriaLabel.defaultMessage); - await user.upload(addFileInput, file); - }); - const addStatus = store.getState().videos.transcriptStatus; - expect(addStatus).toEqual(RequestStatus.FAILED); + await openAddForm(); + await selectLanguage('Arabic'); - expect(screen.getAllByText('Failed to add .')[0]).toBeVisible(); - }); + await act(async () => { + fireEvent.click(screen.getByText(messages.uploadFileLabel.defaultMessage)); + }); + const fileInputs = document.querySelectorAll('input[type="file"]'); + await act(async () => { + await user.upload(fileInputs[fileInputs.length - 1], file); + }); - it('should show api provided error message', async () => { - const user = userEvent.setup(); - axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404, { error: 'api error' }); + const submitButton = screen.getByText(messages.addTranscriptButtonLabel.defaultMessage); await act(async () => { - const addFileInput = screen.getByLabelText(genericMessages.fileInputAriaLabel.defaultMessage); - await user.upload(addFileInput, file); + fireEvent.click(submitButton); }); - const addStatus = store.getState().videos.transcriptStatus; + const addStatus = store.getState().videos.transcriptStatus; expect(addStatus).toEqual(RequestStatus.FAILED); - - expect(screen.getAllByText('api error')[0]).toBeVisible(); }); }); }); - describe('with one transcripts preloaded', () => { + describe('with one transcript preloaded', () => { const updatedProps = { ...defaultProps, transcripts: ['ar'] }; + beforeEach(() => { renderComponent(updatedProps); }); @@ -195,12 +214,12 @@ describe('TranscriptTab', () => { await waitFor(() => { fireEvent.click(cancelButton); }); - expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); }); it('should open delete confirmation modal and handle delete', async () => { - const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); + const confirmButton = screen.getAllByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage) + .find(el => el.closest('.btn-danger') || el.classList.contains('btn-danger')); axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(204); await act(async () => { fireEvent.click(confirmButton); @@ -215,14 +234,13 @@ describe('TranscriptTab', () => { ); }); const deleteStatus = store.getState().videos.transcriptStatus; - expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); - expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); }); - it('should show error message', async () => { - const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); + it('should show error message on delete failure', async () => { + const confirmButton = screen.getAllByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage) + .find(el => el.closest('.btn-danger') || el.classList.contains('btn-danger')); axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(404); await act(async () => { fireEvent.click(confirmButton); @@ -237,11 +255,8 @@ describe('TranscriptTab', () => { ); }); const deleteStatus = store.getState().videos.transcriptStatus; - expect(deleteStatus).toEqual(RequestStatus.FAILED); - expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); - expect(screen.getAllByText('Failed to delete ar transcript.')[0]).toBeVisible(); }); }); @@ -266,11 +281,10 @@ describe('TranscriptTab', () => { fireEvent.click(downloadButton); }); const downloadStatus = store.getState().videos.transcriptStatus; - expect(downloadStatus).toEqual(RequestStatus.SUCCESSFUL); }); - it('should show error message', async () => { + it('should show error message on download failure', async () => { const filename = 'mOckID0.mp4-ar.srt'; axiosMock.onGet( `${getApiBaseUrl()}/transcript_download/?edx_video_id=${updatedProps.id}&language_code=ar`, @@ -279,9 +293,7 @@ describe('TranscriptTab', () => { fireEvent.click(downloadButton); }); const downloadStatus = store.getState().videos.transcriptStatus; - expect(downloadStatus).toEqual(RequestStatus.FAILED); - expect(screen.getAllByText(`Failed to download ${filename}.`)[0]).toBeVisible(); }); }); @@ -289,19 +301,15 @@ describe('TranscriptTab', () => { describe('with multiple transcripts preloaded', () => { describe('uploadVideoTranscript as replace function', () => { - const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); + const srtContent = '1\n00:00:00,000 --> 00:00:01,000\nTest\n'; + const file = new File([srtContent], 'download.srt', { type: 'text/srt' }); + beforeEach(async () => { const updatedProps = { ...defaultProps, transcripts: ['fr', 'ar'] }; renderComponent(updatedProps); - const dropdownButton = screen.getAllByTestId('language-select-dropdown')[0]; - await waitFor(() => { - fireEvent.click(dropdownButton); - }); - const englishOption = screen.getByText('English'); - await act(async () => { - fireEvent.click(englishOption); - }); + const dropdownButtons = screen.getAllByTestId('language-select-dropdown'); + await selectLanguage('English', dropdownButtons[0]); const menuButton = screen.getByTestId('ar-transcript-menu'); await waitFor(() => { @@ -318,33 +326,139 @@ describe('TranscriptTab', () => { axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); await act(async () => { - const addFileInput = screen.getAllByLabelText(genericMessages.fileInputAriaLabel.defaultMessage)[0]; - await user.upload(addFileInput, file); + const fileInputs = document.querySelectorAll('input[type="file"]'); + await user.upload(fileInputs[0], file); }); - const addStatus = store.getState().videos.transcriptStatus; + const addStatus = store.getState().videos.transcriptStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); const updatedTranscripts = store.getState().models.videos[defaultProps.id].transcripts; - expect(updatedTranscripts).toEqual(['fr', 'en']); }); - it('should show error message', async () => { + it('should show error message on replace failure', async () => { const user = userEvent.setup(); axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404); await act(async () => { - const addFileInput = screen.getAllByLabelText(genericMessages.fileInputAriaLabel.defaultMessage)[0]; - await user.upload(addFileInput, file); + const fileInputs = document.querySelectorAll('input[type="file"]'); + await user.upload(fileInputs[0], file); }); const addStatus = store.getState().videos.transcriptStatus; - expect(addStatus).toEqual(RequestStatus.FAILED); - expect(screen.getAllByText('Failed to replace ar with en.')[0]).toBeVisible(); }); }); }); + describe('add transcript form interactions', () => { + const srt = '1\n00:00:01,000 --> 00:00:02,000\nHello\n'; + const chooseFile = async (file) => { + await act(async () => { + fireEvent.change(document.querySelector('input[type="file"]'), { target: { files: file ? [file] : [] } }); + }); + }; + + it('shows the selected SRT and lets the user remove it before submitting', async () => { + renderComponent(defaultProps); + await openAddForm(); + await chooseFile(new File([srt], 'valid.srt', { type: 'text/srt' })); + expect(await screen.findByText('valid.srt')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: messages.removeSelectedFileLabel.defaultMessage })); + await waitFor(() => expect(screen.queryByText('valid.srt')).not.toBeInTheDocument()); + }); + + it('rejects an invalid SRT file on add', async () => { + renderComponent(defaultProps); + await openAddForm(); + await chooseFile(new File(['not an srt'], 'bad.srt', { type: 'text/srt' })); + expect(await screen.findByText(messages.invalidSrtFormat.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText('bad.srt')).not.toBeInTheDocument(); + }); + + it('ignores an empty file selection', async () => { + renderComponent(defaultProps); + await openAddForm(); + await chooseFile(null); + expect(screen.queryByText(messages.invalidSrtFormat.defaultMessage)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.addTranscriptButtonLabel.defaultMessage })).toBeDisabled(); + }); + + it('filters the language list from the search box and reports no results', async () => { + renderComponent(defaultProps); + await openAddForm(); + await act(async () => { + fireEvent.click(screen.getByTestId('language-select-dropdown')); + }); + const search = screen.getByPlaceholderText(transcriptRowMessages.searchLanguagesPlaceholder.defaultMessage); + fireEvent.change(search, { target: { value: 'zzzz' } }); + expect(screen.getByText(transcriptRowMessages.noLanguageResults.defaultMessage)).toBeInTheDocument(); + fireEvent.change(search, { target: { value: 'Eng' } }); + const options = screen.getAllByText('English'); + await act(async () => { + fireEvent.click(options[options.length - 1]); + }); + expect(screen.getByTestId('language-select-dropdown')).toHaveTextContent('English'); + }); + + it('submits a new transcript, then shows a dismissible success toast', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + renderComponent(defaultProps); + await openAddForm(); + await chooseFile(new File([srt], 'valid.srt', { type: 'text/srt' })); + await screen.findByText('valid.srt'); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: messages.addTranscriptButtonLabel.defaultMessage })); + }); + expect(await screen.findByText(messages.newTranscriptAddedLabel.defaultMessage)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + await waitFor(() => + expect(screen.queryByText(messages.newTranscriptAddedLabel.defaultMessage)).not.toBeInTheDocument() + ); + }); + }); + + describe('transcript row interactions', () => { + const rowProps = { ...defaultProps, transcripts: ['ar'] }; + // The row's hidden file input is rendered as a sibling of the row element, first in document order. + const rowFileInput = () => document.querySelectorAll('input[type="file"]')[0]; + + it('rejects an invalid SRT file on replace', async () => { + renderComponent(rowProps); + await act(async () => { + fireEvent.change(rowFileInput(), { target: { files: [new File(['nope'], 'bad.srt', { type: 'text/srt' })] } }); + }); + expect(await screen.findByText(messages.invalidSrtFormat.defaultMessage)).toBeInTheDocument(); + }); + + it('ignores an empty replace selection', async () => { + renderComponent(rowProps); + await act(async () => { + fireEvent.change(rowFileInput(), { target: { files: [] } }); + }); + expect(screen.queryByText(messages.invalidSrtFormat.defaultMessage)).not.toBeInTheDocument(); + }); + + it('opens the transcript editor from the row menu and closes it again', async () => { + axiosMock.onGet(/edx_video_id/).reply(200, ''); + renderComponent(rowProps); + const row = screen.getByTestId('transcript-ar'); + fireEvent.click(within(row).getByRole('button', { name: 'Actions dropdown' })); + await act(async () => { + fireEvent.click(await screen.findByText(transcriptRowMessages.editTranscript.defaultMessage)); + }); + expect(await screen.findByRole('button', { name: editorMessages.insertCueLabel.defaultMessage })) + .toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: editorMessages.cancelButtonLabel.defaultMessage })); + await waitFor(() => + expect(screen.queryByRole('button', { name: editorMessages.insertCueLabel.defaultMessage })).not + .toBeInTheDocument() + ); + }); + }); }); diff --git a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx index d567c54e3c..82381e456d 100644 --- a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx @@ -1,46 +1,22 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Tabs, - Tab, -} from '@openedx/paragon'; -import InfoTab from './InfoTab'; +import { Stack } from '@openedx/paragon'; import TranscriptTab from './TranscriptTab'; import messages from './messages'; -import { TRANSCRIPT_FAILURE_STATUSES } from '../data/constants'; const VideoInfoModalSidebar = ({ video, - activeTab, - setActiveTab, }) => { const intl = useIntl(); return ( - setActiveTab(tab)} - > - - - - - {intl.formatMessage(messages.notificationScreenReaderText)} - - )} - > - - - + +
+ {intl.formatMessage(messages.transcriptTabTitle, { transcriptCount: video.transcripts.length })} +
+ +
); }; @@ -54,8 +30,6 @@ VideoInfoModalSidebar.propTypes = { transcripts: PropTypes.arrayOf(PropTypes.string), transcriptionStatus: PropTypes.string.isRequired, }), - activeTab: PropTypes.string.isRequired, - setActiveTab: PropTypes.func.isRequired, }; VideoInfoModalSidebar.defaultProps = { diff --git a/src/files-and-videos/videos-page/info-sidebar/messages.ts b/src/files-and-videos/videos-page/info-sidebar/messages.ts index a1823e4667..7246ad4205 100644 --- a/src/files-and-videos/videos-page/info-sidebar/messages.ts +++ b/src/files-and-videos/videos-page/info-sidebar/messages.ts @@ -40,6 +40,56 @@ const messages = defineMessages({ defaultMessage: 'Add a transcript', description: 'Label for upload button', }, + newTranscriptTitle: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.newTranscript.title', + defaultMessage: 'New transcript', + description: 'Heading shown when adding a new transcript', + }, + uploadFileLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.uploadFile.label', + defaultMessage: 'Upload file', + description: 'Label for selecting a transcript file', + }, + uploadHelpText: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.uploadFile.helpText', + defaultMessage: 'SRT file, max 25MB', + description: 'Help text shown under transcript upload button', + }, + languageSelectPlaceholder: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.languageSelectPlaceholder', + defaultMessage: 'Select language', + description: 'Placeholder for language selector in new transcript form', + }, + cancelButtonLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.cancel.label', + defaultMessage: 'Cancel', + description: 'Cancel button text in new transcript form', + }, + addTranscriptButtonLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.addTranscript.label', + defaultMessage: 'Add transcript', + description: 'Submit button label in new transcript form', + }, + removeSelectedFileLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.removeSelectedFile.label', + defaultMessage: 'Remove selected file', + description: 'Accessible label for removing selected transcript file', + }, + addTranscriptFailedLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.addTranscript.failed', + defaultMessage: 'Upload failed, please try again', + description: 'Inline error shown when adding transcript fails', + }, + invalidSrtFormat: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.invalidSrtFormat', + defaultMessage: 'Invalid SRT format. Please upload a valid .srt file.', + description: 'Error shown when uploaded transcript file has invalid SRT content', + }, + newTranscriptAddedLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.addTranscript.success', + defaultMessage: 'New transcript added', + description: 'Toast message shown when adding transcript succeeds', + }, }); export default messages; diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx index e1a57cd9cf..d26b20a5b1 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx @@ -1,15 +1,23 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, + Form, Icon, ModalPopup, Menu, MenuItem, useToggle, } from '@openedx/paragon'; -import { Check, ExpandMore, ExpandLess } from '@openedx/paragon/icons'; +import messages from './messages'; +import { + Check, + ExpandMore, + ExpandLess, + Search, +} from '@openedx/paragon/icons'; import { isEmpty } from 'lodash'; const LanguageSelect = ({ @@ -18,39 +26,68 @@ const LanguageSelect = ({ options, handleSelect, placeholderText, + className = 'col-9 p-0', }) => { + const intl = useIntl(); const currentSelection = isEmpty(value) ? placeholderText : options[value]; const [isOpen, , close, toggle] = useToggle(); const [target, setTarget] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredOptions = Object.entries(options).filter(([, text]) => ( + text?.toLowerCase().includes(normalizedQuery) + )); + + const handleClose = () => { + setSearchQuery(''); + close(); + }; return ( <> -
+
- {Object.entries(options).map(([valueKey, text]) => { +
+ setSearchQuery(e.target.value)} + placeholder={intl.formatMessage(messages.searchLanguagesPlaceholder)} + controlClassName="w-100" + floatingLabel={null} + /> +
+ {filteredOptions.length === 0 && ( +
+ +
+ )} + {filteredOptions.map(([valueKey, text]) => { if (valueKey === value) { return ( { handleSelect(valueKey); - close(); + handleClose(); }} key={`${valueKey}-item`} > @@ -95,7 +132,7 @@ const LanguageSelect = ({
- +
@@ -108,6 +145,7 @@ LanguageSelect.propTypes = { handleSelect: PropTypes.func.isRequired, placeholderText: PropTypes.string.isRequired, previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, + className: PropTypes.string, }; export default LanguageSelect; diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.scss b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.scss index 9e893d971f..09127601fc 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.scss +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.scss @@ -1,3 +1,22 @@ +.language-select-dropdown-btn { + border-radius: 6px !important; + border-color: var(--pgn-color-gray-500) !important; + + &__text { + flex: 1; + text-align: left; + } + + &__divider { + display: inline-block; + width: 1px; + height: 1rem; + background-color: var(--pgn-color-gray-500); + margin: 0 .5rem; + align-self: center; + } +} + .language-select { padding: 8px 0; margin: 0; diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx index 77301d7010..4b1feba4c8 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx @@ -1,29 +1,38 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { - Card, + ActionRow, + AlertModal, Button, Icon, IconButton, + Stack, useToggle, } from '@openedx/paragon'; -import { DeleteOutline } from '@openedx/paragon/icons'; +import { DeleteOutline, Error as ErrorIcon } from '@openedx/paragon/icons'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { isEmpty } from 'lodash'; import LanguageSelect from './LanguageSelect'; import TranscriptMenu from './TranscriptMenu'; import messages from './messages'; +import sidebarMessages from '../messages'; import { FileInput, useFileInput } from '../../../generic'; +import TranscriptEditor from '../../transcript-editor'; +import { isValidSrt } from '../../transcript-editor/srtUtils'; const Transcript = ({ languages, transcript, previousSelection, handleTranscript, + video, + transcriptSettings, }) => { const intl = useIntl(); const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle(); const [newLanguage, setNewLanguage] = useState(transcript); + const [editorLanguage, setEditorLanguage] = useState(''); + const [invalidSrtFile, setInvalidSrtFile] = useState(false); const language = transcript; useEffect(() => { @@ -33,11 +42,23 @@ const Transcript = ({ const input = useFileInput({ onAddFile: (files) => { const [file] = files; - handleTranscript({ - file, - language, - newLanguage, - }, 'upload'); + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + if (!isValidSrt(e.target.result)) { + setInvalidSrtFile(true); + } else { + setInvalidSrtFile(false); + handleTranscript({ + file, + language, + newLanguage, + }, 'upload'); + } + }; + reader.readAsText(file); }, setSelectedRows: () => {}, setAddOpen: () => {}, @@ -52,70 +73,81 @@ const Transcript = ({ return ( <> - {isConfirmationOpen ? - ( - - } /> - - - - - - - - - - - ) : - ( -
- + + {transcript === '' ? + ( + - {transcript === '' ? - ( - - ) : - ( - - )} -
- )} + ) : + ( + + )} +
+ {invalidSrtFile && ( + + + {intl.formatMessage(sidebarMessages.invalidSrtFormat)} + + )} + } + isOpen={isConfirmationOpen} + onClose={closeConfirmation} + footerNode={ + + + + + } + > +

+ +

+
+ setEditorLanguage('')} + video={video} + language={editorLanguage} + languages={languages} + transcriptSettings={transcriptSettings} + /> ); }; @@ -125,6 +157,15 @@ Transcript.propTypes = { transcript: PropTypes.string.isRequired, previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, handleTranscript: PropTypes.func.isRequired, + video: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + downloadLink: PropTypes.string, + }).isRequired, + transcriptSettings: PropTypes.shape({ + transcriptDownloadHandlerUrl: PropTypes.string.isRequired, + transcriptUploadHandlerUrl: PropTypes.string.isRequired, + }).isRequired, }; export default Transcript; diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.test.tsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.test.tsx new file mode 100644 index 0000000000..8071e75541 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.test.tsx @@ -0,0 +1,35 @@ +import { + fireEvent, + initializeMocks, + render, + screen, +} from '@src/testUtils'; +import Transcript from './Transcript'; + +describe('Transcript (new, empty row)', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('opens the file picker once a language is chosen for the new transcript', () => { + const clickSpy = jest.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {}); + render( + , + ); + + fireEvent.click(screen.getByTestId('language-select-dropdown')); + fireEvent.click(screen.getByText('Arabic')); + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); +}); diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx index 5f321691e8..e9ca341158 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx @@ -19,6 +19,7 @@ export const TranscriptActionMenu = ({ launchDeleteConfirmation, handleTranscript, input, + onEdit = () => {}, }) => { const [isOpen, , close, toggle] = useToggle(); const [target, setTarget] = useState(null); @@ -39,19 +40,30 @@ export const TranscriptActionMenu = ({ onClose={close} onEscapeKey={close} > - + { + onEdit(language); + close(); + }} + > + + + { + input.click(); + close(); + }} > handleTranscript({ language }, 'download')} > @@ -60,7 +72,6 @@ export const TranscriptActionMenu = ({ @@ -75,6 +86,7 @@ TranscriptActionMenu.propTypes = { language: PropTypes.string.isRequired, handleTranscript: PropTypes.func.isRequired, launchDeleteConfirmation: PropTypes.func.isRequired, + onEdit: PropTypes.func, input: PropTypes.shape({ click: PropTypes.func.isRequired, }).isRequired, diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.test.tsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.test.tsx new file mode 100644 index 0000000000..b790aaeef0 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.test.tsx @@ -0,0 +1,39 @@ +import { + fireEvent, + initializeMocks, + render, + screen, +} from '@src/testUtils'; +import { TranscriptActionMenu } from './TranscriptMenu'; +import messages from './messages'; + +describe('TranscriptActionMenu', () => { + beforeEach(() => { + initializeMocks(); + }); + + const renderMenu = (props = {}) => + render( + , + ); + + it('calls onEdit with the language and closes the menu', () => { + const onEdit = jest.fn(); + renderMenu({ onEdit }); + fireEvent.click(screen.getByRole('button', { name: 'Actions dropdown' })); + fireEvent.click(screen.getByText(messages.editTranscript.defaultMessage)); + expect(onEdit).toHaveBeenCalledWith('ar'); + }); + + it('tolerates a missing onEdit handler', () => { + renderMenu(); + fireEvent.click(screen.getByRole('button', { name: 'Actions dropdown' })); + expect(() => fireEvent.click(screen.getByText(messages.editTranscript.defaultMessage))).not.toThrow(); + }); +}); diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.ts b/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.ts index d7d5e0d52e..e25303abe7 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.ts +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.ts @@ -1,6 +1,11 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + editTranscript: { + id: 'course-authoriong.video-uploads.file-info.transcript.editTranscript', + defaultMessage: 'Edit transcript', + description: 'Message Presented To user for action to edit transcript', + }, fileSizeError: { id: 'course-authoriong.video-uploads.file-info.transcript.error.fileSizeError', defaultMessage: 'Transcript file size exceeds the maximum. Please try again.', @@ -46,6 +51,21 @@ const messages = defineMessages({ defaultMessage: 'Delete this transcript?', description: 'Title for Warning which allows users to select next step in the process of deleting a transcript', }, + searchLanguagesPlaceholder: { + id: 'course-authoriong.video-uploads.file-info.transcript.searchLanguagesPlaceholder', + defaultMessage: 'Search languages', + description: 'Placeholder for the search box in the transcript language selector', + }, + noLanguageResults: { + id: 'course-authoriong.video-uploads.file-info.transcript.noLanguageResults', + defaultMessage: 'No results', + description: 'Shown in the transcript language selector when no languages match the search', + }, + deleteEmptyTranscriptLabel: { + id: 'course-authoriong.video-uploads.file-info.transcript.deleteEmptyTranscript', + defaultMessage: 'Delete empty transcript', + description: 'Accessible label for the button that removes an empty transcript row', + }, }); export default messages; diff --git a/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.scss b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.scss new file mode 100644 index 0000000000..cf282811b1 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.scss @@ -0,0 +1,218 @@ +.transcript-editor-modal { + &__video-container { + background: var(--pgn-color-black); + height: clamp(8rem, 40vh, 32rem); + } + + &__video { + display: block; + width: 100%; + max-height: 100%; + max-width: 100%; + background: var(--pgn-color-black); + object-fit: contain; + } + + &.pgn__modal { + width: 80vw; + max-width: 80vw; + height: 95vh; + max-height: 95vh; + overflow: hidden; + + .pgn__modal-header, + .pgn__modal-footer { + flex: 0 0 auto; + } + } + + .pgn__modal-body { + flex: 1 1 auto; + min-height: 0; + } + + .pgn__modal-body-content { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + padding-left: 0; + padding-right: 0; + padding-bottom: 0; + } + + &__cue-list { + min-height: 0; + overscroll-behavior: contain; + scroll-behavior: smooth; + } + + &__cue-wrapper { + border-left: 3px solid transparent; + border-radius: .375rem; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; + transition: border-color .15s ease; + + &.transcript-editor-modal__cue--active { + background: linear-gradient(90deg, rgb(0 117 180 / .08) 0%, transparent 100%); + border-left: 3px solid var(--pgn-color-info-500); + border-radius: .25rem; + box-shadow: 0 0 0 2px rgb(0 117 180 / .2); + + .transcript-editor-modal__cue-text.form-control { + border-color: var(--pgn-color-info-500) !important; + background: var(--pgn-color-white) !important; + color: var(--pgn-color-gray-900); + font-weight: 500; + } + + .transcript-editor-modal__time.form-control { + border-color: var(--pgn-color-info-500); + color: var(--pgn-color-gray-900); + } + } + } + + &__cue-row { + flex-wrap: nowrap; + align-items: center; + } + + &__cue-text-wrap { + flex: 1 1 auto; + min-width: 0; + position: relative; + + .pgn__form-control-feedback { + position: absolute; + top: 100%; + left: 0; + font-size: .75rem; + white-space: nowrap; + margin-top: .1rem; + } + } + + &__cue-text.form-control { + resize: none; + font-size: .875rem; + border-radius: .25rem; + padding: .25rem .4rem; + border: 1px solid transparent !important; + background: transparent !important; + box-shadow: none !important; + transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; + + &:hover { + border: 1px solid var(--pgn-color-gray-300) !important; + background: transparent !important; + box-shadow: none !important; + } + + &:focus { + border: 1px solid var(--pgn-color-info-500) !important; + background: var(--pgn-color-white) !important; + box-shadow: 0 0 0 2px rgb(0 117 180 / .2) !important; + outline: none !important; + } + + &.is-invalid { + border: 1px solid var(--pgn-color-danger-500) !important; + background: var(--pgn-color-danger-100) !important; + box-shadow: none !important; + } + } + + &__time-wrap { + flex: 0 0 auto; + width: 8rem; + + .pgn__form-control-decorator-group { + width: 100%; + } + } + + &__time.form-control { + width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: .65rem; + padding: .15rem .25rem; + height: auto; + border: 1px solid var(--pgn-color-gray-300); + border-radius: .25rem; + color: var(--pgn-color-gray-700); + background: var(--pgn-color-white); + + &:focus { + outline: none; + border-color: var(--pgn-color-info-500); + box-shadow: 0 0 0 2px rgb(0 117 180 / .2); + } + } + + &__time-sep { + color: var(--pgn-color-gray-500); + flex-shrink: 0; + } + + &__cue-row .btn-icon { + flex-shrink: 0; + } + + &__insert-divider { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 100%; + height: 0; + visibility: hidden; + transition: height .15s ease; + } + + &__insert-divider-line { + width: 100%; + position: absolute; + margin: 0; + border: 0; + border-top: 1px dashed var(--pgn-color-light-500); + z-index: 0; + } + + &__insert-btn.btn { + background: var(--pgn-color-white); + border: 1px dashed var(--pgn-color-gray-500); + color: var(--pgn-color-gray-700); + font-size: .75rem; + padding: .15rem .6rem; + border-radius: 999px; + position: relative; + z-index: 1; + } + + &__cue-wrapper:hover &__insert-divider { + height: 2rem; + visibility: visible; + } + + @media (width <= 767.98px) { + &.pgn__modal { + width: calc(100vw - 1rem); + max-width: calc(100vw - 1rem); + height: calc(100vh - .5rem); + max-height: calc(100vh - .5rem); + margin: 0; + } + } + + @media (width <= 991.98px) { + &__cue-text-wrap { + flex-basis: 100%; + } + + &__time-wrap { + flex: 1 1 7.5rem; + max-width: 9rem; + } + } +} diff --git a/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.test.tsx b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.test.tsx new file mode 100644 index 0000000000..859318454d --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.test.tsx @@ -0,0 +1,403 @@ +import type MockAdapter from 'axios-mock-adapter'; +import { logError } from '@edx/frontend-platform/logging'; + +import { + act, + fireEvent, + initializeMocks, + render, + screen, + waitFor, +} from '@src/testUtils'; +import TranscriptEditor from './TranscriptEditor'; +import messages from './messages'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const SRT = [ + '1', + '00:00:01,000 --> 00:00:02,000', + 'Hello', + '', + '2', + '00:00:03,000 --> 00:00:04,000', + 'World', +].join('\n'); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + language: 'en', + languages: { en: 'English' }, + video: { + id: 'vid-1', + displayName: 'My Video', + downloadLink: 'https://cdn.example.com/video.mp4', + }, + transcriptSettings: { + transcriptDownloadHandlerUrl: '/transcript_download/', + transcriptUploadHandlerUrl: '/transcript_upload/', + }, +}; + +let axiosMock: MockAdapter; +let playSpy: jest.SpyInstance; + +const readFileText = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + +// Lets an in-flight request settle and React process the result, so assertions run +// after any state update the response would have triggered. +const flushPendingResponses = () => + act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + +const renderEditor = (props = {}) => render(); + +const mockTranscript = (body: string = SRT) => { + axiosMock.onGet(/transcript_download/).reply(200, body); +}; + +beforeAll(() => { + // jsdom does not implement these; the component guards on their presence. + global.URL.createObjectURL = jest.fn(() => 'blob:mock-track'); + global.URL.revokeObjectURL = jest.fn(); + Element.prototype.scrollTo = jest.fn(); +}); + +beforeEach(() => { + ({ axiosMock } = initializeMocks()); + jest.clearAllMocks(); + playSpy = jest.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); +}); + +afterEach(() => { + playSpy.mockRestore(); + jest.useRealTimers(); +}); + +describe('TranscriptEditor', () => { + it('fetches the transcript for the video/language and renders its cues', async () => { + mockTranscript(); + renderEditor(); + + expect(await screen.findByDisplayValue('Hello')).toBeInTheDocument(); + expect(screen.getByDisplayValue('World')).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(axiosMock.history.get[0].url).toContain( + '/transcript_download/?edx_video_id=vid-1&language_code=en', + ); + }); + + it('shows a distinct error alert when the transcript fails to load', async () => { + axiosMock.onGet(/transcript_download/).reply(500); + renderEditor(); + + expect(await screen.findByText(messages.loadFailedLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.saveFailedLabel.defaultMessage)).not.toBeInTheDocument(); + }); + + it('gives cue fields accessible names', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + expect(screen.getByRole('textbox', { name: 'Cue 1 text' })).toHaveValue('Hello'); + expect(screen.getByRole('textbox', { name: 'Cue 1 start time' })).toHaveValue('00:00:01,000'); + expect(screen.getByRole('textbox', { name: 'Cue 2 end time' })).toHaveValue('00:00:04,000'); + }); + + it('preserves multi-line cue text through editing and saving', async () => { + mockTranscript(); + axiosMock.onPost(/transcript_upload/).reply(200); + renderEditor(); + + const cueText = await screen.findByRole('textbox', { name: 'Cue 1 text' }); + expect(cueText.tagName).toBe('TEXTAREA'); + fireEvent.change(cueText, { target: { value: 'first line\nsecond line' } }); + expect(screen.getByRole('textbox', { name: 'Cue 1 text' })).toHaveValue('first line\nsecond line'); + + fireEvent.click(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })); + await waitFor(() => expect(axiosMock.history.post).toHaveLength(1)); + const file = (axiosMock.history.post[0].data as FormData).get('file') as File; + expect(await readFileText(file)).toContain('00:00:01,000 --> 00:00:02,000\nfirst line\nsecond line'); + }); + + it('saves edited cues as a re-uploaded SRT file and shows the saved indicator', async () => { + mockTranscript(); + axiosMock.onPost(/transcript_upload/).reply(200); + renderEditor(); + + fireEvent.change(await screen.findByDisplayValue('Hello'), { target: { value: 'Hello edited' } }); + fireEvent.click(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })); + + await waitFor(() => expect(axiosMock.history.post).toHaveLength(1)); + const formData = axiosMock.history.post[0].data as FormData; + expect(formData.get('edx_video_id')).toBe('vid-1'); + expect(formData.get('language_code')).toBe('en'); + expect(formData.get('new_language_code')).toBe('en'); + const file = formData.get('file') as File; + expect(file.name).toBe('My Video-en.srt'); + expect(await readFileText(file)).toContain('Hello edited'); + + expect(await screen.findByText(messages.savedLabel.defaultMessage)).toBeInTheDocument(); + + // The "Saved" indicator clears itself after five seconds. + await waitFor( + () => expect(screen.queryByText(messages.savedLabel.defaultMessage)).not.toBeInTheDocument(), + { timeout: 6000 }, + ); + }, 15000); + + it('surfaces save failures and logs the error', async () => { + mockTranscript(); + axiosMock.onPost(/transcript_upload/).reply(500); + renderEditor(); + + fireEvent.change(await screen.findByDisplayValue('Hello'), { target: { value: 'Hello edited' } }); + fireEvent.click(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })); + + expect(await screen.findByText(messages.saveFailedLabel.defaultMessage)).toBeInTheDocument(); + expect(logError).toHaveBeenCalled(); + expect(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })).toBeEnabled(); + }); + + it('closes immediately when there are no unsaved changes', async () => { + const onClose = jest.fn(); + mockTranscript(); + renderEditor({ onClose }); + + await screen.findByDisplayValue('Hello'); + fireEvent.click(screen.getByRole('button', { name: messages.cancelButtonLabel.defaultMessage })); + + expect(onClose).toHaveBeenCalledWith(false); + expect(screen.queryByText(messages.unsavedModalTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('asks for confirmation before discarding unsaved changes', async () => { + const onClose = jest.fn(); + mockTranscript(); + renderEditor({ onClose }); + + fireEvent.change(await screen.findByDisplayValue('Hello'), { target: { value: 'Changed' } }); + fireEvent.click(screen.getByRole('button', { name: messages.cancelButtonLabel.defaultMessage })); + + expect(await screen.findByText(messages.unsavedModalDescription.defaultMessage)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: messages.keepEditingButtonLabel.defaultMessage })); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: messages.cancelButtonLabel.defaultMessage })); + fireEvent.click(await screen.findByRole('button', { name: messages.closeEditorButtonLabel.defaultMessage })); + expect(onClose).toHaveBeenCalledWith(false); + }); + + it('inserts a first cue into an empty transcript and flags empty text as invalid', async () => { + mockTranscript(''); + renderEditor(); + + fireEvent.click(await screen.findByRole('button', { name: messages.insertCueLabel.defaultMessage })); + + expect(screen.getByDisplayValue('00:00:00,000')).toBeInTheDocument(); + expect(screen.getByDisplayValue('00:00:02,000')).toBeInTheDocument(); + expect(screen.getByText(messages.invalidCueTextLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })).toBeDisabled(); + }); + + it('inserts a cue after an existing one and deletes cues', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + fireEvent.click(screen.getAllByRole('button', { name: messages.insertCueLabel.defaultMessage })[0]); + // New cue starts where the first cue ends, capped at the next cue's start. + expect(screen.getAllByDisplayValue('00:00:02,000')).toHaveLength(2); + expect(screen.getAllByDisplayValue('00:00:03,000')).toHaveLength(2); + + const deleteButtons = screen.getAllByRole('button', { name: messages.deleteCueLabel.defaultMessage }); + expect(deleteButtons).toHaveLength(3); + fireEvent.click(deleteButtons[1]); + expect(screen.getAllByRole('button', { name: messages.deleteCueLabel.defaultMessage })).toHaveLength(2); + }); + + it('normalizes timestamp edits on blur and flags invalid ranges', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + const start = screen.getByDisplayValue('00:00:03,000'); + fireEvent.change(start, { target: { value: '00:00:03.500' } }); + // Dots are normalized to commas while typing... + expect(screen.getByDisplayValue('00:00:03,500')).toBeInTheDocument(); + fireEvent.blur(screen.getByDisplayValue('00:00:03,500'), { target: { value: '00:00:03,500' } }); + expect(screen.getByDisplayValue('00:00:03,500')).toBeInTheDocument(); + + // ...and an end time before the start time is flagged, disabling Save. + const end = screen.getByDisplayValue('00:00:04,000'); + fireEvent.change(end, { target: { value: '00:00:01,000' } }); + expect(screen.getByText(messages.invalidTimestampLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })).toBeDisabled(); + }); + + it('seeks the preview on cue play, ignoring interrupted play() but logging other errors', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + const abortError = new Error('interrupted'); + abortError.name = 'AbortError'; + playSpy.mockRejectedValueOnce(abortError); + + const seekButtons = screen.getAllByRole('button', { name: messages.seekCueLabel.defaultMessage }); + fireEvent.click(seekButtons[0]); + await waitFor(() => expect(playSpy).toHaveBeenCalledTimes(1)); + expect(logError).not.toHaveBeenCalled(); + // Seeking into the first cue's range marks it active. + expect(document.querySelector('.transcript-editor-modal__cue--active')).not.toBeNull(); + + playSpy.mockRejectedValueOnce(new Error('autoplay blocked')); + fireEvent.click(seekButtons[1]); + await waitFor(() => expect(logError).toHaveBeenCalledTimes(1)); + }); + it('leaves invalid timestamps unchanged on blur, for both start and end fields', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + const start = screen.getByRole('textbox', { name: 'Cue 1 start time' }); + fireEvent.change(start, { target: { value: '00:00:01,5' } }); + fireEvent.blur(start, { target: { value: '00:00:01,5' } }); + expect(start).toHaveValue('00:00:01,5'); + + const end = screen.getByRole('textbox', { name: 'Cue 1 end time' }); + fireEvent.change(end, { target: { value: '00:00:02,5' } }); + fireEvent.blur(end, { target: { value: '00:00:02,5' } }); + expect(end).toHaveValue('00:00:02,5'); + }); + + it('updates the active cue from video timeupdate events', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + const video = document.querySelector('video') as HTMLVideoElement; + Object.defineProperty(video, 'currentTime', { value: 1.5, configurable: true }); + fireEvent.timeUpdate(video); + await waitFor(() => expect(document.querySelector('.transcript-editor-modal__cue--active')).not.toBeNull()); + }); + + it('does not close while a save is in progress', async () => { + mockTranscript(); + let finishSave: (value: [number]) => void = () => {}; + axiosMock.onPost(/transcript_upload/).reply(() => + new Promise<[number]>((resolve) => { + finishSave = resolve; + }) + ); + const onClose = jest.fn(); + renderEditor({ onClose }); + + fireEvent.change(await screen.findByDisplayValue('Hello'), { target: { value: 'Hello edited' } }); + fireEvent.click(screen.getByRole('button', { name: messages.saveButtonLabel.defaultMessage })); + await screen.findAllByText(messages.saveInProgressLabel.defaultMessage); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).not.toHaveBeenCalled(); + + finishSave([200]); + expect(await screen.findByText(messages.savedLabel.defaultMessage)).toBeInTheDocument(); + }); + + it('falls back to setting scrollTop when scrollTo is unavailable', async () => { + const original = Element.prototype.scrollTo; + Object.defineProperty(Element.prototype, 'scrollTo', { value: undefined, configurable: true, writable: true }); + try { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + fireEvent.click(screen.getAllByRole('button', { name: messages.seekCueLabel.defaultMessage })[0]); + await waitFor(() => expect(document.querySelector('.transcript-editor-modal__cue--active')).not.toBeNull()); + } finally { + Object.defineProperty(Element.prototype, 'scrollTo', { value: original, configurable: true, writable: true }); + } + }); + + // The fetch effect guards every state update behind an isMounted flag, so a response + // that lands after the editor closes must neither render nor report anything. + it.each([ + ['a transcript', () => mockTranscript()], + ['a load failure', () => { + axiosMock.onGet(/transcript_download/).reply(500); + }], + ])('ignores %s that arrives after the editor unmounted', async (_label, mockResponse) => { + mockResponse(); + const { unmount } = renderEditor(); + unmount(); + await waitFor(() => expect(axiosMock.history.get).toHaveLength(1)); + + await flushPendingResponses(); + + expect(screen.queryByDisplayValue('Hello')).not.toBeInTheDocument(); + expect(screen.queryByText(messages.loadFailedLabel.defaultMessage)).not.toBeInTheDocument(); + expect(logError).not.toHaveBeenCalled(); + }); + + it('closes the unsaved-changes dialog with Escape', async () => { + mockTranscript(); + renderEditor(); + fireEvent.change(await screen.findByDisplayValue('Hello'), { target: { value: 'Changed' } }); + fireEvent.click(screen.getByRole('button', { name: messages.cancelButtonLabel.defaultMessage })); + const description = await screen.findByText(messages.unsavedModalDescription.defaultMessage); + + fireEvent.keyDown(description, { key: 'Escape', code: 'Escape', keyCode: 27 }); + await waitFor(() => + expect(screen.queryByText(messages.unsavedModalDescription.defaultMessage)).not.toBeInTheDocument() + ); + }); + it('inserts after the last cue using a default two-second window', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + const insertButtons = screen.getAllByRole('button', { name: messages.insertCueLabel.defaultMessage }); + fireEvent.click(insertButtons[insertButtons.length - 1]); + // Last cue ends at 00:00:04,000, so the new cue spans 04,000 -> 06,000. + expect(screen.getAllByDisplayValue('00:00:04,000')).toHaveLength(2); + expect(screen.getByDisplayValue('00:00:06,000')).toBeInTheDocument(); + }); + + it('falls back to the language code when no display name is configured', async () => { + mockTranscript(); + renderEditor({ languages: {} }); + await screen.findByDisplayValue('Hello'); + expect(screen.getByText('en')).toBeInTheDocument(); + }); + + it('renders without fetching when no language is selected', () => { + renderEditor({ language: undefined, languages: undefined }); + expect(axiosMock.history.get).toHaveLength(0); + expect(screen.getByRole('button', { name: messages.insertCueLabel.defaultMessage })).toBeInTheDocument(); + }); + it('uses a plain two-second window when the next cue starts before the current one ends', async () => { + mockTranscript(); + renderEditor(); + await screen.findByDisplayValue('Hello'); + + // Push cue 1's end past cue 2's start (00:00:03,000), then insert after cue 1. + const end = screen.getByRole('textbox', { name: 'Cue 1 end time' }); + fireEvent.change(end, { target: { value: '00:00:05,000' } }); + fireEvent.click(screen.getAllByRole('button', { name: messages.insertCueLabel.defaultMessage })[0]); + expect(screen.getAllByDisplayValue('00:00:05,000')).toHaveLength(2); + expect(screen.getByDisplayValue('00:00:07,000')).toBeInTheDocument(); + }); +}); diff --git a/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.tsx b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.tsx new file mode 100644 index 0000000000..a908d0f863 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.tsx @@ -0,0 +1,660 @@ +import { + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { SyntheticEvent } from 'react'; +import { logError } from '@edx/frontend-platform/logging'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, + Button, + Form, + Icon, + IconButton, + ModalDialog, + Spinner, + Stack, +} from '@openedx/paragon'; +import { + Add, + CheckCircle, + DeleteOutline, + Info, + PlayArrow, +} from '@openedx/paragon/icons'; + +import { fetchTranscriptContent, uploadTranscript } from '@src/files-and-videos/videos-page/data/api'; +import { + formatTimestamp, + parseSrt, + parseTimestamp, + serializeSrt, +} from './srtUtils'; +import type { Cue } from './srtUtils'; +import messages from './messages'; +import './TranscriptEditor.scss'; + +type EditorCue = Cue & { id: string; }; +type SaveStatus = 'idle' | 'saving' | 'saved'; + +interface TranscriptEditorProps { + isOpen: boolean; + onClose: (submitted: boolean) => void; + video: { + id: string; + displayName: string; + downloadLink?: string | null; + }; + language?: string; + languages?: Record; + transcriptSettings: { + transcriptDownloadHandlerUrl: string; + transcriptUploadHandlerUrl: string; + }; +} + +const toVttTimestamp = (timestamp: string) => timestamp.replace(',', '.'); +const TIMESTAMP_REGEX = /^\d{2}:\d{2}:\d{2},\d{3}$/; +const hasInvalidCueText = (text = '') => { + const lines = text.split(/\r?\n/); + return text.trim().length === 0 || lines.some((line) => line.trim().length === 0); +}; + +const serializeVtt = (cues: Cue[]) => { + const body = cues + .map((cue, index) => + `${index + 1}\n${toVttTimestamp(cue.startTime)} --> ${toVttTimestamp(cue.endTime)}\n${cue.text}` + ) + .join('\n\n'); + + return `WEBVTT\n\n${body}`; +}; + +const TranscriptEditor = ({ + isOpen, + onClose, + video, + language = '', + languages = {}, + transcriptSettings, +}: TranscriptEditorProps) => { + const intl = useIntl(); + const videoRef = useRef(null); + const cueIdRef = useRef(0); + const cueListRef = useRef(null); + const cueWrapperRefs = useRef>({}); + const pendingFocusId = useRef(null); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [cues, setCues] = useState([]); + const [captionTrackUrl, setCaptionTrackUrl] = useState(''); + const [currentTime, setCurrentTime] = useState(0); + const [initialCuesSnapshot, setInitialCuesSnapshot] = useState('[]'); + const [isUnsavedModalOpen, setIsUnsavedModalOpen] = useState(false); + const [saveStatus, setSaveStatus] = useState('idle'); + + const attachCueIds = (nextCues: (Cue & { id?: string; })[]): EditorCue[] => + nextCues.map((cue) => ({ + ...cue, + id: cue.id ?? `cue-${cueIdRef.current++}`, + })); + + const serializeComparableCues = (nextCues: Cue[]) => + JSON.stringify(nextCues.map((cue) => ({ + startTime: cue.startTime, + endTime: cue.endTime, + text: cue.text, + }))); + + const hasUnsavedChanges = useMemo( + () => serializeComparableCues(cues) !== initialCuesSnapshot, + [cues, initialCuesSnapshot], + ); + + const hasInvalidCueTextInEditor = useMemo( + () => cues.some((cue) => hasInvalidCueText(cue.text)), + [cues], + ); + + const hasInvalidTimestamp = (cue: Cue) => ( + !TIMESTAMP_REGEX.test(cue.startTime) + || !TIMESTAMP_REGEX.test(cue.endTime) + || parseTimestamp(cue.endTime) <= parseTimestamp(cue.startTime) + ); + + const hasInvalidTimestampsInEditor = useMemo( + () => cues.some(hasInvalidTimestamp), + [cues], + ); + + const activeCueId = useMemo(() => { + const activeCue = cues.find((cue) => ( + TIMESTAMP_REGEX.test(cue.startTime) + && TIMESTAMP_REGEX.test(cue.endTime) + && currentTime >= parseTimestamp(cue.startTime) + && currentTime < parseTimestamp(cue.endTime) + )); + + return activeCue?.id || null; + }, [cues, currentTime]); + + useEffect(() => { + if (pendingFocusId.current) { + const wrapper = cueWrapperRefs.current[pendingFocusId.current]; + const el = wrapper?.querySelector('textarea'); + if (el) { + el.focus(); + pendingFocusId.current = null; + } + } + }, [cues]); + + useEffect(() => { + const cueList = cueListRef.current; + const activeCue = activeCueId ? cueWrapperRefs.current[activeCueId] : null; + + if (!cueList || !activeCue) { + return; + } + + const cueListRect = cueList.getBoundingClientRect(); + const activeCueRect = activeCue.getBoundingClientRect(); + const nextScrollTop = cueList.scrollTop + + activeCueRect.top + - cueListRect.top + - ((cueListRect.height - activeCueRect.height) / 2); + + if (typeof cueList.scrollTo === 'function') { + cueList.scrollTo({ + top: Math.max(0, nextScrollTop), + behavior: 'smooth', + }); + } else { + cueList.scrollTop = Math.max(0, nextScrollTop); + } + }, [activeCueId]); + + const inlineErrorMessage = saveError; + + useEffect(() => { + if (!isOpen || !language) { + return undefined; + } + + let isMounted = true; + setLoading(true); + setSaveError(''); + setIsUnsavedModalOpen(false); + setCurrentTime(0); + setSaveStatus('idle'); + + fetchTranscriptContent({ + videoId: video.id, + language, + apiUrl: transcriptSettings.transcriptDownloadHandlerUrl, + }) + .then((text: string) => { + if (!isMounted) { + return undefined; + } + const parsedCues = parseSrt(text); + setCues(attachCueIds(parsedCues)); + setInitialCuesSnapshot(serializeComparableCues(parsedCues)); + return undefined; + }) + .catch(() => { + if (!isMounted) { + return undefined; + } + setCues([]); + setInitialCuesSnapshot('[]'); + setSaveError(intl.formatMessage(messages.loadFailedLabel)); + return undefined; + }) + .finally(() => { + if (isMounted) { + setLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [ + isOpen, + language, + video.id, + transcriptSettings.transcriptDownloadHandlerUrl, + intl, + ]); + + const handleCueChange = (index: number, value: string) => { + setCues(prev => + prev.map((cue, cueIndex) => ( + cueIndex === index ? { ...cue, text: value } : cue + )) + ); + }; + + const handleCueTimeChange = (index: number, field: 'startTime' | 'endTime', value: string) => { + const normalized = value + .replace(/\./g, ',') + .replace(/[^\d:,]/g, '') + .slice(0, 12); + + setCues(prev => + prev.map((cue, cueIndex) => ( + cueIndex === index ? { ...cue, [field]: normalized } : cue + )) + ); + }; + + const handleCueTimeBlur = (index: number, field: 'startTime' | 'endTime', value: string) => { + const normalized = value.replace(/\./g, ',').trim(); + if (!TIMESTAMP_REGEX.test(normalized)) { + return; + } + + setCues(prev => + prev.map((cue, cueIndex) => ( + cueIndex === index + ? { ...cue, [field]: formatTimestamp(parseTimestamp(normalized)) } + : cue + )) + ); + }; + + const handleDeleteCue = (index: number) => { + setCues(prev => prev.filter((_, cueIndex) => cueIndex !== index)); + }; + + const handleInsertCueAfter = (index: number) => { + setCues((prev) => { + // index === -1 means insert as the very first cue (empty list) + if (index === -1) { + const newId = `cue-${cueIdRef.current++}`; + pendingFocusId.current = newId; + return [{ + id: newId, + startTime: formatTimestamp(0), + endTime: formatTimestamp(2), + text: '', + }]; + } + + const currentCue = prev[index]; + /* istanbul ignore if -- insert buttons always pass a valid index */ + if (!currentCue) { + return prev; + } + + const currentEnd = parseTimestamp(currentCue.endTime); + const nextStart = prev[index + 1] + ? parseTimestamp(prev[index + 1].startTime) + : currentEnd + 2; + + const insertedStart = currentEnd; + const insertedEnd = nextStart > currentEnd ? Math.min(currentEnd + 2, nextStart) : currentEnd + 2; + + const newId = `cue-${cueIdRef.current++}`; + pendingFocusId.current = newId; + const newCue = { + id: newId, + startTime: formatTimestamp(insertedStart), + endTime: formatTimestamp(insertedEnd), + text: '', + }; + + const updated = [...prev]; + updated.splice(index + 1, 0, newCue); + return updated; + }); + }; + + const seekTo = (timeCode: string) => { + if (videoRef.current) { + videoRef.current.currentTime = parseTimestamp(timeCode); + const playPromise = videoRef.current.play(); + if (playPromise !== undefined) { + playPromise.catch((error: Error) => { + if (error.name !== 'AbortError') { + logError(error); + } + }); + } + setCurrentTime(parseTimestamp(timeCode)); + } + }; + + const handleVideoTimeUpdate = (event: SyntheticEvent) => { + setCurrentTime(event.currentTarget.currentTime); + }; + + useEffect(() => { + if (!cues.length) { + setCaptionTrackUrl(''); + return undefined; + } + + const vttBlob = new Blob([serializeVtt(cues)], { type: 'text/vtt' }); + const nextUrl = URL.createObjectURL(vttBlob); + setCaptionTrackUrl(nextUrl); + + return () => { + URL.revokeObjectURL(nextUrl); + }; + }, [cues]); + + useEffect(() => { + if (saveStatus !== 'saved') { + return undefined; + } + + const timerId = setTimeout(() => { + setSaveStatus('idle'); + }, 5000); + + return () => clearTimeout(timerId); + }, [saveStatus]); + + const handleSave = async () => { + const hasInvalidCueTextValue = cues.some((cue) => hasInvalidCueText(cue.text)); + /* istanbul ignore if -- the save button is disabled in this state */ + if (hasInvalidCueTextValue) { + return; + } + + setSaving(true); + setSaveStatus('saving'); + setSaveError(''); + + try { + const fileName = `${video.displayName}-${language}.srt`; + const file = new File([serializeSrt(cues)], fileName, { type: 'text/plain' }); + + await uploadTranscript({ + videoId: video.id, + language, + newLanguage: language, + apiUrl: transcriptSettings.transcriptUploadHandlerUrl, + file, + }); + + setInitialCuesSnapshot(serializeComparableCues(cues)); + setSaveStatus('saved'); + } catch (error) { + logError(error as Error); + setSaveStatus('idle'); + setSaveError(intl.formatMessage(messages.saveFailedLabel)); + } finally { + setSaving(false); + } + }; + + const handleRequestClose = () => { + if (saving) { + return; + } + + if (hasUnsavedChanges) { + setIsUnsavedModalOpen(true); + return; + } + + onClose(false); + }; + + return ( + + + +
+
+

{video.displayName}

+
{languages?.[language] || language}
+
+ {saveStatus === 'saving' && ( +
+ + {intl.formatMessage(messages.saveInProgressLabel)} +
+ )} + {saveStatus === 'saved' && ( +
+ + {intl.formatMessage(messages.savedLabel)} +
+ )} +
+
+
+ + + {inlineErrorMessage && ( + + {inlineErrorMessage} + + )} + {loading ? + ( +
+ +
+ ) : + ( + <> + {video.downloadLink && ( +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + +
+ )} + +
+ {cues.length === 0 && ( +
+ +
+ )} + {cues.map((cue, index) => { + const isActive = activeCueId === cue.id; + const hasCueTextError = hasInvalidCueText(cue.text); + return ( + { + if (el) { + cueWrapperRefs.current[cue.id] = el; + } else { + delete cueWrapperRefs.current[cue.id]; + } + }} + gap={2} + className={`px-3 py-2 transcript-editor-modal__cue-wrapper${ + isActive ? ' transcript-editor-modal__cue--active' : '' + }`} + > + +
+ ) => + handleCueChange(index, e.target.value)} + className="transcript-editor-modal__cue-text" + isInvalid={hasCueTextError} + /> + {hasCueTextError && ( + + {intl.formatMessage(messages.invalidCueTextLabel)} + + )} +
+
+ ) => + handleCueTimeChange(index, 'startTime', e.target.value)} + onBlur={(e: React.FocusEvent) => + handleCueTimeBlur(index, 'startTime', e.target.value)} + /> +
+ → +
+ ) => + handleCueTimeChange(index, 'endTime', e.target.value)} + onBlur={(e: React.FocusEvent) => + handleCueTimeBlur(index, 'endTime', e.target.value)} + /> +
+ seekTo(cue.startTime)} + /> + handleDeleteCue(index)} + /> +
+ {hasInvalidTimestamp(cue) && ( +
+ {intl.formatMessage(messages.invalidTimestampLabel)} +
+ )} +
+
+ +
+
+ ); + })} +
+ + )} +
+ + + + + + + {isUnsavedModalOpen && ( + setIsUnsavedModalOpen(false)} + > + + {intl.formatMessage(messages.unsavedModalTitle)} + + +

{intl.formatMessage(messages.unsavedModalDescription)}

+
+ + + + +
+ )} +
+ ); +}; + +export default TranscriptEditor; diff --git a/src/files-and-videos/videos-page/transcript-editor/index.ts b/src/files-and-videos/videos-page/transcript-editor/index.ts new file mode 100644 index 0000000000..678c3fcb31 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/index.ts @@ -0,0 +1 @@ +export { default } from './TranscriptEditor'; diff --git a/src/files-and-videos/videos-page/transcript-editor/messages.ts b/src/files-and-videos/videos-page/transcript-editor/messages.ts new file mode 100644 index 0000000000..f996064910 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/messages.ts @@ -0,0 +1,101 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + loadingLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.loading', + defaultMessage: 'Loading transcript...', + description: 'Loading state for transcript editor', + }, + saveButtonLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.save', + defaultMessage: 'Save', + description: 'Save button label for transcript editor', + }, + cancelButtonLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button label for transcript editor', + }, + saveFailedLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.saveFailed', + defaultMessage: 'Unable to save transcript, please try again.', + description: 'Error shown when transcript save fails', + }, + saveInProgressLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.saving', + defaultMessage: 'Saving...', + description: 'Label shown while transcript save is in progress', + }, + savedLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.saved', + defaultMessage: 'Saved', + description: 'Label shown when transcript save is successful', + }, + insertCueLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.insertCue', + defaultMessage: 'Insert cue here', + description: 'Label for inserting a new cue between transcript cues', + }, + deleteCueLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.deleteCue', + defaultMessage: 'Delete cue', + description: 'Accessible label for deleting a transcript cue', + }, + seekCueLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.seekCue', + defaultMessage: 'Seek to cue time', + description: 'Accessible label for seek button in cue row', + }, + invalidTimestampLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.invalidTimestamp', + defaultMessage: 'Please use valid cue times in HH:MM:SS,mmm format.', + description: 'Error shown when one or more cue timestamps are invalid', + }, + unsavedModalTitle: { + id: 'course-authoring.video-uploads.transcriptEditor.unsavedModalTitle', + defaultMessage: 'Unsaved changes', + description: 'Title for unsaved changes warning modal', + }, + unsavedModalDescription: { + id: 'course-authoring.video-uploads.transcriptEditor.unsavedModalDescription', + defaultMessage: 'Are you sure you want to leave the editor? All unsaved changes will be lost.', + description: 'Description for unsaved changes warning modal', + }, + keepEditingButtonLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.keepEditing', + defaultMessage: 'Keep editing', + description: 'Button label to keep editing transcript changes', + }, + closeEditorButtonLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.closeEditor', + defaultMessage: 'Leave editor', + description: 'Button label to close transcript editor and discard changes', + }, + invalidCueTextLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.invalidCueText', + defaultMessage: 'Cue text cannot be empty.', + description: 'Error shown when one or more cue texts are empty', + }, + loadFailedLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.loadFailed', + defaultMessage: 'Unable to load transcript, please try again.', + description: 'Error shown when the transcript could not be downloaded into the editor', + }, + cueTextLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.cueTextLabel', + defaultMessage: 'Cue {index} text', + description: 'Accessible label for the text field of a transcript cue', + }, + cueStartTimeLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.cueStartTimeLabel', + defaultMessage: 'Cue {index} start time', + description: 'Accessible label for the start timestamp field of a transcript cue', + }, + cueEndTimeLabel: { + id: 'course-authoring.video-uploads.transcriptEditor.cueEndTimeLabel', + defaultMessage: 'Cue {index} end time', + description: 'Accessible label for the end timestamp field of a transcript cue', + }, +}); + +export default messages; diff --git a/src/files-and-videos/videos-page/transcript-editor/srtUtils.test.ts b/src/files-and-videos/videos-page/transcript-editor/srtUtils.test.ts new file mode 100644 index 0000000000..039cbdec5a --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/srtUtils.test.ts @@ -0,0 +1,99 @@ +import { + formatTimestamp, + isValidSrt, + parseSrt, + parseTimestamp, + serializeSrt, +} from './srtUtils'; + +const SRT = [ + '1', + '00:00:01,000 --> 00:00:02,500', + 'Hello there', + '', + '2', + '00:00:03,000 --> 00:00:04,000', + 'Second cue', +].join('\n'); + +describe('parseSrt', () => { + it('parses indexed cue blocks', () => { + expect(parseSrt(SRT)).toEqual([ + { startTime: '00:00:01,000', endTime: '00:00:02,500', text: 'Hello there' }, + { startTime: '00:00:03,000', endTime: '00:00:04,000', text: 'Second cue' }, + ]); + }); + + it('parses cue blocks without index lines', () => { + const noIndex = '00:00:01,000 --> 00:00:02,000\nHi\n\n00:00:03,000 --> 00:00:04,000\nBye'; + expect(parseSrt(noIndex)).toEqual([ + { startTime: '00:00:01,000', endTime: '00:00:02,000', text: 'Hi' }, + { startTime: '00:00:03,000', endTime: '00:00:04,000', text: 'Bye' }, + ]); + }); + + it('keeps multi-line cue text together', () => { + const multi = '1\n00:00:01,000 --> 00:00:02,000\nline one\nline two'; + expect(parseSrt(multi)[0].text).toBe('line one\nline two'); + }); + + it('drops blocks with a malformed timestamp line', () => { + const partiallyBroken = `${SRT}\n\n3\nnot a timestamp\nBroken`; + expect(parseSrt(partiallyBroken)).toHaveLength(2); + }); + + it('drops blocks that have fewer than two lines', () => { + expect(parseSrt(`${SRT}\n\nlonely line`)).toHaveLength(2); + }); + + it('returns an empty list for empty or non-string input', () => { + expect(parseSrt('')).toEqual([]); + expect(parseSrt(null)).toEqual([]); + expect(parseSrt(undefined)).toEqual([]); + }); +}); + +describe('serializeSrt', () => { + it('re-numbers cues and round-trips through parseSrt', () => { + const cues = parseSrt(SRT); + const serialized = serializeSrt(cues); + expect(serialized.startsWith('1\n00:00:01,000 --> 00:00:02,500\nHello there')).toBe(true); + expect(parseSrt(serialized)).toEqual(cues); + }); +}); + +describe('parseTimestamp / formatTimestamp', () => { + it('parses SRT timestamps into seconds', () => { + expect(parseTimestamp('01:02:03,450')).toBeCloseTo(3723.45); + expect(parseTimestamp('00:00:00,000')).toBe(0); + expect(parseTimestamp('00:01:00')).toBe(60); + }); + + it('formats seconds into SRT timestamps and round-trips', () => { + expect(formatTimestamp(3723.45)).toBe('01:02:03,450'); + expect(parseTimestamp(formatTimestamp(59.999))).toBeCloseTo(59.999); + }); + + it('clamps negative and non-numeric input to zero', () => { + expect(formatTimestamp(-5)).toBe('00:00:00,000'); + expect(formatTimestamp(NaN)).toBe('00:00:00,000'); + }); +}); + +describe('isValidSrt', () => { + it('accepts well-formed content, with or without index lines', () => { + expect(isValidSrt(SRT)).toBe(true); + expect(isValidSrt('00:00:01,000 --> 00:00:02,000\nHi')).toBe(true); + }); + + it('treats empty or blank content as a valid empty transcript', () => { + expect(isValidSrt('')).toBe(true); + expect(isValidSrt(null)).toBe(true); + expect(isValidSrt(' \n \n ')).toBe(true); + }); + + it('rejects content where any block is malformed', () => { + expect(isValidSrt(`${SRT}\n\n3\nnot a timestamp\nBroken`)).toBe(false); + expect(isValidSrt('just some text')).toBe(false); + }); +}); diff --git a/src/files-and-videos/videos-page/transcript-editor/srtUtils.ts b/src/files-and-videos/videos-page/transcript-editor/srtUtils.ts new file mode 100644 index 0000000000..dfacd9a5d9 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-editor/srtUtils.ts @@ -0,0 +1,90 @@ +export interface Cue { + startTime: string; + endTime: string; + text: string; +} + +export const parseSrt = (text: string | null | undefined): Cue[] => { + if (!text || typeof text !== 'string') { + return []; + } + + return text + .trim() + .split(/\n\s*\n/) + .map((block) => block.split('\n')) + .map((lines): Cue | null => { + if (lines.length < 2) { + return null; + } + + const hasIndex = /^\d+$/.test(lines[0].trim()); + const timeLine = hasIndex ? lines[1] : lines[0]; + const textLines = hasIndex ? lines.slice(2) : lines.slice(1); + + const match = timeLine.match(/(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})/); + if (!match) { + return null; + } + + return { + startTime: match[1], + endTime: match[2], + text: textLines.join('\n').trim(), + }; + }) + .filter((cue): cue is Cue => cue !== null); +}; + +export const serializeSrt = (cues: Cue[]): string => + cues + .map((cue, index) => `${index + 1}\n${cue.startTime} --> ${cue.endTime}\n${cue.text}`) + .join('\n\n'); + +export const parseTimestamp = (timestamp: string): number => { + const [hms, ms] = timestamp.split(','); + const [hours, minutes, seconds] = hms.split(':').map(Number); + return (hours * 3600) + (minutes * 60) + seconds + (Number(ms || 0) / 1000); +}; + +export const formatTimestamp = (totalSeconds: number): string => { + const safeSeconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = Math.floor(safeSeconds % 60); + const milliseconds = Math.round((safeSeconds - Math.floor(safeSeconds)) * 1000); + + const pad2 = (value: number) => String(value).padStart(2, '0'); + const pad3 = (value: number) => String(value).padStart(3, '0'); + + return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)},${pad3(milliseconds)}`; +}; + +/** + * Strictly validates SRT content. + * Returns true for empty/blank files (valid empty transcript). + * Returns false if the file has cue blocks where ANY block has a malformed + * timestamp line (partial corruption is not allowed). + */ +export const isValidSrt = (text: string | null | undefined): boolean => { + if (!text || typeof text !== 'string') { + return true; + } + + const blocks = text.trim().split(/\n\s*\n/).filter((b) => b.trim().length > 0); + if (blocks.length === 0) { + return true; + } + + const TIMESTAMP_LINE = /^\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/; + + return blocks.every((block) => { + const lines = block.trim().split('\n').map((l) => l.trim()); + if (lines.length < 2) { + return false; + } + const hasIndex = /^\d+$/.test(lines[0]); + const timeLine = hasIndex ? lines[1] : lines[0]; + return TIMESTAMP_LINE.test(timeLine); + }); +}; diff --git a/src/generic/ExpandCollapseIconButton.test.tsx b/src/generic/ExpandCollapseIconButton.test.tsx new file mode 100644 index 0000000000..4a4776647e --- /dev/null +++ b/src/generic/ExpandCollapseIconButton.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import ExpandCollapseIconButton from './ExpandCollapseIconButton'; + +describe('', () => { + it('renders an invisible, disabled placeholder when canExpand is false, regardless of isExpanded, and ignores clicks', () => { + const onToggle = jest.fn(); + render( + , + ); + + const button = screen.getByRole('button', { hidden: true }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-hidden', 'true'); + expect(button).toHaveClass('invisible'); + + fireEvent.click(button); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it('shows the expand-action label and aria-expanded="false" while collapsed, and calls onToggle when clicked', () => { + const onToggle = jest.fn(); + render( + , + ); + + // The accessible name comes from `aria-label`, which this component + // sets to `expandLabel` while collapsed and `collapseLabel` while + // expanded. + const button = screen.getByRole('button', { name: 'Expand' }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(button); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('shows the collapse-action label and aria-expanded="true" while expanded', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Collapse' }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/generic/ExpandCollapseIconButton.tsx b/src/generic/ExpandCollapseIconButton.tsx new file mode 100644 index 0000000000..34e03cb4a6 --- /dev/null +++ b/src/generic/ExpandCollapseIconButton.tsx @@ -0,0 +1,66 @@ +import { IconButton } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; + +interface ExpandCollapseIconButtonProps { + canExpand: boolean; + isExpanded: boolean; + onToggle?: () => void; + /** Label for the "expand" action - shown while the row is collapsed. */ + expandLabel: string; + /** Label for the "collapse" action - shown while the row is expanded. */ + collapseLabel: string; + className?: string; +} + +/** ExpandCollapseIconButton + * Shared disclosure button for one row of a tree or nested list, toggling + * between an expanded and a collapsed state. + * + * Takes plain booleans/callback rather than a TanStack `Row`, so it works + * equally for a `
    /
  • `-rendered tree and for a table row wrapping one. + * When the row has no children (`canExpand` is false), this renders an + * invisible, disabled placeholder icon instead of nothing, so every row's + * label still starts at the same horizontal position. + * + * `expandLabel`/`collapseLabel` are already-formatted strings, not + * message objects, so this component stays free of any particular i18n + * message set - each caller owns its own wording. + */ +const ExpandCollapseIconButton = ({ + canExpand, + isExpanded, + onToggle, + expandLabel, + collapseLabel, + className, +}: ExpandCollapseIconButtonProps) => { + if (!canExpand) { + return ( + + ); + } + + const label = isExpanded ? collapseLabel : expandLabel; + + return ( + + ); +}; + +export default ExpandCollapseIconButton; diff --git a/src/generic/configure-modal/ConfigureModal.test.tsx b/src/generic/configure-modal/ConfigureModal.test.tsx index 941f4350ad..e002202d5b 100644 --- a/src/generic/configure-modal/ConfigureModal.test.tsx +++ b/src/generic/configure-modal/ConfigureModal.test.tsx @@ -1,10 +1,14 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AccessManagedXBlockDataTypes } from '@src/data/types'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import * as authzApi from '@src/authz/data/api'; import initializeStore from '../../store'; import ConfigureModal from './ConfigureModal'; import { @@ -16,6 +20,7 @@ import { import messages from './messages'; let store; +let queryClient; const mockPathname = '/foo-bar'; jest.mock('react-redux', () => ({ @@ -33,33 +38,54 @@ jest.mock('react-router-dom', () => ({ const onCloseMock = jest.fn(); const onConfigureSubmitMock = jest.fn(); +/** + * Common setup. Note `mockWaffleFlags()` leaves authz disabled, so + * `useCourseUserPermissions` synchronously falls back to granting every permission + * (`canEditCourseContent === true`). Tests that need the denied path re-enable authz + * and mock `validateUserPermissions` explicitly. + */ +const setupMocks = () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockWaffleFlags(); +}; + +// AppProvider supplies the Router that CourseAuthoringProvider (useNavigate) needs; the +// QueryClientProvider is required for the course-details/permissions queries it runs. +const withProviders = (ui: React.ReactNode) => ( + + + + + {ui} + + + + +); + const renderComponent = () => - render( - - - - , - , - ); + render(withProviders( + , + )); describe(' for Section', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + setupMocks(); }); it('renders ConfigureModal component correctly', () => { @@ -85,33 +111,20 @@ describe(' for Section', () => { }); const renderSubsectionComponent = (props?: object) => - render( - - - - , - , - ); + render(withProviders( + , + )); describe(' for Subsection', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + setupMocks(); }); it('renders subsection ConfigureModal component correctly', () => { @@ -176,32 +189,19 @@ describe(' for Subsection', () => { }); const renderUnitComponent = (props?: object) => - render( - - - - , - , - ); + render(withProviders( + , + )); describe(' for Unit', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + setupMocks(); }); it('renders unit ConfigureModal component correctly', async () => { @@ -246,33 +246,20 @@ describe(' for Unit', () => { }); const renderXBlockComponent = (props?: object) => - render( - - - - , - , - ); + render(withProviders( + , + )); describe(' for XBlock', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + setupMocks(); }); it('renders unit ConfigureModal component correctly', async () => { @@ -315,36 +302,78 @@ describe(' for XBlock', () => { }); }); -describe(' with enableTimedExams prop', () => { +describe(' permission gating', () => { + let validateUserPermissionsMock; + beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); + setupMocks(); + // Enable authz so `canEditCourseContent` is driven by the mocked permissions API. + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions'); + }); + + it('disables the unit visibility, access and discussion controls when the user cannot edit', async () => { + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent: false }); + const { getByTestId, getByRole } = renderUnitComponent(); + + await waitFor(() => expect(getByTestId('unit-visibility-checkbox')).toBeDisabled()); + expect(getByTestId('group-type-select')).toBeDisabled(); + expect( + getByRole('checkbox', { name: messages.discussionEnabledCheckbox.defaultMessage }), + ).toBeDisabled(); + }); + + it('enables the unit visibility and access controls when the user can edit', async () => { + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent: true }); + const { getByTestId } = renderUnitComponent(); + + await waitFor(() => expect(getByTestId('unit-visibility-checkbox')).not.toBeDisabled()); + expect(getByTestId('group-type-select')).not.toBeDisabled(); + }); + + it('disables the group type select for an xblock when the user cannot edit', async () => { + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent: false }); + const { getByTestId } = renderXBlockComponent(); - store = initializeStore(); + await waitFor(() => expect(getByTestId('group-type-select')).toBeDisabled()); + }); + + it('hides the footer Cancel and Save buttons when the user cannot edit', async () => { + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent: false }); + const { getByTestId, queryByRole } = renderUnitComponent(); + + // Wait for the modal body to render (the visibility checkbox is always present), then + // confirm the permission-gated footer actions are absent. + await waitFor(() => expect(getByTestId('unit-visibility-checkbox')).toBeInTheDocument()); + expect(queryByRole('button', { name: messages.cancelButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: messages.saveButton.defaultMessage })).not.toBeInTheDocument(); + }); + + it('shows the footer Cancel and Save buttons when the user can edit', async () => { + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent: true }); + const { findByRole, getByRole } = renderUnitComponent(); + + expect(await findByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + }); +}); + +describe(' with enableTimedExams prop', () => { + beforeEach(() => { + setupMocks(); }); const renderWithTimedExamsProps = (enableTimedExams = true) => - render( - - - - - , - , - ); + render(withProviders( + , + )); it('passes enableTimedExams=true to AdvancedTab', async () => { const user = userEvent.setup(); @@ -412,20 +441,15 @@ describe(' with enableTimedExams prop', () => { it('defaults enableTimedExams to false when not provided', async () => { const user = userEvent.setup(); - const { getByRole } = render( - - - - - , - , - ); + const { getByRole } = render(withProviders( + , + )); const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage, diff --git a/src/generic/configure-modal/ConfigureModal.tsx b/src/generic/configure-modal/ConfigureModal.tsx index d8c46b8003..04bbdb5e64 100644 --- a/src/generic/configure-modal/ConfigureModal.tsx +++ b/src/generic/configure-modal/ConfigureModal.tsx @@ -19,6 +19,7 @@ import VisibilityTab from './VisibilityTab'; import AdvancedTab from './AdvancedTab'; import { UnitTab } from './UnitTab'; import { PUBLISH_TYPES } from '@src/course-unit/constants'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props { isOpen: boolean; @@ -44,6 +45,7 @@ const ConfigureModal = ({ isOverflowVisible = false, }: Props) => { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); if (!currentItemData) { return null; @@ -337,19 +339,21 @@ const ConfigureModal = ({ {renderModalBody(values, setFieldValue)} - - - - {intl.formatMessage(messages.cancelButton)} - - - - + {canEditCourseContent && ( + + + + {intl.formatMessage(messages.cancelButton)} + + + + + )} )} diff --git a/src/generic/configure-modal/UnitTab.tsx b/src/generic/configure-modal/UnitTab.tsx index 852e9c9b99..83b01004ea 100644 --- a/src/generic/configure-modal/UnitTab.tsx +++ b/src/generic/configure-modal/UnitTab.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type UserPartitionInfo = { selectablePartitions: { @@ -45,16 +46,19 @@ export const DiscussionEditComponent = ({ }: { discussionEnabled: boolean; handleDiscussionChange: (e: any) => void; -}) => ( - <> - - - -

    - -

    - -); +}) => { + const { canEditCourseContent } = useCourseAuthoringContext(); + return ( + <> + + + +

    + +

    + + ); +}; export interface AccessEditComponentProps { selectedPartitionIndex?: number; @@ -70,6 +74,7 @@ export const AccessEditComponent = ({ selectedGroups, }: AccessEditComponentProps) => { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); const checkIsDeletedGroup = (group) => { const isGroupSelected = selectedGroups.includes(group.id.toString()); @@ -92,6 +97,7 @@ export const AccessEditComponent = ({ value={selectedPartitionIndex} onChange={handleSelect} data-testid="group-type-select" + disabled={!canEditCourseContent} >
  • `: + * a row (disclosure button + name + Competency ID badge) and, when expanded + * and the node has children, a nested `
      ` of more `CompetencyTreeItem`s. + * + * Indentation comes from the `
        ` nesting itself (see `CompetencyTree.scss`), + * not from a depth number computed here. Whether a node's `
      • ` reads + * visually as a grey "group" envelope (wrapping its own header row and all + * of its rendered children) or as a plain white leaf "pill" is likewise + * driven by this same `hasChildren` check, via the `competency-group` class + * below - not a depth number or a CSS structural-position selector, so it + * holds recursively at any depth. + */ +const CompetencyTreeItem = ({ node, expandedIds, onToggle }: CompetencyTreeItemProps) => { + const intl = useIntl(); + const nodeId = String(node.id); + const hasChildren = !!node.subRows?.length; + const isExpanded = expandedIds.has(nodeId); + + // `externalId` can be `undefined`, `null`, or `''` depending on the data + // path (the tag-list tree's JSON deep-copy round-trip drops `undefined` + // keys but keeps `null`, and newly-created tags elsewhere are seeded with + // `externalId: ''`), so use a truthiness check rather than `!== null`. + const { externalId } = node; + + return ( +
      • +
        + onToggle(nodeId)} + expandLabel={intl.formatMessage(messages.expandRowButtonLabel)} + collapseLabel={intl.formatMessage(messages.collapseRowButtonLabel)} + /> + {node.value} + {externalId ? + ( + <> + + {intl.formatMessage(messages.competencyIdAccessibleLabel, { externalId })} + + + + ) : + null} +
        + {isExpanded && hasChildren && ( +
          + {node.subRows!.map((child) => ( + + ))} +
        + )} +
      • + ); +}; + +export default CompetencyTreeItem; diff --git a/src/taxonomy/competency-management/index.ts b/src/taxonomy/competency-management/index.ts new file mode 100644 index 0000000000..3819d813ab --- /dev/null +++ b/src/taxonomy/competency-management/index.ts @@ -0,0 +1 @@ +export { default as CompetencyManagementPage } from './CompetencyManagementPage'; diff --git a/src/taxonomy/competency-management/messages.ts b/src/taxonomy/competency-management/messages.ts new file mode 100644 index 0000000000..66be0b3a1e --- /dev/null +++ b/src/taxonomy/competency-management/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headerTitle: { + id: 'course-authoring.competency-management.header.title', + defaultMessage: 'Competencies', + description: 'Document title suffix for the Competency Management page.', + }, + expandAll: { + id: 'course-authoring.competency-management.expand-all', + defaultMessage: 'Expand All', + description: 'Label for the button that expands every node of the competency tree.', + }, + collapseAll: { + id: 'course-authoring.competency-management.collapse-all', + defaultMessage: 'Collapse All', + description: 'Label for the button that collapses every node of the competency tree.', + }, + expandRowButtonLabel: { + id: 'course-authoring.competency-management.expand-row.button-label', + defaultMessage: 'Expand', + description: 'Accessible label for the disclosure button on a collapsed competency tree row.', + }, + collapseRowButtonLabel: { + id: 'course-authoring.competency-management.collapse-row.button-label', + defaultMessage: 'Collapse', + description: 'Accessible label for the disclosure button on an expanded competency tree row.', + }, + competencyIdAccessibleLabel: { + id: 'course-authoring.competency-management.competency-id.accessible-label', + defaultMessage: 'Competency ID: {externalId}', + description: 'Accessible name for the Competency ID badge, read by screen readers since there is no column header to associate it with.', + }, +}); + +export default messages; diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index fd40a10097..7a7d1ad565 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -192,7 +192,7 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => * Use the list of tags in a taxonomy. */ export const useTagListData = (taxonomyId: number, options: QueryOptions) => { - const { pageIndex, pageSize, enabled = true, disablePagination = false } = options; // eslint-disable-line + const { pageIndex, pageSize = null, 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. diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index 5a91cfdbf8..6bed6fdd1c 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -31,7 +31,9 @@ export interface TaxonomyListData { export interface QueryOptions { pageIndex: number; - pageSize: number; + // Unused (and thus omitted from the request URL) when `disablePagination` + // is true; see `apiUrls.tagList` in `./api.ts`. + pageSize?: number; enabled?: boolean; disablePagination?: boolean; } diff --git a/src/taxonomy/index.ts b/src/taxonomy/index.ts index 1a62229c51..495cc08a7f 100644 --- a/src/taxonomy/index.ts +++ b/src/taxonomy/index.ts @@ -1,3 +1,4 @@ +export { CompetencyManagementPage } from './competency-management'; export { TaxonomyDetailPage } from './taxonomy-detail'; export { TaxonomyLayout } from './TaxonomyLayout'; export { TaxonomyListPage } from './TaxonomyListPage'; diff --git a/src/taxonomy/tag-list/OptionalExpandLink.tsx b/src/taxonomy/tag-list/OptionalExpandLink.tsx index e42c198dcb..1a08cf81ef 100644 --- a/src/taxonomy/tag-list/OptionalExpandLink.tsx +++ b/src/taxonomy/tag-list/OptionalExpandLink.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { IconButton } from '@openedx/paragon'; -import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import { Row } from '@tanstack/react-table'; import { useIntl } from '@edx/frontend-platform/i18n'; +import ExpandCollapseIconButton from '@src/generic/ExpandCollapseIconButton'; import type { TreeRowData } from '@src/taxonomy/tree-table/types'; import messages from './messages'; @@ -21,34 +20,15 @@ interface OptionalExpandLinkProps { const OptionalExpandLink = ({ row, forceHide = false }: OptionalExpandLinkProps) => { const intl = useIntl(); const canExpand = !!row?.getCanExpand() && !forceHide; - - if (!canExpand) { - return ( - - ); - } - const isExpanded = !!row?.getIsExpanded(); - const buttonLabel = isExpanded - ? intl.formatMessage(messages.hideSubtagsButtonLabel) - : intl.formatMessage(messages.showSubtagsButtonLabel); return ( - );