From 9efb583cdc1469d73928f6ef854df33c02343e31 Mon Sep 17 00:00:00 2001 From: Jorg Are Date: Mon, 5 Aug 2024 13:46:50 +0100 Subject: [PATCH 1/8] feat: replace ai-translations component with a plugin slot (#1186) * feat: replace ai-translations component with a plugin slot * feat: move ai-translations enabled check to plugin --- package-lock.json | 77 ------------------- package.json | 1 - .../TranscriptSettings.jsx | 27 +++---- .../TranscriptSettings.test.jsx | 33 +------- 4 files changed, 16 insertions(+), 122 deletions(-) diff --git a/package-lock.json b/package-lock.json index c19499ad58..860a39a8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", - "@edx/frontend-component-ai-translations": "^2.1.0", "@edx/frontend-component-footer": "^14.0.3", "@edx/frontend-component-header": "^5.3.3", "@edx/frontend-enterprise-hotjar": "^2.0.0", @@ -2377,82 +2376,6 @@ "eslint-plugin-react-hooks": "^1.7.0 || ^4.0.0" } }, - "node_modules/@edx/frontend-component-ai-translations": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-ai-translations/-/frontend-component-ai-translations-2.1.0.tgz", - "integrity": "sha512-odH8QxBYtanMMoPXiNYljcI4jTWi80NTYGJXm18M3OvIdQ4sL415KheBk73Za0ZVFHFzOs7zpFO9ZU0UgcXFVQ==", - "dependencies": { - "babel-polyfill": "6.26.0", - "prop-types": "^15.5.10", - "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0", - "react-responsive": "8.2.0", - "react-router-dom": "6.16.0", - "ts-jest": "^29.1.2" - }, - "peerDependencies": { - "@edx/frontend-platform": "^7.0.0 || ^8.0.0", - "@openedx/paragon": "^21.5.7 || ^22.0.0", - "prop-types": "^15.5.10", - "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0" - } - }, - "node_modules/@edx/frontend-component-ai-translations/node_modules/@remix-run/router": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", - "integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@edx/frontend-component-ai-translations/node_modules/react-responsive": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", - "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", - "dependencies": { - "hyphenate-style-name": "^1.0.0", - "matchmediaquery": "^0.3.0", - "prop-types": "^15.6.1", - "shallow-equal": "^1.1.0" - }, - "engines": { - "node": ">= 0.10" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@edx/frontend-component-ai-translations/node_modules/react-router-dom": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", - "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", - "dependencies": { - "@remix-run/router": "1.9.0", - "react-router": "6.16.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@edx/frontend-component-ai-translations/node_modules/react-router-dom/node_modules/react-router": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", - "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", - "dependencies": { - "@remix-run/router": "1.9.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/@edx/frontend-component-footer": { "version": "14.0.5", "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.5.tgz", diff --git a/package.json b/package.json index dd1cc0d488..f3daa13742 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", - "@edx/frontend-component-ai-translations": "^2.1.0", "@edx/frontend-component-footer": "^14.0.3", "@edx/frontend-component-header": "^5.3.3", "@edx/frontend-enterprise-hotjar": "^2.0.0", diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx index 7421899093..541ac24451 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx @@ -11,7 +11,7 @@ import { TransitionReplace, } from '@openedx/paragon'; import { ChevronLeft, ChevronRight, Close } from '@openedx/paragon/icons'; -import AITranslationsComponent from '@edx/frontend-component-ai-translations'; +import { PluginSlot } from '@openedx/frontend-plugin-framework'; import OrderTranscriptForm from './OrderTranscriptForm'; import messages from './messages'; import { @@ -114,18 +114,19 @@ const TranscriptSettings = ({ )} - {(!transcriptType && isAiTranslationsEnabled) && ( - -
- -
-
- )} + +
+ +
+
); 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 index 55a97747a4..b45d6e8aa3 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -616,36 +616,7 @@ describe('TranscriptSettings', () => { }); }); - describe('Ai translations component fails', () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: false, - roles: [], - }, - }); - store = initializeStore({ - ...initialState, - videos: { - ...initialState.videos, - pageSettings: { - ...initialState.videos.pageSettings, - }, - }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - - renderComponent(defaultProps); - }); - - it('doesn\'t display AI translations component if not enabled', () => { - expect(screen.queryByTestId('ai-translations-component')).not.toBeInTheDocument(); - }); - }); - - describe('Ai translations component success', () => { + describe('Translations component success', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -671,7 +642,7 @@ describe('TranscriptSettings', () => { }); it('displays AI translations component if enabled', () => { - const component = screen.getByTestId('ai-translations-component'); + const component = screen.getByTestId('translations-component'); expect(component).toBeInTheDocument(); }); }); From 6f13164998270a1827474463770162ebdd886238 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 09:31:12 -0400 Subject: [PATCH 2/8] fix(deps): update dependency @edx/frontend-lib-content-components to v2.6.4 (#1184) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 860a39a8a1..c77c0a1c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2636,9 +2636,10 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.0.tgz", - "integrity": "sha512-dqx94SSbaVSztkyInNH7GbBzMSyFvKdx1zFWa4itWeSf1cUlfvD7QBZfHC5US8kh+CHW7YvrQg6whaF2F2neNg==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.4.tgz", + "integrity": "sha512-lFpiHFaNDzT4QKtMhHJ29QrgXNwYB6T6Zg4TCEYDqAAuM6LU6Prc0AwCYZg20ycWn6dvzVzJ2GDS1/4ZuPPkTQ==", + "license": "AGPL-3.0", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", From 553acd8fcc623459625390f10a265577afbbcda1 Mon Sep 17 00:00:00 2001 From: Brandon Bodine Date: Tue, 6 Aug 2024 07:17:08 -0600 Subject: [PATCH 3/8] chore: remove unused ai-translations env vars (#1204) --- .env | 1 - .env.development | 1 - 2 files changed, 2 deletions(-) diff --git a/.env b/.env index 4235461134..6f871fb94c 100644 --- a/.env +++ b/.env @@ -39,7 +39,6 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=false INVITE_STUDENTS_EMAIL_TO='' -AI_TRANSLATIONS_BASE_URL='' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY='' ENABLE_GRADING_METHOD_IN_PROBLEMS=false diff --git a/.env.development b/.env.development index 5547e8ffec..75b4219d0e 100644 --- a/.env.development +++ b/.env.development @@ -42,7 +42,6 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=true INVITE_STUDENTS_EMAIL_TO="someone@domain.com" -AI_TRANSLATIONS_BASE_URL='http://localhost:18760' ENABLE_HOME_PAGE_COURSE_API_V2=false ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false From 3d82d379439b84bf509c74323d8fede806d14f20 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:50:21 -0400 Subject: [PATCH 4/8] =?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?205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6f13164998270a1827474463770162ebdd886238. --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c77c0a1c3c..860a39a8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2636,10 +2636,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.4.tgz", - "integrity": "sha512-lFpiHFaNDzT4QKtMhHJ29QrgXNwYB6T6Zg4TCEYDqAAuM6LU6Prc0AwCYZg20ycWn6dvzVzJ2GDS1/4ZuPPkTQ==", - "license": "AGPL-3.0", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.0.tgz", + "integrity": "sha512-dqx94SSbaVSztkyInNH7GbBzMSyFvKdx1zFWa4itWeSf1cUlfvD7QBZfHC5US8kh+CHW7YvrQg6whaF2F2neNg==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", From 7379e734a0d8ab93e531c3f524d2f6159086f8ac Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Wed, 7 Aug 2024 12:07:22 -0400 Subject: [PATCH 5/8] feat: replace progress bar with loading spinner (#1192) --- .../videos-page/data/thunks.js | 4 +-- src/files-and-videos/videos-page/messages.js | 2 +- .../upload-modal/UploadProgressList.jsx | 25 +++++++++++++------ .../upload-modal/UploadStatusIcon.jsx | 10 +++++++- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 2fa064ce6a..cefc04ebd0 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -54,7 +54,7 @@ export function cancelAllUploads(courseId, uploadData) { control.abort(); }); Object.entries(uploadData).forEach(([key, value]) => { - if (value.status === RequestStatus.PENDING) { + if (value.status === RequestStatus.IN_PROGRESS) { updateVideoUploadStatus( courseId, key, @@ -324,7 +324,7 @@ export function addVideoFile( if (uploadUrl && edxVideoId) { uploadingIdsRef.current.uploadData = newUploadData({ - status: RequestStatus.PENDING, + status: RequestStatus.IN_PROGRESS, currentData: uploadingIdsRef.current.uploadData, originalValue: { name, progress }, key: `video_${idx}`, diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js index 91096b2f61..e6e25d8aa2 100644 --- a/src/files-and-videos/videos-page/messages.js +++ b/src/files-and-videos/videos-page/messages.js @@ -68,7 +68,7 @@ const messages = defineMessages({ }, videoUploadTrackerAlertEditMessage: { id: 'course-authoring.files-and-videos.video-upload-tracker-alert.edit.message', - defaultMessage: 'Want to coninue editing in Studio during this upload?', + defaultMessage: 'Want to continue editing in Studio during this upload?', description: 'Continue editing message for the Upload Tracker Alert', }, videoUploadTrackerAlertEditHyperlinkLabel: { diff --git a/src/files-and-videos/videos-page/upload-modal/UploadProgressList.jsx b/src/files-and-videos/videos-page/upload-modal/UploadProgressList.jsx index dcdf7f4a3d..fdecdb35b1 100644 --- a/src/files-and-videos/videos-page/upload-modal/UploadProgressList.jsx +++ b/src/files-and-videos/videos-page/upload-modal/UploadProgressList.jsx @@ -1,9 +1,22 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { ProgressBar, Stack, Truncate } from '@openedx/paragon'; +import { Stack, Truncate } from '@openedx/paragon'; import UploadStatusIcon from './UploadStatusIcon'; import { RequestStatus } from '../../../data/constants'; +const getVideoStatus = (status) => { + switch (status) { + case RequestStatus.IN_PROGRESS: + return 'UPLOADING'; + case RequestStatus.PENDING: + return 'QUEUED'; + case RequestStatus.SUCCESSFUL: + return ''; + default: + return status.toUpperCase(); + } +}; + const UploadProgressList = ({ videosList }) => (
{videosList.map(([id, video], index) => { @@ -17,13 +30,9 @@ const UploadProgressList = ({ videosList }) => (
- {video.status === RequestStatus.FAILED ? ( - - {video.status.toUpperCase()} - - ) : ( - - )} + + {getVideoStatus(video.status)} +
diff --git a/src/files-and-videos/videos-page/upload-modal/UploadStatusIcon.jsx b/src/files-and-videos/videos-page/upload-modal/UploadStatusIcon.jsx index 6b8fd5c014..b620372dd0 100644 --- a/src/files-and-videos/videos-page/upload-modal/UploadStatusIcon.jsx +++ b/src/files-and-videos/videos-page/upload-modal/UploadStatusIcon.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Icon } from '@openedx/paragon'; +import { Icon, Spinner } from '@openedx/paragon'; import { Check, ErrorOutline } from '@openedx/paragon/icons'; import { RequestStatus } from '../../../data/constants'; @@ -10,6 +10,14 @@ const UploadStatusIcon = ({ status }) => { return (); case RequestStatus.FAILED: return (); + case RequestStatus.IN_PROGRESS: + return ( + + ); default: return (
); } From a7645afd221a8e46d605f668f8dcb50b0e85f392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Wed, 7 Aug 2024 20:26:32 -0500 Subject: [PATCH 6/8] fix: UI fixes for read-only libraries etc. (#1198) * fix: Hide open Create content buttons without permissions * feat: Read only badge on library Home * refactor: library authoring to get canEditLibrary from useContentLibrary * style: Typo on the code --- src/library-authoring/EmptyStates.tsx | 13 +++- .../LibraryAuthoringPage.test.tsx | 32 +++++++++ .../LibraryAuthoringPage.tsx | 72 +++++++++++++------ .../components/LibraryComponents.test.tsx | 24 +++++++ .../components/LibraryComponents.tsx | 5 +- src/library-authoring/messages.ts | 5 ++ 6 files changed, 122 insertions(+), 29 deletions(-) diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index a968bcaa09..e8e03cb4a6 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -1,4 +1,5 @@ import React, { useContext } from 'react'; +import { useParams } from 'react-router'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button, Stack, @@ -7,16 +8,22 @@ import { Add } from '@openedx/paragon/icons'; import { ClearFiltersButton } from '../search-manager'; import messages from './messages'; import { LibraryContext } from './common/context'; +import { useContentLibrary } from './data/apiHooks'; export const NoComponents = () => { const { openAddContentSidebar } = useContext(LibraryContext); + const { libraryId } = useParams(); + const { data: libraryData } = useContentLibrary(libraryId); + const canEditLibrary = libraryData?.canEditLibrary ?? false; return ( - + {canEditLibrary && ( + + )} ); }; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index fa425a54a4..182a245773 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -235,6 +235,23 @@ describe('', () => { expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); }); + it('show library without components without permission', async () => { + const data = { + ...libraryData, + canEditLibrary: false, + }; + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); + fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); + + render(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + + expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); + }); + it('show new content button', async () => { mockUseParams.mockReturnValue({ libraryId: libraryData.id }); axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); @@ -245,6 +262,21 @@ describe('', () => { expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); }); + it('read only state of library', async () => { + const data = { + ...libraryData, + canEditLibrary: false, + }; + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, data); + + render(); + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); + + expect(screen.getByText('Read Only')).toBeInTheDocument(); + }); + it('show library without search results', async () => { mockUseParams.mockReturnValue({ libraryId: libraryData.id }); axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 02e7d93260..ff940aca03 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -2,12 +2,14 @@ import React, { useContext } from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { + Badge, Button, Col, Container, Icon, IconButton, Row, + Stack, Tab, Tabs, } from '@openedx/paragon'; @@ -42,18 +44,53 @@ enum TabList { collections = 'collections', } -const SubHeaderTitle = ({ title }: { title: string }) => { +interface HeaderActionsProps { + canEditLibrary: boolean; +} + +const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { + const intl = useIntl(); + const { + openAddContentSidebar, + } = useContext(LibraryContext); + + if (!canEditLibrary) { + return null; + } + + return ( + + ); +}; + +const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => { const intl = useIntl(); return ( - <> - {title} - - + + + {title} + + + { !canEditLibrary && ( +
+ + {intl.formatMessage(messages.readOnlyBadge)} + +
+ )} +
); }; @@ -67,7 +104,7 @@ const LibraryAuthoringPage = () => { const currentPath = location.pathname.split('/').pop(); const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; - const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext); + const { sidebarBodyComponent } = useContext(LibraryContext); const [searchParams] = useSearchParams(); @@ -102,18 +139,9 @@ const LibraryAuthoringPage = () => { > } + title={} subtitle={intl.formatMessage(messages.headingSubtitle)} - headerActions={[ - , - ]} + headerActions={} />
diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx index a68309812d..c21036b301 100644 --- a/src/library-authoring/components/LibraryComponents.test.tsx +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -21,6 +21,7 @@ const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; const mockUseLibraryBlockTypes = jest.fn(); const mockFetchNextPage = jest.fn(); const mockUseSearchContext = jest.fn(); +const mockUseContentLibrary = jest.fn(); const data = { totalHits: 1, @@ -75,6 +76,7 @@ const blockTypeData = { jest.mock('../data/apiHooks', () => ({ useLibraryBlockTypes: () => mockUseLibraryBlockTypes(), + useContentLibrary: () => mockUseContentLibrary(), })); jest.mock('../../search-manager', () => ({ @@ -128,9 +130,31 @@ describe('', () => { ...data, totalHits: 0, }); + mockUseContentLibrary.mockReturnValue({ + data: { + canEditLibrary: true, + }, + }); + + render(); + expect(await screen.findByText(/you have not added any content to this library yet\./i)); + expect(screen.getByRole('button', { name: /add component/i })).toBeInTheDocument(); + }); + + it('should render empty state without add content button', async () => { + mockUseSearchContext.mockReturnValue({ + ...data, + totalHits: 0, + }); + mockUseContentLibrary.mockReturnValue({ + data: { + canEditLibrary: false, + }, + }); render(); expect(await screen.findByText(/you have not added any content to this library yet\./i)); + expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument(); }); it('should render components in full variant', async () => { diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index 0e4d978722..24140abaca 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -19,10 +19,7 @@ type LibraryComponentsProps = { * - 'full': Show all components with Infinite scroll pagination. * - 'preview': Show first 4 components without pagination. */ -const LibraryComponents = ({ - libraryId, - variant, -}: LibraryComponentsProps) => { +const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => { const { hits, totalHits: componentCount, diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index eb9a9f21fc..38332b5432 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -100,6 +100,11 @@ const messages = defineMessages({ defaultMessage: 'Close', description: 'Alt text of close button', }, + readOnlyBadge: { + id: 'course-authoring.library-authoring.badge.read-only', + defaultMessage: 'Read Only', + description: 'Text in badge when the user has read only access', + }, }); export default messages; From bb88101255ce1515045be54102afd7466ef24c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 8 Aug 2024 13:32:04 -0300 Subject: [PATCH 7/8] feat: add "copy to clipboard" feature to library authoring UI (#1197) --- .../components/ComponentCard.test.tsx | 126 ++++++++++++++++++ .../components/ComponentCard.tsx | 70 +++++----- src/library-authoring/components/messages.ts | 20 ++- src/search-manager/data/api.ts | 15 ++- 4 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 src/library-authoring/components/ComponentCard.test.tsx diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx new file mode 100644 index 0000000000..0041f5e9a0 --- /dev/null +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import type { Store } from 'redux'; + +import { ToastProvider } from '../../generic/toast-context'; +import { getClipboardUrl } from '../../generic/data/api'; +import { ContentHit } from '../../search-manager'; +import initializeStore from '../../store'; +import ComponentCard from './ComponentCard'; + +let store: Store; +let axiosMock: MockAdapter; + +const contentHit: ContentHit = { + id: '1', + usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d', + type: 'library_block', + blockId: 'a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d', + contextKey: 'lb:org1:Demo_Course', + org: 'org1', + breadcrumbs: [{ displayName: 'Demo Lib' }], + displayName: 'Text Display Name', + formatted: { + displayName: 'Text Display Formated Name', + content: { + htmlContent: 'This is a text: ID=1', + }, + }, + tags: { + level0: ['1', '2', '3'], + }, + blockType: 'text', + created: 1722434322294, + modified: 1722434322294, + lastPublished: null, +}; + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + }); + + it('should render the card with title and description', () => { + const { getByText } = render(); + + expect(getByText('Text Display Formated Name')).toBeInTheDocument(); + expect(getByText('This is a text: ID=1')).toBeInTheDocument(); + }); + + it('should call the updateClipboard function when the copy button is clicked', async () => { + axiosMock.onPost(getClipboardUrl()).reply(200, {}); + const { getByRole, getByTestId, getByText } = render(); + + // Open menu + expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument(); + fireEvent.click(getByTestId('component-card-menu-toggle')); + + // Click copy to clipboard + expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument(); + fireEvent.click(getByRole('button', { name: 'Copy to clipboard' })); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe( + JSON.stringify({ usage_key: contentHit.usageKey }), + ); + + await waitFor(() => { + expect(getByText('Component copied to clipboard')).toBeInTheDocument(); + }); + }); + + it('should show error message if the api call fails', async () => { + axiosMock.onPost(getClipboardUrl()).reply(400); + const { getByRole, getByTestId, getByText } = render(); + + // Open menu + expect(getByTestId('component-card-menu-toggle')).toBeInTheDocument(); + fireEvent.click(getByTestId('component-card-menu-toggle')); + + // Click copy to clipboard + expect(getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument(); + fireEvent.click(getByRole('button', { name: 'Copy to clipboard' })); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe( + JSON.stringify({ usage_key: contentHit.usageKey }), + ); + + await waitFor(() => { + expect(getByText('Failed to copy component to clipboard')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index ce9ef594ab..9bae1303fc 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -1,4 +1,5 @@ -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Card, @@ -9,10 +10,11 @@ import { Stack, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import { updateClipboard } from '../../generic/data/api'; import TagCount from '../../generic/tag-count'; +import { ToastContext } from '../../generic/toast-context'; import { type ContentHit, Highlight } from '../../search-manager'; import messages from './messages'; @@ -21,39 +23,47 @@ type ComponentCardProps = { blockTypeDisplayName: string, }; -const ComponentCardMenu = () => ( - - - - - - - - - - - - - - -); +const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + const updateClipboardClick = () => { + updateClipboard(usageKey) + .then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess))) + .catch(() => showToast(intl.formatMessage(messages.copyToClipboardError))); + }; + + return ( + + + + + {intl.formatMessage(messages.menuEdit)} + + + {intl.formatMessage(messages.menuCopyToClipboard)} + + + {intl.formatMessage(messages.menuAddToCollection)} + + + + ); +}; const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => { const { blockType, formatted, tags, + usageKey, } = contentHit; const description = formatted?.content?.htmlContent ?? ''; const displayName = formatted?.displayName ?? ''; @@ -77,7 +87,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps } actions={( - + )} /> diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 1e80f26c73..7802cd479e 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -1,10 +1,16 @@ 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({ + componentCardMenuAlt: { + id: 'course-authoring.library-authoring.component.menu', + defaultMessage: 'Component actions menu', + description: 'Alt/title text for the component card menu button.', + }, menuEdit: { id: 'course-authoring.library-authoring.component.menu.edit', defaultMessage: 'Edit', @@ -12,14 +18,24 @@ const messages = defineMessages({ }, menuCopyToClipboard: { id: 'course-authoring.library-authoring.component.menu.copy', - defaultMessage: 'Copy to Clipboard', + defaultMessage: 'Copy to clipboard', description: 'Menu item for copy a component.', }, menuAddToCollection: { id: 'course-authoring.library-authoring.component.menu.add', - defaultMessage: 'Add to Collection', + defaultMessage: 'Add to collection', description: 'Menu item for add a component to collection.', }, + copyToClipboardSuccess: { + id: 'course-authoring.library-authoring.component.copyToClipboardSuccess', + defaultMessage: 'Component copied to clipboard', + description: 'Message for successful copy component to clipboard.', + }, + copyToClipboardError: { + id: 'course-authoring.library-authoring.component.copyToClipboardError', + defaultMessage: 'Failed to copy component to clipboard', + description: 'Message for failed to copy component to clipboard.', + }, }); export default messages; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index a16055df62..42d04981ef 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -80,6 +80,17 @@ function formatTagsFilter(tagsFilter?: string[]): string[] { return filters; } +/** + * The tags that are associated with a search result, at various levels of the tag hierarchy. + */ +interface ContentHitTags { + taxonomy?: string[]; + level0?: string[]; + level1?: string[]; + level2?: string[]; + level3?: string[]; +} + /** * Information about a single XBlock returned in the search results * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py @@ -101,13 +112,13 @@ export interface ContentHit { * - After that is the name and usage key of any parent Section/Subsection/Unit/etc. */ breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>]; - tags: Record<'taxonomy' | 'level0' | 'level1' | 'level2' | 'level3', string[]>; + tags: ContentHitTags; content?: ContentDetails; /** Same fields with ... highlights */ formatted: { displayName: string, content?: ContentDetails }; created: number; modified: number; - last_published: number; + lastPublished: number | null; } /** From 8285f8ec5ab84ed44288ab6075f765b4c859c2eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Aug 2024 09:43:34 -0400 Subject: [PATCH 8/8] fix(deps): update dependency @edx/frontend-lib-content-components to v2.6.5 (#1206) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 860a39a8a1..4a53b60ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2636,9 +2636,10 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.0.tgz", - "integrity": "sha512-dqx94SSbaVSztkyInNH7GbBzMSyFvKdx1zFWa4itWeSf1cUlfvD7QBZfHC5US8kh+CHW7YvrQg6whaF2F2neNg==", + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.5.tgz", + "integrity": "sha512-xPdGM8qkxy5MpARYrUJC3tjqpFUxFdkhPrtG9EZihcb3XS7Owf2qgJ4d2FZg4zh/1u8Ox0Pn0fpeoECe2XKyPQ==", + "license": "AGPL-3.0", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0",