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 1/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 860a39a8a..4a53b60ae 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", From 4f5346ed310d7dab2eb9b406d7c7d11278afc240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Tue, 13 Aug 2024 12:37:34 -0500 Subject: [PATCH 2/8] feat: Library info sidebar - allows lib rename+publish (#1138) --- src/index.scss | 2 +- .../LibraryAuthoringPage.test.tsx | 76 ++++++- .../LibraryAuthoringPage.tsx | 54 +++-- .../add-content/AddContentHeader.tsx | 11 + src/library-authoring/add-content/index.ts | 2 +- src/library-authoring/add-content/messages.ts | 5 + src/library-authoring/common/context.tsx | 14 +- src/library-authoring/data/api.test.ts | 29 ++- src/library-authoring/data/api.ts | 71 ++++-- src/library-authoring/data/apiHooks.test.tsx | 24 +- src/library-authoring/data/apiHooks.ts | 53 +++++ src/library-authoring/index.scss | 1 + .../library-info/LibraryInfo.test.tsx | 207 ++++++++++++++++++ .../library-info/LibraryInfo.tsx | 61 ++++++ .../library-info/LibraryInfoHeader.test.tsx | 159 ++++++++++++++ .../library-info/LibraryInfoHeader.tsx | 86 ++++++++ .../library-info/LibraryPublishStatus.scss | 11 + .../library-info/LibraryPublishStatus.tsx | 171 +++++++++++++++ src/library-authoring/library-info/index.ts | 2 + .../library-info/messages.ts | 111 ++++++++++ .../library-sidebar/LibrarySidebar.tsx | 34 ++- src/library-authoring/messages.ts | 5 + 22 files changed, 1124 insertions(+), 65 deletions(-) create mode 100644 src/library-authoring/add-content/AddContentHeader.tsx create mode 100644 src/library-authoring/library-info/LibraryInfo.test.tsx create mode 100644 src/library-authoring/library-info/LibraryInfo.tsx create mode 100644 src/library-authoring/library-info/LibraryInfoHeader.test.tsx create mode 100644 src/library-authoring/library-info/LibraryInfoHeader.tsx create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.scss create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.tsx create mode 100644 src/library-authoring/library-info/index.ts create mode 100644 src/library-authoring/library-info/messages.ts diff --git a/src/index.scss b/src/index.scss index db1b1d8ac..764489d1d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -18,6 +18,7 @@ @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; @import "taxonomy"; +@import "library-authoring"; @import "files-and-videos"; @import "content-tags-drawer"; @import "course-outline/CourseOutline"; @@ -30,7 +31,6 @@ @import "search-manager"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; -@import "library-authoring"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 182a24577..8e7369184 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -83,6 +83,9 @@ const libraryData: ContentLibrary = { numBlocks: 2, version: 0, lastPublished: null, + lastDraftCreated: '2024-07-22', + publishedBy: 'staff', + lastDraftCreatedBy: 'staff', allowLti: false, allowPublicLearning: false, allowPublicRead: false, @@ -90,6 +93,8 @@ const libraryData: ContentLibrary = { hasUnpublishedDeletes: false, canEditLibrary: true, license: '', + created: '2024-06-26', + updated: '2024-07-20', }; const RootWrapper = () => ( @@ -177,7 +182,7 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); const { - getByRole, getByText, getAllByText, queryByText, + getByRole, getByText, queryByText, findByText, findAllByText, } = render(); // Ensure the search endpoint is called: @@ -185,15 +190,15 @@ describe('', () => { // Call 2: To fetch the recently modified components only await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); - expect(getByText('Content library')).toBeInTheDocument(); - expect(getByText(libraryData.title)).toBeInTheDocument(); + expect(await findByText('Content library')).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).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(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument(); // Navigate to the components tab fireEvent.click(getByRole('tab', { name: 'Components' })); @@ -222,10 +227,10 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - const { findByText, getByText } = render(); + const { findByText, getByText, findAllByText } = render(); expect(await findByText('Content library')).toBeInTheDocument(); - expect(await findByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); // Ensure the search endpoint is called: // Call 1: To fetch searchable/filterable/sortable library data @@ -282,10 +287,15 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - const { findByText, getByRole, getByText } = render(); + const { + findByText, + getByRole, + getByText, + findAllByText, + } = render(); expect(await findByText('Content library')).toBeInTheDocument(); - expect(await findByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); // Ensure the search endpoint is called: // Call 1: To fetch searchable/filterable/sortable library data @@ -329,12 +339,54 @@ describe('', () => { expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); }); + it('should open Library Info by default', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + render(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); + }); + + it('should close and open Library Info', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + render(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(screen.queryByText('Draft')).not.toBeInTheDocument(); + expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); + + const libraryInfoButton = screen.getByRole('button', { name: /library info/i }); + fireEvent.click(libraryInfoButton); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + }); + it('show the "View All" button when viewing library with many components', async () => { mockUseParams.mockReturnValue({ libraryId: libraryData.id }); axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); const { - getByRole, getByText, queryByText, getAllByText, + getByRole, getByText, queryByText, getAllByText, findAllByText, } = render(); // Ensure the search endpoint is called: @@ -343,7 +395,7 @@ describe('', () => { await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('Content library')).toBeInTheDocument(); - expect(getByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); }); expect(getByText('Collections (0)')).toBeInTheDocument(); @@ -376,7 +428,7 @@ describe('', () => { fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true }); const { - getByText, queryByText, getAllByText, + getByText, queryByText, getAllByText, findAllByText, } = render(); // Ensure the search endpoint is called: @@ -385,7 +437,7 @@ describe('', () => { await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('Content library')).toBeInTheDocument(); - expect(getByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); }); expect(getByText('Collections (0)')).toBeInTheDocument(); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index ff940aca0..c2eb96929 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -6,8 +6,6 @@ import { Button, Col, Container, - Icon, - IconButton, Row, Stack, Tab, @@ -52,6 +50,7 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { const intl = useIntl(); const { openAddContentSidebar, + openInfoSidebar, } = useContext(LibraryContext); if (!canEditLibrary) { @@ -59,30 +58,32 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { } return ( - + <> + + + ); }; const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => { const intl = useIntl(); + return ( - - {title} - - + {title} { !canEditLibrary && (
@@ -104,7 +105,14 @@ const LibraryAuthoringPage = () => { const currentPath = location.pathname.split('/').pop(); const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; - const { sidebarBodyComponent } = useContext(LibraryContext); + const { + sidebarBodyComponent, + openInfoSidebar, + } = useContext(LibraryContext); + + useEffect(() => { + openInfoSidebar(); + }, []); const [searchParams] = useSearchParams(); @@ -190,8 +198,8 @@ const LibraryAuthoringPage = () => { { sidebarBodyComponent !== null && ( - - + + )} diff --git a/src/library-authoring/add-content/AddContentHeader.tsx b/src/library-authoring/add-content/AddContentHeader.tsx new file mode 100644 index 000000000..a73a41a03 --- /dev/null +++ b/src/library-authoring/add-content/AddContentHeader.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const AddContentHeader = () => ( + + + +); + +export default AddContentHeader; diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts index 876828e16..ae4e4ac7b 100644 --- a/src/library-authoring/add-content/index.ts +++ b/src/library-authoring/add-content/index.ts @@ -1,2 +1,2 @@ -// eslint-disable-next-line import/prefer-default-export export { default as AddContentContainer } from './AddContentContainer'; +export { default as AddContentHeader } from './AddContentHeader'; diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 1d13635e5..6024e144c 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -50,6 +50,11 @@ const messages = defineMessages({ defaultMessage: 'There was an error creating the content.', description: 'Message when creation of content in library is on error', }, + addContentTitle: { + id: 'course-authoring.library-authoring.sidebar.title.add-content', + defaultMessage: 'Add Content', + description: 'Title of add content in library container.', + }, }); export default messages; diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 241ed67d2..548b39aa3 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,20 +1,23 @@ /* eslint-disable react/require-default-props */ import React from 'react'; -enum SidebarBodyComponentId { +export enum SidebarBodyComponentId { AddContent = 'add-content', + Info = 'info', } export interface LibraryContextData { sidebarBodyComponent: SidebarBodyComponentId | null; closeLibrarySidebar: () => void; openAddContentSidebar: () => void; + openInfoSidebar: () => void; } export const LibraryContext = React.createContext({ sidebarBodyComponent: null, closeLibrarySidebar: () => {}, openAddContentSidebar: () => {}, + openInfoSidebar: () => {}, } as LibraryContextData); /** @@ -25,12 +28,19 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []); const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []); + const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []); const context = React.useMemo(() => ({ sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, - }), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]); + openInfoSidebar, + }), [ + sidebarBodyComponent, + closeLibrarySidebar, + openAddContentSidebar, + openInfoSidebar, + ]); return ( diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 66736ad24..557488900 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -1,7 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { createLibraryBlock, getCreateLibraryBlockUrl } from './api'; +import { + commitLibraryChanges, + createLibraryBlock, + getCommitLibraryChangesUrl, + getCreateLibraryBlockUrl, + revertLibraryChanges, +} from './api'; let axiosMock; @@ -21,6 +27,7 @@ describe('library api calls', () => { afterEach(() => { jest.clearAllMocks(); + axiosMock.restore(); }); it('should create library block', async () => { @@ -35,4 +42,24 @@ describe('library api calls', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should commit library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onPost(url).reply(200); + + await commitLibraryChanges(libraryId); + + expect(axiosMock.history.post[0].url).toEqual(url); + }); + + it('should revert library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onDelete(url).reply(200); + + await revertLibraryChanges(libraryId); + + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 717180864..b69041af0 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -16,6 +16,10 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl() */ export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`; export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`; +/** + * Get the URL for commit/revert changes in library. + */ +export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`; export interface ContentLibrary { id: string; @@ -26,7 +30,10 @@ export interface ContentLibrary { description: string; numBlocks: number; version: number; - lastPublished: Date | null; + lastPublished: string | null; + lastDraftCreated: string | null; + publishedBy: string | null; + lastDraftCreatedBy: string | null; allowLti: boolean; allowPublicLearning: boolean; allowPublicRead: boolean; @@ -34,6 +41,8 @@ export interface ContentLibrary { hasUnpublishedDeletes: boolean; canEditLibrary: boolean; license: string; + created: string | null; + updated: string | null; } export interface LibraryBlockType { @@ -41,18 +50,6 @@ export interface LibraryBlockType { displayName: string; } -/** - * Fetch block types of a library - */ -export async function getLibraryBlockTypes(libraryId?: string): Promise { - if (!libraryId) { - throw new Error('libraryId is required'); - } - - const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); - return camelCaseObject(data); -} - export interface LibrariesV2Response { next: string | null, previous: string | null, @@ -94,6 +91,28 @@ export interface CreateBlockDataResponse { tagsCount: number; } +export interface UpdateLibraryDataRequest { + id: string; + title?: string; + description?: string; + allow_public_learning?: boolean; + allow_public_read?: boolean; + type?: string; + license?: string; +} + +/** + * Fetch block types of a library + */ +export async function getLibraryBlockTypes(libraryId?: string): Promise { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); + return camelCaseObject(data); +} + /** * Fetch a content library by its ID. */ @@ -122,6 +141,16 @@ export async function createLibraryBlock({ return data; } +/** + * Update library metadata. + */ +export async function updateLibraryMetadata(libraryData: UpdateLibraryDataRequest): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.patch(getContentLibraryApiUrl(libraryData.id), libraryData); + + return camelCaseObject(data); +} + /** * Get a list of content libraries. */ @@ -140,3 +169,19 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom .get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated }); return camelCaseObject(data); } + +/** + * Commit library changes. + */ +export async function commitLibraryChanges(libraryId: string) { + const client = getAuthenticatedHttpClient(); + await client.post(getCommitLibraryChangesUrl(libraryId)); +} + +/** + * Revert library changes. + */ +export async function revertLibraryChanges(libraryId: string) { + const client = getAuthenticatedHttpClient(); + await client.delete(getCommitLibraryChangesUrl(libraryId)); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 679842376..686e11401 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -5,8 +5,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { renderHook } from '@testing-library/react-hooks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; -import { getCreateLibraryBlockUrl } from './api'; -import { useCreateLibraryBlock } from './apiHooks'; +import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl } from './api'; +import { useCommitLibraryChanges, useCreateLibraryBlock, useRevertLibraryChanges } from './apiHooks'; let axiosMock; @@ -50,4 +50,24 @@ describe('library api hooks', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should commit library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onPost(url).reply(200); + const { result } = renderHook(() => useCommitLibraryChanges(), { wrapper }); + await result.current.mutateAsync(libraryId); + + expect(axiosMock.history.post[0].url).toEqual(url); + }); + + it('should revert library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onDelete(url).reply(200); + const { result } = renderHook(() => useRevertLibraryChanges(), { wrapper }); + await result.current.mutateAsync(libraryId); + + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 4685a6935..c09be62c9 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -6,6 +6,10 @@ import { getLibraryBlockTypes, createLibraryBlock, getContentLibraryV2List, + commitLibraryChanges, + revertLibraryChanges, + updateLibraryMetadata, + ContentLibrary, } from './api'; export const libraryAuthoringQueryKeys = { @@ -61,6 +65,35 @@ export const useCreateLibraryBlock = () => { }); }; +export const useUpdateLibraryMetadata = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateLibraryMetadata, + onMutate: async (data) => { + const queryKey = libraryAuthoringQueryKeys.contentLibrary(data.id); + const previousLibraryData = queryClient.getQueriesData(queryKey)[0][1] as ContentLibrary; + + const newLibraryData = { + ...previousLibraryData, + title: data.title, + }; + + queryClient.setQueryData(queryKey, newLibraryData); + + return { previousLibraryData, newLibraryData }; + }, + onError: (_err, data, context) => { + queryClient.setQueryData( + libraryAuthoringQueryKeys.contentLibrary(data.id), + context?.previousLibraryData, + ); + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.id) }); + }, + }); +}; + /** * Builds the query to fetch list of V2 Libraries */ @@ -71,3 +104,23 @@ export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams keepPreviousData: true, }) ); + +export const useCommitLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: commitLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; + +export const useRevertLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: revertLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 87c22f838..e82ba16ab 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1 +1,2 @@ @import "library-authoring/components/ComponentCard"; +@import "library-authoring/library-info/LibraryPublishStatus"; diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx new file mode 100644 index 000000000..5ace15eb9 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import LibraryInfo from './LibraryInfo'; +import { ToastProvider } from '../../generic/toast-context'; +import { ContentLibrary, getCommitLibraryChangesUrl } from '../data/api'; +import initializeStore from '../../store'; + +let store; +let axiosMock; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const libraryData: ContentLibrary = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + lastDraftCreated: '2024-07-22', + publishedBy: 'staff', + lastDraftCreatedBy: 'staff', + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: true, + hasUnpublishedDeletes: false, + canEditLibrary: true, + license: '', + created: '2024-06-26', + updated: '2024-07-20', +}; + +interface WrapperProps { + data: ContentLibrary, +} + +const RootWrapper = ({ data } : WrapperProps) => ( + + + + + + + + + +); + +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 Library info sidebar', () => { + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); + }); + + it('should render Library info in draft state without user', () => { + const data = { + ...libraryData, + lastDraftCreatedBy: null, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.queryByText('staff')).not.toBeInTheDocument(); + }); + + it('should render Library creation date if last draft created date is null', () => { + const data = { + ...libraryData, + lastDraftCreated: null, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getAllByText('June 26, 2024')[0]).toBeInTheDocument(); + expect(screen.getAllByText('June 26, 2024')[1]).toBeInTheDocument(); + }); + + it('should render library info in draft state without date', () => { + const data = { + ...libraryData, + lastDraftCreated: null, + created: null, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + }); + + it('should render draft library info sidebar', () => { + const data = { + ...libraryData, + lastPublished: '2024-07-26', + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + }); + + it('should render published library info sidebar', () => { + const data = { + ...libraryData, + lastPublished: '2024-07-26', + hasUnpublishedChanges: false, + }; + + render(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('July 26, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + }); + + it('should render published library info without user', () => { + const data = { + ...libraryData, + lastPublished: '2024-07-26', + hasUnpublishedChanges: false, + publishedBy: null, + }; + + render(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('July 26, 2024')).toBeInTheDocument(); + expect(screen.queryByText('staff')).not.toBeInTheDocument(); + }); + + it('should publish library', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onPost(url).reply(200); + render(); + + const publishButton = screen.getByRole('button', { name: /publish/i }); + fireEvent.click(publishButton); + + expect(await screen.findByText('Library published successfully')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); + }); + + it('should show error on publish library', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onPost(url).reply(500); + render(); + + const publishButton = screen.getByRole('button', { name: /publish/i }); + fireEvent.click(publishButton); + + expect(await screen.findByText('There was an error publishing the library.')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); + }); +}); diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx new file mode 100644 index 000000000..b14b8d621 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Stack } from '@openedx/paragon'; +import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import LibraryPublishStatus from './LibraryPublishStatus'; +import { ContentLibrary } from '../data/api'; + +type LibraryInfoProps = { + library: ContentLibrary, +}; + +const LibraryInfo = ({ library } : LibraryInfoProps) => { + const intl = useIntl(); + + return ( + + + + + {intl.formatMessage(messages.organizationSectionTitle)} + + + {library.org} + + + + + {intl.formatMessage(messages.libraryHistorySectionTitle)} + + + + {intl.formatMessage(messages.lastModifiedLabel)} + + + + + + + + {intl.formatMessage(messages.createdLabel)} + + + + + + + + ); +}; + +export default LibraryInfo; diff --git a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx new file mode 100644 index 000000000..c67e9ed0d --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ContentLibrary, getContentLibraryApiUrl } from '../data/api'; +import initializeStore from '../../store'; +import { ToastProvider } from '../../generic/toast-context'; +import LibraryInfoHeader from './LibraryInfoHeader'; + +let store; +let axiosMock; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const libraryData: ContentLibrary = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + lastDraftCreated: '2024-07-22', + publishedBy: 'staff', + lastDraftCreatedBy: 'staff', + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: true, + hasUnpublishedDeletes: false, + canEditLibrary: true, + license: '', + created: '2024-06-26', + updated: '2024-07-20', +}; + +interface WrapperProps { + data: ContentLibrary, +} + +const RootWrapper = ({ data } : WrapperProps) => ( + + + + + + + + + +); + +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 Library info Header', () => { + render(); + + expect(screen.getByText(libraryData.title)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit library name/i })).toBeInTheDocument(); + }); + + it('should not render edit title button without permission', () => { + const data = { + ...libraryData, + canEditLibrary: false, + }; + + render(); + + expect(screen.queryByRole('button', { name: /edit library name/i })).not.toBeInTheDocument(); + }); + + it('should edit library title', async () => { + queryClient.getQueriesData = jest.fn().mockReturnValue([[null, { id: 1, title: 'Old Title' }]]); + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(200); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + fireEvent.change(textBox, { target: { value: 'New Library Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(await screen.findByText('Library updated successfully')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url)); + }); + + it('should close edit library title on press Escape', async () => { + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(200); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 }); + + expect(textBox).not.toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0)); + }); + + it('should show error on edit library tittle', async () => { + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(500); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + fireEvent.change(textBox, { target: { value: 'New Library Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('There was an error updating the library')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url)); + }); +}); diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx new file mode 100644 index 000000000..e10fe2ec6 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -0,0 +1,86 @@ +import React, { useState, useContext } from 'react'; +import { + Icon, + IconButton, + Stack, + Form, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { ContentLibrary } from '../data/api'; +import { useUpdateLibraryMetadata } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; + +type LibraryInfoHeaderProps = { + library: ContentLibrary, +}; + +const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => { + const intl = useIntl(); + const [inputIsActive, setIsActive] = useState(false); + const updateMutation = useUpdateLibraryMetadata(); + const { showToast } = useContext(ToastContext); + + const handleSaveTitle = (event) => { + const newTitle = event.target.value; + if (newTitle && newTitle !== library.title) { + updateMutation.mutateAsync({ + id: library.id, + title: newTitle, + }).then(() => { + showToast(intl.formatMessage(messages.updateLibrarySuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateLibraryErrorMsg)); + }); + } + setIsActive(false); + }; + + const handleClick = () => { + setIsActive(true); + }; + + const hanldeOnKeyDown = (event) => { + if (event.key === 'Enter') { + handleSaveTitle(event); + } else if (event.key === 'Escape') { + setIsActive(false); + } + }; + + return ( + + { inputIsActive + ? ( + + ) + : ( + <> + + {library.title} + + {library.canEditLibrary && ( + + )} + + )} + + ); +}; + +export default LibraryInfoHeader; diff --git a/src/library-authoring/library-info/LibraryPublishStatus.scss b/src/library-authoring/library-info/LibraryPublishStatus.scss new file mode 100644 index 000000000..9b920eea9 --- /dev/null +++ b/src/library-authoring/library-info/LibraryPublishStatus.scss @@ -0,0 +1,11 @@ +.library-publish-status { + &.draft-status { + background-color: #FDF3E9; + border-top: 4px solid #F4B57B; + } + + &.published-status { + background-color: $info-100; + border-top: 4px solid $info-400; + } +} diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx new file mode 100644 index 000000000..e49704265 --- /dev/null +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -0,0 +1,171 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { Button, Container, Stack } from '@openedx/paragon'; +import { FormattedDate, FormattedTime, useIntl } from '@edx/frontend-platform/i18n'; +import { useCommitLibraryChanges } from '../data/apiHooks'; +import { ContentLibrary } from '../data/api'; +import { ToastContext } from '../../generic/toast-context'; +import messages from './messages'; + +type LibraryPublishStatusProps = { + library: ContentLibrary, +}; + +const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { + const intl = useIntl(); + const commitLibraryChanges = useCommitLibraryChanges(); + const { showToast } = useContext(ToastContext); + + const commit = useCallback(() => { + commitLibraryChanges.mutateAsync(library.id) + .then(() => { + showToast(intl.formatMessage(messages.publishSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.publishErrorMsg)); + }); + }, []); + + /** + * TODO, the discard changes breaks the library. + * Discomment this when discard changes is fixed. + const revert = useCallback(() => { + revertLibraryChanges.mutateAsync(library.id) + .then(() => { + showToast(intl.formatMessage(messages.revertSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.revertErrorMsg)); + }); + }, []); + */ + + const { + isPublished, + statusMessage, + extraStatusMessage, + bodyMessage, + } = useMemo(() => { + let isPublishedResult: boolean; + let statusMessageResult : string; + let extraStatusMessageResult : string | undefined; + let bodyMessageResult : string | undefined; + + const buildDate = ((date : string) => ( + + + + )); + + const buildTime = ((date: string) => ( + + + + )); + + const buildDraftBodyMessage = (() => { + if (library.lastDraftCreatedBy && library.lastDraftCreated) { + return intl.formatMessage(messages.lastDraftMsg, { + date: buildDate(library.lastDraftCreated), + time: buildTime(library.lastDraftCreated), + user: {library.lastDraftCreatedBy}, + }); + } + if (library.lastDraftCreated) { + return intl.formatMessage(messages.lastDraftMsgWithoutUser, { + date: buildDate(library.lastDraftCreated), + time: buildTime(library.lastDraftCreated), + }); + } + if (library.created) { + return intl.formatMessage(messages.lastDraftMsgWithoutUser, { + date: buildDate(library.created), + time: buildTime(library.created), + }); + } + return ''; + }); + + if (!library.lastPublished) { + // Library is never published (new) + isPublishedResult = false; + statusMessageResult = intl.formatMessage(messages.draftStatusLabel); + extraStatusMessageResult = intl.formatMessage(messages.neverPublishedLabel); + bodyMessageResult = buildDraftBodyMessage(); + } else if (library.hasUnpublishedChanges || library.hasUnpublishedDeletes) { + // Library is on Draft state + isPublishedResult = false; + statusMessageResult = intl.formatMessage(messages.draftStatusLabel); + extraStatusMessageResult = intl.formatMessage(messages.unpublishedStatusLabel); + bodyMessageResult = buildDraftBodyMessage(); + } else { + // Library is published + isPublishedResult = true; + statusMessageResult = intl.formatMessage(messages.publishedStatusLabel); + if (library.publishedBy) { + bodyMessageResult = intl.formatMessage(messages.lastPublishedMsg, { + date: buildDate(library.lastPublished), + time: buildTime(library.lastPublished), + user: {library.publishedBy}, + }); + } else { + bodyMessageResult = intl.formatMessage(messages.lastPublishedMsgWithoutUser, { + date: buildDate(library.lastPublished), + time: buildTime(library.lastPublished), + }); + } + } + return { + isPublished: isPublishedResult, + statusMessage: statusMessageResult, + extraStatusMessage: extraStatusMessageResult, + bodyMessage: bodyMessageResult, + }; + }, [library]); + + return ( + + + + {statusMessage} + + { extraStatusMessage && ( + + {extraStatusMessage} + + )} + + + + + {bodyMessage} + + + { /* + * TODO, the discard changes breaks the library. + * Discomment this when discard changes is fixed. +
+ +
+ */ } +
+
+
+ ); +}; + +export default LibraryPublishStatus; diff --git a/src/library-authoring/library-info/index.ts b/src/library-authoring/library-info/index.ts new file mode 100644 index 000000000..5ed191c9d --- /dev/null +++ b/src/library-authoring/library-info/index.ts @@ -0,0 +1,2 @@ +export { default as LibraryInfo } from './LibraryInfo'; +export { default as LibraryInfoHeader } from './LibraryInfoHeader'; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts new file mode 100644 index 000000000..6b6b6dbac --- /dev/null +++ b/src/library-authoring/library-info/messages.ts @@ -0,0 +1,111 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + editNameButtonAlt: { + id: 'course-authoring.library-authoring.sidebar.info.edit-name.alt', + defaultMessage: 'Edit library name', + description: 'Alt text for edit library name icon button', + }, + organizationSectionTitle: { + id: 'course-authoring.library-authoring.sidebar.info.organization.title', + defaultMessage: 'Organization', + description: 'Title for Organization section in Library info sidebar.', + }, + libraryHistorySectionTitle: { + id: 'course-authoring.library-authoring.sidebar.info.history.title', + defaultMessage: 'Library History', + description: 'Title for Library History section in Library info sidebar.', + }, + lastModifiedLabel: { + id: 'course-authoring.library-authoring.sidebar.info.history.last-modified', + defaultMessage: 'Last Modified', + description: 'Last Modified label used in Library History section.', + }, + createdLabel: { + id: 'course-authoring.library-authoring.sidebar.info.history.created', + defaultMessage: 'Created', + description: 'Created label used in Library History section.', + }, + draftStatusLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.draft', + defaultMessage: 'Draft', + description: 'Label in library info sidebar when the library is on draft status', + }, + neverPublishedLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.never', + defaultMessage: '(Never Published)', + description: 'Label in library info sidebar when the library is never published', + }, + unpublishedStatusLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.unpublished', + defaultMessage: '(Unpublished Changes)', + description: 'Label in library info sidebar when the library has unpublished changes', + }, + publishedStatusLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.published', + defaultMessage: 'Published', + description: 'Label in library info sidebar when the library is on published status', + }, + publishButtonLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.publish-button', + defaultMessage: 'Publish', + description: 'Label of publish button for a library.', + }, + discardChangesButtonLabel: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.discard-button', + defaultMessage: 'Discard Changes', + description: 'Label of discard changes button for a library.', + }, + lastPublishedMsg: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published', + defaultMessage: 'Last published on {date} at {time} UTC by {user}.', + description: 'Body meesage of the library info sidebar when library is published.', + }, + lastPublishedMsgWithoutUser: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published-no-user', + defaultMessage: 'Last published on {date} at {time} UTC.', + description: 'Body meesage of the library info sidebar when library is published.', + }, + lastDraftMsg: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft', + defaultMessage: 'Draft saved on {date} at {time} UTC by {user}.', + description: 'Body meesage of the library info sidebar when library is on draft status.', + }, + lastDraftMsgWithoutUser: { + id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft-no-user', + defaultMessage: 'Draft saved on {date} at {time} UTC.', + description: 'Body meesage of the library info sidebar when library is on draft status.', + }, + publishSuccessMsg: { + id: 'course-authoring.library-authoring.publish.success', + defaultMessage: 'Library published successfully', + description: 'Message when the library is published successfully.', + }, + publishErrorMsg: { + id: 'course-authoring.library-authoring.publish.error', + defaultMessage: 'There was an error publishing the library.', + description: 'Message when there is an error when publishing the library.', + }, + revertSuccessMsg: { + id: 'course-authoring.library-authoring.revert.success', + defaultMessage: 'Library changes reverted successfully', + description: 'Message when the library changes are reverted successfully.', + }, + revertErrorMsg: { + id: 'course-authoring.library-authoring.publish.error', + defaultMessage: 'There was an error reverting changes in the library.', + description: 'Message when there is an error when reverting changes in the library.', + }, + updateLibrarySuccessMsg: { + id: 'course-authoring.library-authoring.library.update.success', + defaultMessage: 'Library updated successfully', + description: 'Message when the library is updated successfully', + }, + updateLibraryErrorMsg: { + id: 'course-authoring.library-authoring.library.update.error', + defaultMessage: 'There was an error updating the library', + description: 'Message when there is an error when updating the library', + }, +}); + +export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 734379f51..314de8792 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -7,8 +7,14 @@ import { import { Close } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; -import { AddContentContainer } from '../add-content'; -import { LibraryContext } from '../common/context'; +import { AddContentContainer, AddContentHeader } from '../add-content'; +import { LibraryContext, SidebarBodyComponentId } from '../common/context'; +import { LibraryInfo, LibraryInfoHeader } from '../library-info'; +import { ContentLibrary } from '../data/api'; + +type LibrarySidebarProps = { + library: ContentLibrary, +}; /** * Sidebar container for library pages. @@ -19,23 +25,29 @@ import { LibraryContext } from '../common/context'; * You can add more components in `bodyComponentMap`. * Use the slice actions to open and close this sidebar. */ -const LibrarySidebar = () => { +const LibrarySidebar = ({ library }: LibrarySidebarProps) => { const intl = useIntl(); const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext); const bodyComponentMap = { - 'add-content': , + [SidebarBodyComponentId.AddContent]: , + [SidebarBodyComponentId.Info]: , + unknown: null, + }; + + const headerComponentMap = { + 'add-content': , + info: , unknown: null, }; const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown']; + const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; return ( -
+ - - {intl.formatMessage(messages.addContentTitle)} - + {buildHeader()} { variant="black" /> - {buildBody()} -
+
+ {buildBody()} +
+ ); }; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index 38332b543..9a61dc9ed 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', }, + libraryInfoButton: { + id: 'course-authoring.library-authoring.buttons.library-info.text', + defaultMessage: 'Library Info', + description: 'Text of button to open "Library Info sidebar"', + }, readOnlyBadge: { id: 'course-authoring.library-authoring.badge.read-only', defaultMessage: 'Read Only', From 48ffa0f970d6c9bfd3be19894a1c470ec6c2f195 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 17:49:42 +0000 Subject: [PATCH 3/8] fix(deps): update dependency @edx/frontend-component-footer to v14.0.8 (#1161) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 225 ++++++++++++---------------------------------- 1 file changed, 55 insertions(+), 170 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a53b60ae..2b1541609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2377,9 +2377,9 @@ } }, "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", - "integrity": "sha512-r64SGM8wzYZCtAG/J8i+7S7c2XCylKdA4VesSU6Q/Ig8SMndJAhfLc9eo9H4dRrq1E0cYqV2+bQroEucXjzSuQ==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.8.tgz", + "integrity": "sha512-0H91yt9dgFdrjWMyAUI4cgtWqsutylHm1PHXH3sNHXNQcTMPeLyvFfknrA2OLVo0eeDDY0/P23cxmOXG3tAEng==", "license": "AGPL-3.0", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.5.2", @@ -2399,81 +2399,6 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.2", - "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" - } - }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.2", - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.2", - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.5.2", - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.2", - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/react-fontawesome": { - "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" - }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "react": ">=16.3" - } - }, "node_modules/@edx/frontend-component-footer/node_modules/jest-environment-jsdom": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", @@ -2525,63 +2450,6 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", - "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", - "hasInstallScript": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", - "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", - "hasInstallScript": true, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.2", - "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, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.5.2", - "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, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.2", - "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, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.2" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@edx/frontend-component-header/node_modules/jest-environment-jsdom": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", @@ -3326,32 +3194,72 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "0.2.36", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz", - "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", + "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", "hasInstallScript": true, - "peer": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "1.2.36", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz", - "integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, - "peer": true, + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "^0.2.36" + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.5.2", + "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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.5.2", + "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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.5.2", + "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" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/react-fontawesome": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz", - "integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==", + "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" }, @@ -4468,29 +4376,6 @@ "react-intl": "^5.25.1 || ^6.4.0" } }, - "node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.2", - "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" - } - }, - "node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.2", - "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" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@openedx/paragon/node_modules/@fortawesome/react-fontawesome": { "version": "0.1.19", "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz", From ea90e7e93cb011db803feec3366cd11ff05c359d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 17:50:42 +0000 Subject: [PATCH 4/8] fix(deps): update dependency @edx/frontend-lib-content-components to v2.6.6 (#1210) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b1541609..e635923ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2504,9 +2504,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "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==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.6.6.tgz", + "integrity": "sha512-l3jTKgRzxHOO/Xb4w/8el7ZAOQdVS/bl024mgTemPLAB11L4gw/JsK62M6ojp/u9lvGNlaj+GpfzleyLqtJrnQ==", "license": "AGPL-3.0", "dependencies": { "@codemirror/lang-html": "^6.0.0", From 6ff3847c6c5c6ef2cb9555f0dd64dde8973f8080 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 13:53:33 -0400 Subject: [PATCH 5/8] fix(deps): update dependency @edx/openedx-atlas to v0.6.1 (#1123) 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 e635923ed..93f2890ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2659,9 +2659,10 @@ } }, "node_modules/@edx/openedx-atlas": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.0.tgz", - "integrity": "sha512-wZO7hA4VJ/bXjaQNNR7KXGYyTCNs1mBJd3HwQK2EmOwFZYFNX6nzSAm9S7HCfi/kb1PCRpmp3wJt+v/Eu9BEQg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.1.tgz", + "integrity": "sha512-n5b2fN3usGqOHREji4QZDsXSzRwH7b6Bf9NiA49OcHKjbMYhaPNp4BVakIbu3f3wuPyyVY+bgUODx7wRB4OyIQ==", + "license": "AGPL-3.0", "bin": { "atlas": "atlas" } From de3befec089bf9f9510601f74c66d29efc2e2dd4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:08:37 +0000 Subject: [PATCH 6/8] fix(deps): update dependency @edx/frontend-component-header to v5.3.4 (#1179) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 74 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93f2890ab..91d5ba241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2427,14 +2427,15 @@ } }, "node_modules/@edx/frontend-component-header": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.3.3.tgz", - "integrity": "sha512-qOPU8YFg3VT4PbyjqFY0eOfRbLyi7jQFjPCsXn+EEwII4LVRbbg2bW3dtDvscMM1J1awaQZtqFHm2m5I/Rpxtg==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.3.4.tgz", + "integrity": "sha512-niuaXu0+qWPHud9Bs1pqmNXvZc9jpf8WS270/2YEH5owokd+BiDwQ6MWkvS9qbuQIVGPGTSZFFTttUKmQO5O0A==", + "license": "AGPL-3.0", "dependencies": { - "@fortawesome/fontawesome-svg-core": "6.5.2", - "@fortawesome/free-brands-svg-icons": "6.5.2", - "@fortawesome/free-regular-svg-icons": "6.5.2", - "@fortawesome/free-solid-svg-icons": "6.5.2", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-brands-svg-icons": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "^0.2.0", "axios-mock-adapter": "1.22.0", "babel-polyfill": "6.26.0", @@ -2450,10 +2451,68 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, + "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", + "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@edx/frontend-component-header/node_modules/jest-environment-jsdom": { "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", @@ -2480,6 +2539,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", + "license": "MIT", "dependencies": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.3.0", From 7c59b4a210430400facdde534a5042e863b7e837 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:39:37 -0400 Subject: [PATCH 7/8] fix(deps): update dependency @openedx/paragon to v22.7.0 (#1180) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91d5ba241..541a84a61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4386,9 +4386,9 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@openedx/paragon": { - "version": "22.6.1", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.6.1.tgz", - "integrity": "sha512-xblrspAfsYsiDzyLIh+tceiTPgx1HY6v0eceatTYSj/BINxN8Dcqh9uQOZi2eJc1os3w2dr0nZRGnTt8cYu2BA==", + "version": "22.7.0", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.7.0.tgz", + "integrity": "sha512-BWj4vYXUmLS0BinJckxbhNp0o1UPfwURinaSgTxxBkF0L2VUtAO+SldVWvKDqlltzoR062yjcBA5QSGq8Jxgeg==", "license": "Apache-2.0", "workspaces": [ "example", From 95ac0983a3a36c1b836b58714fefa20cb39fb308 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Thu, 15 Aug 2024 20:03:39 +0300 Subject: [PATCH 8/8] feat: Add "Paste from Clipboard" to lib v2 sidebar (#1187) --- .../clipboard/hooks/useCopyToClipboard.js | 22 ++++++- .../LibraryAuthoringPage.test.tsx | 7 +++ .../add-content/AddContentContainer.test.tsx | 58 ++++++++++++++++++- .../add-content/AddContentContainer.tsx | 55 ++++++++++++++---- src/library-authoring/add-content/messages.ts | 20 +++++++ .../components/ComponentCard.test.tsx | 7 +++ .../components/ComponentCard.tsx | 9 ++- .../components/LibraryComponents.test.tsx | 7 +++ src/library-authoring/data/api.ts | 26 +++++++++ src/library-authoring/data/apiHooks.ts | 12 ++++ 10 files changed, 209 insertions(+), 14 deletions(-) diff --git a/src/generic/clipboard/hooks/useCopyToClipboard.js b/src/generic/clipboard/hooks/useCopyToClipboard.js index 86303fab9..ce911c6b3 100644 --- a/src/generic/clipboard/hooks/useCopyToClipboard.js +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -1,6 +1,9 @@ +// @ts-check import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { getClipboard } from '../../data/api'; +import { updateClipboardData } from '../../data/slice'; import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants'; import { getClipboardData } from '../../data/selectors'; @@ -14,6 +17,7 @@ import { getClipboardData } from '../../data/selectors'; * @property {Object} sharedClipboardData - The shared clipboard data object. */ const useCopyToClipboard = (canEdit = true) => { + const dispatch = useDispatch(); const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); @@ -30,6 +34,22 @@ const useCopyToClipboard = (canEdit = true) => { setShowPasteUnit(!!isPasteableUnit); }; + // Called on initial render to fetch and populate the initial clipboard data in redux state. + // Without this, the initial clipboard data redux state is always null. + useEffect(() => { + const fetchInitialClipboardData = async () => { + try { + const userClipboard = await getClipboard(); + dispatch(updateClipboardData(userClipboard)); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to fetch initial clipboard data: ${error}`); + } + }; + + fetchInitialClipboardData(); + }, [dispatch]); + useEffect(() => { // Handle updates to clipboard data if (canEdit) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 8e7369184..3dbd737bc 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -97,6 +97,13 @@ const libraryData: ContentLibrary = { updated: '2024-07-20', }; +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx index 51b07843c..6db80f15b 100644 --- a/src/library-authoring/add-content/AddContentContainer.test.tsx +++ b/src/library-authoring/add-content/AddContentContainer.test.tsx @@ -10,7 +10,10 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import AddContentContainer from './AddContentContainer'; import initializeStore from '../../store'; -import { getCreateLibraryBlockUrl } from '../data/api'; +import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api'; +import { getClipboardUrl } from '../../generic/data/api'; + +import { clipboardXBlock } from '../../__mocks__'; const mockUseParams = jest.fn(); let axiosMock; @@ -31,6 +34,13 @@ const queryClient = new QueryClient({ }, }); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( @@ -69,6 +79,7 @@ describe('', () => { expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument(); }); it('should create a content', async () => { @@ -82,4 +93,49 @@ describe('', () => { await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); }); + + it('should render paste button if clipboard contains pastable xblock', async () => { + const url = getClipboardUrl(); + axiosMock.onGet(url).reply(200, clipboardXBlock); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url)); + + expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument(); + }); + + it('should paste content', async () => { + const clipboardUrl = getClipboardUrl(); + axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + + const pasteUrl = getLibraryPasteClipboardUrl(libraryId); + axiosMock.onPost(pasteUrl).reply(200); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); + + const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + fireEvent.click(pasteButton); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); + }); + + it('should fail pasting content', async () => { + const clipboardUrl = getClipboardUrl(); + axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + + const pasteUrl = getLibraryPasteClipboardUrl(libraryId); + axiosMock.onPost(pasteUrl).reply(400); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); + + const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + fireEvent.click(pasteButton); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); + }); }); diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 9af31593c..421c81be6 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -1,4 +1,5 @@ import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; import { Stack, Button, @@ -12,18 +13,25 @@ import { ThumbUpOutline, Question, VideoCamera, + ContentPaste, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; import { useParams } from 'react-router-dom'; import { ToastContext } from '../../generic/toast-context'; -import { useCreateLibraryBlock } from '../data/apiHooks'; +import { useCopyToClipboard } from '../../generic/clipboard'; +import { getCanEdit } from '../../course-unit/data/selectors'; +import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks'; + import messages from './messages'; const AddContentContainer = () => { const intl = useIntl(); const { libraryId } = useParams(); const createBlockMutation = useCreateLibraryBlock(); + const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); + const canEdit = useSelector(getCanEdit); + const { showPasteXBlock } = useCopyToClipboard(canEdit); const contentTypes = [ { @@ -64,20 +72,47 @@ const AddContentContainer = () => { }, ]; + // Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard + // that can be pasted + if (showPasteXBlock) { + const pasteButton = { + name: intl.formatMessage(messages.pasteButton), + disabled: false, + icon: ContentPaste, + blockType: 'paste', + }; + contentTypes.push(pasteButton); + } + const onCreateContent = (blockType: string) => { if (libraryId) { - createBlockMutation.mutateAsync({ - libraryId, - blockType, - definitionId: `${uuid4()}`, - }).then(() => { - showToast(intl.formatMessage(messages.successCreateMessage)); - }).catch(() => { - showToast(intl.formatMessage(messages.errorCreateMessage)); - }); + if (blockType === 'paste') { + pasteClipboardMutation.mutateAsync({ + libraryId, + blockId: `${uuid4()}`, + }).then(() => { + showToast(intl.formatMessage(messages.successPasteClipboardMessage)); + }).catch(() => { + showToast(intl.formatMessage(messages.errorPasteClipboardMessage)); + }); + } else { + createBlockMutation.mutateAsync({ + libraryId, + blockType, + definitionId: `${uuid4()}`, + }).then(() => { + showToast(intl.formatMessage(messages.successCreateMessage)); + }).catch(() => { + showToast(intl.formatMessage(messages.errorCreateMessage)); + }); + } } }; + if (pasteClipboardMutation.isLoading) { + showToast(intl.formatMessage(messages.pastingClipboardMessage)); + } + return (