From cdc9af2ed42c9cc6248d7ce8a6710941b7263dbe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 16:51:39 +0000 Subject: [PATCH 1/7] fix(deps): update dependency @edx/frontend-lib-content-components to v2.4.3 (#1140) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24fc76ab95..1a9b33c7f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2680,9 +2680,10 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.4.2.tgz", - "integrity": "sha512-99SPZDiO3bu6/3zpcqFL2QYINL21vOrHSjPAUQFjoDf8gKSWu4yd9jy4NcVXgtfQnwiVD7MPdOQ0ommtGX/S0Q==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.4.3.tgz", + "integrity": "sha512-tvLhrMRqFbtwiJmdE4C5Mj5A25a7gysYtCSeQS1nF7rWuS9lwF0FTVG1LnbBaNzEkM0x0OdKWI4cHWSpSm5Tcw==", + "license": "AGPL-3.0", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -11668,6 +11669,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -11679,6 +11681,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11689,7 +11692,8 @@ "node_modules/hosted-git-info/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/hpack.js": { "version": "2.1.6", @@ -15053,6 +15057,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", @@ -15067,6 +15072,7 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -21042,6 +21048,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -21050,12 +21057,14 @@ "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -21064,7 +21073,8 @@ "node_modules/spdx-license-ids": { "version": "3.0.18", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", - "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==" + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true }, "node_modules/spdy": { "version": "4.0.2", @@ -22748,6 +22758,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" From efd73f9c0b04dec61fb287430acddd818ba17bc1 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Mon, 1 Jul 2024 12:52:08 -0400 Subject: [PATCH 2/7] fix: progress bar display for uploads (#1135) --- src/files-and-videos/generic/FileTable.jsx | 18 +++--- .../videos-page/VideosPage.jsx | 18 ++++-- .../videos-page/VideosPage.test.jsx | 1 - .../videos-page/data/thunks.js | 58 ++++++++++++++----- .../videos-page/data/thunks.test.js | 8 +-- .../videos-page/upload-modal/UploadModal.jsx | 6 +- 6 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 9e82ab0502..33a770ffb8 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -243,14 +243,16 @@ const FileTable = ({ fileType={fileType} /> - + {fileType === 'files' && ( + + )} closeUploadTracker(), 500); + break; + case RequestStatus.FAILED: + setTimeout(() => closeUploadTracker(), 500); + break; + default: + closeUploadTracker(); + break; } }, [addVideoStatus]); @@ -298,6 +307,7 @@ const VideosPage = ({ isUploadTrackerOpen, currentUploadingIdsRef: uploadingIdsRef.current, handleUploadCancel, + addVideoStatus, }} /> diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index d11788a6b4..fa73cd2e30 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -685,7 +685,6 @@ describe('Videos page', () => { expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull(); }); - await executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch); await waitFor(() => { const deleteStatus = store.getState().videos.deletingStatus; expect(deleteStatus).toEqual(RequestStatus.FAILED); diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 9d63b588c6..2fa064ce6a 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -224,6 +224,7 @@ const uploadToBucket = async ({ const currentController = new AbortController(); controllers.push(currentController); const currentVideoData = uploadingIdsRef.current.uploadData[edxVideoId]; + try { const putToServerResponse = await uploadVideo( uploadUrl, @@ -279,6 +280,24 @@ const uploadToBucket = async ({ } }; +const newUploadData = ({ + status, + edxVideoId, + currentData, + key, + originalValue, +}) => { + const newData = currentData; + if (edxVideoId && edxVideoId !== key) { + newData[edxVideoId] = { ...originalValue, status }; + delete newData[key]; + return newData; + } + + newData[key] = { ...originalValue, status }; + return newData; +}; + export function addVideoFile( courseId, files, @@ -292,31 +311,38 @@ export function addVideoFile( let hasFailure = false; await Promise.all(files.map(async (file, idx) => { const name = file?.name || `Video ${idx + 1}`; + const progress = 0; + + uploadingIdsRef.current.uploadData = newUploadData({ + status: RequestStatus.PENDING, + currentData: uploadingIdsRef.current.uploadData, + originalValue: { name, progress }, + key: `video_${idx}`, + }); + const { edxVideoId, uploadUrl } = await addVideoToEdxVal(courseId, file, dispatch); + if (uploadUrl && edxVideoId) { - uploadingIdsRef.current.uploadData = { - ...uploadingIdsRef.current.uploadData, - [edxVideoId]: { - name, - status: RequestStatus.PENDING, - progress: 0, - }, - }; + uploadingIdsRef.current.uploadData = newUploadData({ + status: RequestStatus.PENDING, + currentData: uploadingIdsRef.current.uploadData, + originalValue: { name, progress }, + key: `video_${idx}`, + edxVideoId, + }); hasFailure = await uploadToBucket({ courseId, uploadUrl, file, uploadingIdsRef, edxVideoId, dispatch, }); } else { hasFailure = true; - uploadingIdsRef.current.uploadData = { - ...uploadingIdsRef.current.uploadData, - [idx]: { - name, - status: RequestStatus.FAILED, - progress: 0, - }, + uploadingIdsRef.current.uploadData[idx] = { + status: RequestStatus.FAILED, + name, + progress, }; } })); + try { const { videos } = await fetchVideoList(courseId); const newVideos = videos.filter( @@ -337,11 +363,13 @@ export function addVideoFile( updateErrors({ error: 'add', message: 'Failed to load videos' }), ); } + if (hasFailure) { dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); } else { dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); } + uploadingIdsRef.current = { uploadData: {}, uploadCount: 0, diff --git a/src/files-and-videos/videos-page/data/thunks.test.js b/src/files-and-videos/videos-page/data/thunks.test.js index b06634ed7a..dae6508ecc 100644 --- a/src/files-and-videos/videos-page/data/thunks.test.js +++ b/src/files-and-videos/videos-page/data/thunks.test.js @@ -7,8 +7,8 @@ describe('addVideoFile', () => { const courseId = 'course-123'; const mockFile = { name: 'mockName', - }; + const uploadingIdsRef = { current: { uploadData: {} } }; beforeEach(() => { jest.clearAllMocks(); @@ -18,7 +18,7 @@ describe('addVideoFile', () => { status: 404, }); - await addVideoFile(courseId, [mockFile], undefined, { current: [] })(dispatch, getState); + await addVideoFile(courseId, [mockFile], undefined, uploadingIdsRef)(dispatch, getState); expect(dispatch).toHaveBeenCalledWith({ payload: { @@ -43,7 +43,7 @@ describe('addVideoFile', () => { jest.spyOn(api, 'uploadVideo').mockResolvedValue({ status: 404, }); - await addVideoFile(courseId, [mockFile], undefined, { current: [] })(dispatch, getState); + await addVideoFile(courseId, [mockFile], undefined, uploadingIdsRef)(dispatch, getState); expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload failed', 'upload_failed'); expect(dispatch).toHaveBeenCalledWith({ payload: { @@ -70,7 +70,7 @@ describe('addVideoFile', () => { jest.spyOn(api, 'uploadVideo').mockResolvedValue({ status: 200, }); - await addVideoFile(courseId, [mockFile], undefined, { current: [] })(dispatch, getState); + await addVideoFile(courseId, [mockFile], undefined, uploadingIdsRef)(dispatch, getState); expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload completed', 'upload_completed'); }); }); diff --git a/src/files-and-videos/videos-page/upload-modal/UploadModal.jsx b/src/files-and-videos/videos-page/upload-modal/UploadModal.jsx index 4169f31663..d2fa526edd 100644 --- a/src/files-and-videos/videos-page/upload-modal/UploadModal.jsx +++ b/src/files-and-videos/videos-page/upload-modal/UploadModal.jsx @@ -12,15 +12,18 @@ import { import { WarningFilled } from '@openedx/paragon/icons'; import messages from '../messages'; import UploadProgressList from './UploadProgressList'; +import { RequestStatus } from '../../../data/constants'; const UploadModal = ({ isUploadTrackerOpen, handleUploadCancel, currentUploadingIdsRef, + addVideoStatus, }) => { const intl = useIntl(); const videosPagePath = ''; const { uploadData, uploadCount } = currentUploadingIdsRef; + const cancelIsDisabled = addVideoStatus === RequestStatus.FAILED || addVideoStatus === RequestStatus.SUCCESSFUL; return ( - @@ -89,6 +92,7 @@ UploadModal.propTypes = { }).isRequired, uploadCount: PropTypes.number.isRequired, }).isRequired, + addVideoStatus: PropTypes.string.isRequired, }; export default UploadModal; From 9f0be768aa2019f3fbc18c8f6449e3e002fcac7e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:11:05 +0000 Subject: [PATCH 3/7] fix(deps): update dependency @edx/frontend-component-footer to v14.0.5 (#1121) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a9b33c7f5..0e05379a9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2429,9 +2429,10 @@ } }, "node_modules/@edx/frontend-component-footer": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.3.tgz", - "integrity": "sha512-laruK2atzuWYeXrlRH+EdSTZX/7X8fyyul4zf1y+X0moWfN+tg5CynDtEV1lNw8AwRxPLJrMc8+083c/2XL8UQ==", + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.5.tgz", + "integrity": "sha512-r64SGM8wzYZCtAG/J8i+7S7c2XCylKdA4VesSU6Q/Ig8SMndJAhfLc9eo9H4dRrq1E0cYqV2+bQroEucXjzSuQ==", + "license": "AGPL-3.0", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.5.2", "@fortawesome/free-brands-svg-icons": "6.5.2", @@ -2455,6 +2456,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", "hasInstallScript": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2464,6 +2466,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -2476,6 +2479,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz", "integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==", "hasInstallScript": true, + "license": "(CC-BY-4.0 AND MIT)", "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -2488,6 +2492,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz", "integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==", "hasInstallScript": true, + "license": "(CC-BY-4.0 AND MIT)", "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -2500,6 +2505,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", "hasInstallScript": true, + "license": "(CC-BY-4.0 AND MIT)", "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -2511,6 +2517,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", "dependencies": { "prop-types": "^15.8.1" }, @@ -2523,6 +2530,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", From 292663a5e32605087154d1645221a7763134265b Mon Sep 17 00:00:00 2001 From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:19:19 -0400 Subject: [PATCH 4/7] =?UTF-8?q?Revert=20"fix(deps):=20update=20dependency?= =?UTF-8?q?=20@edx/frontend-lib-content-components=20to=20=E2=80=A6"=20(#1?= =?UTF-8?q?142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit cdc9af2ed42c9cc6248d7ce8a6710941b7263dbe. --- package-lock.json | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e05379a9a..aedabd7410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2688,10 +2688,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.4.3.tgz", - "integrity": "sha512-tvLhrMRqFbtwiJmdE4C5Mj5A25a7gysYtCSeQS1nF7rWuS9lwF0FTVG1LnbBaNzEkM0x0OdKWI4cHWSpSm5Tcw==", - "license": "AGPL-3.0", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.4.2.tgz", + "integrity": "sha512-99SPZDiO3bu6/3zpcqFL2QYINL21vOrHSjPAUQFjoDf8gKSWu4yd9jy4NcVXgtfQnwiVD7MPdOQ0ommtGX/S0Q==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -11677,7 +11676,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -11689,7 +11687,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11700,8 +11697,7 @@ "node_modules/hosted-git-info/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/hpack.js": { "version": "2.1.6", @@ -15065,7 +15061,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dev": true, "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", @@ -15080,7 +15075,6 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -21056,7 +21050,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -21065,14 +21058,12 @@ "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -21081,8 +21072,7 @@ "node_modules/spdx-license-ids": { "version": "3.0.18", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", - "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", - "dev": true + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==" }, "node_modules/spdy": { "version": "4.0.2", @@ -22766,7 +22756,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" From 6acee0ac9706bdaafd4a734723b74bf35e9e9110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 3 Jul 2024 16:27:47 +0200 Subject: [PATCH 5/7] feat: library home page bare bones --- src/CourseAuthoringPage.jsx | 33 +-- src/header/Header.jsx | 51 ++-- src/index.jsx | 5 +- src/library-authoring/CreateLibrary.tsx | 27 ++ src/library-authoring/EmptyStates.tsx | 23 ++ .../LibraryAuthoringPage.test.tsx | 236 ++++++++++++++++++ .../LibraryAuthoringPage.tsx | 131 ++++++++++ src/library-authoring/LibraryCollections.tsx | 14 ++ src/library-authoring/LibraryComponents.tsx | 32 +++ src/library-authoring/LibraryHome.tsx | 54 ++++ src/library-authoring/data/api.ts | 38 +++ src/library-authoring/data/apiHook.ts | 48 ++++ src/library-authoring/index.ts | 2 + src/library-authoring/messages.ts | 65 +++++ src/search-modal/SearchResult.tsx | 27 +- src/search-modal/SearchUI.test.tsx | 18 +- src/search-modal/data/apiHooks.ts | 8 +- src/search-modal/index.js | 3 + src/studio-home/StudioHome.jsx | 15 +- .../tabs-section/LibraryV2Placeholder.jsx | 36 --- 20 files changed, 747 insertions(+), 119 deletions(-) create mode 100644 src/library-authoring/CreateLibrary.tsx create mode 100644 src/library-authoring/EmptyStates.tsx create mode 100644 src/library-authoring/LibraryAuthoringPage.test.tsx create mode 100644 src/library-authoring/LibraryAuthoringPage.tsx create mode 100644 src/library-authoring/LibraryCollections.tsx create mode 100644 src/library-authoring/LibraryComponents.tsx create mode 100644 src/library-authoring/LibraryHome.tsx create mode 100644 src/library-authoring/data/api.ts create mode 100644 src/library-authoring/data/apiHook.ts create mode 100644 src/library-authoring/index.ts create mode 100644 src/library-authoring/messages.ts create mode 100644 src/search-modal/index.js delete mode 100644 src/studio-home/tabs-section/LibraryV2Placeholder.jsx diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index c4281a8c13..eaa16c49c2 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -15,29 +15,6 @@ import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; import Loading from './generic/Loading'; -const AppHeader = ({ - courseNumber, courseOrg, courseTitle, courseId, -}) => ( -
-); - -AppHeader.propTypes = { - courseId: PropTypes.string.isRequired, - courseNumber: PropTypes.string, - courseOrg: PropTypes.string, - courseTitle: PropTypes.string.isRequired, -}; - -AppHeader.defaultProps = { - courseNumber: null, - courseOrg: null, -}; - const CourseAuthoringPage = ({ courseId, children }) => { const dispatch = useDispatch(); @@ -74,11 +51,11 @@ const CourseAuthoringPage = ({ courseId, children }) => { This functionality will be removed in TNL-9591 */} {inProgress ? !isEditor && : (!isEditor && ( - ) )} diff --git a/src/header/Header.jsx b/src/header/Header.jsx index 7cc1adcb08..e5ba1a4b3c 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.jsx @@ -6,16 +6,17 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { StudioHeader } from '@edx/frontend-component-header'; import { useToggle } from '@openedx/paragon'; -import SearchModal from '../search-modal/SearchModal'; +import { SearchModal } from '../search-modal'; import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; import messages from './messages'; const Header = ({ - courseId, - courseOrg, - courseNumber, - courseTitle, + contentId, + org, + number, + title, isHiddenMainMenu, + isLibrary, }) => { const intl = useIntl(); @@ -23,40 +24,40 @@ const Header = ({ const studioBaseUrl = getConfig().STUDIO_BASE_URL; const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED); - const mainMenuDropdowns = [ + const mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.content']), - items: getContentMenuItems({ studioBaseUrl, courseId, intl }), + items: getContentMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, { id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.settings']), - items: getSettingMenuItems({ studioBaseUrl, courseId, intl }), + items: getSettingMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, { id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.tools']), - items: getToolsMenuItems({ studioBaseUrl, courseId, intl }), + items: getToolsMenuItems({ studioBaseUrl, courseId: contentId, intl }), }, - ]; - const outlineLink = `${studioBaseUrl}/course/${courseId}`; + ] : []; + const outlineLink = !isLibrary ? `${studioBaseUrl}/course/${contentId}` : `/course-authoring/library/${contentId}`; return ( <> { meiliSearchEnabled && ( )} @@ -65,19 +66,21 @@ const Header = ({ }; Header.propTypes = { - courseId: PropTypes.string, - courseNumber: PropTypes.string, - courseOrg: PropTypes.string, - courseTitle: PropTypes.string, + contentId: PropTypes.string, + number: PropTypes.string, + org: PropTypes.string, + title: PropTypes.string, isHiddenMainMenu: PropTypes.bool, + isLibrary: PropTypes.bool, }; Header.defaultProps = { - courseId: '', - courseNumber: '', - courseOrg: '', - courseTitle: '', + contentId: '', + number: '', + org: '', + title: '', isHiddenMainMenu: false, + isLibrary: false, }; export default Header; diff --git a/src/index.jsx b/src/index.jsx index f881441df9..bf7ee9c423 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -19,11 +19,11 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; import { logError } from '@edx/frontend-platform/logging'; import messages from './i18n'; +import { CreateLibrary, LibraryAuthoringPage } from './library-authoring'; import initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; import { StudioHome } from './studio-home'; -import LibraryV2Placeholder from './studio-home/tabs-section/LibraryV2Placeholder'; import CourseRerun from './course-rerun'; import { TaxonomyLayout, TaxonomyDetailPage, TaxonomyListPage } from './taxonomy'; import { ContentTagsDrawer } from './content-tags-drawer'; @@ -55,7 +55,8 @@ const App = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( diff --git a/src/library-authoring/CreateLibrary.tsx b/src/library-authoring/CreateLibrary.tsx new file mode 100644 index 0000000000..227f14dbe5 --- /dev/null +++ b/src/library-authoring/CreateLibrary.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Container } from '@openedx/paragon'; + +import Header from '../header'; +import SubHeader from '../generic/sub-header/SubHeader'; + +import messages from './messages'; + +/* istanbul ignore next This is only a placeholder component */ +const CreateLibrary = () => ( + <> +
+ + } + /> +
+ +
+
+ +); + +export default CreateLibrary; diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx new file mode 100644 index 0000000000..d7b718c71d --- /dev/null +++ b/src/library-authoring/EmptyStates.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Button, Stack, +} from '@openedx/paragon'; +import { Add } from '@openedx/paragon/icons'; + +import messages from './messages'; + +export const NoComponents = () => ( + + + + +); + +export const NoSearchResults = () => ( +
+ +
+); diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx new file mode 100644 index 0000000000..db7e815a10 --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock-jest'; + +import initializeStore from '../store'; +import { getContentSearchConfigUrl } from '../search-modal/data/api'; +import mockResult from '../search-modal/__mocks__/search-result.json'; +import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; +import LibraryAuthoringPage from './LibraryAuthoringPage'; +import { getContentLibraryApiUrl } from './data/api'; + +let store; +const mockUseParams = jest.fn(); +let axiosMock; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useParams: () => mockUseParams(), +})); + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const returnEmptyResult = (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. + mockEmptyResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return mockEmptyResult; +}; + +const libraryData = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + allowLti: false, + allowPublic_learning: false, + allowPublic_read: false, + hasUnpublished_changes: true, + hasUnpublished_deletes: false, + license: '', +}; + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + mockUseParams.mockReturnValue({ libraryId: '1' }); + + // The API method to get the Meilisearch connection details uses Axios: + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { + url: 'http://mock.meilisearch.local', + index_name: 'studio', + api_key: 'test-key', + }); + + // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.post(searchEndpoint, (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise Instantsearch will update the UI and change the query, + // leading to unexpected results in the test cases. + mockResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return mockResult; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + fetchMock.mockReset(); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', () => { + mockUseParams.mockReturnValue({ libraryId: '1' }); + // @ts-ignore Use unresolved promise to keep the Loading visible + axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise()); + const { getByRole } = render(); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); + + it('shows an error component if no library returned', async () => { + mockUseParams.mockReturnValue({ libraryId: 'invalid' }); + axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400); + + const { findByTestId } = render(); + + expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + + it('shows an error component if no library param', async () => { + mockUseParams.mockReturnValue({ libraryId: '' }); + + const { findByTestId } = render(); + + expect(await findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + + it('show library data', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + const { + getByRole, getByText, queryByText, + } = render(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + expect(getByText('Content library')).toBeInTheDocument(); + expect(getByText(libraryData.title)).toBeInTheDocument(); + + expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + expect(getByText('There are 6 components in this library')).toBeInTheDocument(); + + // Navigate to the components tab + fireEvent.click(getByRole('tab', { name: 'Components' })); + expect(queryByText('Recently Modified')).not.toBeInTheDocument(); + expect(queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(getByText('There are 6 components in this library')).toBeInTheDocument(); + + // Navigate to the collections tab + fireEvent.click(getByRole('tab', { name: 'Collections' })); + expect(queryByText('Recently Modified')).not.toBeInTheDocument(); + expect(queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument(); + expect(getByText('Coming soon!')).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(getByRole('tab', { name: 'Home' })); + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + expect(getByText('There are 6 components in this library')).toBeInTheDocument(); + }); + + it('show library without components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + const { findByText, getByText } = render(); + + expect(await findByText('Content library')).toBeInTheDocument(); + expect(await findByText(libraryData.title)).toBeInTheDocument(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + }); + + it('show library without search results', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + const { findByText, getByRole, getByText } = render(); + + expect(await findByText('Content library')).toBeInTheDocument(); + expect(await findByText(libraryData.title)).toBeInTheDocument(); + + // Ensure the search endpoint is called + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + + fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); + + // Ensure the search endpoint is called again + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Navigate to the components tab + fireEvent.click(getByRole('tab', { name: 'Components' })); + expect(getByText('No matching components found in this library.')).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(getByRole('tab', { name: 'Home' })); + }); +}); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx new file mode 100644 index 0000000000..305c9e753d --- /dev/null +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Icon, IconButton, SearchField, Tab, Tabs, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { + Routes, Route, useLocation, useNavigate, useParams, +} from 'react-router-dom'; + +import Loading from '../generic/Loading'; +import SubHeader from '../generic/sub-header/SubHeader'; +import Header from '../header'; +import NotFoundAlert from '../generic/NotFoundAlert'; +import LibraryComponents from './LibraryComponents'; +import LibraryCollections from './LibraryCollections'; +import LibraryHome from './LibraryHome'; +import { useContentLibrary } from './data/apiHook'; +import messages from './messages'; + +const TAB_LIST = { + home: '', + components: 'components', + collections: 'collections', +}; + +const SubHeaderTitle = ({ title }: { title: string }) => { + const intl = useIntl(); + return ( + <> + {title} + + + ); +}; + +const LibraryAuthoringPage = () => { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const [tabKey, setTabKey] = useState(TAB_LIST.home); + const [searchKeywords, setSearchKeywords] = useState(''); + + const { libraryId } = useParams(); + + const { data: libraryData, isLoading } = useContentLibrary(libraryId); + + useEffect(() => { + const currentPath = location.pathname.split('/').pop(); + if (currentPath && Object.values(TAB_LIST).includes(currentPath)) { + setTabKey(currentPath); + } else { + setTabKey(TAB_LIST.home); + } + }, [location]); + + if (isLoading) { + return ; + } + + if (!libraryId || !libraryData) { + return ; + } + + const handleTabChange = (key: string) => { + setTabKey(key); + navigate(key); + }; + + return ( + <> +
+ + } + subtitle={intl.formatMessage(messages.headingSubtitle)} + /> + setSearchKeywords(value)} + onSubmit={() => {}} + className="w-50" + /> + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + ); +}; + +export default LibraryAuthoringPage; diff --git a/src/library-authoring/LibraryCollections.tsx b/src/library-authoring/LibraryCollections.tsx new file mode 100644 index 0000000000..2f1eb8951f --- /dev/null +++ b/src/library-authoring/LibraryCollections.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +const LibraryCollections = () => ( +
+ +
+); + +export default LibraryCollections; diff --git a/src/library-authoring/LibraryComponents.tsx b/src/library-authoring/LibraryComponents.tsx new file mode 100644 index 0000000000..fee8cb3502 --- /dev/null +++ b/src/library-authoring/LibraryComponents.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { NoComponents, NoSearchResults } from './EmptyStates'; +import { useLibraryComponentCount } from './data/apiHook'; +import messages from './messages'; + +type LibraryComponentsProps = { + libraryId: string; + filter: { + searchKeywords: string; + }; +}; + +const LibraryComponents = ({ libraryId, filter: { searchKeywords } }: LibraryComponentsProps) => { + const { componentCount } = useLibraryComponentCount(libraryId, searchKeywords); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( +
+ +
+ ); +}; + +export default LibraryComponents; diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx new file mode 100644 index 0000000000..1201e8a848 --- /dev/null +++ b/src/library-authoring/LibraryHome.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Card, Stack, +} from '@openedx/paragon'; + +import { NoComponents, NoSearchResults } from './EmptyStates'; +import LibraryCollections from './LibraryCollections'; +import LibraryComponents from './LibraryComponents'; +import { useLibraryComponentCount } from './data/apiHook'; +import messages from './messages'; + +const Section = ({ title, children } : { title: string, children: React.ReactNode }) => ( + + + + {children} + + +); + +type LibraryHomeProps = { + libraryId: string, + filter: { + searchKeywords: string, + }, +}; + +const LibraryHome = ({ libraryId, filter } : LibraryHomeProps) => { + const { searchKeywords } = filter; + const { componentCount, collectionCount } = useLibraryComponentCount(libraryId, searchKeywords); + + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + + return ( + +
+ +
+
+ +
+
+ +
+
+ ); +}; + +export default LibraryHome; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts new file mode 100644 index 0000000000..95126d8269 --- /dev/null +++ b/src/library-authoring/data/api.ts @@ -0,0 +1,38 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +/** + * Get the URL for the content library API. + */ +export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`; + +export interface ContentLibrary { + id: string; + type: string; + org: string; + slug: string; + title: string; + description: string; + numBlocks: number; + version: number; + lastPublished: Date | null; + allowLti: boolean; + allowPublicLearning: boolean; + allowPublicRead: boolean; + hasUnpublishedChanges: boolean; + hasUnpublishedDeletes: boolean; + license: string; +} + +/** + * Fetch a content library by its ID. + */ +export async function getContentLibrary(libraryId?: string): Promise { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts new file mode 100644 index 0000000000..56a6791d26 --- /dev/null +++ b/src/library-authoring/data/apiHook.ts @@ -0,0 +1,48 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; +import { getContentLibrary } from './api'; + +/** + * Hook to fetch a content library by its ID. + */ +export const useContentLibrary = (libraryId?: string) => ( + useQuery({ + queryKey: ['contentLibrary', libraryId], + queryFn: () => getContentLibrary(libraryId), + }) +); + +/** + * Hook to fetch the count of components and collections in a library. + */ +export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => { + // Meilisearch code to get Collection and Component counts + const { data: connectionDetails } = useContentSearchConnection(); + + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + const libFilter = `context_key = "${libraryId}"`; + + const { totalHits: componentCount } = useContentSearchResults({ + client, + indexName, + searchKeywords, + extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented + }); + + const collectionCount = 0; // ToDo: Implement collections count + + return { + componentCount, + collectionCount, + }; +}; diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.ts new file mode 100644 index 0000000000..40da2db4af --- /dev/null +++ b/src/library-authoring/index.ts @@ -0,0 +1,2 @@ +export { default as LibraryAuthoringPage } from './LibraryAuthoringPage'; +export { default as CreateLibrary } from './CreateLibrary'; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts new file mode 100644 index 0000000000..0aabbccd3a --- /dev/null +++ b/src/library-authoring/messages.ts @@ -0,0 +1,65 @@ +import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n'; +import type { defineMessages as defineMessagesType } from 'react-intl'; + +// frontend-platform currently doesn't provide types... do it ourselves. +const defineMessages = _defineMessages as typeof defineMessagesType; + +const messages = defineMessages({ + headingSubtitle: { + id: 'course-authoring.library-authoring.heading-subtitle', + defaultMessage: 'Content library', + description: 'The page heading for the library page.', + }, + headingInfoAlt: { + id: 'course-authoring.library-authoring.heading-info-alt', + defaultMessage: 'Info', + description: 'Alt text for the info icon next to the page heading.', + }, + searchPlaceholder: { + id: 'course-authoring.library-authoring.search', + defaultMessage: 'Search...', + description: 'Placeholder for search field', + }, + noSearchResults: { + id: 'course-authoring.library-authoring.no-search-results', + defaultMessage: 'No matching components found in this library.', + description: 'Message displayed when no search results are found', + }, + noComponents: { + id: 'course-authoring.library-authoring.no-components', + defaultMessage: 'You have not added any content to this library yet.', + description: 'Message displayed when the library is empty', + }, + addComponent: { + id: 'course-authoring.library-authoring.add-component', + defaultMessage: 'Add component', + description: 'Button text to add a new component', + }, + componentsTempPlaceholder: { + id: 'course-authoring.library-authoring.components-temp-placeholder', + defaultMessage: 'There are {componentCount} components in this library', + description: 'Temp placeholder for the component container. This will be replaced with the actual component list.', + }, + collectionsTempPlaceholder: { + id: 'course-authoring.library-authoring.collections-temp-placeholder', + defaultMessage: 'Coming soon!', + description: 'Temp placeholder for the collections container. This will be replaced with the actual collection list.', + }, + recentComponentsTempPlaceholder: { + id: 'course-authoring.library-authoring.recent-components-temp-placeholder', + defaultMessage: 'Recently modified components and collections will be displayed here.', + description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.', + }, + createLibrary: { + id: 'course-authoring.library-authoring.create-library', + defaultMessage: 'Create library', + description: 'Header for the create library form', + }, + createLibraryTempPlaceholder: { + id: 'course-authoring.library-authoring.create-library-temp-placeholder', + defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.', + description: 'Temp placeholder for the create library container. This will be replaced with the new library form.', + }, +}); + +export default messages; diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 77f806be4a..ada7cdd862 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -35,9 +35,9 @@ function getItemIcon(blockType: string): React.ReactElement { /** * Returns the URL Suffix for library/library component hit */ -function getLibraryHitUrl(hit: ContentHit, libraryAuthoringMfeUrl: string): string { +function getLibraryComponentUrlSuffix(hit: ContentHit) { const { contextKey } = hit; - return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`); + return `library/${contextKey}`; } /** @@ -117,10 +117,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { const { closeSearchModal } = useSearchContext(); const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData); - const { usageKey } = hit; - - const noRedirectUrl = usageKey.startsWith('lb:') && !redirectToLibraryAuthoringMfe; - /** * Returns the URL for the context of the hit */ @@ -136,13 +132,19 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { return `/${urlSuffix}`; } - if (usageKey.startsWith('lb:')) { - if (redirectToLibraryAuthoringMfe) { - return getLibraryHitUrl(hit, libraryAuthoringMfeUrl); + if (contextKey.startsWith('lib:')) { + const urlSuffix = getLibraryComponentUrlSuffix(hit); + if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) { + return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix); } + + if (newWindow) { + return `${getPath(getConfig().PUBLIC_PATH)}${urlSuffix}`; + } + return `/${urlSuffix}`; } - // No context URL for this hit (e.g. a library without library authoring mfe) + // istanbul ignore next - This case should never be reached return undefined; }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]); @@ -189,12 +191,12 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { return ( @@ -213,7 +215,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => { diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx index 92f0f244d3..7a62193e6d 100644 --- a/src/search-modal/SearchUI.test.tsx +++ b/src/search-modal/SearchUI.test.tsx @@ -342,9 +342,10 @@ describe('', () => { window.location = location; }); - test('click lib component result doesnt navigates to the context withou libraryAuthoringMfe', async () => { + test('click lib component result navigates to course-authoring/library without libraryAuthoringMfe', async () => { const data = generateGetStudioHomeDataApiResponse(); data.redirectToLibraryAuthoringMfe = false; + data.libraryAuthoringMfeUrl = ''; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -354,18 +355,21 @@ describe('', () => { const resultItem = await findByRole('button', { name: /Library Content/ }); // Clicking the "Open in new window" button should open the result in a new window: - const { open, location } = window; + const { open } = window; window.open = jest.fn(); fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); - expect(window.open).not.toHaveBeenCalled(); + + expect(window.open).toHaveBeenCalledWith( + '/library/lib:org1:libafter1', + '_blank', + ); window.open = open; - // @ts-ignore - window.location = { href: '' }; // Clicking in the result should navigate to the result's URL: fireEvent.click(resultItem); - expect(window.location.href === location.href); - window.location = location; + expect(mockNavigate).toHaveBeenCalledWith( + '/library/lib:org1:libafter1', + ); }); }); diff --git a/src/search-modal/data/apiHooks.ts b/src/search-modal/data/apiHooks.ts index cfc4454d5f..fe77482285 100644 --- a/src/search-modal/data/apiHooks.ts +++ b/src/search-modal/data/apiHooks.ts @@ -35,8 +35,8 @@ export const useContentSearchResults = ({ indexName, extraFilter, searchKeywords, - blockTypesFilter, - tagsFilter, + blockTypesFilter = [], + tagsFilter = [], }: { /** The Meilisearch API client */ client?: MeiliSearch; @@ -47,9 +47,9 @@ export const useContentSearchResults = ({ /** The keywords that the user is searching for, if any */ searchKeywords: string; /** Only search for these block types (e.g. `["html", "problem"]`) */ - blockTypesFilter: string[]; + blockTypesFilter?: string[]; /** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */ - tagsFilter: string[]; + tagsFilter?: string[]; }) => { const query = useInfiniteQuery({ enabled: client !== undefined && indexName !== undefined, diff --git a/src/search-modal/index.js b/src/search-modal/index.js new file mode 100644 index 0000000000..190635618d --- /dev/null +++ b/src/search-modal/index.js @@ -0,0 +1,3 @@ +// @ts-check +export { default as SearchModal } from './SearchModal'; +export { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index 4953c6f3ae..4400e54517 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Button, Container, @@ -11,6 +11,7 @@ import { Add as AddIcon, Error } from '@openedx/paragon/icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; import { getConfig, getPath } from '@edx/frontend-platform'; +import { useLocation } from 'react-router-dom'; import { constructLibraryAuthoringURL } from '../utils'; import Loading from '../generic/Loading'; @@ -19,7 +20,7 @@ import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import HomeSidebar from './home-sidebar'; import TabsSection from './tabs-section'; -import { isMixedOrV2LibrariesMode } from './tabs-section/utils'; +import { isMixedOrV1LibrariesMode, isMixedOrV2LibrariesMode } from './tabs-section/utils'; import OrganizationSection from './organization-section'; import VerifyEmailLayout from './verify-email-layout'; import CreateNewCourseForm from './create-new-course-form'; @@ -28,6 +29,8 @@ import { useStudioHome } from './hooks'; import AlertMessage from '../generic/alert-message'; const StudioHome = ({ intl }) => { + const location = useLocation(); + const isPaginationCoursesEnabled = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2; const { isLoadingPage, @@ -47,6 +50,8 @@ const StudioHome = ({ intl }) => { const libMode = getConfig().LIBRARY_MODE; + const v1LibraryTab = isMixedOrV1LibrariesMode(libMode) && location?.pathname.split('/').pop() === 'libraries-v1'; + const { userIsActive, studioShortName, @@ -55,7 +60,7 @@ const StudioHome = ({ intl }) => { redirectToLibraryAuthoringMfe, } = studioHomeData; - function getHeaderButtons() { + const getHeaderButtons = useCallback(() => { const headerButtons = []; if (isFailedLoadingPage || !userIsActive) { @@ -83,7 +88,7 @@ const StudioHome = ({ intl }) => { } let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`; - if (isMixedOrV2LibrariesMode(libMode)) { + if (isMixedOrV2LibrariesMode(libMode) && !v1LibraryTab) { libraryHref = libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create') // Redirection to the placeholder is done in the MFE rather than @@ -106,7 +111,7 @@ const StudioHome = ({ intl }) => { ); return headerButtons; - } + }, [location]); const headerButtons = userIsActive ? getHeaderButtons() : []; if (isLoadingPage && !isFiltered) { diff --git a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx b/src/studio-home/tabs-section/LibraryV2Placeholder.jsx deleted file mode 100644 index 6b13853a2c..0000000000 --- a/src/studio-home/tabs-section/LibraryV2Placeholder.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Container } from '@openedx/paragon'; -import { StudioFooter } from '@edx/frontend-component-footer'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import Header from '../../header'; -import SubHeader from '../../generic/sub-header/SubHeader'; -import messages from './messages'; - -/* istanbul ignore next */ -const LibraryV2Placeholder = () => { - const intl = useIntl(); - - return ( - <> -
- -
-
-
- -
-
-
-

{intl.formatMessage(messages.libraryV2PlaceholderBody)}

-
-
-
- - - ); -}; - -export default LibraryV2Placeholder; From 751597544557f4ef9e2997fb7f74751c37d8fb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 3 Jul 2024 17:45:16 +0200 Subject: [PATCH 6/7] refactor: migrate Header to ts and fix some types --- src/header/{Header.jsx => Header.tsx} | 44 +++++++++++---------------- src/search-modal/SearchModal.tsx | 3 +- src/search-modal/SearchUI.tsx | 2 +- 3 files changed, 20 insertions(+), 29 deletions(-) rename src/header/{Header.jsx => Header.tsx} (82%) diff --git a/src/header/Header.jsx b/src/header/Header.tsx similarity index 82% rename from src/header/Header.jsx rename to src/header/Header.tsx index e5ba1a4b3c..d32c4de66a 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.tsx @@ -1,6 +1,5 @@ -// @ts-check +/* eslint-disable react/require-default-props */ import React from 'react'; -import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StudioHeader } from '@edx/frontend-component-header'; @@ -10,14 +9,23 @@ import { SearchModal } from '../search-modal'; import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; import messages from './messages'; +interface HeaderProps { + contentId?: string, + number?: string, + org?: string, + title?: string, + isHiddenMainMenu?: boolean, + isLibrary?: boolean, +} + const Header = ({ - contentId, - org, - number, - title, - isHiddenMainMenu, - isLibrary, -}) => { + contentId = '', + org = '', + number = '', + title = '', + isHiddenMainMenu = false, + isLibrary = false, +}: HeaderProps) => { const intl = useIntl(); const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false); @@ -65,22 +73,4 @@ const Header = ({ ); }; -Header.propTypes = { - contentId: PropTypes.string, - number: PropTypes.string, - org: PropTypes.string, - title: PropTypes.string, - isHiddenMainMenu: PropTypes.bool, - isLibrary: PropTypes.bool, -}; - -Header.defaultProps = { - contentId: '', - number: '', - org: '', - title: '', - isHiddenMainMenu: false, - isLibrary: false, -}; - export default Header; diff --git a/src/search-modal/SearchModal.tsx b/src/search-modal/SearchModal.tsx index 9cb24dabf1..ca143df51f 100644 --- a/src/search-modal/SearchModal.tsx +++ b/src/search-modal/SearchModal.tsx @@ -5,7 +5,8 @@ import { ModalDialog } from '@openedx/paragon'; import messages from './messages'; import SearchUI from './SearchUI'; -const SearchModal: React.FC<{ courseId: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => { +// eslint-disable-next-line react/require-default-props +const SearchModal: React.FC<{ courseId?: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => { const intl = useIntl(); const title = intl.formatMessage(messages.title); diff --git a/src/search-modal/SearchUI.tsx b/src/search-modal/SearchUI.tsx index 1ce23a8dbd..ce60d762b8 100644 --- a/src/search-modal/SearchUI.tsx +++ b/src/search-modal/SearchUI.tsx @@ -18,7 +18,7 @@ import Stats from './Stats'; import { SearchContextProvider } from './manager/SearchManager'; import messages from './messages'; -const SearchUI: React.FC<{ courseId: string, closeSearchModal?: () => void }> = (props) => { +const SearchUI: React.FC<{ courseId?: string, closeSearchModal?: () => void }> = (props) => { const hasCourseId = Boolean(props.courseId); const [searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId); const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []); From 905dbb58de46bc4da9e9565c832b94fcc696563f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 5 Jul 2024 18:17:16 +0200 Subject: [PATCH 7/7] fix: add intl to tab names --- src/library-authoring/LibraryAuthoringPage.tsx | 6 +++--- src/library-authoring/messages.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 305c9e753d..8a6c449407 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -100,9 +100,9 @@ const LibraryAuthoringPage = () => { onSelect={handleTabChange} className="my-3" > - - - + + +