diff --git a/.stylelintrc.json b/.stylelintrc.json index 43148337a9..7c00badf8b 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -8,7 +8,7 @@ "ignoreUnits": ["\\.5"] }], "property-no-vendor-prefix": [true, { - "ignoreProperties": ["animation", "filter"] + "ignoreProperties": ["animation", "filter", "transform", "transition"] }], "value-no-vendor-prefix": [true, { "ignoreValues": ["fill-available"] diff --git a/package-lock.json b/package-lock.json index a63345a8d5..922f91bc9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ "@edx/frontend-lib-content-components": "1.175.0", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^20.45.4", - "@fortawesome/fontawesome-svg-core": "1.2.28", - "@fortawesome/free-brands-svg-icons": "5.11.2", - "@fortawesome/free-regular-svg-icons": "5.11.2", - "@fortawesome/free-solid-svg-icons": "5.11.2", + "@fortawesome/fontawesome-svg-core": "1.2.36", + "@fortawesome/free-brands-svg-icons": "5.15.4", + "@fortawesome/free-regular-svg-icons": "5.15.4", + "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", "@reduxjs/toolkit": "1.5.0", "@tanstack/react-query": "4.36.1", @@ -30,7 +30,7 @@ "formik": "2.2.6", "jszip": "^3.10.1", "lodash": "4.17.21", - "moment": "2.29.2", + "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", @@ -49,20 +49,21 @@ "yup": "0.31.1" }, "devDependencies": { - "@edx/browserslist-config": "1.0.0", + "@edx/browserslist-config": "1.2.0", "@edx/frontend-build": "13.0.1", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "^2.3.0", "@edx/typescript-config": "^1.0.1", - "@testing-library/jest-dom": "5.16.4", + "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/user-event": "^13.2.1", "@wojtekmaj/enzyme-adapter-react-17": "0.8.0", "axios-mock-adapter": "1.20.0", "enzyme": "3.11.0", "enzyme-to-json": "^3.6.2", - "glob": "7.2.0", + "glob": "7.2.3", "husky": "7.0.4", + "jest-canvas-mock": "^2.5.2", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" @@ -76,6 +77,12 @@ "node": ">=0.10.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", + "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==", + "dev": true + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "license": "Apache-2.0", @@ -2231,9 +2238,9 @@ "integrity": "sha512-mBvxR7aB9290j9+h3d/9G8VkG1b8ecLSmlxc0vskfm7DL/fKUzFmHAj3PI7Z4kkwCQOL4QT5mJHJKC0ZFf7qvQ==" }, "node_modules/@edx/browserslist-config": { - "version": "1.0.0", - "dev": true, - "license": "AGPL-3.0" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@edx/browserslist-config/-/browserslist-config-1.2.0.tgz", + "integrity": "sha512-T1+6P52Yx7SMkmoIr4O0Q3m/DyRdrLTJbv1xVijdRLFEq1hqdafEs+Ln1423U5LSkTePb9AOkEtL1G0RZLFl1w==" }, "node_modules/@edx/eslint-config": { "version": "3.2.0", @@ -3977,11 +3984,6 @@ "react-dom": "^16.14.0 || ^17.0.0" } }, - "node_modules/@edx/frontend-lib-content-components/node_modules/@edx/browserslist-config": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@edx/browserslist-config/-/browserslist-config-1.2.0.tgz", - "integrity": "sha512-T1+6P52Yx7SMkmoIr4O0Q3m/DyRdrLTJbv1xVijdRLFEq1hqdafEs+Ln1423U5LSkTePb9AOkEtL1G0RZLFl1w==" - }, "node_modules/@edx/frontend-lib-content-components/node_modules/@reduxjs/toolkit": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", @@ -4022,14 +4024,6 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@edx/frontend-lib-content-components/node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "engines": { - "node": "*" - } - }, "node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", @@ -4118,24 +4112,6 @@ "form-data": "^4.0.0" } }, - "node_modules/@edx/frontend-platform/node_modules/glob": { - "version": "7.2.3", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@edx/new-relic-source-map-webpack-plugin": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-2.1.0.tgz", @@ -4556,40 +4532,48 @@ } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "1.2.28", - "license": "MIT", + "version": "1.2.36", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz", + "integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==", + "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.28" + "@fortawesome/fontawesome-common-types": "^0.2.36" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "5.11.2", - "license": "(CC-BY-4.0 AND MIT)", + "version": "5.15.4", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz", + "integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==", + "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.25" + "@fortawesome/fontawesome-common-types": "^0.2.36" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "5.11.2", - "license": "(CC-BY-4.0 AND MIT)", + "version": "5.15.4", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz", + "integrity": "sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==", + "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.25" + "@fortawesome/fontawesome-common-types": "^0.2.36" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "5.11.2", - "license": "(CC-BY-4.0 AND MIT)", + "version": "5.15.4", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz", + "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==", + "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.25" + "@fortawesome/fontawesome-common-types": "^0.2.36" }, "engines": { "node": ">=6" @@ -6195,15 +6179,16 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "5.16.4", + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", "dev": true, - "license": "MIT", "dependencies": { + "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", "aria-query": "^5.0.0", "chalk": "^3.0.0", - "css": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.5.6", "lodash": "^4.17.15", @@ -6217,8 +6202,9 @@ }, "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -6231,8 +6217,9 @@ }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6243,8 +6230,9 @@ }, "node_modules/@testing-library/jest-dom/node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -6254,21 +6242,24 @@ }, "node_modules/@testing-library/jest-dom/node_modules/color-name": { "version": "1.1.4", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@testing-library/jest-dom/node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -11308,16 +11299,6 @@ "semver": "bin/semver" } }, - "node_modules/css": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "source-map": "^0.6.1", - "source-map-resolve": "^0.6.0" - } - }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -11436,14 +11417,6 @@ "dev": true, "license": "MIT" }, - "node_modules/css/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cssesc": { "version": "3.0.0", "license": "MIT", @@ -11454,6 +11427,12 @@ "node": ">=4" } }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "node_modules/cssnano": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.1.tgz", @@ -14278,14 +14257,14 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, @@ -15872,6 +15851,16 @@ "node": ">=8" } }, + "node_modules/jest-canvas-mock": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz", + "integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==", + "dev": true, + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "node_modules/jest-circus": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", @@ -18833,8 +18822,9 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, "node_modules/moment": { - "version": "2.29.2", - "license": "MIT", + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "engines": { "node": "*" } @@ -18854,6 +18844,21 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4" + } + }, + "node_modules/moo-color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/mpd-parser": { "version": "0.21.1", "license": "Apache-2.0", @@ -23362,15 +23367,6 @@ "webpack": "^5.72.1" } }, - "node_modules/source-map-resolve": { - "version": "0.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "license": "MIT", diff --git a/package.json b/package.json index 0d38c011d3..8080378a01 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ "@edx/frontend-lib-content-components": "1.175.0", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^20.45.4", - "@fortawesome/fontawesome-svg-core": "1.2.28", - "@fortawesome/free-brands-svg-icons": "5.11.2", - "@fortawesome/free-regular-svg-icons": "5.11.2", - "@fortawesome/free-solid-svg-icons": "5.11.2", + "@fortawesome/fontawesome-svg-core": "1.2.36", + "@fortawesome/free-brands-svg-icons": "5.15.4", + "@fortawesome/free-regular-svg-icons": "5.15.4", + "@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.2.0", "@reduxjs/toolkit": "1.5.0", "@tanstack/react-query": "4.36.1", @@ -56,7 +56,7 @@ "formik": "2.2.6", "jszip": "^3.10.1", "lodash": "4.17.21", - "moment": "2.29.2", + "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", @@ -75,20 +75,21 @@ "yup": "0.31.1" }, "devDependencies": { - "@edx/browserslist-config": "1.0.0", + "@edx/browserslist-config": "1.2.0", "@edx/frontend-build": "13.0.1", "@edx/reactifex": "^1.0.3", "@edx/stylelint-config-edx": "^2.3.0", "@edx/typescript-config": "^1.0.1", - "@testing-library/jest-dom": "5.16.4", + "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/user-event": "^13.2.1", "@wojtekmaj/enzyme-adapter-react-17": "0.8.0", "axios-mock-adapter": "1.20.0", "enzyme": "3.11.0", "enzyme-to-json": "^3.6.2", - "glob": "7.2.0", + "glob": "7.2.3", "husky": "7.0.4", + "jest-canvas-mock": "^2.5.2", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index e1dd1ddc73..7bf5b864ab 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -8,7 +8,7 @@ import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettin import EditorContainer from './editors/EditorContainer'; import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; -import FilesAndUploads from './files-and-uploads'; +import { FilesPage, VideosPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; @@ -49,11 +49,11 @@ const CourseAuthoringRoutes = () => { /> } + element={} /> : null} + element={process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? : null} /> { - const [lockedState, setLockedState] = useState(asset?.locked); - const handleLock = (e) => { - const locked = e.target.checked; - setLockedState(locked); - handleLockedAsset(asset?.id, locked); - }; - const fileSize = getFileSizeToClosestByte(asset?.fileSize); - - return ( - - - -
- - {asset?.displayName} - -
-
-
- -
-
-
- -
- -
- -
- -
- -
- {fileSize} -
- -
- -
- - {asset?.portableUrl} - -
- - navigator.clipboard.writeText(asset?.portableUrl)} - /> -
-
- -
- -
- - {asset?.externalUrl} - -
- - navigator.clipboard.writeText(asset?.externalUrl)} - /> -
- -
- -
- - - -
-
-
-
- -
- -
-
- ); -}; -FileInfo.propTypes = { - asset: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - wrapperType: PropTypes.string.isRequired, - locked: PropTypes.bool.isRequired, - externalUrl: PropTypes.string.isRequired, - thumbnail: PropTypes.string, - id: PropTypes.string.isRequired, - portableUrl: PropTypes.string.isRequired, - dateAdded: PropTypes.string.isRequired, - fileSize: PropTypes.number.isRequired, - usageLocations: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - onClose: PropTypes.func.isRequired, - isOpen: PropTypes.bool.isRequired, - handleLockedAsset: PropTypes.func.isRequired, - usagePathStatus: PropTypes.string.isRequired, - error: PropTypes.arrayOf(PropTypes.string).isRequired, - // injected - intl: intlShape.isRequired, -}; - -export default injectIntl(FileInfo); diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx deleted file mode 100644 index c8aafcefcc..0000000000 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ /dev/null @@ -1,376 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; -import isEmpty from 'lodash/isEmpty'; -import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; -import { - DataTable, - TextFilter, - CheckboxFilter, - Dropzone, - CardView, - useToggle, - AlertModal, - ActionRow, - Button, -} from '@edx/paragon'; -import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components'; - -import { RequestStatus } from '../data/constants'; -import { useModels, useModel } from '../generic/model-store'; -import { - addAssetFile, - deleteAssetFile, - fetchAssets, - resetErrors, - getUsagePaths, - updateAssetLock, - updateAssetOrder, - fetchAssetDownload, -} from './data/thunks'; -import { getFileSizeToClosestByte, sortFiles } from './data/utils'; -import messages from './messages'; - -import FileInfo from './FileInfo'; -import FileInput, { useFileInput } from './FileInput'; -import FilesAndUploadsProvider from './FilesAndUploadsProvider'; -import { - GalleryCard, - TableActions, -} from './table-components'; -import { AccessColumn, MoreInfoColumn, ThumbnailColumn } from './table-components/table-custom-columns'; -import ApiStatusToast from './ApiStatusToast'; -import { clearErrors } from './data/slice'; -import getPageHeadTitle from '../generic/utils'; -import FilterStatus from './table-components/FilterStatus'; - -const FilesAndUploads = ({ - courseId, - // injected - intl, -}) => { - const dispatch = useDispatch(); - const defaultVal = 'card'; - const columnSizes = { - xs: 12, - sm: 6, - md: 4, - lg: 2, - }; - const [currentView, setCurrentView] = useState(defaultVal); - const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false); - const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); - const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); - const [selectedRows, setSelectedRows] = useState([]); - const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); - - const courseDetails = useModel('courseDetails', courseId); - document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); - - useEffect(() => { - dispatch(fetchAssets(courseId)); - }, [courseId]); - const { - totalCount, - assetIds, - loadingStatus, - addingStatus: addAssetStatus, - deletingStatus: deleteAssetStatus, - updatingStatus: updateAssetStatus, - usageStatus: usagePathStatus, - errors: errorMessages, - } = useSelector(state => state.assets); - const fileInputControl = useFileInput({ - onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)), - setSelectedRows, - setAddOpen, - }); - const assets = useModels('assets', assetIds); - const handleDropzoneAsset = ({ fileData, handleError }) => { - try { - const file = fileData.get('file'); - dispatch(addAssetFile(courseId, file, totalCount)); - } catch (error) { - handleError(error); - } - }; - - const handleSort = (sortType) => { - const newAssetIdOrder = sortFiles(assets, sortType); - dispatch(updateAssetOrder(courseId, newAssetIdOrder, sortType)); - }; - - const handleBulkDelete = () => { - closeDeleteConfirmation(); - setDeleteOpen(); - dispatch(resetErrors({ errorType: 'delete' })); - const assetIdsToDelete = selectedRows.map(row => row.original.id); - assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount))); - }; - - const handleBulkDownload = useCallback(async (selectedFlatRows) => { - dispatch(resetErrors({ errorType: 'download' })); - dispatch(fetchAssetDownload({ selectedRows: selectedFlatRows, courseId })); - }, []); - - const handleLockedAsset = (assetId, locked) => { - dispatch(clearErrors({ errorType: 'lock' })); - dispatch(updateAssetLock({ courseId, assetId, locked })); - }; - - const handleOpenDeleteConfirmation = (selectedFlatRows) => { - setSelectedRows(selectedFlatRows); - openDeleteConfirmation(); - }; - - const handleOpenAssetInfo = (original) => { - dispatch(resetErrors({ errorType: 'usageMetrics' })); - setSelectedRows([{ original }]); - dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows })); - openAssetInfo(); - }; - - const headerActions = ({ selectedFlatRows }) => ( - - ); - - const fileCard = ({ className, original }) => ( - - ); - - const accessColumn = { - id: 'locked', - Header: 'Access', - Cell: ({ row }) => AccessColumn({ row }), - }; - const thumbnailColumn = { - id: 'thumbnail', - Header: '', - Cell: ({ row }) => ThumbnailColumn({ row }), - }; - const fileSizeColumn = { - id: 'fileSize', - Header: 'File size', - Cell: ({ row }) => { - const { fileSize } = row.original; - return getFileSizeToClosestByte(fileSize); - }, - }; - const moreInfoColumn = { - id: 'moreInfo', - Header: '', - Cell: ({ row }) => MoreInfoColumn({ - row, - handleLock: handleLockedAsset, - handleBulkDownload, - handleOpenAssetInfo, - handleOpenDeleteConfirmation, - }), - }; - - const tableColumns = [ - { ...thumbnailColumn }, - { - Header: 'File name', - accessor: 'displayName', - }, - { ...fileSizeColumn }, - { - Header: 'Type', - accessor: 'wrapperType', - Filter: CheckboxFilter, - filter: 'includesValue', - filterChoices: [ - { - name: 'Code', - value: 'code', - }, - { - name: 'Images', - value: 'image', - }, - { - name: 'Documents', - value: 'document', - }, - { - name: 'Audio', - value: 'audio', - }, - ], - }, - { ...accessColumn }, - { ...moreInfoColumn }, - ]; - - if (loadingStatus === RequestStatus.DENIED) { - return ( -
- -
- ); - } - return ( - -
-
- -
    - {errorMessages.add.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
- -
    - {errorMessages.delete.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
- -
    - {errorMessages.lock.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} - {errorMessages.download.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
-
- -
-
- setCurrentView(val), - defaultActiveStateValue: defaultVal, - togglePlacement: 'left', - }} - initialState={{ - pageSize: 50, - }} - tableActions={headerActions} - bulkActions={headerActions} - columns={tableColumns} - itemCount={totalCount} - pageCount={Math.ceil(totalCount / 50)} - data={assets} - FilterStatusComponent={FilterStatus} - > - {isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( - - ) : ( -
- - { currentView === 'card' && } - { currentView === 'list' && } - - - - -
- )} -
- - {!isEmpty(selectedRows) && ( - - )} - - - - - )} - > - {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} - -
-
- ); -}; - -FilesAndUploads.propTypes = { - courseId: PropTypes.string.isRequired, - // injected - intl: intlShape.isRequired, -}; - -export default injectIntl(FilesAndUploads); diff --git a/src/files-and-uploads/index.js b/src/files-and-uploads/index.js deleted file mode 100644 index c0b84a0096..0000000000 --- a/src/files-and-uploads/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './FilesAndUploads'; diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx deleted file mode 100644 index 19d62ab58e..0000000000 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - ActionRow, - Icon, - Card, - Chip, - Truncate, - Image, -} from '@edx/paragon'; -import { - MoreVert, -} from '@edx/paragon/icons'; -import FileMenu from '../FileMenu'; -import { getSrc } from '../data/utils'; - -const GalleryCard = ({ - className, - original, - handleBulkDownload, - handleLockedAsset, - handleOpenDeleteConfirmation, - handleOpenAssetInfo, -}) => { - const lockAsset = () => { - const { locked } = original; - handleLockedAsset(original.id, !locked); - }; - const src = getSrc({ - thumbnail: original.thumbnail, - wrapperType: original.wrapperType, - }); - - return ( - - - handleOpenAssetInfo(original)} - portableUrl={original.portableUrl} - iconSrc={MoreVert} - id={original.id} - onDownload={() => handleBulkDownload( - [{ original: { id: original.id, displayName: original.displayName } }], - )} - openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} - /> - - )} - /> - -
- {original.thumbnail ? ( - - ) : ( -
- -
- )} -
-
- - {original.displayName} - -
-
- - - {original.wrapperType} - - -
- ); -}; - -GalleryCard.defaultProps = { - className: null, -}; -GalleryCard.propTypes = { - className: PropTypes.string, - original: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - wrapperType: PropTypes.string.isRequired, - locked: PropTypes.bool.isRequired, - externalUrl: PropTypes.string.isRequired, - thumbnail: PropTypes.string, - id: PropTypes.string.isRequired, - portableUrl: PropTypes.string.isRequired, - }).isRequired, - handleBulkDownload: PropTypes.func.isRequired, - handleLockedAsset: PropTypes.func.isRequired, - handleOpenDeleteConfirmation: PropTypes.func.isRequired, - handleOpenAssetInfo: PropTypes.func.isRequired, -}; - -export default GalleryCard; diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx deleted file mode 100644 index 9a1d38b1bb..0000000000 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - ActionRow, - Icon, - Card, - Chip, - Truncate, - Image, -} from '@edx/paragon'; -import { - MoreVert, -} from '@edx/paragon/icons'; -import FileMenu from '../FileMenu'; -import { getSrc } from '../data/utils'; - -const ListCard = ({ - className, - original, - handleBulkDownload, - handleLockedAsset, - handleOpenDeleteConfirmation, - handleOpenAssetInfo, -}) => { - const lockAsset = () => { - const { locked } = original; - handleLockedAsset(original.id, !locked); - }; - const src = getSrc({ - thumbnail: original.thumbnail, - wrapperType: original.wrapperType, - }); - - return ( - -
- {original.thumbnail ? ( - - ) : ( -
- -
- )} -
- - -
- - {original.displayName} - -
- - {original.wrapperType} - -
-
- - - handleOpenAssetInfo(original)} - portableUrl={original.portableUrl} - iconSrc={MoreVert} - id={original.id} - onDownload={() => handleBulkDownload( - [{ original: { id: original.id, displayName: original.displayName } }], - )} - openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} - /> - - -
- ); -}; - -ListCard.defaultProps = { - className: null, -}; -ListCard.propTypes = { - className: PropTypes.string, - original: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - wrapperType: PropTypes.string.isRequired, - locked: PropTypes.bool.isRequired, - externalUrl: PropTypes.string.isRequired, - thumbnail: PropTypes.string, - id: PropTypes.string.isRequired, - portableUrl: PropTypes.string.isRequired, - }).isRequired, - handleBulkDownload: PropTypes.func.isRequired, - handleLockedAsset: PropTypes.func.isRequired, - handleOpenDeleteConfirmation: PropTypes.func.isRequired, - handleOpenAssetInfo: PropTypes.func.isRequired, -}; - -export default ListCard; diff --git a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx deleted file mode 100644 index a221528839..0000000000 --- a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { PropTypes } from 'prop-types'; -import { Image, Icon } from '@edx/paragon'; -import { getSrc } from '../../data/utils'; - -const ThumbnailColumn = ({ row }) => { - const { - thumbnail, - wrapperType, - externalUrl, - } = row.original; - - const src = getSrc({ thumbnail, wrapperType, externalUrl }); - return ( -
- {thumbnail ? ( - - ) : ( -
- -
- )} -
- ); -}; - -ThumbnailColumn.propTypes = { - row: { - original: { - thumbnail: PropTypes.string, - wrapperType: PropTypes.string.isRequired, - externalUrl: PropTypes.string, - }.isRequired, - }.isRequired, -}; - -export default ThumbnailColumn; diff --git a/src/files-and-uploads/ApiStatusToast.jsx b/src/files-and-videos/ApiStatusToast.jsx similarity index 100% rename from src/files-and-uploads/ApiStatusToast.jsx rename to src/files-and-videos/ApiStatusToast.jsx diff --git a/src/files-and-videos/EditFileErrors.jsx b/src/files-and-videos/EditFileErrors.jsx new file mode 100644 index 0000000000..35304e38d1 --- /dev/null +++ b/src/files-and-videos/EditFileErrors.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { ErrorAlert } from '@edx/frontend-lib-content-components'; +import { RequestStatus } from '../data/constants'; +import messages from './messages'; + +const EditFileErrors = ({ + resetErrors, + errorMessages, + addFileStatus, + deleteFileStatus, + updateFileStatus, + // injected + intl, +}) => ( + <> + resetErrors({ errorType: 'add' })} + isError={addFileStatus === RequestStatus.FAILED} + > +
    + {errorMessages.add.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ resetErrors({ errorType: 'delete' })} + isError={deleteFileStatus === RequestStatus.FAILED} + > +
    + {errorMessages.delete.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ resetErrors({ errorType: 'update' })} + isError={updateFileStatus === RequestStatus.FAILED} + > +
    + {errorMessages.lock?.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} + {errorMessages.download.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} + {errorMessages.thumbnail?.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ +); + +EditFileErrors.propTypes = { + resetErrors: PropTypes.func.isRequired, + errorMessages: PropTypes.shape({ + add: PropTypes.arrayOf(PropTypes.string).isRequired, + delete: PropTypes.arrayOf(PropTypes.string).isRequired, + lock: PropTypes.arrayOf(PropTypes.string), + download: PropTypes.arrayOf(PropTypes.string).isRequired, + usageMetrics: PropTypes.arrayOf(PropTypes.string).isRequired, + thumbnail: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + addFileStatus: PropTypes.string.isRequired, + deleteFileStatus: PropTypes.string.isRequired, + updateFileStatus: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(EditFileErrors); diff --git a/src/files-and-videos/FileInfo.jsx b/src/files-and-videos/FileInfo.jsx new file mode 100644 index 0000000000..a644cfde3c --- /dev/null +++ b/src/files-and-videos/FileInfo.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + injectIntl, + FormattedMessage, +} from '@edx/frontend-platform/i18n'; +import { + ModalDialog, + Truncate, +} from '@edx/paragon'; + +import messages from './messages'; +import UsageMetricsMessages from './UsageMetricsMessage'; +import FileInfoAssetSidebar from './files-page/FileInfoAssetSidebar'; +import FileInfoVideoSidebar from './videos-page/info-sidebar/FileInfoVideoSidebar'; +import FileThumbnail from './FileThumbnail'; + +const FileInfo = ({ + file, + isOpen, + onClose, + handleLockedFile, + thumbnailPreview, + usagePathStatus, + error, +}) => ( + + + +
+ + {file?.displayName} + +
+
+
+ +
+
+
+ +
+
+ {file?.wrapperType === 'video' ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+
+); + +FileInfo.propTypes = { + file: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, + locked: PropTypes.bool, + externalUrl: PropTypes.string, + thumbnail: PropTypes.string, + id: PropTypes.string.isRequired, + portableUrl: PropTypes.string, + dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + usageLocations: PropTypes.arrayOf(PropTypes.string), + status: PropTypes.string, + }), + onClose: PropTypes.func.isRequired, + isOpen: PropTypes.bool.isRequired, + handleLockedFile: PropTypes.func.isRequired, + usagePathStatus: PropTypes.string.isRequired, + error: PropTypes.arrayOf(PropTypes.string).isRequired, + thumbnailPreview: PropTypes.func.isRequired, +}; + +FileInfo.defaultProps = { + file: null, +}; + +export default injectIntl(FileInfo); diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-videos/FileInput.jsx similarity index 67% rename from src/files-and-uploads/FileInput.jsx rename to src/files-and-videos/FileInput.jsx index a094cdd15a..c245e855f3 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-videos/FileInput.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { getSupportedFormats } from './videos-page/data/utils'; export const useFileInput = ({ onAddFile, @@ -23,14 +24,15 @@ export const useFileInput = ({ }; }; -const FileInput = ({ fileInput: hook }) => ( +const FileInput = ({ fileInput: hook, supportedFileFormats, allowMultiple }) => ( ); @@ -44,6 +46,16 @@ FileInput.propTypes = { PropTypes.shape({ current: PropTypes.instanceOf(Element) }), ]), }).isRequired, + supportedFileFormats: PropTypes.oneOfType([ + PropTypes.shape({}), + PropTypes.arrayOf(PropTypes.string), + ]), + allowMultiple: PropTypes.bool, +}; + +FileInput.defaultProps = { + supportedFileFormats: null, + allowMultiple: true, }; export default FileInput; diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-videos/FileMenu.jsx similarity index 53% rename from src/files-and-uploads/FileMenu.jsx rename to src/files-and-videos/FileMenu.jsx index 7d9ac47cdc..8a84511cdd 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-videos/FileMenu.jsx @@ -6,6 +6,7 @@ import { IconButton, Icon, } from '@edx/paragon'; +import { MoreHoriz } from '@edx/paragon/icons'; import messages from './messages'; const FileMenu = ({ @@ -16,8 +17,8 @@ const FileMenu = ({ openAssetInfo, openDeleteConfirmation, portableUrl, - iconSrc, id, + wrapperType, // injected intl, }) => ( @@ -25,28 +26,38 @@ const FileMenu = ({ - navigator.clipboard.writeText(portableUrl)} - > - {intl.formatMessage(messages.copyStudioUrlTitle)} - - navigator.clipboard.writeText(externalUrl)} - > - {intl.formatMessage(messages.copyWebUrlTitle)} - + {wrapperType === 'video' ? ( + navigator.clipboard.writeText(id)} + > + Copy video ID + + ) : ( + <> + navigator.clipboard.writeText(portableUrl)} + > + {intl.formatMessage(messages.copyStudioUrlTitle)} + + navigator.clipboard.writeText(externalUrl)} + > + {intl.formatMessage(messages.copyWebUrlTitle)} + + + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + + )} {intl.formatMessage(messages.downloadTitle)} - - {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} - {intl.formatMessage(messages.infoTitle)} @@ -62,17 +73,24 @@ const FileMenu = ({ ); FileMenu.propTypes = { - externalUrl: PropTypes.string.isRequired, - handleLock: PropTypes.func.isRequired, - locked: PropTypes.bool.isRequired, + externalUrl: PropTypes.string, + handleLock: PropTypes.func, + locked: PropTypes.bool, onDownload: PropTypes.func.isRequired, openAssetInfo: PropTypes.func.isRequired, openDeleteConfirmation: PropTypes.func.isRequired, - portableUrl: PropTypes.string.isRequired, - iconSrc: PropTypes.func.isRequired, + portableUrl: PropTypes.string, id: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; +FileMenu.defaultProps = { + externalUrl: null, + handleLock: null, + locked: null, + portableUrl: null, +}; + export default injectIntl(FileMenu); diff --git a/src/files-and-videos/FileTable.jsx b/src/files-and-videos/FileTable.jsx new file mode 100644 index 0000000000..80a805e2c4 --- /dev/null +++ b/src/files-and-videos/FileTable.jsx @@ -0,0 +1,306 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import isEmpty from 'lodash/isEmpty'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + DataTable, + TextFilter, + Dropzone, + CardView, + useToggle, + AlertModal, + ActionRow, + Button, +} from '@edx/paragon'; + +import { RequestStatus } from '../data/constants'; +import { sortFiles } from './data/utils'; +import messages from './messages'; + +import FileInfo from './FileInfo'; +import FileInput, { useFileInput } from './FileInput'; +import { + GalleryCard, + TableActions, +} from './table-components'; +import ApiStatusToast from './ApiStatusToast'; +import FilterStatus from './table-components/FilterStatus'; +import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn'; + +const FileTable = ({ + files, + data, + handleAddFile, + handleLockFile, + handleDeleteFile, + handleDownloadFile, + handleUsagePaths, + handleErrorReset, + handleFileOrder, + tableColumns, + maxFileSize, + thumbnailPreview, + // injected + intl, +}) => { + const defaultVal = 'card'; + const columnSizes = { + xs: 12, + sm: 6, + md: 4, + lg: 2, + }; + const [currentView, setCurrentView] = useState(defaultVal); + const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false); + const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false); + const [isAddOpen, setAddOpen, setAddClose] = useToggle(false); + const [selectedRows, setSelectedRows] = useState([]); + const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false); + + const { + totalCount, + loadingStatus, + usagePathStatus, + usageErrorMessages, + encodingsDownloadUrl, + supportedFileFormats, + } = data; + + useEffect(() => { + if (!isEmpty(selectedRows) && Object.keys(selectedRows[0]).length > 0) { + const updatedRows = []; + selectedRows.forEach(row => { + const currentFile = row.original; + if (currentFile) { + const [updatedFile] = files.filter(file => file.id === currentFile?.id); + updatedRows.push({ original: updatedFile }); + } + }); + setSelectedRows(updatedRows); + } + }, [files]); + + const fileInputControl = useFileInput({ + onAddFile: (file) => handleAddFile(file), + setSelectedRows, + setAddOpen, + }); + const handleDropzoneAsset = ({ fileData, handleError }) => { + try { + const file = fileData.get('file'); + handleAddFile(file); + } catch (error) { + handleError(error); + } + }; + + const handleSort = (sortType) => { + const newFileIdOrder = sortFiles(files, sortType); + handleFileOrder({ newFileIdOrder, sortType }); + }; + + const handleBulkDelete = () => { + closeDeleteConfirmation(); + setDeleteOpen(); + handleErrorReset({ errorType: 'delete' }); + const fileIdsToDelete = selectedRows.map(row => row.original.id); + fileIdsToDelete.forEach(id => handleDeleteFile(id)); + }; + + const handleBulkDownload = useCallback(async (selectedFlatRows) => { + handleErrorReset({ errorType: 'download' }); + handleDownloadFile(selectedFlatRows); + }, []); + + const handleLockedFile = (fileId, locked) => { + handleErrorReset({ errorType: 'lock' }); + handleLockFile({ fileId, locked }); + }; + + const handleOpenDeleteConfirmation = (selectedFlatRows) => { + setSelectedRows(selectedFlatRows); + openDeleteConfirmation(); + }; + + const handleOpenFileInfo = (original) => { + handleErrorReset({ errorType: 'usageMetrics' }); + setSelectedRows([{ original }]); + handleUsagePaths(original); + openAssetInfo(); + }; + + const headerActions = ({ selectedFlatRows }) => ( + + ); + + const fileCard = ({ className, original }) => ( + + ); + + const moreInfoColumn = { + id: 'moreInfo', + Header: '', + Cell: ({ row }) => MoreInfoColumn({ + row, + handleLock: handleLockedFile, + handleBulkDownload, + handleOpenFileInfo, + handleOpenDeleteConfirmation, + }), + }; + + const hasMoreInfoColumn = tableColumns.filter(col => col.id === 'moreInfo').length === 1; + if (!hasMoreInfoColumn) { + tableColumns.push({ ...moreInfoColumn }); + } + + return ( + <> + setCurrentView(val), + defaultActiveStateValue: defaultVal, + togglePlacement: 'left', + }} + initialState={{ + pageSize: 50, + }} + tableActions={headerActions} + bulkActions={headerActions} + columns={tableColumns} + itemCount={totalCount} + pageCount={Math.ceil(totalCount / 50)} + data={files} + FilterStatusComponent={FilterStatus} + > + {isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( + + ) : ( +
+ + { currentView === 'card' && } + { currentView === 'list' && } + + +
+ )} + + + +
+ + {!isEmpty(selectedRows) && ( + + )} + + + + + )} + > + {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} + + + ); +}; + +FileTable.propTypes = { + courseId: PropTypes.string.isRequired, + files: PropTypes.arrayOf(PropTypes.shape({})), + data: PropTypes.shape({ + totalCount: PropTypes.number.isRequired, + fileIds: PropTypes.arrayOf(PropTypes.string).isRequired, + loadingStatus: PropTypes.string.isRequired, + usagePathStatus: PropTypes.string.isRequired, + usageErrorMessages: PropTypes.arrayOf(PropTypes.string).isRequired, + encodingsDownloadUrl: PropTypes.string, + supportedFileFormats: PropTypes.shape({}), + }).isRequired, + handleAddFile: PropTypes.func.isRequired, + handleDeleteFile: PropTypes.func.isRequired, + handleDownloadFile: PropTypes.func.isRequired, + handleUsagePaths: PropTypes.func.isRequired, + handleLockFile: PropTypes.func, + handleErrorReset: PropTypes.func.isRequired, + handleFileOrder: PropTypes.func.isRequired, + tableColumns: PropTypes.arrayOf(PropTypes.shape({ + Header: PropTypes.string, + accessor: PropTypes.string, + })).isRequired, + maxFileSize: PropTypes.number.isRequired, + thumbnailPreview: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +FileTable.defaultProps = { + files: null, + handleLockFile: () => {}, +}; + +export default injectIntl(FileTable); diff --git a/src/files-and-videos/FileThumbnail.jsx b/src/files-and-videos/FileThumbnail.jsx new file mode 100644 index 0000000000..fd7e2a6c80 --- /dev/null +++ b/src/files-and-videos/FileThumbnail.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const FileThumbnail = ({ + thumbnail, + wrapperType, + externalUrl, + displayName, + imageSize, + id, + status, + thumbnailPreview, +}) => ( + <> + {thumbnailPreview({ + thumbnail, + wrapperType, + externalUrl, + displayName, + imageSize, + id, + status, + })} + +); +FileThumbnail.defaultProps = { + thumbnail: null, + wrapperType: null, + externalUrl: null, + displayName: null, + id: null, + status: null, +}; +FileThumbnail.propTypes = { + thumbnail: PropTypes.string, + wrapperType: PropTypes.string, + externalUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string, + status: PropTypes.string, + thumbnailPreview: PropTypes.func.isRequired, + imageSize: PropTypes.shape({ + height: PropTypes.string.isRequired, + width: PropTypes.string.isRequired, + }).isRequired, +}; + +export default FileThumbnail; diff --git a/src/files-and-uploads/UsageMetricsMessage.jsx b/src/files-and-videos/UsageMetricsMessage.jsx similarity index 100% rename from src/files-and-uploads/UsageMetricsMessage.jsx rename to src/files-and-videos/UsageMetricsMessage.jsx diff --git a/src/files-and-uploads/data/api.js b/src/files-and-videos/data/api.js similarity index 100% rename from src/files-and-uploads/data/api.js rename to src/files-and-videos/data/api.js diff --git a/src/files-and-uploads/data/api.test.js b/src/files-and-videos/data/api.test.js similarity index 100% rename from src/files-and-uploads/data/api.test.js rename to src/files-and-videos/data/api.test.js diff --git a/src/files-and-uploads/data/constant.js b/src/files-and-videos/data/constant.js similarity index 98% rename from src/files-and-uploads/data/constant.js rename to src/files-and-videos/data/constant.js index 9fe5121f37..95a95e8f1f 100644 --- a/src/files-and-uploads/data/constant.js +++ b/src/files-and-videos/data/constant.js @@ -33,6 +33,7 @@ const FILES_AND_UPLOAD_TYPE_FILTERS = { 'application/java-vm', 'text/x-c++src', 'text/xml', 'text/x-scss', 'application/x-python-code', 'application/java-archive', 'text/x-python-script', 'application/x-ruby', 'application/mathematica', 'text/coffeescript', 'text/x-matlab', 'application/sql', 'text/php'], + video: ['.mp4', '.mov'], }; export default FILES_AND_UPLOAD_TYPE_FILTERS; diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-videos/data/slice.js similarity index 100% rename from src/files-and-uploads/data/slice.js rename to src/files-and-videos/data/slice.js diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-videos/data/thunks.js similarity index 96% rename from src/files-and-uploads/data/thunks.js rename to src/files-and-videos/data/thunks.js index 53af42cf05..8669fbfe91 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-videos/data/thunks.js @@ -130,13 +130,19 @@ export function resetErrors({ errorType }) { return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; } -export function getUsagePaths({ asset, courseId, setSelectedRows }) { +export function getUsagePaths({ asset, courseId }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS })); try { const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId }); - setSelectedRows([{ original: { ...asset, usageLocations } }]); + dispatch(updateModel({ + modelType: 'assets', + model: { + id: asset.id, + usageLocations, + }, + })); dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); } catch (error) { dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` })); diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-videos/data/utils.js similarity index 95% rename from src/files-and-uploads/data/utils.js rename to src/files-and-videos/data/utils.js index 75ace4caff..9729065df3 100644 --- a/src/files-and-uploads/data/utils.js +++ b/src/files-and-videos/data/utils.js @@ -1,4 +1,8 @@ -import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; +import { + InsertDriveFile, + Terminal, + AudioFile, +} from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; @@ -63,6 +67,8 @@ export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { return `${fileSizeFixedDecimal} KB`; case 2: return `${fileSizeFixedDecimal} MB`; + case 3: + return `${fileSizeFixedDecimal} GB`; default: return `${fileSizeFixedDecimal} B`; } diff --git a/src/files-and-uploads/data/utils.test.js b/src/files-and-videos/data/utils.test.js similarity index 78% rename from src/files-and-uploads/data/utils.test.js rename to src/files-and-videos/data/utils.test.js index ebe9a38ebb..f2e9995dd9 100644 --- a/src/files-and-uploads/data/utils.test.js +++ b/src/files-and-videos/data/utils.test.js @@ -17,5 +17,10 @@ describe('FilesAndUploads utils', () => { const actualSize = getFileSizeToClosestByte(2190000); expect(expectedSize).toEqual(actualSize); }); + it('should return file size with GB for gigabytes', () => { + const expectedSize = '2.03 GB'; + const actualSize = getFileSizeToClosestByte(2034190000); + expect(expectedSize).toEqual(actualSize); + }); }); }); diff --git a/src/files-and-uploads/FileThumbnail.jsx b/src/files-and-videos/files-page/AssetThumbnail.jsx similarity index 51% rename from src/files-and-uploads/FileThumbnail.jsx rename to src/files-and-videos/files-page/AssetThumbnail.jsx index 720e05e2c8..c47efacd71 100644 --- a/src/files-and-uploads/FileThumbnail.jsx +++ b/src/files-and-videos/files-page/AssetThumbnail.jsx @@ -1,42 +1,48 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, Image, } from '@edx/paragon'; -import { getSrc } from './data/utils'; +import { getSrc } from '../data/utils'; +import messages from './messages'; const AssetThumbnail = ({ thumbnail, wrapperType, externalUrl, displayName, + imageSize, + // injected + intl, }) => { const src = getSrc({ thumbnail, externalUrl, wrapperType, }); + const { width, height } = imageSize; return (
{thumbnail ? ( {`Thumbnail ) : (
@@ -46,12 +52,21 @@ const AssetThumbnail = ({ }; AssetThumbnail.defaultProps = { thumbnail: null, + wrapperType: null, + externalUrl: null, + displayName: null, }; AssetThumbnail.propTypes = { thumbnail: PropTypes.string, - wrapperType: PropTypes.string.isRequired, - externalUrl: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, + wrapperType: PropTypes.string, + externalUrl: PropTypes.string, + displayName: PropTypes.string, + imageSize: PropTypes.shape({ + width: PropTypes.string, + height: PropTypes.string.isRequired, + }).isRequired, + // injected + intl: intlShape.isRequired, }; -export default AssetThumbnail; +export default injectIntl(AssetThumbnail); diff --git a/src/files-and-videos/files-page/FileInfoAssetSidebar.jsx b/src/files-and-videos/files-page/FileInfoAssetSidebar.jsx new file mode 100644 index 0000000000..5bc69b4f2e --- /dev/null +++ b/src/files-and-videos/files-page/FileInfoAssetSidebar.jsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { + injectIntl, + FormattedMessage, + FormattedDate, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { + Stack, + IconButton, + ActionRow, + Icon, + Truncate, + IconButtonWithTooltip, + CheckboxControl, +} from '@edx/paragon'; +import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; + +import { getFileSizeToClosestByte } from '../data/utils'; +import messages from './messages'; + +const FileInfoAssetSidebar = ({ + asset, + handleLockedAsset, + // injected + intl, +}) => { + const [lockedState, setLockedState] = useState(asset?.locked); + const handleLock = (e) => { + const locked = e.target.checked; + setLockedState(locked); + handleLockedAsset(asset?.id, locked); + }; + const fileSize = getFileSizeToClosestByte(asset?.fileSize); + + return ( + +
+ +
+ +
+ +
+ {fileSize} +
+ +
+ +
+ + {asset?.portableUrl} + +
+ + navigator.clipboard.writeText(asset?.portableUrl)} + /> +
+
+ +
+ +
+ + {asset?.externalUrl} + +
+ + navigator.clipboard.writeText(asset?.externalUrl)} + /> +
+ +
+ +
+ + + +
+
+ ); +}; +FileInfoAssetSidebar.propTypes = { + asset: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, + locked: PropTypes.bool.isRequired, + externalUrl: PropTypes.string.isRequired, + thumbnail: PropTypes.string, + id: PropTypes.string.isRequired, + portableUrl: PropTypes.string.isRequired, + dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + usageLocations: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + handleLockedAsset: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(FileInfoAssetSidebar); diff --git a/src/files-and-videos/files-page/FilesAndUploads.jsx b/src/files-and-videos/files-page/FilesAndUploads.jsx new file mode 100644 index 0000000000..40b1226db5 --- /dev/null +++ b/src/files-and-videos/files-page/FilesAndUploads.jsx @@ -0,0 +1,186 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { CheckboxFilter } from '@edx/paragon'; +import Placeholder from '@edx/frontend-lib-content-components'; + +import { RequestStatus } from '../../data/constants'; +import { useModels, useModel } from '../../generic/model-store'; +import { + addAssetFile, + deleteAssetFile, + fetchAssets, + updateAssetLock, + fetchAssetDownload, + getUsagePaths, + resetErrors, + updateAssetOrder, +} from '../data/thunks'; +import messages from './messages'; +import FilesAndUploadsProvider from './FilesAndUploadsProvider'; +import getPageHeadTitle from '../../generic/utils'; +import FileTable from '../FileTable'; +import EditFileErrors from '../EditFileErrors'; +import { getFileSizeToClosestByte } from '../data/utils'; +import ThumbnailColumn from '../table-components/table-custom-columns/ThumbnailColumn'; +import ActiveColumn from '../table-components/table-custom-columns/ActiveColumn'; +import AccessColumn from '../table-components/table-custom-columns/AccessColumn'; +import AssetThumbnail from './AssetThumbnail'; + +const FilesAndUploads = ({ + courseId, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + + useEffect(() => { + dispatch(fetchAssets(courseId)); + }, [courseId]); + + const { + totalCount, + assetIds, + loadingStatus, + addingStatus: addAssetStatus, + deletingStatus: deleteAssetStatus, + updatingStatus: updateAssetStatus, + usageStatus: usagePathStatus, + errors: errorMessages, + } = useSelector(state => state.assets); + + const handleAddFile = (file) => dispatch(addAssetFile(courseId, file, totalCount)); + const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id, totalCount)); + const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); + const handleLockFile = ({ fileId, locked }) => dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); + const handleUsagePaths = (asset) => dispatch(getUsagePaths({ asset, courseId })); + const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleFileOrder = ({ newFileIdOrder, sortType }) => { + dispatch(updateAssetOrder(courseId, newFileIdOrder, sortType)); + }; + + const thumbnailPreview = (props) => AssetThumbnail(props); + + const assets = useModels('assets', assetIds); + const data = { + totalCount, + fileIds: assetIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, + }; + const maxFileSize = 20 * 1048576; + + const activeColumn = { + id: 'usageLocations', + Header: 'Active', + Cell: ({ row }) => ActiveColumn({ row }), + }; + const accessColumn = { + id: 'locked', + Header: 'Access', + Cell: ({ row }) => AccessColumn({ row }), + }; + const thumbnailColumn = { + id: 'thumbnail', + Header: '', + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), + }; + const fileSizeColumn = { + id: 'fileSize', + Header: 'File size', + Cell: ({ row }) => { + const { fileSize } = row.original; + return getFileSizeToClosestByte(fileSize); + }, + }; + + const tableColumns = [ + { ...thumbnailColumn }, + { + Header: 'File name', + accessor: 'displayName', + }, + { ...fileSizeColumn }, + { + Header: 'Type', + accessor: 'wrapperType', + Filter: CheckboxFilter, + filter: 'includesValue', + filterChoices: [ + { + name: 'Code', + value: 'code', + }, + { + name: 'Images', + value: 'image', + }, + { + name: 'Documents', + value: 'document', + }, + { + name: 'Audio', + value: 'audio', + }, + ], + }, + { ...activeColumn }, + { ...accessColumn }, + ]; + + if (loadingStatus === RequestStatus.DENIED) { + return ( +
+ +
+ ); + } + return ( + +
+
+ +
+ +
+
+ +
+
+ ); +}; + +FilesAndUploads.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(FilesAndUploads); diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-videos/files-page/FilesAndUploads.test.jsx similarity index 98% rename from src/files-and-uploads/FilesAndUploads.test.jsx rename to src/files-and-videos/files-page/FilesAndUploads.test.jsx index 2195afe59a..0d898827a3 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-videos/files-page/FilesAndUploads.test.jsx @@ -16,9 +16,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import initializeStore from '../store'; -import { executeThunk } from '../utils'; -import { RequestStatus } from '../data/constants'; +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import { RequestStatus } from '../../data/constants'; import FilesAndUploads from './FilesAndUploads'; import { generateFetchAssetApiResponse, @@ -35,9 +35,9 @@ import { deleteAssetFile, updateAssetLock, getUsagePaths, -} from './data/thunks'; -import { getAssetsUrl } from './data/api'; -import messages from './messages'; +} from '../data/thunks'; +import { getAssetsUrl } from '../data/api'; +import messages from '../messages'; let axiosMock; let store; @@ -333,7 +333,7 @@ describe('FilesAndUploads', () => { axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] }); await waitFor(() => { - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Info')); executeThunk(getUsagePaths({ courseId, @@ -358,7 +358,7 @@ describe('FilesAndUploads', () => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] }); await waitFor(() => { - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Info')); executeThunk(getUsagePaths({ courseId, @@ -390,7 +390,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false }); - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Unlock')); executeThunk(updateAssetLock({ courseId, @@ -412,7 +412,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true }); - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Lock')); executeThunk(updateAssetLock({ courseId, @@ -433,7 +433,7 @@ describe('FilesAndUploads', () => { expect(assetMenuButton).toBeVisible(); await waitFor(() => { - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Download')); }); expect(saveAs).toHaveBeenCalled(); @@ -449,7 +449,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); @@ -507,7 +507,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404); - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); @@ -534,7 +534,7 @@ describe('FilesAndUploads', () => { axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404); await waitFor(() => { - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Info')); executeThunk(getUsagePaths({ courseId, @@ -556,7 +556,7 @@ describe('FilesAndUploads', () => { await waitFor(() => { axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404); - fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle')); + fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Lock')); executeThunk(updateAssetLock({ courseId, diff --git a/src/files-and-uploads/FilesAndUploadsProvider.jsx b/src/files-and-videos/files-page/FilesAndUploadsProvider.jsx similarity index 100% rename from src/files-and-uploads/FilesAndUploadsProvider.jsx rename to src/files-and-videos/files-page/FilesAndUploadsProvider.jsx diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx similarity index 98% rename from src/files-and-uploads/factories/mockApiResponses.jsx rename to src/files-and-videos/files-page/factories/mockApiResponses.jsx index 6b99e18d62..e7df3d0e70 100644 --- a/src/files-and-uploads/factories/mockApiResponses.jsx +++ b/src/files-and-videos/files-page/factories/mockApiResponses.jsx @@ -1,4 +1,4 @@ -import { RequestStatus } from '../../data/constants'; +import { RequestStatus } from '../../../data/constants'; export const courseId = 'course-v1:edX+DemoX+Demo_Course'; diff --git a/src/files-and-videos/files-page/index.js b/src/files-and-videos/files-page/index.js new file mode 100644 index 0000000000..7bfa2519d8 --- /dev/null +++ b/src/files-and-videos/files-page/index.js @@ -0,0 +1,3 @@ +import FilesAndUploads from './FilesAndUploads'; + +export default FilesAndUploads; diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js new file mode 100644 index 0000000000..ccc1273dc9 --- /dev/null +++ b/src/files-and-videos/files-page/messages.js @@ -0,0 +1,50 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.files-and-uploads.heading', + defaultMessage: 'Files', + }, + thumbnailAltMessage: { + id: 'course-authoring.files-and-uploads.thumbnail.alt', + defaultMessage: '{displayName} file preview', + }, + copyStudioUrlTitle: { + id: 'course-authoring.files-and-uploads.file-info.copyStudioUrl.title', + defaultMessage: 'Copy Studio Url', + }, + copyWebUrlTitle: { + id: 'course-authoring.files-and-uploads.file-info.copyWebUrl.title', + defaultMessage: 'Copy Web Url', + }, + dateAddedTitle: { + id: 'course-authoring.files-and-uploads.file-info.dateAdded.title', + defaultMessage: 'Date added', + }, + fileSizeTitle: { + id: 'course-authoring.files-and-uploads.file-info.fileSize.title', + defaultMessage: 'File size', + }, + studioUrlTitle: { + id: 'course-authoring.files-and-uploads.file-info.studioUrl.title', + defaultMessage: 'Studio URL', + }, + webUrlTitle: { + id: 'course-authoring.files-and-uploads.file-info.webUrl.title', + defaultMessage: 'Web URL', + }, + lockFileTitle: { + id: 'course-authoring.files-and-uploads.file-info.lockFile.title', + defaultMessage: 'Lock file', + }, + lockFileTooltipContent: { + id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content', + defaultMessage: `By default, anyone can access a file you upload if + they know the web URL, even if they are not enrolled in your course. + You can prevent outside access to a file by locking the file. When + you lock a file, the web URL only allows learners who are enrolled + in your course and signed in to access the file.`, + }, +}); + +export default messages; diff --git a/src/files-and-videos/index.js b/src/files-and-videos/index.js new file mode 100644 index 0000000000..aff054a680 --- /dev/null +++ b/src/files-and-videos/index.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ +export { default as FilesPage } from './files-page'; +export { default as VideosPage } from './videos-page'; diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss new file mode 100644 index 0000000000..087984e13c --- /dev/null +++ b/src/files-and-videos/index.scss @@ -0,0 +1,3 @@ +@import "files-and-videos/videos-page/transcript-settings/TranscriptSettings"; +@import "files-and-videos/videos-page/VideoThumbnail"; +@import "files-and-videos/table-components/GalleryCard" diff --git a/src/files-and-uploads/messages.js b/src/files-and-videos/messages.js similarity index 77% rename from src/files-and-uploads/messages.js rename to src/files-and-videos/messages.js index 283013f688..883bcaa7b8 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-videos/messages.js @@ -1,14 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - heading: { - id: 'course-authoring.files-and-uploads.heading', - defaultMessage: 'Files', - }, - subheading: { - id: 'course-authoring.files-and-uploads.subheading', - defaultMessage: 'Content', - }, apiStatusToastMessage: { id: 'course-authoring.files-and-upload.apiStatus.message', defaultMessage: '{actionType} {selectedRowCount} file(s)', @@ -41,34 +33,6 @@ const messages = defineMessages({ id: 'course-authoring.files-and-upload.errorAlert.message', defaultMessage: '{message}', }, - dateAddedTitle: { - id: 'course-authoring.files-and-uploads.file-info.dateAdded.title', - defaultMessage: 'Date added', - }, - fileSizeTitle: { - id: 'course-authoring.files-and-uploads.file-info.fileSize.title', - defaultMessage: 'File size', - }, - studioUrlTitle: { - id: 'course-authoring.files-and-uploads.file-info.studioUrl.title', - defaultMessage: 'Studio URL', - }, - webUrlTitle: { - id: 'course-authoring.files-and-uploads.file-info.webUrl.title', - defaultMessage: 'Web URL', - }, - lockFileTitle: { - id: 'course-authoring.files-and-uploads.file-info.lockFile.title', - defaultMessage: 'Lock file', - }, - lockFileTooltipContent: { - id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content', - defaultMessage: `By default, anyone can access a file you upload if - they know the web URL, even if they are not enrolled in your course. - You can prevent outside access to a file by locking the file. When - you lock a file, the web URL only allows learners who are enrolled - in your course and signed in to access the file.`, - }, usageTitle: { id: 'course-authoring.files-and-uploads.file-info.usage.title', defaultMessage: 'Usage', @@ -105,6 +69,10 @@ const messages = defineMessages({ id: 'course-authoring.files-and-uploads.cardMenu.infoTitle', defaultMessage: 'Info', }, + downloadEncodingsTitle: { + id: 'course-authoring.files-and-uploads.cardMenu.downloadEncodingsTitle', + defaultMessage: 'Download video list (.csv)', + }, deleteTitle: { id: 'course-authoring.files-and-uploads.cardMenu.deleteTitle', defaultMessage: 'Delete', diff --git a/src/files-and-uploads/table-components/FilterStatus.jsx b/src/files-and-videos/table-components/FilterStatus.jsx similarity index 100% rename from src/files-and-uploads/table-components/FilterStatus.jsx rename to src/files-and-videos/table-components/FilterStatus.jsx diff --git a/src/files-and-videos/table-components/GalleryCard.jsx b/src/files-and-videos/table-components/GalleryCard.jsx new file mode 100644 index 0000000000..a5347ee07f --- /dev/null +++ b/src/files-and-videos/table-components/GalleryCard.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, + Icon, + Card, + Chip, + Truncate, +} from '@edx/paragon'; +import { ClosedCaption } from '@edx/paragon/icons'; +import FileMenu from '../FileMenu'; +import FileThumbnail from '../FileThumbnail'; + +const GalleryCard = ({ + className, + original, + handleBulkDownload, + handleLockedFile, + handleOpenDeleteConfirmation, + handleOpenFileInfo, + thumbnailPreview, +}) => { + const lockFile = () => { + const { locked, id } = original; + handleLockedFile(id, !locked); + }; + + return ( + + + handleOpenFileInfo(original)} + portableUrl={original.portableUrl} + id={original.id} + wrapperType={original.wrapperType} + onDownload={() => handleBulkDownload([{ + original: { + id: original.id, + displayName: + original.displayName, + downloadLink: original?.downloadLink, + }, + }])} + openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} + /> + + )} + /> + +
+ +
+
+ + {original.displayName} + +
+
+ + + {original.wrapperType} + + {original.transcripts?.length > 0 && } + +
+ ); +}; + +GalleryCard.defaultProps = { + className: null, +}; +GalleryCard.propTypes = { + className: PropTypes.string, + original: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, + locked: PropTypes.bool, + externalUrl: PropTypes.string, + thumbnail: PropTypes.string, + id: PropTypes.string.isRequired, + portableUrl: PropTypes.string, + status: PropTypes.string, + transcripts: PropTypes.arrayOf(PropTypes.string), + downloadLink: PropTypes.string, + }).isRequired, + handleBulkDownload: PropTypes.func.isRequired, + handleLockedFile: PropTypes.func.isRequired, + handleOpenDeleteConfirmation: PropTypes.func.isRequired, + handleOpenFileInfo: PropTypes.func.isRequired, + thumbnailPreview: PropTypes.func.isRequired, +}; + +export default GalleryCard; diff --git a/src/files-and-uploads/table-components/GalleryCard.scss b/src/files-and-videos/table-components/GalleryCard.scss similarity index 100% rename from src/files-and-uploads/table-components/GalleryCard.scss rename to src/files-and-videos/table-components/GalleryCard.scss diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-videos/table-components/TableActions.jsx similarity index 90% rename from src/files-and-uploads/table-components/TableActions.jsx rename to src/files-and-videos/table-components/TableActions.jsx index e395ca301e..af8cb49df5 100644 --- a/src/files-and-uploads/table-components/TableActions.jsx +++ b/src/files-and-videos/table-components/TableActions.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import _ from 'lodash'; import { PropTypes } from 'prop-types'; import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; import { ActionRow, Button, @@ -19,6 +20,7 @@ const TableActions = ({ handleSort, handleBulkDownload, handleOpenDeleteConfirmation, + encodingsDownloadUrl, // injected intl, }) => { @@ -41,6 +43,14 @@ const TableActions = ({ + {encodingsDownloadUrl ? ( + + + + ) : null} handleBulkDownload(selectedFlatRows)} disabled={_.isEmpty(selectedFlatRows)} @@ -161,11 +171,11 @@ TableActions.propTypes = { original: PropTypes.shape({ displayName: PropTypes.string.isRequired, wrapperType: PropTypes.string.isRequired, - locked: PropTypes.bool.isRequired, - externalUrl: PropTypes.string.isRequired, + locked: PropTypes.bool, + externalUrl: PropTypes.string, thumbnail: PropTypes.string, id: PropTypes.string.isRequired, - portableUrl: PropTypes.string.isRequired, + portableUrl: PropTypes.string, }).isRequired, }), ), @@ -174,9 +184,14 @@ TableActions.propTypes = { }).isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleBulkDownload: PropTypes.func.isRequired, + encodingsDownloadUrl: PropTypes.string, handleSort: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; +TableActions.defaultProps = { + encodingsDownloadUrl: null, +}; + export default injectIntl(TableActions); diff --git a/src/files-and-uploads/table-components/index.js b/src/files-and-videos/table-components/index.js similarity index 73% rename from src/files-and-uploads/table-components/index.js rename to src/files-and-videos/table-components/index.js index 9df0da049c..cafee08288 100644 --- a/src/files-and-uploads/table-components/index.js +++ b/src/files-and-videos/table-components/index.js @@ -1,9 +1,7 @@ import GalleryCard from './GalleryCard'; -import ListCard from './ListCard'; import TableActions from './TableActions'; export { TableActions, GalleryCard, - ListCard, }; diff --git a/src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/AccessColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/AccessColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx similarity index 89% rename from src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx index a604dfe348..149c0736ef 100644 --- a/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx +++ b/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx @@ -5,7 +5,7 @@ import { Check } from '@edx/paragon/icons'; const ActiveColumn = ({ row }) => { const { usageLocations } = row.original; - const numOfUsageLocations = usageLocations.length; + const numOfUsageLocations = usageLocations?.length; return numOfUsageLocations > 0 ? : null; }; diff --git a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx similarity index 90% rename from src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx index 762b553ab7..0dfd12880e 100644 --- a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -18,7 +18,7 @@ const MoreInfoColumn = ({ row, handleLock, handleBulkDownload, - handleOpenAssetInfo, + handleOpenFileInfo, handleOpenDeleteConfirmation, // injected intl, @@ -36,7 +36,13 @@ const MoreInfoColumn = ({ } = row.original; return ( <> - + handleOpenAssetInfo(row.original)} + onClick={() => handleOpenFileInfo(row.original)} > {intl.formatMessage(messages.infoTitle)} @@ -135,7 +141,7 @@ const MoreInfoColumn = ({ }; MoreInfoColumn.propTypes = { - row: { + row: PropTypes.shape({ original: { externalUrl: PropTypes.string, locked: PropTypes.bool, @@ -143,13 +149,17 @@ MoreInfoColumn.propTypes = { id: PropTypes.string.isRequired, wrapperType: PropTypes.string, }.isRequired, - }.isRequired, - handleLock: PropTypes.func.isRequired, + }).isRequired, + handleLock: PropTypes.func, handleBulkDownload: PropTypes.func.isRequired, - handleOpenAssetInfo: PropTypes.func.isRequired, + handleOpenFileInfo: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; +MoreInfoColumn.defaultProps = { + handleLock: null, +}; + export default injectIntl(MoreInfoColumn); diff --git a/src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/StatusColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/StatusColumn.jsx diff --git a/src/files-and-videos/table-components/table-custom-columns/ThumbnailColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/ThumbnailColumn.jsx new file mode 100644 index 0000000000..1df2315b16 --- /dev/null +++ b/src/files-and-videos/table-components/table-custom-columns/ThumbnailColumn.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import FileThumbnail from '../../FileThumbnail'; + +const ThumbnailColumn = ({ row, thumbnailPreview }) => { + const { + thumbnail, + wrapperType, + externalUrl, + displayName, + id, + status, + } = row.original; + return ( + + ); +}; + +ThumbnailColumn.propTypes = { + row: { + original: { + thumbnail: PropTypes.string, + wrapperType: PropTypes.string.isRequired, + }.isRequired, + }.isRequired, + thumbnailPreview: PropTypes.func.isRequired, +}; + +export default ThumbnailColumn; diff --git a/src/files-and-uploads/table-components/table-custom-columns/index.js b/src/files-and-videos/table-components/table-custom-columns/index.js similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/index.js rename to src/files-and-videos/table-components/table-custom-columns/index.js diff --git a/src/files-and-videos/videos-page/VideoThumbnail.jsx b/src/files-and-videos/videos-page/VideoThumbnail.jsx new file mode 100644 index 0000000000..0fcb1fb102 --- /dev/null +++ b/src/files-and-videos/videos-page/VideoThumbnail.jsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { VideoFile } from '@edx/paragon/icons'; +import { + Badge, + Button, + Icon, + Image, +} from '@edx/paragon'; +import FileInput, { useFileInput } from '../FileInput'; +import messages from './messages'; + +const VideoThumbnail = ({ + thumbnail, + displayName, + id, + imageSize, + handleAddThumbnail, + videoImageSettings, + status, + // injected + intl, +}) => { + const fileInputControl = useFileInput({ + onAddFile: (file) => handleAddThumbnail(file, id), + setSelectedRows: () => {}, + setAddOpen: () => false, + }); + const [thumbnailError, setThumbnailError] = useState(false); + const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled; + + let addThumbnailMessage = 'Add thumbnail'; + if (allowThumbnailUpload) { + if (thumbnail) { + addThumbnailMessage = 'Edit thumbnail'; + } + } + const supportedFiles = videoImageSettings?.supportedFileFormats + ? Object.values(videoImageSettings.supportedFileFormats) : null; + let isUploaded = false; + + switch (status) { + case 'Ready': + isUploaded = true; + break; + case 'Imported': + isUploaded = true; + break; + default: + break; + } + + const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded; + + return ( +
+ {allowThumbnailUpload &&
} + {showThumbnail && !thumbnailError ? ( +
+ {intl.formatMessage(messages.thumbnailAltMessage, setThumbnailError(true)} + /> +
+ ) : ( + <> +
+ +
+
+ {!isUploaded && ( + + {status} + + )} +
+ + )} + {allowThumbnailUpload && ( + <> +
+ +
+ + + )} +
+ ); +}; + +VideoThumbnail.propTypes = { + thumbnail: PropTypes.string, + displayName: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + imageSize: PropTypes.shape({ + width: PropTypes.string, + height: PropTypes.string, + }).isRequired, + handleAddThumbnail: PropTypes.func.isRequired, + videoImageSettings: PropTypes.shape({ + videoImageUploadEnabled: PropTypes.bool.isRequired, + supportedFileFormats: PropTypes.shape({}), + }).isRequired, + status: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +VideoThumbnail.defaultProps = { + thumbnail: null, +}; + +export default injectIntl(VideoThumbnail); diff --git a/src/files-and-videos/videos-page/VideoThumbnail.scss b/src/files-and-videos/videos-page/VideoThumbnail.scss new file mode 100644 index 0000000000..e97c75b246 --- /dev/null +++ b/src/files-and-videos/videos-page/VideoThumbnail.scss @@ -0,0 +1,61 @@ +.video-thumbnail { + position: relative; + width: 90%; + max-width: 400px; + margin: auto; + overflow: hidden; +} + +.video-thumbnail .thumbnail-overlay { + background: rgba(0 0 0 / .7); + position: absolute; + height: 99%; + width: 100%; + left: 0; + top: 0; + bottom: 0; + right: 0; + opacity: 0; + -webkit-transition: all .4s ease-in-out 0s; + -moz-transition: all .4s ease-in-out 0s; + transition: all .4s ease-in-out 0s; +} + +.status-badge { + position: absolute; + text-align: center; + padding-left: 1em; + padding-right: 1em; + top: 50%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.video-thumbnail:hover .thumbnail-overlay { + opacity: 1; +} + +.add-thumbnail { + position: absolute; + text-align: center; + padding-left: 1em; + padding-right: 1em; + width: 100%; + top: 50%; + left: 50%; + opacity: 0; + -webkit-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + -webkit-transition: all .3s ease-in-out 0s; + -moz-transition: all .3s ease-in-out 0s; + transition: all .3s ease-in-out 0s; +} + +.video-thumbnail:hover .add-thumbnail { + top: 50%; + left: 50%; + opacity: 1; +} diff --git a/src/files-and-videos/videos-page/Videos.jsx b/src/files-and-videos/videos-page/Videos.jsx new file mode 100644 index 0000000000..68b4b5002b --- /dev/null +++ b/src/files-and-videos/videos-page/Videos.jsx @@ -0,0 +1,227 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { + injectIntl, + FormattedMessage, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { + useToggle, + ActionRow, + Button, +} from '@edx/paragon'; +import Placeholder from '@edx/frontend-lib-content-components'; + +import { RequestStatus } from '../../data/constants'; +import { useModels, useModel } from '../../generic/model-store'; +import { + addVideoFile, + addVideoThumbnail, + deleteVideoFile, + fetchVideoDownload, + fetchVideos, + getUsagePaths, + resetErrors, + updateVideoOrder, +} from './data/thunks'; +import messages from './messages'; +import VideosProvider from './VideosProvider'; +import getPageHeadTitle from '../../generic/utils'; +import FileTable from '../FileTable'; +import EditFileErrors from '../EditFileErrors'; +import ThumbnailColumn from '../table-components/table-custom-columns/ThumbnailColumn'; +import ActiveColumn from '../table-components/table-custom-columns/ActiveColumn'; +import StatusColumn from '../table-components/table-custom-columns/StatusColumn'; +import TranscriptSettings from './transcript-settings'; +import VideoThumbnail from './VideoThumbnail'; +import { getFormattedDuration, resampleFile } from './data/utils'; +import FILES_AND_UPLOAD_TYPE_FILTERS from '../data/constant'; + +const Videos = ({ + courseId, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); + const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + + useEffect(() => { + dispatch(fetchVideos(courseId)); + }, [courseId]); + + const { + totalCount, + videoIds, + loadingStatus, + transcriptStatus, + addingStatus: addVideoStatus, + deletingStatus: deleteVideoStatus, + updatingStatus: updateVideoStatus, + usageStatus: usagePathStatus, + errors: errorMessages, + pageSettings, + } = useSelector(state => state.videos); + + const { + isVideoTranscriptEnabled, + encodingsDownloadUrl, + videoUploadMaxFileSize, + videoSupportedFileFormats, + videoImageSettings, + } = pageSettings; + + const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video }; + + const handleAddFile = (file) => dispatch(addVideoFile(courseId, file)); + const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount)); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows })); + const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); + const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleFileOrder = ({ newFileIdOrder, sortType }) => { + dispatch(updateVideoOrder(courseId, newFileIdOrder, sortType)); + }; + const handleAddThumbnail = (file, videoId) => resampleFile({ + file, + dispatch, + courseId, + videoId, + addVideoThumbnail, + }); + + const videos = useModels('videos', videoIds); + + const data = { + supportedFileFormats, + encodingsDownloadUrl, + totalCount, + fileIds: videoIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, + }; + const thumbnailPreview = (props) => VideoThumbnail({ ...props, handleAddThumbnail, videoImageSettings }); + const maxFileSize = videoUploadMaxFileSize * 1073741824; + const transcriptColumn = { + id: 'transcripts', + Header: 'Transcript', + Cell: ({ row }) => { + const { transcripts } = row.original; + const numOfTranscripts = transcripts?.length; + return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; + }, + }; + const activeColumn = { + id: 'usageLocations', + Header: 'Active', + Cell: ({ row }) => ActiveColumn({ row }), + }; + const durationColumn = { + id: 'duration', + Header: 'Video length', + Cell: ({ row }) => { + const { duration } = row.original; + return getFormattedDuration(duration); + }, + }; + const processingStatusColumn = { + id: 'status', + Header: '', + Cell: ({ row }) => StatusColumn({ row }), + }; + const videoThumbnailColumn = { + id: 'courseVideoImageUrl', + Header: '', + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), + }; + const tableColumns = [ + { ...videoThumbnailColumn }, + { + Header: 'File name', + accessor: 'clientVideoId', + }, + { ...durationColumn }, + { ...transcriptColumn }, + { ...activeColumn }, + { ...processingStatusColumn }, + ]; + + if (loadingStatus === RequestStatus.DENIED) { + return ( +
+ +
+ ); + } + return ( + +
+
+ + +
+ +
+ + {isVideoTranscriptEnabled ? ( + + ) : null} +
+
+ {isVideoTranscriptEnabled ? ( + + ) : null} + +
+
+ ); +}; + +Videos.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(Videos); diff --git a/src/files-and-videos/videos-page/Videos.test.jsx b/src/files-and-videos/videos-page/Videos.test.jsx new file mode 100644 index 0000000000..24bd308145 --- /dev/null +++ b/src/files-and-videos/videos-page/Videos.test.jsx @@ -0,0 +1,632 @@ +import { + render, + act, + fireEvent, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import { RequestStatus } from '../../data/constants'; +import Videos from './Videos'; +import { + generateFetchVideosApiResponse, + generateEmptyApiResponse, + generateNewVideoApiResponse, + generateAddVideoApiResponse, + getStatusValue, + courseId, + initialState, +} from './factories/mockApiResponses'; + +import { + fetchVideos, + addVideoFile, + deleteVideoFile, + getUsagePaths, + addVideoThumbnail, + fetchVideoDownload, +} from './data/thunks'; +import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api'; +import videoMessages from './messages'; +import messages from '../messages'; + +let axiosMock; +let store; +let file; +jest.mock('file-saver'); + +const renderComponent = () => { + render( + + + + + , + ); +}; + +const mockStore = async ( + status, +) => { + const fetchVideosUrl = getVideosUrl(courseId); + axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateFetchVideosApiResponse()); + await executeThunk(fetchVideos(courseId), store.dispatch); +}; + +const emptyMockStore = async (status) => { + const fetchVideosUrl = getVideosUrl(courseId); + axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse()); + await executeThunk(fetchVideos(courseId), store.dispatch); +}; + +describe('FilesAndUploads', () => { + describe('empty state', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore({ + ...initialState, + videos: { + ...initialState.videos, + videoIds: [], + }, + models: {}, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + file = new File(['(⌐□_□)'], 'download.mp4', { type: 'video/mp4' }); + }); + + it('should return placeholder component', async () => { + renderComponent(); + await mockStore(RequestStatus.DENIED); + expect(screen.getByTestId('under-construction-placeholder')).toBeVisible(); + }); + + it('should not render transcript settings button', async () => { + renderComponent(); + await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.queryByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage)); + }); + + it('should have Video uploads title', async () => { + renderComponent(); + await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByText(videoMessages.heading.defaultMessage)).toBeVisible(); + }); + + it('should render dropzone', async () => { + renderComponent(); + await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('files-dropzone')).toBeVisible(); + + expect(screen.queryByTestId('files-data-table')).toBeNull(); + }); + + it('should upload a single file', async () => { + renderComponent(); + await emptyMockStore(RequestStatus.SUCCESSFUL); + const dropzone = screen.getByTestId('files-dropzone'); + await act(async () => { + const mockResponseData = { status: '200', ok: true, blob: () => 'Data' }; + const mockFetchResponse = Promise.resolve(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + + Object.defineProperty(dropzone, 'files', { + value: [file], + }); + fireEvent.drop(dropzone); + await executeThunk(addVideoFile(courseId, file), store.dispatch); + }); + const addStatus = store.getState().videos.addingStatus; + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('files-dropzone')).toBeNull(); + + expect(screen.getByTestId('files-data-table')).toBeVisible(); + }); + }); + + describe('valid videos', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); + }); + + describe('table view', () => { + it('should render transcript settings button', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const transcriptSettingsButton = screen.getByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage); + expect(transcriptSettingsButton).toBeVisible(); + + await act(async () => { + fireEvent.click(transcriptSettingsButton); + }); + + expect(screen.getByLabelText('close settings')).toBeVisible(); + }); + + it('should render table with gallery card', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('files-data-table')).toBeVisible(); + + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + }); + + it('should switch table to list view', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('files-data-table')).toBeVisible(); + + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + expect(screen.queryByRole('table')).toBeNull(); + + const listButton = screen.getByLabelText('List'); + await act(async () => { + fireEvent.click(listButton); + }); + expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); + + expect(screen.getByRole('table')).toBeVisible(); + }); + + it('should update video thumbnail', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(200, { image_url: 'url' }); + const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1'); + const thumbnail = new File(['test'], 'sOMEUrl.jpg', { type: 'image/jpg' }); + await act(async () => { + fireEvent.click(addThumbnailButton); + await executeThunk(addVideoThumbnail({ file: thumbnail, videoId: 'mOckID1', courseId }), store.dispatch); + }); + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + }); + + describe('table actions', () => { + it('should upload a single file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const mockResponseData = { status: '200', ok: true, blob: () => 'Data' }; + const mockFetchResponse = Promise.resolve(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + + const addFilesButton = screen.getAllByLabelText('file-input')[3]; + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addVideoFile(courseId, file), store.dispatch); + }); + const addStatus = store.getState().videos.addingStatus; + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('should have disabled action buttons', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + expect(screen.getByText(messages.downloadTitle.defaultMessage).closest('a')).toHaveClass('disabled'); + + expect(screen.getByText(messages.deleteTitle.defaultMessage).closest('a')).toHaveClass('disabled'); + }); + + it('delete button should be enabled and delete selected file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; + fireEvent.click(selectCardButton); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a'); + expect(deleteButton).not.toHaveClass('disabled'); + + axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204); + + fireEvent.click(deleteButton); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + await act(async () => { + userEvent.click(deleteButton); + }); + + // Wait for the delete confirmation button to appear + const confirmDeleteButton = await screen.findByRole('button', { + name: messages.deleteFileButtonLabel.defaultMessage, + }); + + await act(async () => { + userEvent.click(confirmDeleteButton); + }); + + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + + // Check if the video is deleted in the store and UI + const deleteStatus = store.getState().videos.deletingStatus; + expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); + }); + + it('download button should be enabled and download single selected file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0]; + fireEvent.click(selectCardButton); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + await act(async () => { + fireEvent.click(downloadButton); + }); + + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('download button should be enabled and download multiple selected files', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); + fireEvent.click(selectCardButtons[0]); + fireEvent.click(selectCardButtons[1]); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' }); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID5`).reply(200, { download_link: 'http://download.org' }); + + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + await act(async () => { + fireEvent.click(downloadButton); + }); + + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('sort button should be enabled and sort files by name', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); + expect(sortsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(sortsButton); + expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + }); + + const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage); + fireEvent.click(sortNameAscendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); + + it('sort button should be enabled and sort files by file size', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); + expect(sortsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(sortsButton); + expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + }); + + const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); + fireEvent.click(sortBySizeDescendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); + }); + + describe('card menu actions', () => { + it('should open video info', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) + .reply(201, { usageLocations: ['subsection - unit / block'] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); + + const { usageStatus } = store.getState().videos; + + expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getByText('subsection - unit / block')).toBeVisible(); + }); + + it('should open video info modal and show info tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + + const infoTab = screen.getAllByRole('tab')[0]; + expect(infoTab).toBeVisible(); + + expect(infoTab).toHaveClass('active'); + }); + + it('should open video info modal and show transcript tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + expect(transcriptTab).toBeVisible(); + + expect(transcriptTab).toHaveClass('active'); + }); + + it('download button should download file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'test' }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Download')); + }); + + const updateStatus = store.getState().videos.updatingStatus; + + expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('delete button should delete file', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + const fileMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(fileMenuButton).toBeVisible(); + + await waitFor(() => { + axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204); + fireEvent.click(within(fileMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + + executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch); + }); + const deleteStatus = store.getState().videos.deletingStatus; + expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull(); + }); + }); + + describe('api errors', () => { + it('invalid file size should show error', async () => { + const errorMessage = 'File download.png exceeds maximum size of 5 GB.'; + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(413, { error: errorMessage }); + const addFilesButton = screen.getAllByLabelText('file-input')[3]; + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addVideoFile(courseId, file), store.dispatch); + }); + const addStatus = store.getState().videos.addingStatus; + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('404 add file should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(404); + const addFilesButton = screen.getAllByLabelText('file-input')[3]; + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addVideoFile(courseId, file), store.dispatch); + }); + const addStatus = store.getState().videos.addingStatus; + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('404 add thumbnail should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(404); + const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1'); + const thumbnail = new File(['test'], 'sOMEUrl.jpg', { type: 'image/jpg' }); + await act(async () => { + fireEvent.click(addThumbnailButton); + await executeThunk(addVideoThumbnail({ file: thumbnail, videoId: 'mOckID1', courseId }), store.dispatch); + }); + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('404 upload file to server should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const mockResponseData = { status: '404', ok: false, blob: () => 'Data' }; + const mockFetchResponse = Promise.reject(mockResponseData); + global.fetch = jest.fn().mockImplementation(() => mockFetchResponse); + + axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse()); + axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse()); + const addFilesButton = screen.getAllByLabelText('file-input')[3]; + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addVideoFile(courseId, file), store.dispatch); + }); + const addStatus = store.getState().videos.addingStatus; + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('404 delete should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); + + await waitFor(() => { + axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(404); + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible(); + + fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); + expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull(); + + executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch); + }); + const deleteStatus = store.getState().videos.deletingStatus; + expect(deleteStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + + expect(screen.getByText('Error')).toBeVisible(); + }); + + it('404 usage path fetch should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible(); + + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); + expect(videoMenuButton).toBeVisible(); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(404); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + executeThunk(getUsagePaths({ + courseId, + video: { id: 'mOckID3', displayName: 'mOckID3' }, + }), store.dispatch); + }); + const { usageStatus } = store.getState().videos; + expect(usageStatus).toEqual(RequestStatus.FAILED); + }); + + it('multiple video files fetch failure should show error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); + fireEvent.click(selectCardButtons[0]); + fireEvent.click(selectCardButtons[2]); + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + expect(actionsButton).toBeVisible(); + + await waitFor(() => { + fireEvent.click(actionsButton); + }); + const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); + expect(downloadButton).not.toHaveClass('disabled'); + + await waitFor(() => { + fireEvent.click(downloadButton); + executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), store.dispatch); + }); + + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/VideosProvider.jsx b/src/files-and-videos/videos-page/VideosProvider.jsx new file mode 100644 index 0000000000..d196168093 --- /dev/null +++ b/src/files-and-videos/videos-page/VideosProvider.jsx @@ -0,0 +1,25 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +export const VideosContext = React.createContext({}); + +const VideosProvider = ({ courseId, children }) => { + const contextValue = useMemo(() => ({ + courseId, + path: `/course/${courseId}/videos`, + }), []); + return ( + + {children} + + ); +}; + +VideosProvider.propTypes = { + courseId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export default VideosProvider; diff --git a/src/files-and-videos/videos-page/data/api.js b/src/files-and-videos/videos-page/data/api.js new file mode 100644 index 0000000000..16fdc73948 --- /dev/null +++ b/src/files-and-videos/videos-page/data/api.js @@ -0,0 +1,238 @@ +/* eslint-disable import/prefer-default-export */ +import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import saveAs from 'file-saver'; +import { isEmpty } from 'lodash'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; +export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; + +/** + * Fetches the course custom pages for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getVideos(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getVideosUrl(courseId)); + const { video_transcript_settings: videoTranscriptSettings } = data; + const { transcription_plans: transcriptionPlans } = videoTranscriptSettings; + return { + ...camelCaseObject(data), + videoTranscriptSettings: { + ...camelCaseObject(videoTranscriptSettings), + transcriptionPlans, + }, + }; +} + +/** + * Fetches the course custom pages for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function fetchVideoList(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCoursVideosApiUrl(courseId)); + return camelCaseObject(data); +} + +export async function deleteTranscript({ videoId, language, apiUrl }) { + await getAuthenticatedHttpClient() + .delete(`${getApiBaseUrl()}${apiUrl}/${videoId}/${language}`); +} + +export async function downloadTranscript({ + videoId, + language, + apiUrl, + filename, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getApiBaseUrl()}${apiUrl}?edx_video_id=${videoId}&language_code=${language}`); + const file = new Blob([data], { type: 'text/plain;charset=utf-8' }); + saveAs(file, filename); +} + +export async function uploadTranscript({ + videoId, + newLanguage, + apiUrl, + file, + language, +}) { + const formData = new FormData(); + formData.append('file', file); + formData.append('edx_video_id', videoId); + formData.append('language_code', language); + formData.append('new_language_code', newLanguage); + await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); +} + +export async function getDownload(selectedRows) { + const downloadErrors = []; + if (selectedRows?.length > 0) { + await Promise.allSettled( + selectedRows.map(async row => { + try { + const video = row.original; + const { downloadLink } = video; + if (!isEmpty(downloadLink)) { + saveAs(downloadLink, video.displayName); + } else { + downloadErrors.push(`Cannot find download file for ${video?.displayName}.`); + } + } catch (error) { + downloadErrors.push('Failed to download video.'); + } + }), + ); + } else { + downloadErrors.push('No files were selected to download.'); + } + return downloadErrors; +} + +/** + * Fetch where a video is used in a course. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function getVideoUsagePaths({ courseId, videoId }) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getVideosUrl(courseId)}/${videoId}/usage`); + return camelCaseObject(data); +} + +/** + * Delete video from course. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function deleteVideo(courseId, videoId) { + await getAuthenticatedHttpClient() + .delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`); +} + +/** + * Add thumbnail to video. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function addThumbnail({ courseId, videoId, file }) { + const formData = new FormData(); + formData.append('file', file); + const { data } = await getAuthenticatedHttpClient() + .post(`${getApiBaseUrl()}/video_images/${courseId}/${videoId}`, formData); + return camelCaseObject(data); +} + +/** + * Add video to course. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function addVideo(courseId, file) { + const postJson = { + files: [{ file_name: file.name, content_type: file.type }], + }; + + const { data } = await getAuthenticatedHttpClient() + .post(getCoursVideosApiUrl(courseId), postJson); + return camelCaseObject(data); +} + +export async function uploadVideo( + courseId, + uploadUrl, + uploadFile, + edxVideoId, +) { + const formData = new FormData(); + formData.append('uploaded-file', uploadFile); + const uploadErrors = []; + await fetch(uploadUrl, { + method: 'PUT', + body: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then(async () => { + await getAuthenticatedHttpClient() + .post(getCoursVideosApiUrl(courseId), [{ + edxVideoId, + message: 'Upload completed', + status: 'upload_completed', + }]); + }) + .catch(async () => { + uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`); + await getAuthenticatedHttpClient() + .post(getCoursVideosApiUrl(courseId), [{ + edxVideoId, + message: 'Upload failed', + status: 'upload_failed', + }]); + }); + return uploadErrors; +} + +export async function deleteTranscriptPreferences(courseId) { + await getAuthenticatedHttpClient().delete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`); +} + +export async function setTranscriptPreferences(courseId, preferences) { + const { + cielo24Fidelity, + cielo24Turnaround, + global, + preferredLanguages, + provider, + threePlayTurnaround, + videoSourceLanguage, + } = preferences; + const postJson = { + cielo24_fideltiy: cielo24Fidelity?.toUpperCase(), + cielo24_turnaround: cielo24Turnaround, + global, + preferred_languages: preferredLanguages, + provider, + video_source_language: videoSourceLanguage, + three_play_turnaround: threePlayTurnaround, + }; + + const { data } = await getAuthenticatedHttpClient() + .post(`${getApiBaseUrl()}/transcript_preferences/${courseId}`, postJson); + return camelCaseObject(data); +} + +export async function setTranscriptCredentials(courseId, formFields) { + const { + apiKey, + global, + provider, + ...otherFields + } = formFields; + const postJson = { + api_key: apiKey, + global, + provider, + }; + + if (provider === '3PlayMedia') { + const { apiSecretKey } = otherFields; + postJson.api_secret_key = apiSecretKey; + } else { + const { username } = otherFields; + postJson.username = username; + } + await getAuthenticatedHttpClient() + .post(`${getApiBaseUrl()}/transcript_credentials/${courseId}`, postJson); +} diff --git a/src/files-and-videos/videos-page/data/api.test.js b/src/files-and-videos/videos-page/data/api.test.js new file mode 100644 index 0000000000..7604a7a08a --- /dev/null +++ b/src/files-and-videos/videos-page/data/api.test.js @@ -0,0 +1,55 @@ +import { getDownload } from './api'; +import 'file-saver'; + +jest.mock('file-saver'); + +describe('api.js', () => { + describe('getDownload', () => { + describe('selectedRows length is undefined or less than zero', () => { + it('should return with no files selected error if selectedRows is empty', async () => { + const expected = ['No files were selected to download.']; + const actual = await getDownload([], 'courseId'); + expect(actual).toEqual(expected); + }); + it('should return with no files selected error if selectedRows is null', async () => { + const expected = ['No files were selected to download.']; + const actual = await getDownload(null, 'courseId'); + expect(actual).toEqual(expected); + }); + }); + describe('selectedRows length is greater than one', () => { + it('should not throw error when blob returns null', async () => { + const expected = []; + const actual = await getDownload([ + { original: { displayName: 'test1', downloadLink: 'test1.com' } }, + { original: { displayName: 'test2', id: '2', downloadLink: 'test2.com' } }, + ]); + expect(actual).toEqual(expected); + }); + it('should return error if row does not contain .original attribute', async () => { + const expected = ['Failed to download video.']; + const actual = await getDownload([ + { asset: { displayName: 'test1', id: '1' } }, + { original: { displayName: 'test2', id: '2', downloadLink: 'test1.com' } }, + ]); + expect(actual).toEqual(expected); + }); + it('should return error if original does not contain .downloadLink attribute', async () => { + const expected = ['Cannot find download file for test2.']; + const actual = await getDownload([ + { original: { displayName: 'test2', id: '2' } }, + ]); + expect(actual).toEqual(expected); + }); + }); + describe('selectedRows length equals one', () => { + it('should return error if row does not contain .original ancestor', async () => { + const expected = ['Failed to download video.']; + const actual = await getDownload([ + { asset: { displayName: 'test1', id: '1', download_link: 'test1.com' } }, + ]); + expect(actual).toEqual(expected); + }); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/data/constants.js b/src/files-and-videos/videos-page/data/constants.js new file mode 100644 index 0000000000..b534e7eb73 --- /dev/null +++ b/src/files-and-videos/videos-page/data/constants.js @@ -0,0 +1,8 @@ +export const MAX_FILE_SIZE_MB = 2000000; +export const MIN_FILE_SIZE_KB = 2000; +export const MAX_WIDTH = 1280; +export const MAX_HEIGHT = 720; +export const MIN_WIDTH = 640; +export const MIN_HEIGHT = 360; +export const ASPECT_RATIO = 16 / 9; +export const ASPECT_RATIO_ERROR_MARGIN = 0.1; diff --git a/src/files-and-videos/videos-page/data/slice.js b/src/files-and-videos/videos-page/data/slice.js new file mode 100644 index 0000000000..3e766292ff --- /dev/null +++ b/src/files-and-videos/videos-page/data/slice.js @@ -0,0 +1,109 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../../data/constants'; + +const slice = createSlice({ + name: 'videos', + initialState: { + videoIds: [], + pageSettings: {}, + loadingStatus: RequestStatus.IN_PROGRESS, + updatingStatus: '', + addingStatus: '', + deletingStatus: '', + usageStatus: '', + transcriptStatus: '', + errors: { + add: [], + delete: [], + thumbnail: [], + download: [], + usageMetrics: [], + transcript: [], + }, + totalCount: 0, + }, + reducers: { + setVideoIds: (state, { payload }) => { + state.videoIds = payload.videoIds; + }, + setPageSettings: (state, { payload }) => { + state.pageSettings = payload; + }, + setTotalCount: (state, { payload }) => { + state.totalCount = payload.totalCount; + }, + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateEditStatus: (state, { payload }) => { + const { editType, status } = payload; + switch (editType) { + case 'delete': + state.deletingStatus = status; + break; + case 'add': + state.addingStatus = status; + break; + case 'thumbnail': + state.updatingStatus = status; + break; + case 'download': + state.updatingStatus = status; + break; + case 'usageMetrics': + state.usageStatus = status; + break; + case 'transcript': + state.transcriptStatus = status; + break; + default: + break; + } + }, + deleteVideoSuccess: (state, { payload }) => { + state.videoIds = state.videoIds.filter(id => id !== payload.videoId); + }, + addVideoSuccess: (state, { payload }) => { + state.videoIds = [payload.videoId, ...state.videoIds]; + }, + updateTranscriptCredentialsSuccess: (state, { payload }) => { + const { provider } = payload; + state.pageSettings.transcriptCredentials = { + ...state.pageSettings.transcriptCredentials, + [provider]: true, + }; + }, + updateTranscriptPreferenceSuccess: (state, { payload }) => { + state.pageSettings.activeTranscriptPreferences = payload; + }, + updateErrors: (state, { payload }) => { + const { error, message } = payload; + const currentErrorState = state.errors[error]; + state.errors[error] = [...currentErrorState, message]; + }, + clearErrors: (state, { payload }) => { + const { error } = payload; + state.errors[error] = []; + }, + }, +}); + +export const { + setVideoIds, + setPageSettings, + setTotalCount, + updateLoadingStatus, + deleteVideoSuccess, + addVideoSuccess, + updateErrors, + clearErrors, + updateEditStatus, + updateTranscriptCredentialsSuccess, + updateTranscriptPreferenceSuccess, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js new file mode 100644 index 0000000000..7f9aa1a40c --- /dev/null +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -0,0 +1,352 @@ +import { camelCase, isEmpty } from 'lodash'; +import { getConfig } from '@edx/frontend-platform'; +import { RequestStatus } from '../../../data/constants'; +import { + addModels, + removeModel, + updateModel, + updateModels, +} from '../../../generic/model-store'; +import { + addThumbnail, + addVideo, + deleteVideo, + fetchVideoList, + getVideos, + uploadVideo, + getDownload, + deleteTranscript, + downloadTranscript, + uploadTranscript, + getVideoUsagePaths, + deleteTranscriptPreferences, + setTranscriptCredentials, + setTranscriptPreferences, +} from './api'; +import { + setVideoIds, + setPageSettings, + setTotalCount, + updateLoadingStatus, + deleteVideoSuccess, + addVideoSuccess, + updateErrors, + clearErrors, + updateEditStatus, + updateTranscriptCredentialsSuccess, + updateTranscriptPreferenceSuccess, +} from './slice'; + +import { updateFileValues } from './utils'; + +export function fetchVideos(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); + + try { + const { previousUploads, ...data } = await getVideos(courseId); + const parsedVideos = updateFileValues(previousUploads); + dispatch(addModels({ modelType: 'videos', models: parsedVideos })); + dispatch(setVideoIds({ + videoIds: parsedVideos.map(video => video.id), + })); + dispatch(setPageSettings({ ...data })); + dispatch(setTotalCount({ totalCount: parsedVideos.length })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + } catch (error) { + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED })); + } + } + }; +} + +export function resetErrors({ errorType }) { + return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; +} + +export function updateVideoOrder(courseId, videoIds) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); + dispatch(setVideoIds({ videoIds })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + }; +} + +export function deleteVideoFile(courseId, id, totalCount) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS })); + + try { + await deleteVideo(courseId, id); + dispatch(deleteVideoSuccess({ videoId: id })); + dispatch(removeModel({ modelType: 'videos', id })); + dispatch(setTotalCount({ totalCount: totalCount - 1 })); + + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` })); + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED })); + } + }; +} + +export function addVideoFile(courseId, file) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); + + try { + const { files } = await addVideo(courseId, file); + const { edxVideoId, uploadUrl } = files[0]; + const errors = await uploadVideo( + courseId, + uploadUrl, + file, + edxVideoId, + ); + const { videos } = await fetchVideoList(courseId); + const parsedVideos = updateFileValues(videos); + dispatch(updateModels({ + modelType: 'videos', + models: parsedVideos, + })); + dispatch(addVideoSuccess({ + videoId: edxVideoId, + })); + dispatch(setTotalCount({ totalCount: parsedVideos.length })); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); + if (!isEmpty(errors)) { + errors.forEach(error => { + dispatch(updateErrors({ error: 'add', message: error })); + }); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); + } + } catch (error) { + if (error.response && error.response.status === 413) { + const message = error.response.data.error; + dispatch(updateErrors({ error: 'add', message })); + } else { + dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` })); + } + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); + } + }; +} + +export function addVideoThumbnail({ file, videoId, courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.IN_PROGRESS })); + dispatch(resetErrors({ errorType: 'thumbnail' })); + try { + const { imageUrl } = await addThumbnail({ courseId, videoId, file }); + let thumbnail = imageUrl; + if (thumbnail.startsWith('/')) { + thumbnail = `${getConfig().STUDIO_BASE_URL}${imageUrl}`; + } + dispatch(updateModel({ + modelType: 'videos', + model: { + id: videoId, + thumbnail, + }, + })); + dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + if (error.response?.data?.error) { + const message = error.response.data.error; + dispatch(updateErrors({ error: 'thumbnail', message })); + } else { + dispatch(updateErrors({ error: 'thumbnail', message: `Failed to add thumbnail for video id ${videoId}.` })); + } + dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.FAILED })); + } + }; +} + +export function deleteVideoTranscript({ + language, + videoId, + transcripts, + apiUrl, +}) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + + try { + await deleteTranscript({ + videoId, + language, + apiUrl, + }); + const updatedTranscripts = transcripts.filter(transcript => transcript !== language); + dispatch(updateModel({ + modelType: 'videos', + model: { + id: videoId, + transcripts: updatedTranscripts, + }, + })); + + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'transcript', message: `Failed to delete ${language} transcript.` })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} + +export function downloadVideoTranscript({ + language, + videoId, + filename, + apiUrl, +}) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + + try { + await downloadTranscript({ + videoId, + language, + apiUrl, + filename, + }); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'transcript', message: `Failed to download ${filename}.` })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} + +export function uploadVideoTranscript({ + language, + newLanguage, + videoId, + file, + apiUrl, + transcripts, +}) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + const isReplacement = !isEmpty(language); + + try { + await uploadTranscript({ + videoId, + language, + apiUrl, + file, + newLanguage, + }); + let updatedTranscripts = transcripts; + if (isReplacement) { + const removeTranscript = transcripts.filter(transcript => transcript !== language); + updatedTranscripts = [...removeTranscript, newLanguage]; + } else { + updatedTranscripts = [...transcripts, newLanguage]; + } + + dispatch(updateModel({ + modelType: 'videos', + model: { + id: videoId, + transcripts: updatedTranscripts, + }, + })); + + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + if (error.response?.data?.error) { + const message = error.response.data.error; + dispatch(updateErrors({ error: 'transcript', message })); + } else { + const message = isReplacement ? `Failed to replace ${language} with ${newLanguage}.` : `Failed to add ${newLanguage}.`; + dispatch(updateErrors({ error: 'transcript', message })); + } + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} + +export function getUsagePaths({ video, courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS })); + + try { + const { usageLocations } = await getVideoUsagePaths({ videoId: video.id, courseId }); + dispatch(updateModel({ + modelType: 'videos', + model: { + id: video.id, + usageLocations, + }, + })); + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${video.displayName}.` })); + dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED })); + } + }; +} + +export function fetchVideoDownload({ selectedRows }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); + const errors = await getDownload(selectedRows); + if (isEmpty(errors)) { + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL })); + } else { + errors.forEach(error => { + dispatch(updateErrors({ error: 'download', message: error })); + }); + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED })); + } + }; +} + +export function clearAutomatedTranscript({ courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + + try { + await deleteTranscriptPreferences(courseId); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'transcript', message: 'Failed to update order transcripts settings.' })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} + +export function updateTranscriptCredentials({ courseId, data }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + + try { + await setTranscriptCredentials(courseId, data); + dispatch(updateTranscriptCredentialsSuccess({ provider: camelCase(data.provider) })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'transcript', message: `Failed to update ${data.provider} credentials.` })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} + +export function updateTranscriptPreference({ courseId, data }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); + + try { + const preferences = await setTranscriptPreferences(courseId, data); + dispatch(updateTranscriptPreferenceSuccess(preferences)); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateErrors({ error: 'transcript', message: `Failed to update ${data.provider} transcripts settings.` })); + dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js new file mode 100644 index 0000000000..9676e0649c --- /dev/null +++ b/src/files-and-videos/videos-page/data/utils.js @@ -0,0 +1,252 @@ +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { isArray, isEmpty } from 'lodash'; +import { + ASPECT_RATIO, + ASPECT_RATIO_ERROR_MARGIN, + MAX_HEIGHT, + MAX_WIDTH, + MIN_HEIGHT, + MIN_WIDTH, +} from './constants'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +export const updateFileValues = (files) => { + const updatedFiles = []; + files.forEach(file => { + const { + edxVideoId, + clientVideoId, + created, + courseVideoImageUrl, + } = file; + const wrapperType = 'video'; + + let thumbnail = courseVideoImageUrl; + if (thumbnail && thumbnail.startsWith('/')) { + thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; + } + + updatedFiles.push({ + ...file, + displayName: clientVideoId, + id: edxVideoId, + wrapperType, + dateAdded: created.toString(), + usageLocations: [], + thumbnail, + }); + }); + + return updatedFiles; +}; + +export const getFormattedDuration = (value) => { + if (!value || typeof value !== 'number' || value <= 0) { + return '00:00:00'; + } + const seconds = Math.floor(value % 60); + const minutes = Math.floor((value / 60) % 60); + const hours = Math.floor((value / 360) % 60); + const zeroPad = (num) => String(num).padStart(2, '0'); + return [hours, minutes, seconds].map(zeroPad).join(':'); +}; + +export const getLanguages = (availableLanguages) => { + const languages = {}; + availableLanguages?.forEach(language => { + const { languageCode, languageText } = language; + languages[languageCode] = languageText; + }); + return languages; +}; + +export const getSupportedFormats = (supportedFileFormats) => { + if (isEmpty(supportedFileFormats)) { + return null; + } + if (isArray(supportedFileFormats)) { + return supportedFileFormats; + } + const supportedFormats = []; + Object.entries(supportedFileFormats).forEach(([key, value]) => { + let format; + if (isArray(value)) { + value.forEach(val => { + format = key.replace('*', val.substring(1)); + supportedFormats.push(format); + }); + } else { + format = key.replace('*', value?.substring(1)); + supportedFormats.push(format); + } + }); + return supportedFormats; +}; + +/** createResampledFile({ canvasUrl, filename, mimeType }) + * createResampledFile takes a canvasUrl, filename, and a valid mimeType. The + * canvasUrl is parsed and written to an 8-bit array of unsigned integers. The + * new array is saved to a new file with the same filename as the original image. + * @param {string} canvasUrl - string of base64 URL for new image canvas + * @param {string} filename - string of the original image's filename + * @param {string} mimeType - string of mimeType for the canvas + * @return {File} new File object + */ +export const createResampledFile = ({ canvasUrl, filename, mimeType }) => { + const arr = canvasUrl.split(','); + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mimeType }); +}; + +/** resampleImage({ image, filename }) + * resampledImage takes a canvasUrl, filename, and a valid mimeType. The + * canvasUrl is parsed and written to an 8-bit array of unsigned integers. The + * new array is saved to a new file with the same filename as the original image. + * @param {File} canvasUrl - string of base64 URL for new image canvas + * @param {string} filename - string of the image's filename + * @return {array} array containing the base64 URL for the resampled image and the file containing the resampled image + */ +export const resampleImage = ({ image, filename }) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Determine new dimensions for image + if (image.naturalWidth > MAX_WIDTH) { + // Set dimensions to the maximum size + canvas.width = MAX_WIDTH; + canvas.height = MAX_HEIGHT; + } else if (image.naturalWidth < MIN_WIDTH) { + // Set dimensions to the minimum size + canvas.width = MIN_WIDTH; + canvas.height = MIN_HEIGHT; + } else { + // Set dimensions to the closest 16:9 ratio + const heightRatio = 9 / 16; + canvas.width = image.naturalWidth; + canvas.height = image.naturalWidth * heightRatio; + } + const cropLeft = (image.naturalWidth - canvas.width) / 2; + const cropTop = (image.naturalHeight - canvas.height) / 2; + + ctx.drawImage(image, cropLeft, cropTop, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); + const resampledFile = createResampledFile({ canvasUrl: canvas.toDataURL(), filename, mimeType: 'image/png' }); + return resampledFile; +}; + +export const hasValidDimensions = ({ width, height }) => { + const imageAspectRatio = Math.abs((width / height) - ASPECT_RATIO); + + if (width < MIN_WIDTH || height < MIN_HEIGHT) { + return false; + } + if (imageAspectRatio >= ASPECT_RATIO_ERROR_MARGIN) { + return false; + } + return true; +}; + +export const resampleFile = ({ + file, + dispatch, + videoId, + courseId, + addVideoThumbnail, +}) => { + const reader = new FileReader(); + const image = new Image(); + reader.onload = () => { + image.src = reader.result; + image.onload = () => { + const width = image.naturalWidth; + const height = image.naturalHeight; + if (!hasValidDimensions({ width, height })) { + const resampledFile = resampleImage({ image, filename: file.name }); + dispatch(addVideoThumbnail({ courseId, videoId, file: resampledFile })); + } else { + dispatch(addVideoThumbnail({ courseId, videoId, file })); + } + }; + }; + reader.readAsDataURL(file); +}; + +export const getLanguageOptions = (keys, languages) => { + const options = {}; + if (keys) { + keys.forEach(key => { + options[key] = languages[key]; + }); + } + return options; +}; + +export const getFidelityOptions = (fidelities) => { + const options = {}; + Object.entries(fidelities).forEach(([key, value]) => { + const { display_name: displayName } = value; + options[key] = displayName; + }); + return options; +}; + +export const checkCredentials = (transcriptCredentials) => { + const cieloHasCredentials = transcriptCredentials.cielo24; + const threePlayHasCredentials = transcriptCredentials['3PlayMedia']; + return [cieloHasCredentials, threePlayHasCredentials]; +}; + +export const checkTranscriptionPlans = (transcriptionPlans) => { + let cieloIsValid = !isEmpty(transcriptionPlans.Cielo24); + let threePlayIsValid = !isEmpty(transcriptionPlans['3PlayMedia']); + + if (cieloIsValid) { + const { fidelity, turnaround } = transcriptionPlans.Cielo24; + cieloIsValid = !isEmpty(fidelity) && !isEmpty(turnaround); + } + + if (threePlayIsValid) { + const { languages, turnaround, translations } = transcriptionPlans['3PlayMedia']; + threePlayIsValid = !isEmpty(turnaround) && !isEmpty(languages) && !isEmpty(translations); + } + + return [cieloIsValid, threePlayIsValid]; +}; + +export const validateForm = (cieloHasCredentials, threePlayHasCredentials, provider, data) => { + const { + apiKey, + apiSecretKey, + username, + cielo24Fidelity, + cielo24Turnaround, + preferredLanguages, + threePlayTurnaround, + videoSourceLanguage, + } = data; + switch (provider) { + case 'Cielo24': + if (cieloHasCredentials) { + return !isEmpty(cielo24Fidelity) && !isEmpty(cielo24Turnaround) + && !isEmpty(preferredLanguages) && !isEmpty(videoSourceLanguage); + } + return !isEmpty(apiKey) && !isEmpty(username); + case '3PlayMedia': + if (threePlayHasCredentials) { + return !isEmpty(threePlayTurnaround) && !isEmpty(preferredLanguages) && !isEmpty(videoSourceLanguage); + } + return !isEmpty(apiKey) && !isEmpty(apiSecretKey); + case 'order': + return true; + default: + break; + } + return false; +}; diff --git a/src/files-and-videos/videos-page/data/utils.test.js b/src/files-and-videos/videos-page/data/utils.test.js new file mode 100644 index 0000000000..2d3708a99d --- /dev/null +++ b/src/files-and-videos/videos-page/data/utils.test.js @@ -0,0 +1,259 @@ +import 'jest-canvas-mock'; +import { + hasValidDimensions, + getSupportedFormats, + resampleImage, + createResampledFile, + validateForm, + checkTranscriptionPlans, +} from './utils'; + +describe('getSupportedFormats', () => { + it('should return null', () => { + const supportedFileFormats = getSupportedFormats(''); + expect(supportedFileFormats).toBeNull(); + }); + it('should return provided supportedFileFormats', () => { + const expected = ['image/png', 'video/mp4']; + const actual = getSupportedFormats(expected); + expect(expected).toEqual(actual); + }); + it('should return array of valid file types', () => { + const expected = ['image/png']; + const actual = getSupportedFormats({ 'image/*': '.png' }); + expect(expected).toEqual(actual); + }); + it('should return array of valid file types', () => { + const expected = ['video/mp4', 'video/mov']; + const actual = getSupportedFormats({ 'video/*': ['.mp4', '.mov'] }); + expect(expected).toEqual(actual); + }); +}); + +describe('createResampledFile', () => { + it('should return resampled file object', () => { + const expected = new File([{ name: 'imageName', size: 20000 }], 'testVALUEVALIDIMAGE'); + const actual = createResampledFile({ + canvasUrl: 'data:MimETYpe,sOMEUrl', + filename: 'imageName', + mimeType: 'sOmEuiMAge', + }); + + expect(expected).toEqual(actual); + }); +}); + +describe('resampleImage', () => { + it('should return filename and file', () => { + const resampledFile = new File([{ name: 'testVALUEVALIDIMAGE', size: 20000 }], 'testVALUEVALIDIMAGE'); + const image = document.createElement('img'); + image.height = '800'; + image.width = '800'; + const actualImage = resampleImage({ image, filename: 'testVALUEVALIDIMAGE' }); + + expect(actualImage).toEqual(resampledFile); + }); +}); + +describe('checkValidDimensions', () => { + it('returns false for images less than min width and min height', () => { + const image = { width: 500, height: 281 }; + const actual = hasValidDimensions(image); + expect(actual).toBeFalsy(); + }); + it('returns false for images that do not have a 16:9 aspect ratio', () => { + const image = { width: 800, height: 800 }; + const actual = hasValidDimensions(image); + expect(actual).toBeFalsy(); + }); + it('returns true for images that have a 16:9 aspect ratio and larger than min width/height', () => { + const image = { width: 1280, height: 720 }; + const actual = hasValidDimensions(image); + expect(actual).toBeTruthy(); + }); +}); + +describe('validateForm', () => { + describe('provider equals Cielo24', () => { + describe('with credentials', () => { + it('should return false', () => { + const isValid = validateForm( + true, + false, + 'Cielo24', + { + cielo24Fidelity: 'test-fidelity', + cielo24Turnaround: 'test-turnaround', + preferredLanguages: [], + videoSourceLanguage: 'test-source', + }, + ); + expect(isValid).toBeFalsy(); + }); + it('should return true', () => { + const isValid = validateForm( + true, + false, + 'Cielo24', + { + cielo24Fidelity: 'test-fidelity', + cielo24Turnaround: 'test-turnaround', + preferredLanguages: ['test-language'], + videoSourceLanguage: 'test-source', + }, + ); + expect(isValid).toBeTruthy(); + }); + }); + describe('with no credentials', () => { + it('should return false', () => { + const isValid = validateForm( + false, + false, + 'Cielo24', + { + apiKey: 'test-key', + username: '', + }, + ); + expect(isValid).toBeFalsy(); + }); + it('should return true', () => { + const isValid = validateForm( + false, + false, + 'Cielo24', + { + apiKey: 'test-key', + username: 'test-username', + }, + ); + expect(isValid).toBeTruthy(); + }); + }); + }); + describe('provider equals 3PlayMedia', () => { + describe('with credentials', () => { + it('should return false', () => { + const isValid = validateForm( + false, + true, + '3PlayMedia', + { + threePlayTurnaround: 'test-turnaround', + preferredLanguages: ['test-language'], + videoSourceLanguage: '', + }, + ); + expect(isValid).toBeFalsy(); + }); + it('should return true', () => { + const isValid = validateForm( + true, + true, + '3PlayMedia', + { + threePlayTurnaround: 'test-turnaround', + preferredLanguages: ['test-language'], + videoSourceLanguage: 'test-source', + }, + ); + expect(isValid).toBeTruthy(); + }); + }); + describe('with no credentials', () => { + it('should return false', () => { + const isValid = validateForm( + true, + false, + '3PlayMedia', + { + apiKey: 'test-key', + username: '', + }, + ); + expect(isValid).toBeFalsy(); + }); + it('should return true', () => { + const isValid = validateForm( + false, + false, + '3PlayMedia', + { + apiKey: 'test-key', + apiSecretKey: 'test-username', + }, + ); + expect(isValid).toBeTruthy(); + }); + }); + }); + describe('provider equals order', () => { + it('should return true', () => { + const isValid = validateForm( + false, + false, + 'order', + {}, + ); + expect(isValid).toBeTruthy(); + }); + }); + describe('provider equals null', () => { + it('should return false', () => { + const isValid = validateForm( + false, + false, + null, + {}, + ); + expect(isValid).toBeFalsy(); + }); + }); +}); + +describe('checkTranscriptionPlans', () => { + describe('invalid Cielo24 plan', () => { + it('Cielo24 is empty should return [false, false]', () => { + const expected = [false, false]; + const actual = checkTranscriptionPlans({ '3PlayMedia': {} }); + expect(actual).toEqual(expected); + }); + it('Cielo24 is missing required atrribute fidelity should return [false, true]', () => { + const expected = [false, true]; + const actual = checkTranscriptionPlans({ + '3PlayMedia': { + languages: ['en'], + turnaround: 'test', + translations: { en: 'English' }, + }, + Cielo24: { + turnaround: ['tomorrow'], + }, + }); + expect(actual).toEqual(expected); + }); + }); + describe('invalid 3PlayMedia plan', () => { + it('3PlayMedia is empty should return [false, false]', () => { + const expected = [false, false]; + const actual = checkTranscriptionPlans({ Cielo24: {} }); + expect(actual).toEqual(expected); + }); + it('3PlayMedia atrribute languages is empty should return [true, false]', () => { + const expected = [true, false]; + const actual = checkTranscriptionPlans({ + Cielo24: { + turnaround: ['tomorrow'], + fidelity: 'test', + }, + '3PlayMedia': { + languages: [], + turnaround: 'test', + translations: { en: 'English' }, + }, + }); + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx new file mode 100644 index 0000000000..8a5085b704 --- /dev/null +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -0,0 +1,244 @@ +import { RequestStatus } from '../../../data/constants'; + +export const courseId = 'course'; + +export const initialState = { + courseDetail: { + courseId, + status: 'sucessful', + }, + videos: { + videoIds: ['mOckID0'], + pageSettings: { + transcriptAvailableLanguages: [ + { languageCode: 'ar', languageText: 'Arabic' }, + { languageCode: 'en', languageText: 'English' }, + { languageCode: 'fr', languageText: 'French' }, + ], + videoImageSettings: { + videoImageUploadEnabled: false, + maxSize: 2097152, + minSize: 2048, + maxWidth: 1280, + maxHeight: 720, + supportedFileFormats: { + '.bmp': 'image/bmp', + '.bmp2': 'image/x-ms-bmp', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + }, + }, + isVideoTranscriptEnabled: false, + activeTranscriptPreferences: null, + videoTranscriptSettings: { + transcriptDownloadHandlerUrl: '/transcript_download/', + transcriptUploadHandlerUrl: '/transcript_upload/', + transcriptDeleteHandlerUrl: `/transcript_delete/${courseId}`, + transcriptionPlans: { + Cielo24: { + turnaround: { PRIORITY: 'Priority (24 hours)' }, + fidelity: { + PREMIUM: { display_name: 'Premium (95% accuracy)', languages: { en: 'English' } }, + PROFESSIONAL: { + display_name: 'Professional (99% accuracy)', + languages: { + ar: 'Arabic', + en: 'English', + fr: 'French', + es: 'Spanish', + }, + }, + }, + }, + '3PlayMedia': { + turnaround: { two_hour: '2 hours' }, + translations: { + es: ['en'], + en: ['ar', 'en', 'es', 'fr'], + }, + languages: { + ar: 'Arabic', + en: 'English', + fr: 'French', + es: 'Spanish', + }, + }, + }, + }, + transcriptCredentials: { cielo24: false, '3PlayMedia': false }, + }, + loadingStatus: RequestStatus.SUCCESSFUL, + updatingStatus: '', + addingStatus: '', + deletingStatus: '', + usageStatus: '', + transcriptStatus: '', + errors: { + add: [], + delete: [], + thumbnail: [], + download: [], + usageMetrics: [], + transcript: [], + }, + totalCount: 0, + }, + models: { + videos: { + mOckID0: { + id: 'mOckID0', + displayName: 'mOckID0.mp4', + wrapperType: 'video', + dateAdded: '', + thumbnail: '/video', + fileSize: null, + edx_video_id: 'mOckID0', + clientVideoId: 'mOckID0.mp4', + created: '', + courseVideoImageUrl: '/video', + transcripts: [], + status: 'Imported', + downloadLink: 'http://mOckID0.mp4', + }, + }, + }, +}; + +export const generateFetchVideosApiResponse = () => ({ + image_upload_url: '/video_images/course', + video_handler_url: '/videos/course', + encodings_download_url: '/video_encodings_download/course', + default_video_image_url: '/static/studio/images/video-images/default_video_image.png', + previous_uploads: [ + { + edx_video_id: 'mOckID1', + clientVideoId: 'mOckID1.mp4', + created: '', + courseVideoImageUrl: '/video', + transcripts: [], + status: 'Imported', + duration: 12333, + downloadLink: 'http://mOckID1.mp4', + }, + { + edx_video_id: 'mOckID5', + clientVideoId: 'mOckID5.mp4', + created: '', + courseVideoImageUrl: 'http:/video', + transcripts: ['en'], + status: 'Failed', + duration: 12, + downloadLink: 'http://mOckID5.mp4', + }, + { + edx_video_id: 'mOckID3', + clientVideoId: 'mOckID3.mp4', + created: '', + courseVideoImageUrl: null, + transcripts: ['en'], + status: 'Ready', + duration: null, + downloadLink: '', + }, + ], + concurrent_upload_limit: 4, + video_supported_file_formats: ['.mp4', '.mov'], + video_upload_max_file_size: '5', + video_image_settings: { + video_image_upload_enabled: true, + max_size: 2097152, + min_size: 2048, + max_width: 1280, + max_height: 720, + supported_file_formats: { + '.bmp': 'image/bmp', + '.bmp2': 'image/x-ms-bmp', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + }, + }, + is_video_transcript_enabled: true, + active_transcript_preferences: null, + transcript_credentials: {}, + transcript_available_languages: [{ language_code: 'ab', language_text: 'Abkhazian' }], + video_transcript_settings: { + transcript_download_handler_url: '/transcript_download/', + transcript_upload_handler_url: '/transcript_upload/', + transcript_delete_handler_url: '/transcript_delete/course', + trancript_download_file_format: 'srt', + transcript_preferences_handler_url: '/transcript_preferences/course', + transcript_credentials_handler_url: '/transcript_credentials/course', + transcription_plans: { + Cielo24: { + display_name: 'Cielo24', + turnaround: { PRIORITY: 'Priority (24 hours)', STANDARD: 'Standard (48 hours)' }, + fidelity: { + MECHANICAL: { + display_name: 'Mechanical (75% accuracy)', + languages: { nl: 'Dutch', en: 'English', fr: 'French' }, + }, + PREMIUM: { display_name: 'Premium (95% accuracy)', languages: { en: 'English' } }, + PROFESSIONAL: { + display_name: 'Professional (99% accuracy)', + languages: { ar: 'Arabic', 'zh-tw': 'Chinese - Mandarin (Traditional)' }, + }, + }, + }, + '3PlayMedia': { + display_name: '3Play Media', + turnaround: { + two_hour: '2 hours', + same_day: 'Same day', + rush: '24 hours (rush)', + expedited: '2 days (expedited)', + standard: '4 days (standard)', + extended: '10 days (extended)', + }, + languages: { en: 'English', el: 'Greek', zh: 'Chinese' }, + translations: { + es: ['en'], + en: ['el', 'en', 'zh'], + }, + }, + }, + }, + pagination_context: {}, +}); + +export const generateAddVideoApiResponse = () => ({ + videos: [ + { + edx_video_id: 'mOckID4', + clientVideoId: 'mOckID4.mov', + created: '', + courseVideoImageUrl: null, + transcripts: ['en'], + status: 'Uploaded', + duration: 168.001, + }, + ], +}); + +export const generateEmptyApiResponse = () => ([{ + previousUploads: [], +}]); + +export const generateNewVideoApiResponse = () => ({ + files: [{ + edx_video_id: 'mOckID4', + upload_url: 'http://testing.org', + }], +}); + +export const getStatusValue = (status) => { + switch (status) { + case RequestStatus.DENIED: + return 403; + default: + return 200; + } +}; diff --git a/src/files-and-videos/videos-page/index.js b/src/files-and-videos/videos-page/index.js new file mode 100644 index 0000000000..70fd52042b --- /dev/null +++ b/src/files-and-videos/videos-page/index.js @@ -0,0 +1,6 @@ +import TranscriptSettings from './transcript-settings'; +import Videos from './Videos'; +import VideoThumbnail from './VideoThumbnail'; + +export default Videos; +export { TranscriptSettings, VideoThumbnail }; diff --git a/src/files-and-videos/videos-page/info-sidebar/FileInfoVideoSidebar.jsx b/src/files-and-videos/videos-page/info-sidebar/FileInfoVideoSidebar.jsx new file mode 100644 index 0000000000..09ae0b66fb --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/FileInfoVideoSidebar.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Tabs, + Tab, +} from '@edx/paragon'; +import InfoTab from './InfoTab'; +import TranscriptTab from './TranscriptTab'; +import messages from './messages'; + +const FileInfoVideoSidebar = ({ + video, + // injected + intl, +}) => ( + + + + + + + + +); + +FileInfoVideoSidebar.propTypes = { + video: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + transcripts: PropTypes.arrayOf(PropTypes.string), + }), + // injected + intl: intlShape.isRequired, +}; + +FileInfoVideoSidebar.defaultProps = { + video: null, +}; + +export default injectIntl(FileInfoVideoSidebar); diff --git a/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx b/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx new file mode 100644 index 0000000000..1662dab266 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Stack } from '@edx/paragon'; +import { injectIntl, FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { getFileSizeToClosestByte } from '../../data/utils'; +import { getFormattedDuration } from '../data/utils'; +import messages from './messages'; + +const InfoTab = ({ video }) => { + const fileSize = getFileSizeToClosestByte(video?.fileSize); + const duration = getFormattedDuration(video?.duration); + + return ( + +
+ +
+ +
+ +
+ {fileSize} +
+ +
+ {duration} +
+ ); +}; + +InfoTab.propTypes = { + video: PropTypes.shape({ + duration: PropTypes.number.isRequired, + dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + }), +}; + +InfoTab.defaultProps = { + video: {}, +}; + +export default injectIntl(InfoTab); diff --git a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx new file mode 100644 index 0000000000..c0e947d8a4 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { isEmpty } from 'lodash'; +import { ErrorAlert } from '@edx/frontend-lib-content-components'; +import { Button, Stack } from '@edx/paragon'; +import { Add } from '@edx/paragon/icons'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getLanguages } from '../data/utils'; +import Transcript from './transcript-item'; +import { + deleteVideoTranscript, + downloadVideoTranscript, + resetErrors, + uploadVideoTranscript, +} from '../data/thunks'; +import { RequestStatus } from '../../../data/constants'; +import messages from './messages'; + +const TranscriptTab = ({ + video, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const { transcriptStatus, errors } = useSelector(state => state.videos); + const { + transcriptAvailableLanguages, + videoTranscriptSettings, + } = useSelector(state => state.videos.pageSettings); + const { + transcriptDeleteHandlerUrl, + transcriptUploadHandlerUrl, + transcriptDownloadHandlerUrl, + } = videoTranscriptSettings; + const { transcripts, id, displayName } = video; + const languages = getLanguages(transcriptAvailableLanguages); + + const [previousSelection, setPreviousSelection] = useState(transcripts); + useEffect(() => { + dispatch(resetErrors({ errorType: 'transcript' })); + setPreviousSelection(transcripts); + }, [transcripts]); + + const handleTranscript = (data, actionType) => { + const { + language, + newLanguage, + file, + } = data; + dispatch(resetErrors({ errorType: 'transcript' })); + switch (actionType) { + case 'delete': + if (isEmpty(language)) { + const updatedSelection = previousSelection.filter(selection => selection !== ''); + setPreviousSelection(updatedSelection); + } else { + dispatch(deleteVideoTranscript({ + language, + videoId: id, + apiUrl: transcriptDeleteHandlerUrl, + transcripts, + })); + } + break; + case 'download': + dispatch(downloadVideoTranscript({ + filename: `${displayName}-${language}.srt`, + language, + videoId: id, + apiUrl: transcriptDownloadHandlerUrl, + })); + break; + case 'upload': + dispatch(uploadVideoTranscript({ + language, + videoId: id, + apiUrl: transcriptUploadHandlerUrl, + newLanguage, + file, + transcripts, + })); + break; + default: + break; + } + }; + + return ( + + +
    + {errors.transcript.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ {previousSelection.map(transcript => ( + + ))} + +
+ ); +}; + +TranscriptTab.propTypes = { + video: PropTypes.shape({ + transcripts: PropTypes.arrayOf(PropTypes.string).isRequired, + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + }).isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(TranscriptTab); 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 new file mode 100644 index 0000000000..94e9cf7037 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx @@ -0,0 +1,336 @@ +import { + render, + act, + fireEvent, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ReactDOM from 'react-dom'; + +import { + initializeMockApp, +} from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from '../../../store'; +import { executeThunk } from '../../../utils'; +import { RequestStatus } from '../../../data/constants'; +import TranscriptTab from './TranscriptTab'; +import { + courseId, + initialState, +} from '../factories/mockApiResponses'; + +import { getApiBaseUrl } from '../data/api'; +import messages from './messages'; +import transcriptRowMessages from './transcript-item/messages'; +import VideosProvider from '../VideosProvider'; +import { deleteVideoTranscript } from '../data/thunks'; + +ReactDOM.createPortal = jest.fn(node => node); + +const defaultProps = { + id: 'mOckID0', + displayName: 'mOckID0.mp4', + wrapperType: 'video', + dateAdded: '', + thumbnail: '/video', + fileSize: null, + edx_video_id: 'mOckID0', + clientVideoId: 'mOckID0.mp4', + created: '', + courseVideoImageUrl: '/video', + transcripts: [], + status: 'Imported', +}; + +let axiosMock; +let store; +jest.mock('file-saver'); + +const renderComponent = (props) => { + render( + + + + + + + , + ); +}; + +describe('TranscriptTab', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + 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 }); + expect(addButton).toBeInTheDocument(); + expect(transcriptRow).toBeNull(); + }); + + it('should delete empty transcript row', 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); }); + + expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + + const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); + await act(async () => { fireEvent.click(confirmButton); }); + + expect(screen.queryByTestId('transcript-')).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); }); + }); + + it('should upload new transcript', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + await act(async () => { + const addFileInput = screen.getByLabelText('file-input'); + expect(addFileInput).toBeInTheDocument(); + + userEvent.upload(addFileInput, file); + }); + const addStatus = store.getState().videos.transcriptStatus; + + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('should show default error message', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404); + await act(async () => { + const addFileInput = screen.getByLabelText('file-input'); + userEvent.upload(addFileInput, file); + }); + const addStatus = store.getState().videos.transcriptStatus; + + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getAllByText('Failed to add .')[0]).toBeVisible(); + }); + + it('should show api provided error message', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404, { error: 'api error' }); + await act(async () => { + const addFileInput = screen.getByLabelText('file-input'); + userEvent.upload(addFileInput, file); + }); + const addStatus = store.getState().videos.transcriptStatus; + + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getAllByText('api error')[0]).toBeVisible(); + }); + }); + }); + + describe('with one transcripts preloaded', () => { + const updatedProps = { ...defaultProps, transcripts: ['ar'] }; + beforeEach(() => { + renderComponent(updatedProps); + }); + + it('should contain transcript row', () => { + const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); + const transcriptRow = screen.getByTestId('transcript-ar'); + expect(addButton).toBeInTheDocument(); + expect(transcriptRow).toBeInTheDocument(); + }); + + describe('deleteVideoTranscript', () => { + beforeEach(async () => { + const menuButton = screen.getByTestId('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + + const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a'); + fireEvent.click(deleteButton); + }); + + it('should open delete confirmation modal and cancel delete', async () => { + const cancelButton = screen.getByText(transcriptRowMessages.cancelDeleteLabel.defaultMessage); + 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); + axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(204); + await act(async () => { + fireEvent.click(confirmButton); + executeThunk(deleteVideoTranscript({ + language: 'ar', + videoId: updatedProps.id, + transcripts: updatedProps.transcripts, + apiUrl: `/transcript_delete/${courseId}`, + }), store.dispatch); + }); + 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); + axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(404); + await act(async () => { + fireEvent.click(confirmButton); + executeThunk(deleteVideoTranscript({ + language: 'ar', + videoId: updatedProps.id, + transcripts: updatedProps.transcripts, + apiUrl: `/transcript_delete/${courseId}`, + }), store.dispatch); + }); + 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(); + }); + }); + + describe('downloadVideoTranscript', () => { + let downloadButton; + beforeEach(async () => { + const menuButton = screen.getByTestId('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + downloadButton = screen.getByText( + transcriptRowMessages.downloadTranscript.defaultMessage, + ).closest('a'); + }); + + it('should download transcript', async () => { + axiosMock.onGet( + `${getApiBaseUrl()}/transcript_download/?edx_video_id=${updatedProps.id}&language_code=ar`, + ).reply(200, 'string of transcript'); + await act(async () => { + fireEvent.click(downloadButton); + }); + const downloadStatus = store.getState().videos.transcriptStatus; + + expect(downloadStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('should show error message', async () => { + const filename = 'mOckID0.mp4-ar.srt'; + axiosMock.onGet( + `${getApiBaseUrl()}/transcript_download/?edx_video_id=${updatedProps.id}&language_code=ar`, + ).reply(404); + await act(async () => { + fireEvent.click(downloadButton); + }); + const downloadStatus = store.getState().videos.transcriptStatus; + + expect(downloadStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getAllByText(`Failed to download ${filename}.`)[0]).toBeVisible(); + }); + }); + }); + + describe('with multiple transcripts preloaded', () => { + describe('uploadVideoTranscript as replace function', () => { + const file = new File(['(⌐□_□)'], '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'); + const arabicOption = screen.getAllByRole('button', { name: 'Arabic' })[0]; + await act(async () => { + expect(arabicOption).toHaveClass('disabled'); + fireEvent.click(englishOption); + }); + + const menuButton = screen.getByTestId('fr-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const replaceButton = screen.getByText( + transcriptRowMessages.replaceTranscript.defaultMessage, + ).closest('a'); + fireEvent.click(replaceButton); + }); + + it('should replace transcript', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + + await act(async () => { + const addFileInput = screen.getAllByLabelText('file-input')[0]; + expect(addFileInput).toBeInTheDocument(); + + userEvent.upload(addFileInput, file); + }); + const addStatus = store.getState().videos.transcriptStatus; + + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + + const updatedTranscripts = store.getState().models.videos[defaultProps.id].transcripts; + + expect(updatedTranscripts).toEqual(['ar', 'en']); + }); + + it('should show error message', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404); + + await act(async () => { + const addFileInput = screen.getAllByLabelText('file-input')[0]; + expect(addFileInput).toBeInTheDocument(); + + userEvent.upload(addFileInput, file); + }); + + const addStatus = store.getState().videos.transcriptStatus; + + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getAllByText('Failed to replace fr with en.')[0]).toBeVisible(); + }); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/info-sidebar/messages.js b/src/files-and-videos/videos-page/info-sidebar/messages.js new file mode 100644 index 0000000000..3beb245c61 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/messages.js @@ -0,0 +1,40 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + infoTabTitle: { + id: 'course-authoring.video-uploads.file-info.infoTab.title', + defaultMessage: 'Info', + description: 'Title for info tab', + }, + transcriptTabTitle: { + id: 'course-authoring.video-uploads.file-info.transcriptTab.title', + defaultMessage: 'Transcript ({transcriptCount})', + description: 'Title for info tab', + }, + dateAddedTitle: { + id: 'course-authoring.video-uploads.file-info.infoTab.dateAdded.title', + defaultMessage: 'Date added', + description: 'Title for date added section', + }, + fileSizeTitle: { + id: 'course-authoring.video-uploads.file-info.infoTab.fileSize.title', + defaultMessage: 'File size', + description: 'Title for file size section', + }, + videoLengthTitle: { + id: 'course-authoring.video-uploads.file-info.infoTab.videoLength.title', + defaultMessage: 'Video length', + description: 'Title for video length section', + }, + errorAlertMessage: { + id: 'course-authoring.files-and-upload.file-info.transcriptTab.errorAlert.message', + defaultMessage: '{message}', + }, + uploadButtonLabel: { + id: 'course-authoriong.video-uploads.file-info.transcriptTab.upload.label', + defaultMessage: 'Add a transcript', + description: 'Label for upload button', + }, +}); + +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 new file mode 100644 index 0000000000..12ed61c6b7 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, Icon } from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; +import { isEmpty } from 'lodash'; + +const LanguageSelect = ({ + value, + previousSelection, + options, + handleSelect, + placeholderText, +}) => { + const currentSelection = isEmpty(value) ? placeholderText : options[value]; + return ( + + + {currentSelection} + + + {Object.entries(options).map(([valueKey, text]) => { + if (valueKey === value) { + return ( + + {text} + + ); + } + if (!previousSelection.includes(valueKey)) { + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + } + return ( + + {text} + + ); + })} + + + ); +}; + +LanguageSelect.propTypes = { + value: PropTypes.string.isRequired, + options: PropTypes.shape({}).isRequired, + handleSelect: PropTypes.func.isRequired, + placeholderText: PropTypes.string.isRequired, + previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default LanguageSelect; 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 new file mode 100644 index 0000000000..c0e194bc1e --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Card, + Button, + Icon, + IconButton, + useToggle, +} from '@edx/paragon'; +import { DeleteOutline } from '@edx/paragon/icons'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { isEmpty } from 'lodash'; +import LanguageSelect from './LanguageSelect'; +import TranscriptMenu from './TranscriptMenu'; +import messages from './messages'; +import FileInput, { useFileInput } from '../../../FileInput'; + +const Transcript = ({ + languages, + transcript, + previousSelection, + handleTranscript, + // injected + intl, +}) => { + const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle(); + const [newLanguage, setNewLanguage] = useState(transcript); + const language = transcript; + + const input = useFileInput({ + onAddFile: (file) => { + handleTranscript({ + file, + language, + newLanguage, + }, 'upload'); + }, + setSelectedRows: () => {}, + setAddOpen: () => {}, + }); + + const updateLangauge = (selected) => { + setNewLanguage(selected); + if (isEmpty(language)) { + input.click(); + } + }; + + return ( + <> + {isConfirmationOpen ? ( + + )} /> + + + + + + + + + + + ) : ( +
+
+ +
+ { transcript === '' ? ( + + ) : ( + + )} +
+ )} + + + ); +}; + +Transcript.propTypes = { + languages: PropTypes.shape({}).isRequired, + transcript: PropTypes.string.isRequired, + previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, + handleTranscript: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(Transcript); 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 new file mode 100644 index 0000000000..ee97f0f8af --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import { Dropdown, Icon, IconButton } from '@edx/paragon'; +import { MoreHoriz } from '@edx/paragon/icons'; + +import messages from './messages'; + +export const TranscriptActionMenu = ({ + language, + launchDeleteConfirmation, + handleTranscript, + input, +}) => ( + + + + + + + handleTranscript({ language }, 'download')} + > + + + + + + + +); + +TranscriptActionMenu.propTypes = { + language: PropTypes.string.isRequired, + handleTranscript: PropTypes.func.isRequired, + launchDeleteConfirmation: PropTypes.func.isRequired, + input: PropTypes.shape({ + click: PropTypes.func.isRequired, + }).isRequired, +}; + +export default injectIntl(TranscriptActionMenu); diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/index.js b/src/files-and-videos/videos-page/info-sidebar/transcript-item/index.js new file mode 100644 index 0000000000..9f14ebaa24 --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/index.js @@ -0,0 +1,3 @@ +import Transcript from './Transcript'; + +export default Transcript; diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js b/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js new file mode 100644 index 0000000000..b8b53f11ee --- /dev/null +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js @@ -0,0 +1,51 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + fileSizeError: { + id: 'course-authoriong.video-uploads.file-info.transcript.error.fileSizeError', + defaultMessage: 'Transcript file size exeeds the maximum. Please try again.', + description: 'Message presented to user when transcript file size is too large', + }, + deleteTranscript: { + id: 'course-authoriong.video-uploads.file-info.transcript.deleteTranscript', + defaultMessage: 'Delete', + description: 'Message Presented To user for action to delete transcript', + }, + replaceTranscript: { + id: 'course-authoriong.video-uploads.file-info.transcript.replaceTranscript', + defaultMessage: 'Replace', + description: 'Message Presented To user for action to replace transcript', + }, + downloadTranscript: { + id: 'course-authoriong.video-uploads.file-info.transcript.downloadTranscript', + defaultMessage: 'Download', + description: 'Message Presented To user for action to download transcript', + }, + languageSelectPlaceholder: { + id: 'course-authoriong.video-uploads.file-info.transcripts.languageSelectPlaceholder', + defaultMessage: 'Select language', + description: 'Placeholder For Dropdown, which allows users to set the language associtated with a transcript', + }, + cancelDeleteLabel: { + id: 'course-authoriong.video-uploads.file-info.transcripts.cancelDeleteLabel', + defaultMessage: 'Cancel', + description: 'Label For Button, which allows users to stop the process of deleting a transcript', + }, + confirmDeleteLabel: { + id: 'course-authoriong.video-uploads.file-info.transcripts.confirmDeleteLabel', + defaultMessage: 'Delete', + description: 'Label For Button, which allows users to confirm the process of deleting a transcript', + }, + deleteConfirmationMessage: { + id: 'course-authoriong.video-uploads.file-info.transcripts.deleteConfirmationMessage', + defaultMessage: 'Are you sure you want to delete this transcript?', + description: 'Warning which allows users to select next step in the process of deleting a transcript', + }, + deleteConfirmationHeader: { + id: 'course-authoriong.video-uploads.file-info.transcripts.deleteConfirmationTitle', + defaultMessage: 'Delete this transcript?', + description: 'Title for Warning which allows users to select next step in the process of deleting a transcript', + }, +}); + +export default messages; diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js new file mode 100644 index 0000000000..b0dea00c22 --- /dev/null +++ b/src/files-and-videos/videos-page/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.video-uploads.heading', + defaultMessage: 'Videos', + }, + transcriptSettingsButtonLabel: { + id: 'course-authoring.video-uploads.transcript-settings.button.toggle', + defaultMessage: 'Transcript settings', + }, + thumbnailAltMessage: { + id: 'course-authoring.video-uploads.thumbnail.alt', + defaultMessage: '{displayName} video thumbnail', + }, +}); + +export default messages; diff --git a/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx b/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx new file mode 100644 index 0000000000..76dad30e44 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Form, Stack, TransitionReplace } from '@edx/paragon'; +import FormDropdown from './FormDropdown'; +import { getFidelityOptions } from '../data/utils'; +import messages from './messages'; + +const Cielo24Form = ({ + hasTranscriptCredentials, + data, + setData, + transcriptionPlan, + // injected + intl, +}) => { + if (hasTranscriptCredentials) { + const { fidelity } = transcriptionPlan; + const selectedLanguage = data.preferredLanguages ? data.preferredLanguages : ''; + const turnaroundOptions = transcriptionPlan.turnaround; + const fidelityOptions = getFidelityOptions(fidelity); + const sourceLanguageOptions = data.cielo24Fidelity ? fidelity[data.cielo24Fidelity]?.languages : {}; + const languages = data.cielo24Fidelity === 'PROFESSIONAL' ? sourceLanguageOptions : { + [data.videoSourceLanguage]: sourceLanguageOptions[data.videoSourceLanguage], + }; + return ( + + + + + + setData({ ...data, cielo24Turnaround: value })} + placeholderText={intl.formatMessage(messages.cieloTurnaroundPlaceholder)} + /> + + + + + + setData({ ...data, cielo24Fidelity: value, videoSourceLanguage: '' })} + placeholderText={intl.formatMessage(messages.cieloFidelityPlaceholder)} + /> + + + {isEmpty(data.cielo24Fidelity) ? null : ( + + + + + setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })} + placeholderText={intl.formatMessage(messages.cieloSourceLanguagePlaceholder)} + /> + + )} + + + {isEmpty(data.videoSourceLanguage) ? null : ( + + + + + setData({ ...data, preferredLanguages: [value] })} + placeholderText={intl.formatMessage(messages.cieloTranscriptLanguagePlaceholder)} + /> + + )} + + + ); + } + + return ( + +
+ +
+ + + + + setData({ ...data, apiKey: e.target.value })} /> + + + + + + setData({ ...data, username: e.target.value })} /> + +
+ ); +}; + +Cielo24Form.propTypes = { + hasTranscriptCredentials: PropTypes.bool.isRequired, + data: PropTypes.shape({ + apiKey: PropTypes.string, + apiSecretKey: PropTypes.string, + cielo24Turnaround: PropTypes.string, + cielo24Fidelity: PropTypes.string, + preferredLanguages: PropTypes.arrayOf(PropTypes.string), + videoSourceLanguage: PropTypes.string, + }).isRequired, + setData: PropTypes.func.isRequired, + transcriptionPlan: PropTypes.shape({ + turnaround: PropTypes.shape({}), + fidelity: PropTypes.shape({}), + }).isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(Cielo24Form); diff --git a/src/files-and-videos/videos-page/transcript-settings/FormDropdown.jsx b/src/files-and-videos/videos-page/transcript-settings/FormDropdown.jsx new file mode 100644 index 0000000000..99c03556e2 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/FormDropdown.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Dropdown, Form, Icon } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { Check } from '@edx/paragon/icons'; +import { isArray, isEmpty } from 'lodash'; + +const FormDropdown = ({ + value, + allowMultiple, + options, + handleSelect, + placeholderText, +}) => { + let currentSelection; + if (isEmpty(value)) { + currentSelection = placeholderText; + } else { + currentSelection = isArray(value) && value.length > 1 ? 'Multiple' : options[value]; + } + + return ( + + + + {currentSelection} + + + + {Object.entries(options).map(([valueKey, text]) => { + if (allowMultiple) { + return ( + handleSelect([valueKey, e.target.checked])} key={`${valueKey}-item`}> + {text} + + ); + } + if (valueKey === value) { + return ( + + {text} + + ); + } + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + })} + + + ); +}; + +FormDropdown.propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, + allowMultiple: PropTypes.bool, + options: PropTypes.shape({}).isRequired, + handleSelect: PropTypes.func.isRequired, + placeholderText: PropTypes.string.isRequired, +}; + +FormDropdown.defaultProps = { + allowMultiple: false, +}; + +export default FormDropdown; diff --git a/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx new file mode 100644 index 0000000000..87dd5646b7 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Button, SelectableBox, Stack } from '@edx/paragon'; +import { ErrorAlert } from '@edx/frontend-lib-content-components'; +import Cielo24Form from './Cielo24Form'; +import ThreePlayMediaForm from './ThreePlayMediaForm'; +import { RequestStatus } from '../../../data/constants'; +import messages from './messages'; +import { checkCredentials, checkTranscriptionPlans, validateForm } from '../data/utils'; + +const OrderTranscriptForm = ({ + setTranscriptType, + activeTranscriptPreferences, + transcriptType, + transcriptCredentials, + closeTranscriptSettings, + handleOrderTranscripts, + transcriptionPlans, + errorMessages, + transcriptStatus, + // injected + intl, +}) => { + const [data, setData] = useState(activeTranscriptPreferences || { videoSourceLanguage: '' }); + + const [validCieloTranscriptionPlan, validThreePlayTranscriptionPlan] = checkTranscriptionPlans(transcriptionPlans); + + let [cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials); + useEffect(() => { + [cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials); + }, [transcriptCredentials]); + + let isFormValid = validateForm(cieloHasCredentials, threePlayHasCredentials, transcriptType, data); + useEffect(() => { + isFormValid = validateForm(cieloHasCredentials, threePlayHasCredentials, transcriptType, data); + }, [data]); + + const handleDiscard = () => { + setTranscriptType(activeTranscriptPreferences); + closeTranscriptSettings(); + }; + + const handleUpdate = () => handleOrderTranscripts(data, transcriptType); + + let form; + switch (transcriptType) { + case 'Cielo24': + form = ( + + ); + break; + case '3PlayMedia': + form = ( + + ); + break; + default: + break; + } + return ( + <> + + + + + + + +
    + {errorMessages.transcript.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ { + setTranscriptType(e.target.value); + }} + > + + + + + + + + + + + {form} + + + + + + ); +}; + +OrderTranscriptForm.propTypes = { + setTranscriptType: PropTypes.func.isRequired, + activeTranscriptPreferences: PropTypes.shape({}), + transcriptType: PropTypes.string.isRequired, + transcriptCredentials: PropTypes.shape({ + cielo24: PropTypes.bool.isRequired, + '3PlayMedia': PropTypes.bool.isRequired, + }).isRequired, + closeTranscriptSettings: PropTypes.func.isRequired, + transcriptStatus: PropTypes.string.isRequired, + errorMessages: PropTypes.shape({ + transcript: PropTypes.arrayOf(PropTypes.string).isRequired, + }).isRequired, + handleOrderTranscripts: PropTypes.func.isRequired, + transcriptionPlans: PropTypes.shape({ + Cielo24: PropTypes.shape({ + turnaround: PropTypes.shape({}), + fidelity: PropTypes.shape({}), + }).isRequired, + '3PlayMedia': PropTypes.shape({ + turnaround: PropTypes.shape({}), + translations: PropTypes.shape({}), + languages: PropTypes.shape({}), + }).isRequired, + }).isRequired, + // injected + intl: intlShape.isRequired, +}; + +OrderTranscriptForm.defaultProps = { + activeTranscriptPreferences: null, +}; + +export default injectIntl(OrderTranscriptForm); diff --git a/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx b/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx new file mode 100644 index 0000000000..fdde6f1c25 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { + Form, + Icon, + Stack, + TransitionReplace, +} from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; +import FormDropdown from './FormDropdown'; +import { getLanguageOptions } from '../data/utils'; +import messages from './messages'; + +const ThreePlayMediaForm = ({ + hasTranscriptCredentials, + data, + setData, + transcriptionPlan, + // injected + intl, +}) => { + if (hasTranscriptCredentials) { + const selectedLanguages = data.preferredLanguages ? data.preferredLanguages : []; + const turnaroundOptions = transcriptionPlan.turnaround; + const sourceLangaugeOptions = getLanguageOptions( + Object.keys(transcriptionPlan.translations), + transcriptionPlan.languages, + ); + const languages = getLanguageOptions( + transcriptionPlan.translations[data.videoSourceLanguage], + transcriptionPlan.languages, + ); + const allowMultiple = Object.keys(languages).length > 1; + return ( + + + + + + setData({ ...data, threePlayTurnaround: value })} + placeholderText={intl.formatMessage(messages.threePlayMediaTurnaroundPlaceholder)} + /> + + + + + + setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })} + placeholderText={intl.formatMessage(messages.threePlayMediaSourceLanguagePlaceholder)} + /> + + + {!isEmpty(data.videoSourceLanguage) ? ( + + + + + { + if (!allowMultiple) { + setData({ ...data, preferredLanguages: [value] }); + } else { + const [lang, checked] = value; + if (checked) { + setData({ ...data, preferredLanguages: [...selectedLanguages, lang] }); + } else { + const updatedLangList = selectedLanguages.filter((selected) => selected !== lang); + setData({ ...data, preferredLanguages: updatedLangList }); + } + } + }} + placeholderText={intl.formatMessage(messages.threePlayMediaTranscriptLanguagePlaceholder)} + /> + +
    + {selectedLanguages.map(language => ( +
  • + {languages[language]} +
  • + ))} +
+
+
+ ) : null } +
+
+ ); + } + return ( + +
+ +
+ + + + + setData({ ...data, apiKey: e.target.value })} /> + + + + + + setData({ ...data, apiSecretKey: e.target.value })} /> + +
+ ); +}; + +ThreePlayMediaForm.propTypes = { + hasTranscriptCredentials: PropTypes.bool.isRequired, + data: PropTypes.shape({ + apiKey: PropTypes.string, + apiSecretKey: PropTypes.string, + threePlayTurnaround: PropTypes.string, + preferredLanguages: PropTypes.arrayOf(PropTypes.string), + videoSourceLanguage: PropTypes.string, + }).isRequired, + setData: PropTypes.func.isRequired, + transcriptionPlan: PropTypes.shape({ + turnaround: PropTypes.shape({}), + translations: PropTypes.shape({}), + languages: PropTypes.shape({}), + }).isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(ThreePlayMediaForm); diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx new file mode 100644 index 0000000000..4149ad97dd --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Collapsible, + Icon, IconButton, + Sheet, + TransitionReplace, +} from '@edx/paragon'; +import { ChevronLeft, ChevronRight, Close } from '@edx/paragon/icons'; +import OrderTranscriptForm from './OrderTranscriptForm'; +import messages from './messages'; +import { + clearAutomatedTranscript, + resetErrors, + updateTranscriptCredentials, + updateTranscriptPreference, +} from '../data/thunks'; + +const TranscriptSettings = ({ + isTranscriptSettingsOpen, + closeTranscriptSettings, + courseId, +}) => { + const dispatch = useDispatch(); + const { errors: errorMessages, pageSettings, transcriptStatus } = useSelector(state => state.videos); + const { + activeTranscriptPreferences, + transcriptCredentials, + videoTranscriptSettings, + } = pageSettings; + const { transcriptionPlans } = videoTranscriptSettings || {}; + const [transcriptType, setTranscriptType] = useState(activeTranscriptPreferences); + + const handleOrderTranscripts = (data, provider) => { + const noCredentials = isEmpty(transcriptCredentials) || data.apiKey; + dispatch(resetErrors({ errorType: 'transcript' })); + if (provider === 'order') { + dispatch(clearAutomatedTranscript({ courseId })); + } else if (noCredentials) { + dispatch(updateTranscriptCredentials({ courseId, data: { ...data, provider, global: false } })); + } else { + dispatch(updateTranscriptPreference({ courseId, data: { ...data, provider, global: false } })); + } + }; + + return ( + +
+ + + {transcriptType ? ( + setTranscriptType(null)} + alt="back button to main transcript settings view" + /> + ) : ( +
+ +
+ )} +
+ + +
+ + {transcriptType ? ( +
+ +
+ ) : ( +
+ setTranscriptType('order')} + > + + + + + +
+ )} +
+
+
+ ); +}; + +TranscriptSettings.propTypes = { + closeTranscriptSettings: PropTypes.func.isRequired, + isTranscriptSettingsOpen: PropTypes.bool.isRequired, + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(TranscriptSettings); diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss new file mode 100644 index 0000000000..465d640e2b --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss @@ -0,0 +1,4 @@ +.pgn__selectable_box:disabled, +.pgn__selectable_box[disabled] { + opacity: .5; +} diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx new file mode 100644 index 0000000000..9a0eeb1f11 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -0,0 +1,578 @@ +import { + render, + act, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + initializeMockApp, +} from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import initializeStore from '../../../store'; +import { RequestStatus } from '../../../data/constants'; +import TranscriptSettings from './TranscriptSettings'; +import { + courseId, + initialState, +} from '../factories/mockApiResponses'; +import { getApiBaseUrl } from '../data/api'; +import messages from './messages'; +import VideosProvider from '../VideosProvider'; + +const defaultProps = { + isTranscriptSettingsOpen: true, + closeTranscriptSettings: jest.fn(), + courseId, +}; + +let axiosMock; +let store; + +const renderComponent = () => { + render( + + + + + + + , + ); +}; + +describe('TranscriptSettings', () => { + describe('default behaviors', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('should have Transcript settings title', async () => { + renderComponent(); + const header = screen.getByText(messages.transcriptSettingsTitle.defaultMessage); + + expect(header).toBeVisible(); + }); + + it('should change view to order form', async () => { + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + const selectableButtons = screen.getAllByLabelText('none radio')[0]; + + expect(selectableButtons).toBeVisible(); + }); + + it('should return to order transcript collapsible', async () => { + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + const selectableButtons = screen.getAllByLabelText('none radio')[0]; + + expect(selectableButtons).toBeVisible(); + + const backButton = screen.getByLabelText('back button to main transcript settings view'); + await waitFor(() => { + userEvent.click(backButton); + + expect(screen.queryByLabelText('back button to main transcript settings view')).toBeNull(); + }); + }); + + it('discard changes should call closeTranscriptSettings', async () => { + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + const discardButton = screen.getByText(messages.discardSettingsLabel.defaultMessage); + await act(async () => { + userEvent.click(discardButton); + }); + + expect(defaultProps.closeTranscriptSettings).toHaveBeenCalled(); + }); + }); + + describe('delete transcript preferences', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + const noneButton = screen.getAllByLabelText('none radio')[0]; + await act(async () => { + userEvent.click(noneButton); + }); + }); + + it('api should succeed', async () => { + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + axiosMock.onDelete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(204); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('should show error alert', async () => { + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + axiosMock.onDelete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Failed to update order transcripts settings.')).toBeVisible(); + }); + }); + + describe('with no credentials set', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + }); + + it('should ask for Cielo24 or 3Play Media credentials', async () => { + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + const cieloCredentialMessage = screen.getByTestId('cieloCredentialMessage'); + + expect(cieloCredentialMessage).toBeVisible(); + + const threePlayMediaButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayMediaButton); + }); + const threePlayMediaCredentialMessage = screen.getByTestId('threePlayMediaCredentialMessage'); + + expect(threePlayMediaCredentialMessage).toBeVisible(); + }); + + describe('api succeeds', () => { + it('should update cielo24 credentials ', async () => { + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + + const firstInput = screen.getByLabelText(messages.cieloApiKeyLabel.defaultMessage); + const secondInput = screen.getByLabelText(messages.cieloUsernameLabel.defaultMessage); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'username'); + + expect(updateButton).not.toHaveAttribute('disabled'); + }); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200); + await waitFor(() => { + userEvent.click(updateButton); + }); + + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('cieloCredentialMessage')).toBeNull(); + + expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible(); + }); + + it('should update 3Play Media credentials', async () => { + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage); + const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage); + + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'secretKey'); + + expect(updateButton).not.toHaveAttribute('disabled'); + }); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('threePlayCredentialMessage')).toBeNull(); + + expect(screen.getByText(messages.threePlayMediaTurnaroundLabel.defaultMessage)).toBeVisible(); + }); + }); + + describe('api fails', () => { + it('should show error alert on Cielo24 credentials update', async () => { + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + + const firstInput = screen.getByLabelText(messages.cieloApiKeyLabel.defaultMessage); + const secondInput = screen.getByLabelText(messages.cieloUsernameLabel.defaultMessage); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'username'); + + expect(updateButton).not.toHaveAttribute('disabled'); + }); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(503); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Failed to update Cielo24 credentials.')).toBeVisible(); + }); + + it('should show error alert on 3PlayMedia credentials update', async () => { + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage); + const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage); + + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'secretKey'); + + expect(updateButton).not.toHaveAttribute('disabled'); + }); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Failed to update 3PlayMedia credentials.')).toBeVisible(); + }); + }); + }); + + describe('with credentials set', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore({ + ...initialState, + videos: { + ...initialState.videos, + pageSettings: { + ...initialState.videos.pageSettings, + transcriptCredentials: { + cielo24: true, + '3PlayMedia': true, + }, + }, + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + }); + + it('should not show credentials request for Cielo24 and 3Play Media', async () => { + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + const cieloCredentialMessage = screen.queryByTestId('cieloCredentialMessage'); + + expect(cieloCredentialMessage).toBeNull(); + + const threePlayMediaButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayMediaButton); + }); + const threePlayMediaCredentialMessage = screen.queryByTestId('threePlayMediaCredentialMessage'); + + expect(threePlayMediaCredentialMessage).toBeNull(); + }); + + describe('api succeeds', () => { + it('should update cielo24 preferences', async () => { + const apiResponse = { + videoSourceLanguage: 'en', + cielo24Turnaround: 'PRIORITY', + cielo24FidelityTypee: 'PREMIUM', + preferredLanguages: ['en'], + provider: 'cielo24', + global: false, + }; + + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const turnaround = screen.getByText(messages.cieloTurnaroundPlaceholder.defaultMessage); + const fidelity = screen.getByText(messages.cieloFidelityPlaceholder.defaultMessage); + + await waitFor(() => { + userEvent.click(turnaround); + userEvent.click(screen.getByText('Priority (24 hours)')); + + userEvent.click(fidelity); + userEvent.click(screen.getByText('Premium (95% accuracy)')); + + const source = screen.getAllByText(messages.cieloSourceLanguagePlaceholder.defaultMessage)[0]; + userEvent.click(source); + userEvent.click(screen.getByText('English')); + + const language = screen.getByText(messages.cieloTranscriptLanguagePlaceholder.defaultMessage); + userEvent.click(language); + userEvent.click(screen.getAllByText('English')[2]); + }); + + expect(updateButton).not.toHaveAttribute('disabled'); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible(); + }); + + it('should update 3Play Media preferences with english as source language', async () => { + const apiResponse = { + videoSourceLanguage: 'en', + threePlayTurnaround: 'two_hour', + preferredLanguages: ['ar', 'fr'], + provider: '3PlayMedia', + global: false, + }; + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage); + const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage); + + await waitFor(() => { + userEvent.click(turnaround); + userEvent.click(screen.getByText('2 hours')); + + userEvent.click(source); + userEvent.click(screen.getByText('English')); + + const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage); + userEvent.click(language); + userEvent.click(screen.getByText('Arabic')); + userEvent.click(screen.getByText('French')); + userEvent.click(screen.getAllByText('Arabic')[0]); + + expect(updateButton).not.toHaveAttribute('disabled'); + }); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + + it('should update 3Play Media preferences with spanish as source language', async () => { + const apiResponse = { + videoSourceLanguage: 'en', + threePlayTurnaround: 'two_hour', + preferredLanguages: ['ar', 'fr'], + provider: '3PlayMedia', + global: false, + }; + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage); + const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage); + + await waitFor(() => { + userEvent.click(turnaround); + userEvent.click(screen.getByText('2 hours')); + + userEvent.click(source); + userEvent.click(screen.getByText('Spanish')); + + const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage); + userEvent.click(language); + userEvent.click(screen.getAllByText('English')[1]); + }); + expect(updateButton).not.toHaveAttribute('disabled'); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + }); + }); + + describe('api fails', () => { + it('should show error alert on Cielo24 preferences update', async () => { + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; + await act(async () => { + userEvent.click(cielo24Button); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const turnaround = screen.getByText(messages.cieloTurnaroundPlaceholder.defaultMessage); + const fidelity = screen.getByText(messages.cieloFidelityPlaceholder.defaultMessage); + + await waitFor(() => { + userEvent.click(turnaround); + userEvent.click(screen.getByText('Priority (24 hours)')); + + userEvent.click(fidelity); + userEvent.click(screen.getByText('Premium (95% accuracy)')); + + const source = screen.getAllByText(messages.cieloSourceLanguagePlaceholder.defaultMessage)[0]; + userEvent.click(source); + userEvent.click(screen.getByText('English')); + + const language = screen.getByText(messages.cieloTranscriptLanguagePlaceholder.defaultMessage); + userEvent.click(language); + userEvent.click(screen.getAllByText('English')[2]); + }); + + expect(updateButton).not.toHaveAttribute('disabled'); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(503); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Failed to update Cielo24 transcripts settings.')).toBeVisible(); + }); + + it('should show error alert on 3PlayMedia preferences update', async () => { + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage); + const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage); + + await waitFor(() => { + userEvent.click(turnaround); + userEvent.click(screen.getByText('2 hours')); + + userEvent.click(source); + userEvent.click(screen.getByText('Spanish')); + + const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage); + userEvent.click(language); + userEvent.click(screen.getAllByText('English')[1]); + }); + expect(updateButton).not.toHaveAttribute('disabled'); + + axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; + + expect(transcriptStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Failed to update 3PlayMedia transcripts settings.')).toBeVisible(); + }); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/transcript-settings/index.js b/src/files-and-videos/videos-page/transcript-settings/index.js new file mode 100644 index 0000000000..00661e5b6d --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/index.js @@ -0,0 +1,3 @@ +import TranscriptSettings from './TranscriptSettings'; + +export default TranscriptSettings; diff --git a/src/files-and-videos/videos-page/transcript-settings/messages.js b/src/files-and-videos/videos-page/transcript-settings/messages.js new file mode 100644 index 0000000000..ef8817a7e2 --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/messages.js @@ -0,0 +1,153 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + transcriptSettingsTitle: { + id: 'course-authoring.video-uploads.transcriptSettings.title', + defaultMessage: 'Transcript settings', + description: 'Title for transcript settings sheet', + }, + invalidCielo24TranscriptionPlanMessage: { + id: 'course-authoring.video-uploads.transcriptSettings.cielo24.errorAlert.message', + defaultMessage: 'No transcription plans found for Cielo24.', + }, + invalid3PlayMediaTranscriptionPlanMessage: { + id: 'course-authoring.video-uploads.transcriptSettings.3PlayMedia.errorAlert.message', + defaultMessage: 'No transcription plans found for 3PlayMedia.', + }, + errorAlertMessage: { + id: 'course-authoring.video-uploads.transcriptSettings.errorAlert.message', + defaultMessage: '{message}', + }, + orderTranscriptsTitle: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.title', + defaultMessage: 'Order transcripts', + description: 'Title for order transcript collapsible', + }, + noneLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.none.label', + defaultMessage: 'None', + description: 'Label for order transcript None option', + }, + cieloLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.label', + defaultMessage: 'Cielo24', + description: 'Label for order transcript Cieol24 option', + }, + threePlayMediaLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.label', + defaultMessage: '3Play Media', + description: 'Label for order transcript 3Play Media option', + }, + updateSettingsLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.updateSettings.label', + defaultMessage: 'Update settings', + description: 'Label for order transcript update settings button', + }, + discardSettingsLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.discardSettings.label', + defaultMessage: 'Discard settings', + description: 'Label for order transcript discard settings button', + }, + threePlayMediaTurnaroundLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.turnaround.label', + defaultMessage: 'Transcript turnaround', + description: 'Label for 3Play Media transcript turnaround dropdown', + }, + threePlayMediaTurnaroundPlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.turnaround.dropdown.placeholder', + defaultMessage: 'Select turnaround', + description: 'Label for 3Play Media transcript turnaround dropdown placeholder', + }, + threePlayMediaSourceLanguageLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.sourceLanguage.label', + defaultMessage: 'Video source language', + description: 'Label for 3Play Media video source language dropdown', + }, + threePlayMediaSourceLanguagePlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.sourceLanguage.dropdown.placeholder', + defaultMessage: 'Select language', + description: 'Label for 3Play Media video source language dropdown placeholder', + }, + threePlayMediaTranscriptLanguageLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.transcriptLanguage.label', + defaultMessage: 'Transcript language', + description: 'Label for 3Play Media video source language dropdown', + }, + threePlayMediaTranscriptLanguagePlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.transcriptLanguage.dropdown.placeholder', + defaultMessage: 'Select language(s)', + description: 'Label for 3Play Media transcript language dropdown placeholder', + }, + threePlayMediaCredentialMessage: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.credential.message', + defaultMessage: 'Enter the account information for your organization.', + description: 'Message for 3Play Media credential view', + }, + threePlayMediaApiKeyLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.apiKey.label', + defaultMessage: 'API key', + description: 'Label for 3Play Media API key input', + }, + threePlayMediaApiSecretLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.apiSecret.label', + defaultMessage: 'API secret', + description: 'Label for 3Play Media API secret input', + }, + cieloTurnaroundLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.turnaround.label', + defaultMessage: 'Transcript turnaround', + description: 'Label for Cielo24 transcript turnaround dropdown', + }, + cieloTurnaroundPlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.turnaround.dropdown.placeholder', + defaultMessage: 'Select turnaround', + description: 'Label for Cielo24 transcript turnaround dropdown placeholder', + }, + cieloFidelityLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.fidelity.label', + defaultMessage: 'Transcript fidelity', + description: 'Label for Cielo24 transcript fidelity dropdown', + }, + cieloFidelityPlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.fidelity.dropdown.placeholder', + defaultMessage: 'Select fidelity', + description: 'Label for Cielo24 transcript fidelity dropdown placeholder', + }, + cieloSourceLanguageLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.sourceLanguage.label', + defaultMessage: 'Video source language', + description: 'Label for Cielo24 video source language dropdown', + }, + cieloSourceLanguagePlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.sourceLanguage.dropdown.placeholder', + defaultMessage: 'Select language', + description: 'Label for Cielo24 video source language dropdown placeholder', + }, + cieloTranscriptLanguageLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.transcriptLanguage.label', + defaultMessage: 'Transcript language', + description: 'Label for Cielo24 video source language dropdown', + }, + cieloTranscriptLanguagePlaceholder: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.transcriptLanguage.dropdown.placeholder', + defaultMessage: 'Select language', + description: 'Label for Cielo24 transcript language dropdown placeholder', + }, + cieloCredentialMessage: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.credential.message', + defaultMessage: 'Enter the account information for your organization.', + description: 'Message for Cielo24 credential view', + }, + cieloApiKeyLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.apiKey.label', + defaultMessage: 'API key', + description: 'Label for Cielo24 API key input', + }, + cieloUsernameLabel: { + id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.username.label', + defaultMessage: 'Username', + description: 'Label for Cielo24 username input', + }, +}); + +export default messages; diff --git a/src/header/messages.js b/src/header/messages.js index d2dce3ec27..81b5d96f11 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -43,7 +43,7 @@ const messages = defineMessages({ }, 'header.links.videoUploads': { id: 'header.links.videoUploads', - defaultMessage: 'Video Uploads', + defaultMessage: 'Videos', description: 'Link to Studio Video Uploads page', }, 'header.links.scheduleAndDetails': { diff --git a/src/index.scss b/src/index.scss index 430007166e..3aed647989 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,5 +19,5 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "files-and-uploads/table-components/GalleryCard"; -@import "taxonomy/taxonomy-card/TaxonomyCard.scss"; +@import "taxonomy/taxonomy-card/TaxonomyCard"; +@import "files-and-videos"; diff --git a/src/store.js b/src/store.js index 4b88a64353..69dddbfd32 100644 --- a/src/store.js +++ b/src/store.js @@ -10,7 +10,7 @@ import { reducer as gradingSettingsReducer } from './grading-settings/data/slice import { reducer as studioHomeReducer } from './studio-home/data/slice'; import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice'; import { reducer as liveReducer } from './pages-and-resources/live/data/slice'; -import { reducer as filesReducer } from './files-and-uploads/data/slice'; +import { reducer as filesReducer } from './files-and-videos/data/slice'; import { reducer as courseTeamReducer } from './course-team/data/slice'; import { reducer as CourseUpdatesReducer } from './course-updates/data/slice'; import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice'; @@ -18,6 +18,7 @@ import { reducer as helpUrlsReducer } from './help-urls/data/slice'; import { reducer as courseExportReducer } from './export-page/data/slice'; import { reducer as genericReducer } from './generic/data/slice'; import { reducer as courseImportReducer } from './import-page/data/slice'; +import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -40,6 +41,7 @@ export default function initializeStore(preloadedState = undefined) { courseExport: courseExportReducer, generic: genericReducer, courseImport: courseImportReducer, + videos: videosReducer, }, preloadedState, }); diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 7ab0172a5b..79ca9982aa 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -8,8 +8,8 @@ import { import { useIntl } from '@edx/frontend-platform/i18n'; import SubHeader from '../generic/sub-header/SubHeader'; import messages from './messages'; -import TaxonomyCard from './taxonomy-card/TaxonomyCard'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; +import TaxonomyCard from './taxonomy-card'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; const TaxonomyListPage = () => { const intl = useIntl(); diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 99c947b75e..8e68347568 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -7,11 +7,11 @@ import { act, render } from '@testing-library/react'; import initializeStore from '../store'; import TaxonomyListPage from './TaxonomyListPage'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; let store; -jest.mock('./api/hooks/selectors', () => ({ +jest.mock('./data/apiHooks', () => ({ useTaxonomyListDataResponse: jest.fn(), useIsTaxonomyListDataLoaded: jest.fn(), })); diff --git a/src/taxonomy/__mocks__/index.js b/src/taxonomy/__mocks__/index.js new file mode 100644 index 0000000000..bbbeb62a2d --- /dev/null +++ b/src/taxonomy/__mocks__/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as taxonomyListMock } from './taxonomyListMock'; diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.js new file mode 100644 index 0000000000..5eab0af023 --- /dev/null +++ b/src/taxonomy/__mocks__/taxonomyListMock.js @@ -0,0 +1,50 @@ +module.exports = { + next: null, + previous: null, + count: 4, + numPages: 1, + currentPage: 1, + start: 0, + results: [ + { + id: -2, + name: 'Content Authors', + description: 'Allows tags for any user ID created on the instance.', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: true, + visibleToAuthors: false, + }, + { + id: -1, + name: 'Languages', + description: 'lang lang lang lang lang lang lang lang', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: true, + visibleToAuthors: true, + }, + { + id: 1, + name: 'Taxonomy', + description: 'This is a Description', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: false, + visibleToAuthors: true, + }, + { + id: 2, + name: 'Taxonomy long long long long long long long long long long long long long long long long long long long', + description: 'This is a Description long lon', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: false, + visibleToAuthors: true, + }, + ], +}; diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js deleted file mode 100644 index b524bd197f..0000000000 --- a/src/taxonomy/api/hooks/api.js +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-check -import { useQuery } from '@tanstack/react-query'; -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; -export const getExportTaxonomyApiUrl = (pk, format) => new URL( - `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, - getApiBaseUrl(), -).href; - -/** - * @returns {import("../types.mjs").UseQueryResult} - */ -export const useTaxonomyListData = () => ( - useQuery({ - queryKey: ['taxonomyList'], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()) - .then(camelCaseObject), - }) -); - -export const exportTaxonomy = (pk, format) => { - window.location.href = getExportTaxonomyApiUrl(pk, format); -}; diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js deleted file mode 100644 index b2c678be78..0000000000 --- a/src/taxonomy/api/hooks/selectors.js +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-check -import { - useTaxonomyListData, - exportTaxonomy, -} from './api'; - -/** - * @returns {import("../types.mjs").TaxonomyListData | undefined} - */ -export const useTaxonomyListDataResponse = () => { - const response = useTaxonomyListData(); - if (response.status === 'success') { - return response.data.data; - } - return undefined; -}; - -/** - * @returns {boolean} - */ -export const useIsTaxonomyListDataLoaded = () => ( - useTaxonomyListData().status === 'success' -); - -export const callExportTaxonomy = (pk, format) => ( - exportTaxonomy(pk, format) -); diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js new file mode 100644 index 0000000000..513f7fdaf9 --- /dev/null +++ b/src/taxonomy/data/api.js @@ -0,0 +1,29 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; +export const getExportTaxonomyApiUrl = (pk, format) => new URL( + `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, + getApiBaseUrl(), +).href; + +/** + * Get list of taxonomies. + * @returns {Promise} + */ +export async function getTaxonomyListData() { + const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()); + return camelCaseObject(data); +} + +/** + * Downloads the file of the exported taxonomy + * @param {number} pk + * @param {string} format + * @returns {void} + */ +export function getTaxonomyExportFile(pk, format) { + window.location.href = getExportTaxonomyApiUrl(pk, format); +} diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js new file mode 100644 index 0000000000..5928c70098 --- /dev/null +++ b/src/taxonomy/data/api.test.js @@ -0,0 +1,61 @@ +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { taxonomyListMock } from '../__mocks__'; + +import { + getTaxonomyListApiUrl, + getExportTaxonomyApiUrl, + getTaxonomyListData, + getTaxonomyExportFile, +} from './api'; + +let axiosMock; + +describe('taxonomy api calls', () => { + const { location } = window; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + delete window.location; + window.location = { + href: '', + }; + }); + + afterAll(() => { + window.location = location; + }); + + it('should get taxonomy list data', async () => { + axiosMock.onGet(getTaxonomyListApiUrl()).reply(200, taxonomyListMock); + const result = await getTaxonomyListData(); + + expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl()); + expect(result).toEqual(taxonomyListMock); + }); + + it('should set window.location.href correctly', () => { + const pk = 1; + const format = 'json'; + + getTaxonomyExportFile(pk, format); + + expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format)); + }); +}); diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx new file mode 100644 index 0000000000..40eadf3d1d --- /dev/null +++ b/src/taxonomy/data/apiHooks.jsx @@ -0,0 +1,46 @@ +// @ts-check +/** + * This is a file used especially in this `taxonomy` module. + * + * We are using a new approach, using `useQuery` to build and execute the queries to the APIs. + * This approach accelerates the development. + * + * In this file you will find two types of hooks: + * - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file. + * Ex. useTaxonomyListData. + * - Hooks that calls the query hook, prepare and return the data. + * Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded. + */ +import { useQuery } from '@tanstack/react-query'; +import { getTaxonomyListData } from './api'; + +/** + * Builds the query yo get the taxonomy list + * @returns {import("./types.mjs").UseQueryResult} + */ +const useTaxonomyListData = () => ( + useQuery({ + queryKey: ['taxonomyList'], + queryFn: getTaxonomyListData, + }) +); + +/** + * Gets the taxonomy list data + * @returns {import("./types.mjs").TaxonomyListData | undefined} + */ +export const useTaxonomyListDataResponse = () => { + const response = useTaxonomyListData(); + if (response.status === 'success') { + return response.data; + } + return undefined; +}; + +/** + * Returns the status of the taxonomy list query + * @returns {boolean} + */ +export const useIsTaxonomyListDataLoaded = () => ( + useTaxonomyListData().status === 'success' +); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/data/apiHooks.test.jsx similarity index 51% rename from src/taxonomy/api/hooks/selectors.test.js rename to src/taxonomy/data/apiHooks.test.jsx index 772b39c4d8..66715b04c9 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -1,27 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, - callExportTaxonomy, -} from './selectors'; -import { useTaxonomyListData, exportTaxonomy } from './api'; - -jest.mock('./api', () => ({ - __esModule: true, - useTaxonomyListData: jest.fn(), - exportTaxonomy: jest.fn(), +} from './apiHooks'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { it('should return data when status is success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); + useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); const result = useTaxonomyListDataResponse(); - expect(result).toEqual('data'); + expect(result).toEqual({ data: 'data' }); }); it('should return undefined when status is not success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'error' }); + useQuery.mockReturnValueOnce({ status: 'error' }); const result = useTaxonomyListDataResponse(); @@ -31,7 +28,7 @@ describe('useTaxonomyListDataResponse', () => { describe('useIsTaxonomyListDataLoaded', () => { it('should return true when status is success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'success' }); + useQuery.mockReturnValueOnce({ status: 'success' }); const result = useIsTaxonomyListDataLoaded(); @@ -39,18 +36,10 @@ describe('useIsTaxonomyListDataLoaded', () => { }); it('should return false when status is not success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'error' }); + useQuery.mockReturnValueOnce({ status: 'error' }); const result = useIsTaxonomyListDataLoaded(); expect(result).toBe(false); }); }); - -describe('callExportTaxonomy', () => { - it('should trigger exportTaxonomy', () => { - callExportTaxonomy(1, 'csv'); - - expect(exportTaxonomy).toHaveBeenCalled(); - }); -}); diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/data/types.mjs similarity index 100% rename from src/taxonomy/api/types.mjs rename to src/taxonomy/data/types.mjs diff --git a/src/taxonomy/modals/ExportModal.jsx b/src/taxonomy/export-modal/index.jsx similarity index 93% rename from src/taxonomy/modals/ExportModal.jsx rename to src/taxonomy/export-modal/index.jsx index 1f6ca9943a..d380aea6e9 100644 --- a/src/taxonomy/modals/ExportModal.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -7,8 +7,8 @@ import { } from '@edx/paragon'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; -import { callExportTaxonomy } from '../api/hooks/selectors'; +import messages from './messages'; +import { getTaxonomyExportFile } from '../data/api'; const ExportModal = ({ taxonomyId, @@ -20,7 +20,7 @@ const ExportModal = ({ const onClickExport = () => { onClose(); - callExportTaxonomy(taxonomyId, outputFormat); + getTaxonomyExportFile(taxonomyId, outputFormat); }; return ( diff --git a/src/taxonomy/export-modal/messages.js b/src/taxonomy/export-modal/messages.js new file mode 100644 index 0000000000..5992f0557a --- /dev/null +++ b/src/taxonomy/export-modal/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + exportModalTitle: { + id: 'course-authoring.taxonomy-list.modal.export.title', + defaultMessage: 'Select format to export', + }, + exportModalBodyDescription: { + id: 'course-authoring.taxonomy-list.modal.export.body', + defaultMessage: 'Select the file format in which you would like the taxonomy to be exported:', + }, + exportModalSubmitButtonLabel: { + id: 'course-authoring.taxonomy-list.modal.export.submit.label', + defaultMessage: 'Export', + }, + taxonomyCSVFormat: { + id: 'course-authoring.taxonomy-list.csv-format', + defaultMessage: 'CSV file', + }, + taxonomyJSONFormat: { + id: 'course-authoring.taxonomy-list.json-format', + defaultMessage: 'JSON file', + }, + taxonomyModalsCancelLabel: { + id: 'course-authoring.taxonomy-list.modal.cancel', + defaultMessage: 'Cancel', + }, +}); + +export default messages; diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index 24f80bf6da..82467e09ba 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -17,58 +17,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.select.org.default', defaultMessage: 'All taxonomies', }, - systemDefinedBadge: { - id: 'course-authoring.taxonomy-list.badge.system-defined.label', - defaultMessage: 'System-level', - }, - assignedToOrgsLabel: { - id: 'course-authoring.taxonomy-list.orgs-count.label', - defaultMessage: 'Assigned to {orgsCount} orgs', - }, usageLoadingMessage: { id: 'course-authoring.taxonomy-list.spinner.loading', defaultMessage: 'Loading', }, - systemTaxonomyPopoverTitle: { - id: 'course-authoring.taxonomy-list.popover.system-defined.title', - defaultMessage: 'System taxonomy', - }, - systemTaxonomyPopoverBody: { - id: 'course-authoring.taxonomy-list.popover.system-defined.body', - defaultMessage: 'This is a system-level taxonomy and is enabled by default.', - }, - taxonomyCardExportMenu: { - id: 'course-authoring.taxonomy-list.menu.export.label', - defaultMessage: 'Export', - }, - taxonomyMenuAlt: { - id: 'course-authoring.taxonomy-list.menu.alt', - defaultMessage: '{name} menu', - }, - exportModalTitle: { - id: 'course-authoring.taxonomy-list.modal.export.title', - defaultMessage: 'Select format to export', - }, - exportModalBodyDescription: { - id: 'course-authoring.taxonomy-list.modal.export.body', - defaultMessage: 'Select the file format in which you would like the taxonomy to be exported:', - }, - exportModalSubmitButtonLabel: { - id: 'course-authoring.taxonomy-list.modal.export.submit.label', - defaultMessage: 'Export', - }, - taxonomyCSVFormat: { - id: 'course-authoring.taxonomy-list.csv-format', - defaultMessage: 'CSV file', - }, - taxonomyJSONFormat: { - id: 'course-authoring.taxonomy-list.json-format', - defaultMessage: 'JSON file', - }, - taxonomyModalsCancelLabel: { - id: 'course-authoring.taxonomy-list.modal.cancel', - defaultMessage: 'Cancel', - }, }); export default messages; diff --git a/src/taxonomy/modals/ExportModal.test.jsx b/src/taxonomy/modals/ExportModal.test.jsx deleted file mode 100644 index 31f8217018..0000000000 --- a/src/taxonomy/modals/ExportModal.test.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { render, fireEvent } from '@testing-library/react'; -import ExportModal from './ExportModal'; -import initializeStore from '../../store'; -import { callExportTaxonomy } from '../api/hooks/selectors'; - -const onClose = jest.fn(); -let store; -const taxonomyId = 1; - -jest.mock('../api/hooks/selectors', () => ({ - callExportTaxonomy: jest.fn(), -})); - -const ExportModalComponent = () => ( - - - - - -); - -describe('', async () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - }); - - it('should render the modal', () => { - const { getByText } = render(); - expect(getByText('Select format to export')).toBeInTheDocument(); - }); - - it('should call export endpoint', () => { - const { getByText } = render(); - - fireEvent.click(getByText('JSON file')); - fireEvent.click(getByText('Export')); - - expect(onClose).toHaveBeenCalled(); - expect(callExportTaxonomy).toHaveBeenCalledWith(taxonomyId, 'json'); - }); -}); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 91e76c984a..79f72eeb2e 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -6,17 +6,22 @@ import { render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; import initializeStore from '../../store'; - -import TaxonomyCard from './TaxonomyCard'; +import { getTaxonomyExportFile } from '../data/api'; +import TaxonomyCard from '.'; let store; +const taxonomyId = 1; const data = { - id: 1, + id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', }; +jest.mock('../data/api', () => ({ + getTaxonomyExportFile: jest.fn(), +})); + const TaxonomyCardComponent = ({ original }) => ( @@ -121,4 +126,20 @@ describe('', async () => { // Modal closed expect(() => getByText('Select format to export')).toThrow(); }); + + test('should export a taxonomy', () => { + const { getByTestId, getByText } = render(); + + // Click on export menu + fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); + fireEvent.click(getByText('Export')); + + // Select JSON format and click on export + fireEvent.click(getByText('JSON file')); + fireEvent.click(getByText('Export')); + + // Modal closed + expect(() => getByText('Select format to export')).toThrow(); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json'); + }); }); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx index dd0e8e2b1e..7cccb24008 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -9,7 +9,7 @@ import { import { MoreVert } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; +import messages from './messages'; const TaxonomyCardMenu = ({ id, name, onClickMenuItem, diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx b/src/taxonomy/taxonomy-card/index.jsx similarity index 92% rename from src/taxonomy/taxonomy-card/TaxonomyCard.jsx rename to src/taxonomy/taxonomy-card/index.jsx index 65548e1c26..c3f1c41346 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -9,9 +9,9 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../messages'; +import messages from './messages'; import TaxonomyCardMenu from './TaxonomyCardMenu'; -import ExportModal from '../modals/ExportModal'; +import ExportModal from '../export-modal'; const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; @@ -73,18 +73,15 @@ const TaxonomyCard = ({ className, original }) => { const intl = useIntl(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const onClickMenuItem = (menuName) => { - switch (menuName) { - // Add here more menu items - case 'export': - setIsExportModalOpen(true); - break; - default: - /* istanbul ignore next */ - break; - } + // Add here more menu item actions + const menuItemActions = { + export: () => setIsExportModalOpen(true), }; + const onClickMenuItem = (menuName) => ( + menuItemActions[menuName]?.() + ); + const getHeaderActions = () => { if (systemDefined) { // We don't show the export menu, because the system-taxonomies diff --git a/src/taxonomy/taxonomy-card/messages.js b/src/taxonomy/taxonomy-card/messages.js new file mode 100644 index 0000000000..6886c2f99c --- /dev/null +++ b/src/taxonomy/taxonomy-card/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + systemTaxonomyPopoverTitle: { + id: 'course-authoring.taxonomy-list.popover.system-defined.title', + defaultMessage: 'System taxonomy', + }, + systemTaxonomyPopoverBody: { + id: 'course-authoring.taxonomy-list.popover.system-defined.body', + defaultMessage: 'This is a system-level taxonomy and is enabled by default.', + }, + systemDefinedBadge: { + id: 'course-authoring.taxonomy-list.badge.system-defined.label', + defaultMessage: 'System-level', + }, + assignedToOrgsLabel: { + id: 'course-authoring.taxonomy-list.orgs-count.label', + defaultMessage: 'Assigned to {orgsCount} orgs', + }, + taxonomyCardExportMenu: { + id: 'course-authoring.taxonomy-list.menu.export.label', + defaultMessage: 'Export', + }, + taxonomyMenuAlt: { + id: 'course-authoring.taxonomy-list.menu.alt', + defaultMessage: '{name} menu', + }, +}); + +export default messages;