From 6b445e25137f12665e17d908272a189b54808ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 17 Mar 2025 14:57:46 -0300 Subject: [PATCH 1/9] feat: create unit workflow --- src/generic/block-type-utils/constants.ts | 7 +- src/generic/key-utils.test.ts | 2 + src/generic/key-utils.ts | 10 +- .../LibraryAuthoringPage.test.tsx | 148 +++++++++++++++++- src/library-authoring/LibraryLayout.tsx | 2 + .../add-content/AddContentContainer.tsx | 36 ++--- src/library-authoring/add-content/messages.ts | 5 + .../common/context/LibraryContext.tsx | 11 ++ .../create-collection/messages.ts | 6 +- .../create-library/CreateLibrary.tsx | 1 - .../create-unit/CreateUnitModal.tsx | 108 +++++++++++++ src/library-authoring/create-unit/index.tsx | 1 + src/library-authoring/create-unit/messages.ts | 46 ++++++ src/library-authoring/data/api.ts | 17 ++ src/library-authoring/data/apiHooks.ts | 15 ++ 15 files changed, 375 insertions(+), 40 deletions(-) create mode 100644 src/library-authoring/create-unit/CreateUnitModal.tsx create mode 100644 src/library-authoring/create-unit/index.tsx create mode 100644 src/library-authoring/create-unit/messages.ts diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 97f9ae0f70..604519e53e 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -3,9 +3,10 @@ import { BackHand as BackHandIcon, BookOpen as BookOpenIcon, Casino as ProblemBankIcon, + ContentPaste as ContentPasteIcon, Edit as EditIcon, EditNote as EditNoteIcon, - FormatListBulleted as FormatListBulletedIcon, + CalendarViewDay, HelpOutline as HelpOutlineIcon, LibraryAdd as LibraryIcon, Lock as LockIcon, @@ -35,7 +36,7 @@ export const COMPONENT_TYPES = { export const UNIT_TYPE_ICONS_MAP: Record = { video: VideoCameraIcon, other: BookOpenIcon, - vertical: FormatListBulletedIcon, + vertical: CalendarViewDay, problem: EditIcon, lock: LockIcon, }; @@ -58,6 +59,8 @@ export const STRUCTURAL_TYPE_ICONS: Record = { sequential: Folder, chapter: Folder, collection: Folder, + libraryContent: Folder, + paste: ContentPasteIcon, }; export const COMPONENT_TYPE_STYLE_COLOR_MAP = { diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index 224f4a9632..3a13167937 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -32,6 +32,8 @@ describe('component utils', () => { ['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'], ['lib-collection:org:lib:coll', 'lib:org:lib'], ['lib-collection:OpenCraftX:ALPHA:coll', 'lib:OpenCraftX:ALPHA'], + ['lct:org:lib:unit:my-unit-9284e2', 'lib:org:lib'], + ['lct:OpenCraftX:ALPHA:my-unit-a3223f', 'lib:OpenCraftX:ALPHA'], ]) { it(`returns '${expected}' for usage key '${input}'`, () => { expect(getLibraryId(input)).toStrictEqual(expected); diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 6334171197..c1e4ad0ac1 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -19,12 +19,10 @@ export function getBlockType(usageKey: string): string { * @returns The library key, e.g. `lib:org:lib` */ export function getLibraryId(usageKey: string): string { - if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lib-collection:'))) { - const org = usageKey.split(':')[1]; - const lib = usageKey.split(':')[2]; - if (org && lib) { - return `lib:${org}:${lib}`; - } + const [blockType, org, lib] = usageKey?.split(':') || []; + + if (['lb', 'lib-collection', 'lct'].includes(blockType) && org && lib) { + return `lib:${org}:${lib}`; } throw new Error(`Invalid usageKey: ${usageKey}`); } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index cd3e75eb12..dbcc496d30 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -22,7 +22,10 @@ import { studioHomeMock } from '../studio-home/__mocks__'; import { getStudioHomeApiUrl } from '../studio-home/data/api'; import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; -import { getLibraryCollectionsApiUrl } from './data/api'; +import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api'; + +let axiosMock; +let mockShowToast; mockGetCollectionMetadata.applyMock(); mockContentSearchConfig.applyMock(); @@ -53,7 +56,9 @@ const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { beforeEach(async () => { - const { axiosMock } = initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); // The Meilisearch client-side API uses fetch, not Axios. @@ -563,7 +568,6 @@ describe('', () => { const title = 'This is a Test'; const description = 'This is the description of the Test'; const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); - const { axiosMock } = initializeMocks(); axiosMock.onPost(url).reply(200, { id: '1', slug: 'this-is-a-test', @@ -600,6 +604,15 @@ describe('', () => { fireEvent.change(nameField, { target: { value: title } }); fireEvent.change(descriptionField, { target: { value: description } }); fireEvent.click(createButton); + + // Check success toast + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"title":"${title}"`); + expect(axiosMock.history.post[0].data).toContain(`"description":"${description}"`); + expect(mockShowToast).toHaveBeenCalledWith('Collection created successfully'); + }); }); it('should show validations in create collection', async () => { @@ -608,7 +621,6 @@ describe('', () => { const title = 'This is a Test'; const description = 'This is the description of the Test'; const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); - const { axiosMock } = initializeMocks(); axiosMock.onPost(url).reply(200, { id: '1', slug: 'this-is-a-test', @@ -647,7 +659,6 @@ describe('', () => { const title = 'This is a Test'; const description = 'This is the description of the Test'; const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId); - const { axiosMock } = initializeMocks(); axiosMock.onPost(url).reply(500); expect(await screen.findByRole('heading')).toBeInTheDocument(); @@ -673,6 +684,132 @@ describe('', () => { fireEvent.change(nameField, { target: { value: title } }); fireEvent.change(descriptionField, { target: { value: description } }); fireEvent.click(createButton); + + // Check error toast + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection'); + }); + }); + + it('should create a unit', async () => { + await renderLibraryPage(); + const title = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + }); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New unit Modal + const sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + // Click on Cancel button + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + expect(unitModalHeading).not.toBeInTheDocument(); + + // Open new unit modal again and create a collection + fireEvent.click(newUnitButton); + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + + fireEvent.change(nameField, { target: { value: title } }); + fireEvent.click(createButton); + + // Check success toast + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"display_name":"${title}"`); + expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"'); + expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully'); + }); + }); + + it('should show validations in create unit', async () => { + await renderLibraryPage(); + + const title = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + }); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New unit Modal + const sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + fireEvent.focus(nameField); + fireEvent.blur(nameField); + + // Click on create with an empty name + const createButton = screen.getByRole('button', { name: /create/i }); + fireEvent.click(createButton); + + expect(await screen.findByText(/unit name is required/i)).toBeInTheDocument(); + }); + + it('should show error on create unit', async () => { + await renderLibraryPage(); + const displayName = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(url).reply(500); + + expect(await screen.findByRole('heading')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + // Open Add content sidebar + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + // Open New collection Modal + const sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + // Create a unit + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + + fireEvent.change(nameField, { target: { value: displayName } }); + fireEvent.click(createButton); + + // Check error toast + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library unit'); + }); }); it('shows a single block when usageKey query param is set', async () => { @@ -806,7 +943,6 @@ describe('', () => { }); it('Shows an error if libraries V2 is disabled', async () => { - const { axiosMock } = initializeMocks(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, { ...studioHomeMock, libraries_v2_enabled: false, diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index add33f95a0..d0231cea9f 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -12,6 +12,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage'; import { LibraryProvider } from './common/context/LibraryContext'; import { SidebarProvider } from './common/context/SidebarContext'; import { CreateCollectionModal } from './create-collection'; +import { CreateUnitModal } from './create-unit'; import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; @@ -44,6 +45,7 @@ const LibraryLayout = () => { <> {childPage} + diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 94e2692d45..1899318d56 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -9,20 +9,13 @@ import { import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { - Article, AutoAwesome, - BookOpen, - Create, - Folder, - ThumbUpOutline, - Question, - VideoCamera, - ContentPaste, KeyboardBackspace, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; import { ToastContext } from '../../generic/toast-context'; +import { getItemIcon } from '../../generic/block-type-utils'; import { useClipboard } from '../../generic/clipboard'; import { getCanEdit } from '../../course-unit/data/selectors'; import { @@ -41,7 +34,7 @@ import type { BlockTypeMetadata } from '../data/api'; type ContentType = { name: string, disabled: boolean, - icon: React.ComponentType, + icon?: React.ComponentType, blockType: string, }; @@ -76,7 +69,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro variant="outline-primary" disabled={disabled} className="m-2" - iconBefore={icon} + iconBefore={icon || getItemIcon(blockType)} onClick={() => onCreateContent(blockType)} > {name} @@ -99,14 +92,18 @@ const AddContentView = ({ const collectionButtonData = { name: intl.formatMessage(messages.collectionButton), disabled: false, - icon: BookOpen, blockType: 'collection', }; + const unitButtonData = { + name: intl.formatMessage(messages.unitButton), + disabled: false, + blockType: 'vertical', + }; + const libraryContentButtonData = { name: intl.formatMessage(messages.libraryContentButton), disabled: false, - icon: Folder, blockType: 'libraryContent', }; @@ -125,6 +122,7 @@ const AddContentView = ({ ) : ( )} +
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */} {contentTypes.filter(ct => !ct.disabled).map((contentType) => ( @@ -194,6 +192,7 @@ const AddContentContainer = () => { libraryId, collectionId, openCreateCollectionModal, + openCreateUnitModal, openComponentEditor, } = useLibraryContext(); const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId); @@ -220,31 +219,26 @@ const AddContentContainer = () => { { name: intl.formatMessage(messages.textTypeButton), disabled: !isBlockTypeEnabled('html'), - icon: Article, blockType: 'html', }, { name: intl.formatMessage(messages.problemTypeButton), disabled: !isBlockTypeEnabled('problem'), - icon: Question, blockType: 'problem', }, { name: intl.formatMessage(messages.openResponseTypeButton), disabled: !isBlockTypeEnabled('openassessment'), - icon: Create, blockType: 'openassessment', }, { name: intl.formatMessage(messages.dragDropTypeButton), disabled: !isBlockTypeEnabled('drag-and-drop-v2'), - icon: ThumbUpOutline, blockType: 'drag-and-drop-v2', }, { name: intl.formatMessage(messages.videoTypeButton), disabled: !isBlockTypeEnabled('video'), - icon: VideoCamera, blockType: 'video', }, ]; @@ -259,13 +253,13 @@ const AddContentContainer = () => { // Include the 'Advanced / Other' button if there are enabled advanced Xblocks if (Object.keys(advancedBlocks).length > 0) { - const pasteButton = { + const advancedButton = { name: intl.formatMessage(messages.otherTypeButton), disabled: false, icon: AutoAwesome, blockType: 'advancedXBlock', }; - contentTypes.push(pasteButton); + contentTypes.push(advancedButton); } // Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard @@ -274,7 +268,6 @@ const AddContentContainer = () => { const pasteButton = { name: intl.formatMessage(messages.pasteButton), disabled: false, - icon: ContentPaste, blockType: 'paste', }; contentTypes.push(pasteButton); @@ -313,6 +306,7 @@ const AddContentContainer = () => { )); }); }; + const onCreateBlock = (blockType: string) => { const suportedEditorTypes = Object.values(blockTypes); if (suportedEditorTypes.includes(blockType)) { @@ -347,6 +341,8 @@ const AddContentContainer = () => { showAddLibraryContentModal(); } else if (blockType === 'advancedXBlock') { showAdvancedList(); + } else if (blockType === 'vertical') { + openCreateUnitModal(); } else { onCreateBlock(blockType); } diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index dc0f33efff..e8e6d601be 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'Collection', description: 'Content of button to create a Collection.', }, + unitButton: { + id: 'course-authoring.library-authoring.add-content.buttons.unit', + defaultMessage: 'Unit', + description: 'Content of button to create a Unit.', + }, libraryContentButton: { id: 'course-authoring.library-authoring.add-content.buttons.library-content', defaultMessage: 'Existing Library Content', diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 061a1326d5..76d7af2883 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -35,6 +35,10 @@ export type LibraryContextData = { isCreateCollectionModalOpen: boolean; openCreateCollectionModal: () => void; closeCreateCollectionModal: () => void; + // "Create New Unit" modal + isCreateUnitModalOpen: boolean; + openCreateUnitModal: () => void; + closeCreateUnitModal: () => void; // Editor modal - for editing some component /** If the editor is open and the user is editing some component, this is the component being edited. */ componentBeingEdited: ComponentEditorInfo | undefined; @@ -80,6 +84,7 @@ export const LibraryProvider = ({ componentPicker, }: LibraryProviderProps) => { const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false); + const [isCreateUnitModalOpen, openCreateUnitModal, closeCreateUnitModal] = useToggle(false); const [componentBeingEdited, setComponentBeingEdited] = useState(); const closeComponentEditor = useCallback((data) => { setComponentBeingEdited((prev) => { @@ -122,6 +127,9 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + isCreateUnitModalOpen, + openCreateUnitModal, + closeCreateUnitModal, componentBeingEdited, openComponentEditor, closeComponentEditor, @@ -142,6 +150,9 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + isCreateUnitModalOpen, + openCreateUnitModal, + closeCreateUnitModal, componentBeingEdited, openComponentEditor, closeComponentEditor, diff --git a/src/library-authoring/create-collection/messages.ts b/src/library-authoring/create-collection/messages.ts index 36a11138e8..eed95a67ef 100644 --- a/src/library-authoring/create-collection/messages.ts +++ b/src/library-authoring/create-collection/messages.ts @@ -1,8 +1,4 @@ -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; +import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ createCollectionModalTitle: { diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index c4f3695c7e..7939a2b4f6 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { diff --git a/src/library-authoring/create-unit/CreateUnitModal.tsx b/src/library-authoring/create-unit/CreateUnitModal.tsx new file mode 100644 index 0000000000..8f1929f1b3 --- /dev/null +++ b/src/library-authoring/create-unit/CreateUnitModal.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { + ActionRow, + Button, + Form, + ModalDialog, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Formik } from 'formik'; +import * as Yup from 'yup'; +import FormikControl from '../../generic/FormikControl'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import messages from './messages'; +import { useCreateLibraryContainer } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; + +const CreateUnitModal = () => { + const intl = useIntl(); + const { + libraryId, + isCreateUnitModalOpen, + closeCreateUnitModal, + } = useLibraryContext(); + const create = useCreateLibraryContainer(libraryId); + const { showToast } = React.useContext(ToastContext); + + const handleCreate = React.useCallback((values) => { + create + .mutateAsync({ + containerType: 'unit', + ...values, + }) + .then(() => { + closeCreateUnitModal(); + // TODO: Navigate to the new unit + // navigate(`/library/${libraryId}/units/${data.key}`); + showToast(intl.formatMessage(messages.createUnitSuccess)); + }) + .catch(() => { + showToast(intl.formatMessage(messages.createUnitError)); + }); + }, []); + + return ( + + + + {intl.formatMessage(messages.createUnitModalTitle)} + + + + + {(formikProps) => ( + <> + +
+ + {intl.formatMessage(messages.createUnitModalNameLabel)} + + )} + value={formikProps.values.displayName} + placeholder={intl.formatMessage(messages.createUnitModalNamePlaceholder)} + controlClasses="pb-2" + /> + +
+ + + + {intl.formatMessage(messages.createUnitModalCancel)} + + + + + + )} +
+
+ ); +}; + +export default CreateUnitModal; diff --git a/src/library-authoring/create-unit/index.tsx b/src/library-authoring/create-unit/index.tsx new file mode 100644 index 0000000000..ee82f395fe --- /dev/null +++ b/src/library-authoring/create-unit/index.tsx @@ -0,0 +1 @@ +export { default as CreateUnitModal } from './CreateUnitModal'; diff --git a/src/library-authoring/create-unit/messages.ts b/src/library-authoring/create-unit/messages.ts new file mode 100644 index 0000000000..db52c1ed6e --- /dev/null +++ b/src/library-authoring/create-unit/messages.ts @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + createUnitModalTitle: { + id: 'course-authoring.library-authoring.modals.create-unit.title', + defaultMessage: 'New Unit', + description: 'Title of the Create Unit modal', + }, + createUnitModalCancel: { + id: 'course-authoring.library-authoring.modals.create-unit.cancel', + defaultMessage: 'Cancel', + description: 'Label of the Cancel button of the Create Unit modal', + }, + createUnitModalCreate: { + id: 'course-authoring.library-authoring.modals.create-unit.create', + defaultMessage: 'Create', + description: 'Label of the Create button of the Create Unit modal', + }, + createUnitModalNameLabel: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name', + defaultMessage: 'Name your unit', + description: 'Label of the Name field of the Create Unit modal form', + }, + createUnitModalNamePlaceholder: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name.placeholder', + defaultMessage: 'Give a descriptive title', + description: 'Placeholder of the Name field of the Create Unit modal form', + }, + createUnitModalNameInvalid: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name.invalid', + defaultMessage: 'Unit name is required', + description: 'Message when the Name field of the Create Unit modal form is invalid', + }, + createUnitSuccess: { + id: 'course-authoring.library-authoring.modals.create-unit.success', + defaultMessage: 'Unit created successfully', + description: 'Success message when creating a library unit', + }, + createUnitError: { + id: 'course-authoring.library-authoring.modals.create-unit.error', + defaultMessage: 'There is an error when creating the library unit', + description: 'Error message when creating a library unit', + }, +}); + +export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 49009f9721..f0079ca134 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -103,6 +103,10 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; * Get the URL for the content store api. */ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`; +/** + * Get the URL for the library container api. + */ +export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/containers/`; export interface ContentLibrary { id: string; @@ -557,3 +561,16 @@ export async function updateComponentCollections(usageKey: string, collectionKey collection_keys: collectionKeys, }); } + +export interface CreateLibraryContainerDataRequest { + title: string; + containerType: string; +} + +/** + * Create a library container + */ +export async function createLibraryContainer(libraryId: string, containerData: CreateLibraryContainerDataRequest) { + const client = getAuthenticatedHttpClient(); + await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData)); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 2b16615a3e..abab56ffca 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -46,6 +46,8 @@ import { deleteXBlockAsset, restoreLibraryBlock, getBlockTypes, + createLibraryContainer, + type CreateLibraryContainerDataRequest, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -560,3 +562,16 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin }, }); }; + +/** + * Use this mutation to create a library container + */ +export const useCreateLibraryContainer = (libraryId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateLibraryContainerDataRequest) => createLibraryContainer(libraryId, data), + onSettled: () => { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + }, + }); +}; From d94d2d5e94ee76c9f767672571fe5889a669b18a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 19 Mar 2025 10:23:40 -0300 Subject: [PATCH 2/9] fix: applying suggestions from review Co-authored-by: Navin Karkera --- src/library-authoring/create-unit/CreateUnitModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/library-authoring/create-unit/CreateUnitModal.tsx b/src/library-authoring/create-unit/CreateUnitModal.tsx index 8f1929f1b3..c3a4139e71 100644 --- a/src/library-authoring/create-unit/CreateUnitModal.tsx +++ b/src/library-authoring/create-unit/CreateUnitModal.tsx @@ -48,6 +48,7 @@ const CreateUnitModal = () => { onClose={closeCreateUnitModal} hasCloseButton isFullscreenOnMobile + isOverflowVisible={false} > From f0e0c4e28a072f6224a0f938d9250e31888a1224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 19 Mar 2025 10:40:29 -0300 Subject: [PATCH 3/9] refactor: migrate LoadingButton to typescript --- ...Button.test.jsx => LoadingButton.test.tsx} | 11 +++-- .../loading-button/{index.jsx => index.tsx} | 43 ++++++------------- 2 files changed, 18 insertions(+), 36 deletions(-) rename src/generic/loading-button/{LoadingButton.test.jsx => LoadingButton.test.tsx} (90%) rename src/generic/loading-button/{index.jsx => index.tsx} (57%) diff --git a/src/generic/loading-button/LoadingButton.test.jsx b/src/generic/loading-button/LoadingButton.test.tsx similarity index 90% rename from src/generic/loading-button/LoadingButton.test.jsx rename to src/generic/loading-button/LoadingButton.test.tsx index f52d43c3c8..dbcd5fdad5 100644 --- a/src/generic/loading-button/LoadingButton.test.jsx +++ b/src/generic/loading-button/LoadingButton.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { act, fireEvent, @@ -9,7 +8,7 @@ import LoadingButton from '.'; const buttonTitle = 'Button Title'; -const RootWrapper = (onClick) => ( +const RootWrapper = (onClick?: () => (Promise | void)) => ( ); @@ -31,8 +30,8 @@ describe('', () => { }); it('renders the spinner correctly', async () => { - let resolver; - const longFunction = () => new Promise((resolve) => { + let resolver: () => void; + const longFunction = () => new Promise((resolve) => { resolver = resolve; }); const { container, getByRole, getByText } = render(RootWrapper(longFunction)); @@ -51,8 +50,8 @@ describe('', () => { }); it('renders the spinner correctly even with error', async () => { - let rejecter; - const longFunction = () => new Promise((_resolve, reject) => { + let rejecter: (err: Error) => void; + const longFunction = () => new Promise((_resolve, reject) => { rejecter = reject; }); const { container, getByRole, getByText } = render(RootWrapper(longFunction)); diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.tsx similarity index 57% rename from src/generic/loading-button/index.jsx rename to src/generic/loading-button/index.tsx index f41bdea39b..de04d7d6fb 100644 --- a/src/generic/loading-button/index.jsx +++ b/src/generic/loading-button/index.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useCallback, useEffect, @@ -8,20 +7,20 @@ import React, { import { StatefulButton, } from '@openedx/paragon'; -import PropTypes from 'prop-types'; + +interface LoadingButtonProps { + label: string; + onClick?: (e: any) => (Promise | void); + disabled?: boolean; + size?: string; + variant?: string; + className?: string; +} /** - * A button that shows a loading spinner when clicked. - * @param {object} props - * @param {string} props.label - * @param {function=} props.onClick - * @param {boolean=} props.disabled - * @param {string=} props.size - * @param {string=} props.variant - * @param {string=} props.className - * @returns {JSX.Element} + * A button that shows a loading spinner when clicked, if the onClick function returns a Promise. */ -const LoadingButton = ({ +const LoadingButton: React.FC = ({ label, onClick, disabled, @@ -37,7 +36,8 @@ const LoadingButton = ({ componentMounted.current = false; }, []); - const loadingOnClick = useCallback(async (e) => { + // @ts-ignore + const loadingOnClick = useCallback(async (e: any) => { if (!onClick) { return; } @@ -67,21 +67,4 @@ const LoadingButton = ({ ); }; -LoadingButton.propTypes = { - label: PropTypes.string.isRequired, - onClick: PropTypes.func, - disabled: PropTypes.bool, - size: PropTypes.string, - variant: PropTypes.string, - className: PropTypes.string, -}; - -LoadingButton.defaultProps = { - onClick: undefined, - disabled: undefined, - size: undefined, - variant: '', - className: '', -}; - export default LoadingButton; From 9054aa1d63a0892024a90c9d6e1355fb1c7b39a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 19 Mar 2025 11:29:57 -0300 Subject: [PATCH 4/9] fix: using LoadingButton --- .../create-unit/CreateUnitModal.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/library-authoring/create-unit/CreateUnitModal.tsx b/src/library-authoring/create-unit/CreateUnitModal.tsx index c3a4139e71..33f491460a 100644 --- a/src/library-authoring/create-unit/CreateUnitModal.tsx +++ b/src/library-authoring/create-unit/CreateUnitModal.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActionRow, - Button, Form, ModalDialog, } from '@openedx/paragon'; @@ -13,6 +12,7 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import messages from './messages'; import { useCreateLibraryContainer } from '../data/apiHooks'; import { ToastContext } from '../../generic/toast-context'; +import LoadingButton from '../../generic/loading-button'; const CreateUnitModal = () => { const intl = useIntl(); @@ -24,21 +24,20 @@ const CreateUnitModal = () => { const create = useCreateLibraryContainer(libraryId); const { showToast } = React.useContext(ToastContext); - const handleCreate = React.useCallback((values) => { - create - .mutateAsync({ + const handleCreate = React.useCallback(async (values) => { + try { + await create.mutateAsync({ containerType: 'unit', ...values, - }) - .then(() => { - closeCreateUnitModal(); - // TODO: Navigate to the new unit - // navigate(`/library/${libraryId}/units/${data.key}`); - showToast(intl.formatMessage(messages.createUnitSuccess)); - }) - .catch(() => { - showToast(intl.formatMessage(messages.createUnitError)); }); + // TODO: Navigate to the new unit + // navigate(`/library/${libraryId}/units/${data.key}`); + showToast(intl.formatMessage(messages.createUnitSuccess)); + } catch (error) { + showToast(intl.formatMessage(messages.createUnitError)); + } finally { + closeCreateUnitModal(); + } }, []); return ( @@ -90,13 +89,12 @@ const CreateUnitModal = () => { {intl.formatMessage(messages.createUnitModalCancel)} - + disabled={!formikProps.isValid || !formikProps.dirty} + label={intl.formatMessage(messages.createUnitModalCreate)} + /> From 98687848b2082bfa99b718a978bd02a5e86af9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 19 Mar 2025 12:15:06 -0300 Subject: [PATCH 5/9] test: reduce test runtime --- .../LibraryAuthoringPage.test.tsx | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index dbcc496d30..40b49a3d00 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -457,15 +457,15 @@ describe('', () => { expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true'); }); - it('can filter by capa problem type', async () => { - const problemTypes = { - 'Multiple Choice': 'choiceresponse', - Checkboxes: 'multiplechoiceresponse', - 'Numerical Input': 'numericalresponse', - Dropdown: 'optionresponse', - 'Text Input': 'stringresponse', - }; + const problemTypes = { + 'Multiple Choice': 'choiceresponse', + Checkboxes: 'multiplechoiceresponse', + 'Numerical Input': 'numericalresponse', + Dropdown: 'optionresponse', + 'Text Input': 'stringresponse', + }; + it.each(Object.keys(problemTypes))('can filter by capa problem type (%s)', async (submenuText) => { await renderLibraryPage(); // Ensure the search endpoint is called @@ -479,35 +479,26 @@ describe('', () => { expect(showProbTypesSubmenuBtn).not.toBeNull(); fireEvent.click(showProbTypesSubmenuBtn!); - const validateSubmenu = async (submenuText: string) => { - const submenu = screen.getByText(submenuText); - expect(submenu).toBeInTheDocument(); - fireEvent.click(submenu); + const submenu = screen.getByText(submenuText); + expect(submenu).toBeInTheDocument(); + fireEvent.click(submenu); - await waitFor(() => { - expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { - body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), - method: 'POST', - headers: expect.anything(), - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), + method: 'POST', + headers: expect.anything(), }); + }); - fireEvent.click(submenu); - await waitFor(() => { - expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { - body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), - method: 'POST', - headers: expect.anything(), - }); + fireEvent.click(submenu); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), + method: 'POST', + headers: expect.anything(), }); - }; - - // Validate per submenu - // eslint-disable-next-line no-restricted-syntax - for (const key of Object.keys(problemTypes)) { - // eslint-disable-next-line no-await-in-loop - await validateSubmenu(key); - } + }); }); it('can filter by block type', async () => { From 51137d7cc36f8500cea35afed1ed3505e0eb9508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 19 Mar 2025 12:46:34 -0300 Subject: [PATCH 6/9] fix: removing ts-ignore --- src/generic/loading-button/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/generic/loading-button/index.tsx b/src/generic/loading-button/index.tsx index de04d7d6fb..5b9cbd4a43 100644 --- a/src/generic/loading-button/index.tsx +++ b/src/generic/loading-button/index.tsx @@ -36,7 +36,6 @@ const LoadingButton: React.FC = ({ componentMounted.current = false; }, []); - // @ts-ignore const loadingOnClick = useCallback(async (e: any) => { if (!onClick) { return; From e09586502baf14f3bfe41fc77853515412a79232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 20 Mar 2025 15:48:15 -0300 Subject: [PATCH 7/9] test: use only one assertion inside waitFor --- .../LibraryAuthoringPage.test.tsx | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 40b49a3d00..b713f90314 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -597,13 +597,11 @@ describe('', () => { fireEvent.click(createButton); // Check success toast - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - expect(axiosMock.history.post[0].url).toBe(url); - expect(axiosMock.history.post[0].data).toContain(`"title":"${title}"`); - expect(axiosMock.history.post[0].data).toContain(`"description":"${description}"`); - expect(mockShowToast).toHaveBeenCalledWith('Collection created successfully'); - }); + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"title":"${title}"`); + expect(axiosMock.history.post[0].data).toContain(`"description":"${description}"`); + expect(mockShowToast).toHaveBeenCalledWith('Collection created successfully'); }); it('should show validations in create collection', async () => { @@ -677,10 +675,8 @@ describe('', () => { fireEvent.click(createButton); // Check error toast - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection'); - }); + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection'); }); it('should create a unit', async () => { @@ -721,14 +717,13 @@ describe('', () => { fireEvent.change(nameField, { target: { value: title } }); fireEvent.click(createButton); - // Check success toast - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - expect(axiosMock.history.post[0].url).toBe(url); - expect(axiosMock.history.post[0].data).toContain(`"display_name":"${title}"`); - expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"'); - expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully'); - }); + // Check success + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"display_name":"${title}"`); + expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"'); + expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully'); }); it('should show validations in create unit', async () => { @@ -797,10 +792,8 @@ describe('', () => { fireEvent.click(createButton); // Check error toast - await waitFor(() => { - expect(axiosMock.history.post.length).toBe(1); - expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library unit'); - }); + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library unit'); }); it('shows a single block when usageKey query param is set', async () => { From 7c8f2274d43014a8d5bad71a05321293f00771a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 17 Mar 2025 19:39:18 -0300 Subject: [PATCH 8/9] feat: unit cards in library --- src/generic/block-type-utils/constants.ts | 2 + .../component-count/ComponentCount.test.tsx | 14 +++ src/generic/component-count/index.tsx | 18 ++++ .../{TagCount.test.jsx => TagCount.test.tsx} | 0 .../tag-count/{index.jsx => index.tsx} | 27 +++--- .../LibraryAuthoringPage.tsx | 2 +- src/library-authoring/LibraryContent.tsx | 25 +++-- ...ponentCard.scss => BaseComponentCard.scss} | 4 + .../components/BaseComponentCard.tsx | 38 +++++--- .../components/CollectionCard.test.tsx | 22 ++--- .../components/CollectionCard.tsx | 8 +- .../components/ComponentCard.test.tsx | 2 +- .../components/ComponentCard.tsx | 9 +- .../components/ContainerCard.test.tsx | 83 +++++++++++++++++ .../components/ContainerCard.tsx | 92 +++++++++++++++++++ src/library-authoring/components/messages.ts | 9 +- src/library-authoring/index.scss | 2 +- src/search-manager/SearchManager.ts | 5 +- src/search-manager/data/api.ts | 21 ++++- src/search-manager/index.ts | 9 +- 20 files changed, 317 insertions(+), 75 deletions(-) create mode 100644 src/generic/component-count/ComponentCount.test.tsx create mode 100644 src/generic/component-count/index.tsx rename src/generic/tag-count/{TagCount.test.jsx => TagCount.test.tsx} (100%) rename src/generic/tag-count/{index.jsx => index.tsx} (54%) rename src/library-authoring/components/{ComponentCard.scss => BaseComponentCard.scss} (90%) create mode 100644 src/library-authoring/components/ContainerCard.test.tsx create mode 100644 src/library-authoring/components/ContainerCard.tsx diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 604519e53e..a9a87a0c7c 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -56,6 +56,7 @@ export const COMPONENT_TYPE_ICON_MAP: Record = { export const STRUCTURAL_TYPE_ICONS: Record = { vertical: UNIT_TYPE_ICONS_MAP.vertical, + unit: UNIT_TYPE_ICONS_MAP.vertical, sequential: Folder, chapter: Folder, collection: Folder, @@ -73,6 +74,7 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = { [COMPONENT_TYPES.video]: 'component-style-video', [COMPONENT_TYPES.dragAndDrop]: 'component-style-default', vertical: 'component-style-vertical', + unit: 'component-style-vertical', sequential: 'component-style-default', chapter: 'component-style-default', collection: 'component-style-collection', diff --git a/src/generic/component-count/ComponentCount.test.tsx b/src/generic/component-count/ComponentCount.test.tsx new file mode 100644 index 0000000000..57b37fea7a --- /dev/null +++ b/src/generic/component-count/ComponentCount.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import ComponentCount from '.'; + +describe('', () => { + it('should render the component', () => { + render(); + expect(screen.getByText('17')).toBeInTheDocument(); + }); + + it('should render the component with zero', () => { + render(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); +}); diff --git a/src/generic/component-count/index.tsx b/src/generic/component-count/index.tsx new file mode 100644 index 0000000000..2856764072 --- /dev/null +++ b/src/generic/component-count/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Icon, Stack } from '@openedx/paragon'; +import { Widgets } from '@openedx/paragon/icons'; + +type ComponentCountProps = { + count?: number; +}; + +const ComponentCount: React.FC = ({ count }) => ( + count !== undefined ? ( + + + {count} + + ) : null +); + +export default ComponentCount; diff --git a/src/generic/tag-count/TagCount.test.jsx b/src/generic/tag-count/TagCount.test.tsx similarity index 100% rename from src/generic/tag-count/TagCount.test.jsx rename to src/generic/tag-count/TagCount.test.tsx diff --git a/src/generic/tag-count/index.jsx b/src/generic/tag-count/index.tsx similarity index 54% rename from src/generic/tag-count/index.jsx rename to src/generic/tag-count/index.tsx index bb6dada9d7..d8787bb2bf 100644 --- a/src/generic/tag-count/index.jsx +++ b/src/generic/tag-count/index.tsx @@ -1,14 +1,20 @@ -import PropTypes from 'prop-types'; -import { Icon, Button } from '@openedx/paragon'; +import { Button, Icon, Stack } from '@openedx/paragon'; import { Tag } from '@openedx/paragon/icons'; import classNames from 'classnames'; -const TagCount = ({ count, onClick }) => { +type TagCountProps = { + count: number; + onClick?: () => void; + size?: Parameters[0]['size']; +}; + +// eslint-disable-next-line react/prop-types +const TagCount: React.FC = ({ count, onClick, size }) => { const renderContent = () => ( - <> - + + {count} - + ); return ( @@ -26,13 +32,4 @@ const TagCount = ({ count, onClick }) => { ); }; -TagCount.defaultProps = { - onClick: undefined, -}; - -TagCount.propTypes = { - count: PropTypes.number.isRequired, - onClick: PropTypes.func, -}; - export default TagCount; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index f40f187e5f..7c4c54f38d 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -215,7 +215,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage } const activeTypeFilters = { - components: 'NOT type = "collection"', + components: 'type = "library_block"', collections: 'type = "collection"', }; if (activeKey !== ContentType.home) { diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index 1913994b28..53fdb23ecd 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -6,6 +6,7 @@ import { useLibraryContext } from './common/context/LibraryContext'; import { useSidebarContext } from './common/context/SidebarContext'; import CollectionCard from './components/CollectionCard'; import ComponentCard from './components/ComponentCard'; +import ContainerCard from './components/ContainerCard'; import { ContentType } from './routes'; import { useLoadOnScroll } from '../hooks'; import messages from './collections/messages'; @@ -22,6 +23,12 @@ type LibraryContentProps = { contentType?: ContentType; }; +const LibraryItemCard = { + collection: CollectionCard, + library_block: ComponentCard, + library_container: ContainerCard, +}; + const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) => { const { hits, @@ -69,19 +76,11 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) return (
- {hits.map((contentHit) => ( - contentHit.type === 'collection' ? ( - - ) : ( - - ) - ))} + {hits.map((contentHit) => { + const CardComponent = LibraryItemCard[contentHit.type] || ComponentCard; + + return ; + })}
); }; diff --git a/src/library-authoring/components/ComponentCard.scss b/src/library-authoring/components/BaseComponentCard.scss similarity index 90% rename from src/library-authoring/components/ComponentCard.scss rename to src/library-authoring/components/BaseComponentCard.scss index cdf72300e6..bacdbb3b90 100644 --- a/src/library-authoring/components/ComponentCard.scss +++ b/src/library-authoring/components/BaseComponentCard.scss @@ -21,4 +21,8 @@ margin: .25rem 0 .25rem 1rem; } } + + .badge-container { + min-height: 20px; + } } diff --git a/src/library-authoring/components/BaseComponentCard.tsx b/src/library-authoring/components/BaseComponentCard.tsx index 0c52c0a88f..425ebc8736 100644 --- a/src/library-authoring/components/BaseComponentCard.tsx +++ b/src/library-authoring/components/BaseComponentCard.tsx @@ -9,13 +9,14 @@ import { import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import ComponentCount from '../../generic/component-count'; import TagCount from '../../generic/tag-count'; import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager'; type BaseComponentCardProps = { componentType: string; displayName: string; - description: string; + description?: string; numChildren?: number; tags: ContentHitTags; actions: React.ReactNode; @@ -26,7 +27,7 @@ type BaseComponentCardProps = { const BaseComponentCard = ({ componentType, displayName, - description, + description = '', numChildren, tags, actions, @@ -67,24 +68,33 @@ const BaseComponentCard = ({
e.stopPropagation()}>{actions}
} /> - + - - - - - - - - -
-
- {props.hasUnpublishedChanges ? {intl.formatMessage(messages.unpublishedChanges)} : null} +
+ + + + + + + + + + + + +
+ {props.hasUnpublishedChanges && ( + {intl.formatMessage(messages.unpublishedChanges)} + )} +
+
+
); diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx index 3f7dc7fc88..2cb43481fc 100644 --- a/src/library-authoring/components/CollectionCard.test.tsx +++ b/src/library-authoring/components/CollectionCard.test.tsx @@ -10,7 +10,7 @@ import CollectionCard from './CollectionCard'; import messages from './messages'; import { getLibraryCollectionApiUrl, getLibraryCollectionRestoreApiUrl } from '../data/api'; -const CollectionHitSample: CollectionHit = { +const collectionHitSample: CollectionHit = { id: 'lib-collectionorg1democourse-collection-display-name', type: 'collection', contextKey: 'lb:org1:Demo_Course', @@ -55,23 +55,23 @@ describe('', () => { }); it('should render the card with title and description', () => { - render(); + render(); expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); expect(screen.queryByText('Collection description')).toBeInTheDocument(); - expect(screen.queryByText('Collection (2)')).toBeInTheDocument(); + expect(screen.queryByText('2')).toBeInTheDocument(); // Component count }); it('should render published content', () => { - render(, true); + render(, true); expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); expect(screen.queryByText('Collection description')).toBeInTheDocument(); - expect(screen.queryByText('Collection (1)')).toBeInTheDocument(); + expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count }); it('should navigate to the collection if the open menu clicked', async () => { - render(); + render(); // Open menu expect(screen.getByTestId('collection-card-menu-toggle')).toBeInTheDocument(); @@ -85,9 +85,9 @@ describe('', () => { }); it('should show confirmation box, delete collection and show toast to undo deletion', async () => { - const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + const url = getLibraryCollectionApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId); axiosMock.onDelete(url).reply(204); - render(); + render(); expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); // Open menu @@ -123,7 +123,7 @@ describe('', () => { // Get restore / undo func from the toast const restoreFn = mockShowToast.mock.calls[0][1].onClick; - const restoreUrl = getLibraryCollectionRestoreApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + const restoreUrl = getLibraryCollectionRestoreApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId); axiosMock.onPost(restoreUrl).reply(200); // restore collection restoreFn(); @@ -134,9 +134,9 @@ describe('', () => { }); it('should show failed toast on delete collection failure', async () => { - const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + const url = getLibraryCollectionApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId); axiosMock.onDelete(url).reply(404); - render(); + render(); expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); // Open menu diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx index ee165e04a0..503a153fa6 100644 --- a/src/library-authoring/components/CollectionCard.tsx +++ b/src/library-authoring/components/CollectionCard.tsx @@ -103,10 +103,10 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { }; type CollectionCardProps = { - collectionHit: CollectionHit, + hit: CollectionHit, }; -const CollectionCard = ({ collectionHit } : CollectionCardProps) => { +const CollectionCard = ({ hit } : CollectionCardProps) => { const { componentPickerMode } = useComponentPickerContext(); const { showOnlyPublished } = useLibraryContext(); const { openCollectionInfoSidebar } = useSidebarContext(); @@ -118,7 +118,7 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => { tags, numChildren, published, - } = collectionHit; + } = hit; const numChildrenCount = showOnlyPublished ? ( published?.numChildren || 0 @@ -144,7 +144,7 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => { numChildren={numChildrenCount} actions={!componentPickerMode && ( - + )} onSelect={openCollection} diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index c6e5ba5c9e..adea385683 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -47,7 +47,7 @@ const clipboardBroadcastChannelMock = { (global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); const libraryId = 'lib:org1:Demo_Course'; -const render = () => baseRender(, { +const render = () => baseRender(, { extraWrapper: ({ children }) => ( { children } diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index e482cf7d8d..f86ce79889 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -17,7 +17,7 @@ import { import { useClipboard } from '../../generic/clipboard'; import { ToastContext } from '../../generic/toast-context'; -import { type ContentHit } from '../../search-manager'; +import { type ContentHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; @@ -28,10 +28,9 @@ import BaseComponentCard from './BaseComponentCard'; import { canEditComponent } from './ComponentEditorModal'; import messages from './messages'; import ComponentDeleter from './ComponentDeleter'; -import { PublishStatus } from '../../search-manager/data/api'; type ComponentCardProps = { - contentHit: ContentHit, + hit: ContentHit, }; export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { @@ -181,7 +180,7 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => return null; }; -const ComponentCard = ({ contentHit }: ComponentCardProps) => { +const ComponentCard = ({ hit }: ComponentCardProps) => { const { showOnlyPublished } = useLibraryContext(); const { openComponentInfoSidebar } = useSidebarContext(); const { componentPickerMode } = useComponentPickerContext(); @@ -192,7 +191,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => { tags, usageKey, publishStatus, - } = contentHit; + } = hit; const componentDescription: string = ( showOnlyPublished ? formatted.published?.description : formatted.description ) ?? ''; diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx new file mode 100644 index 0000000000..657a2e112f --- /dev/null +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -0,0 +1,83 @@ +import userEvent from '@testing-library/user-event'; + +import { + initializeMocks, render as baseRender, screen, +} from '../../testUtils'; +import { LibraryProvider } from '../common/context/LibraryContext'; +import { type ContainerHit, PublishStatus } from '../../search-manager'; +import ContainerCard from './ContainerCard'; + +const containerHitSample: ContainerHit = { + id: 'lctorg1democourse-unit-display-name-123', + type: 'library_container', + contextKey: 'lb:org1:Demo_Course', + usageKey: 'lct:org1:Demo_Course:unit:unit-display-name-123', + org: 'org1', + blockId: 'unit-display-name-123', + blockType: 'unit', + breadcrumbs: [{ displayName: 'Demo Lib' }], + displayName: 'Unit Display Name', + formatted: { + displayName: 'Unit Display Formated Name', + published: { + displayName: 'Published Unit Display Name', + }, + }, + created: 1722434322294, + modified: 1722434322294, + numChildren: 2, + published: { + numChildren: 1, + }, + tags: {}, + publishStatus: PublishStatus.Published, +}; + +const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { + extraWrapper: ({ children }) => ( + + {children} + + ), +}); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('should render the card with title', () => { + render(); + + expect(screen.queryByText('Unit Display Formated Name')).toBeInTheDocument(); + expect(screen.queryByText('2')).toBeInTheDocument(); // Component count + }); + + it('should render published content', () => { + render(, true); + + expect(screen.queryByText('Published Unit Display Name')).toBeInTheDocument(); + expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count + }); + + it('should navigate to the container if the open menu clicked', async () => { + render(); + + // Open menu + expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('container-card-menu-toggle')); + + // Open menu item + const openMenuItem = screen.getByRole('link', { name: 'Open' }); + expect(openMenuItem).toBeInTheDocument(); + + // TODO: To be implemented + // expect(openMenuItem).toHaveAttribute( + // 'href', + // '/library/lb:org1:Demo_Course/container/container-display-name-123', + // ); + }); +}); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx new file mode 100644 index 0000000000..d3460b14c9 --- /dev/null +++ b/src/library-authoring/components/ContainerCard.tsx @@ -0,0 +1,92 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Dropdown, + Icon, + IconButton, +} from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; +import { Link } from 'react-router-dom'; + +import { type ContainerHit, PublishStatus } from '../../search-manager'; +import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import BaseComponentCard from './BaseComponentCard'; +import messages from './messages'; + +type ContainerMenuProps = { + containerHit: ContainerHit, +}; + +const ContainerMenu = ({ containerHit } : ContainerMenuProps) => { + const intl = useIntl(); + + return ( + + + + + + + + + ); +}; + +type ContainerCardProps = { + hit: ContainerHit, +}; + +const ContainerCard = ({ hit } : ContainerCardProps) => { + const { componentPickerMode } = useComponentPickerContext(); + const { showOnlyPublished } = useLibraryContext(); + + const { + blockType: componentType, + formatted, + tags, + numChildren, + published, + publishStatus, + } = hit; + + const numChildrenCount = showOnlyPublished ? ( + published?.numChildren || 0 + ) : numChildren; + + const displayName: string = ( + showOnlyPublished ? formatted.published?.displayName : formatted.displayName + ) ?? ''; + + const openContainer = () => {}; + + return ( + + + + )} + hasUnpublishedChanges={publishStatus !== PublishStatus.Published} + onSelect={openContainer} + /> + ); +}; + +export default ContainerCard; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 0cb32d1953..40276ca7ce 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -11,10 +11,15 @@ const messages = defineMessages({ defaultMessage: 'Collection actions menu', description: 'Alt/title text for the collection card menu button.', }, + containerCardMenuAlt: { + id: 'course-authoring.library-authoring.container.menu', + defaultMessage: 'Container actions menu', + description: 'Alt/title text for the container card menu button.', + }, menuOpen: { - id: 'course-authoring.library-authoring.collection.menu.open', + id: 'course-authoring.library-authoring.menu.open', defaultMessage: 'Open', - description: 'Menu item for open a collection.', + description: 'Menu item for open a collection/container.', }, menuEdit: { id: 'course-authoring.library-authoring.component.menu.edit', diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 62097ddbc3..778a6621bd 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1,5 +1,5 @@ @import "./component-info/ComponentPreview"; -@import "./components/ComponentCard"; +@import "./components/BaseComponentCard"; @import "./generic"; @import "./LibraryAuthoringPage"; diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index f9fb7a2367..d53ceb6755 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -9,8 +9,7 @@ import { MeiliSearch, type Filter } from 'meilisearch'; import { union } from 'lodash'; import { - CollectionHit, - ContentHit, + type HitType, SearchSortOption, forceArray, PublishStatus, } from './data/api'; @@ -39,7 +38,7 @@ export interface SearchContextData { searchSortOrder: SearchSortOption; setSearchSortOrder: React.Dispatch>; defaultSearchSortOrder: SearchSortOption; - hits: (ContentHit | CollectionHit)[]; + hits: HitType[]; totalHits: number; isLoading: boolean; hasNextPage: boolean | undefined; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 99dbcfa591..549054b3fe 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -105,7 +105,7 @@ export interface ContentHitTags { */ interface BaseContentHit { id: string; - type: 'course_block' | 'library_block' | 'collection'; + type: 'course_block' | 'library_block' | 'collection' | 'library_container'; displayName: string; usageKey: string; blockId: string; @@ -167,11 +167,26 @@ export interface CollectionHit extends BaseContentHit { published?: ContentPublishedData; } +/** + * Information about a single container returned in the search results + * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py + */ +export interface ContainerHit extends BaseContentHit { + type: 'library_container'; + blockType: 'unit'; // This should be expanded to include other container types + numChildren?: number; + published?: ContentPublishedData; + publishStatus: PublishStatus; + formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, }; +} + +export type HitType = ContentHit | CollectionHit | ContainerHit; + /** * Convert search hits to camelCase * @param hit A search result directly from Meilisearch */ -export function formatSearchHit(hit: Record): ContentHit | CollectionHit { +export function formatSearchHit(hit: Record): HitType { // eslint-disable-next-line @typescript-eslint/naming-convention const { _formatted, ...newHit } = hit; newHit.formatted = { @@ -214,7 +229,7 @@ export async function fetchSearchResults({ skipBlockTypeFetch = false, limit = 20, }: FetchSearchParams): Promise<{ - hits: (ContentHit | CollectionHit)[], + hits: HitType[], nextOffset: number | undefined, totalHits: number, blockTypes: Record, diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index cd73551f0e..bb4fed4112 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -8,8 +8,13 @@ export { default as Highlight } from './Highlight'; export { default as SearchKeywordsField } from './SearchKeywordsField'; export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; -export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; +export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG, PublishStatus } from './data/api'; export { useContentSearchConnection, useContentSearchResults, useGetBlockTypes } from './data/apiHooks'; export { TypesFilterData } from './hooks'; -export type { CollectionHit, ContentHit, ContentHitTags } from './data/api'; +export type { + CollectionHit, + ContainerHit, + ContentHit, + ContentHitTags, +} from './data/api'; From a863cf34e1a4ea365e991df82eddced649cd8e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 28 Mar 2025 12:45:08 -0300 Subject: [PATCH 9/9] refactor: rename BaseComponentCard -> BaseCard --- .../{BaseComponentCard.scss => BaseCard.scss} | 6 ++-- .../{BaseComponentCard.tsx => BaseCard.tsx} | 24 +++++++------- .../components/CollectionCard.tsx | 32 +++++++++++-------- .../components/ComponentCard.tsx | 6 ++-- .../components/ContainerCard.tsx | 17 +++++----- src/library-authoring/index.scss | 2 +- 6 files changed, 47 insertions(+), 40 deletions(-) rename src/library-authoring/components/{BaseComponentCard.scss => BaseCard.scss} (80%) rename src/library-authoring/components/{BaseComponentCard.tsx => BaseCard.tsx} (82%) diff --git a/src/library-authoring/components/BaseComponentCard.scss b/src/library-authoring/components/BaseCard.scss similarity index 80% rename from src/library-authoring/components/BaseComponentCard.scss rename to src/library-authoring/components/BaseCard.scss index bacdbb3b90..9346618a31 100644 --- a/src/library-authoring/components/BaseComponentCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -1,14 +1,14 @@ -.library-component-card { +.library-item-card { .pgn__card { height: 100% } - .library-component-header { + .library-item-header { border-top-left-radius: .375rem; border-top-right-radius: .375rem; padding: 0 .5rem 0 1.25rem; - .library-component-header-icon { + .library-item-header-icon { width: 2.3rem; height: 2.3rem; } diff --git a/src/library-authoring/components/BaseComponentCard.tsx b/src/library-authoring/components/BaseCard.tsx similarity index 82% rename from src/library-authoring/components/BaseComponentCard.tsx rename to src/library-authoring/components/BaseCard.tsx index 425ebc8736..f327c78011 100644 --- a/src/library-authoring/components/BaseComponentCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -13,8 +13,8 @@ import ComponentCount from '../../generic/component-count'; import TagCount from '../../generic/tag-count'; import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager'; -type BaseComponentCardProps = { - componentType: string; +type BaseCardProps = { + itemType: string; displayName: string; description?: string; numChildren?: number; @@ -24,8 +24,8 @@ type BaseComponentCardProps = { onSelect: () => void }; -const BaseComponentCard = ({ - componentType, +const BaseCard = ({ + itemType, displayName, description = '', numChildren, @@ -33,7 +33,7 @@ const BaseComponentCard = ({ actions, onSelect, ...props -} : BaseComponentCardProps) => { +} : BaseCardProps) => { const tagCount = useMemo(() => { if (!tags) { return 0; @@ -42,11 +42,11 @@ const BaseComponentCard = ({ + (tags.level2?.length || 0) + (tags.level3?.length || 0); }, [tags]); - const componentIcon = getItemIcon(componentType); + const itemIcon = getItemIcon(itemType); const intl = useIntl(); return ( - + + } actions={ // Wrap the actions in a div to prevent the card from being clicked when the actions are clicked @@ -80,9 +80,9 @@ const BaseComponentCard = ({ - + - + @@ -100,4 +100,4 @@ const BaseComponentCard = ({ ); }; -export default BaseComponentCard; +export default BaseCard; diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx index 503a153fa6..7067d50096 100644 --- a/src/library-authoring/components/CollectionCard.tsx +++ b/src/library-authoring/components/CollectionCard.tsx @@ -15,23 +15,29 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useLibraryRoutes } from '../routes'; -import BaseComponentCard from './BaseComponentCard'; +import BaseCard from './BaseCard'; import { ToastContext } from '../../generic/toast-context'; import { useDeleteCollection, useRestoreCollection } from '../data/apiHooks'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; import messages from './messages'; type CollectionMenuProps = { - collectionHit: CollectionHit, + hit: CollectionHit, }; -const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { +const CollectionMenu = ({ hit } : CollectionMenuProps) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const { closeLibrarySidebar, sidebarComponentInfo } = useSidebarContext(); + const { + contextKey, + blockId, + type, + displayName, + } = hit; - const restoreCollectionMutation = useRestoreCollection(collectionHit.contextKey, collectionHit.blockId); + const restoreCollectionMutation = useRestoreCollection(contextKey, blockId); const restoreCollection = useCallback(() => { restoreCollectionMutation.mutateAsync() .then(() => { @@ -41,9 +47,9 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { }); }, []); - const deleteCollectionMutation = useDeleteCollection(collectionHit.contextKey, collectionHit.blockId); + const deleteCollectionMutation = useDeleteCollection(contextKey, blockId); const deleteCollection = useCallback(async () => { - if (sidebarComponentInfo?.id === collectionHit.blockId) { + if (sidebarComponentInfo?.id === blockId) { // Close sidebar if current collection is open to avoid displaying // deleted collection in sidebar closeLibrarySidebar(); @@ -79,7 +85,7 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { @@ -92,9 +98,9 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { isOpen={isDeleteModalOpen} close={closeDeleteModal} variant="warning" - category={collectionHit.type} + category={type} description={intl.formatMessage(messages.deleteCollectionConfirm, { - collectionTitle: collectionHit.displayName, + collectionTitle: displayName, })} onDeleteSubmit={deleteCollection} /> @@ -112,7 +118,7 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { const { openCollectionInfoSidebar } = useSidebarContext(); const { - type: componentType, + type: itemType, blockId: collectionId, formatted, tags, @@ -136,15 +142,15 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { }, [collectionId, navigateTo, openCollectionInfoSidebar]); return ( - - + )} onSelect={openCollection} diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index f86ce79889..672a48b8bb 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -24,7 +24,7 @@ import { SidebarActions, useSidebarContext } from '../common/context/SidebarCont import { useRemoveComponentsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; -import BaseComponentCard from './BaseComponentCard'; +import BaseCard from './BaseCard'; import { canEditComponent } from './ComponentEditorModal'; import messages from './messages'; import ComponentDeleter from './ComponentDeleter'; @@ -209,8 +209,8 @@ const ComponentCard = ({ hit }: ComponentCardProps) => { }, [usageKey, navigateTo, openComponentInfoSidebar]); return ( - { +const ContainerMenu = ({ hit } : ContainerMenuProps) => { const intl = useIntl(); + const { contextKey, blockId } = hit; return ( @@ -35,7 +36,7 @@ const ContainerMenu = ({ containerHit } : ContainerMenuProps) => { @@ -54,7 +55,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { const { showOnlyPublished } = useLibraryContext(); const { - blockType: componentType, + blockType: itemType, formatted, tags, numChildren, @@ -73,14 +74,14 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { const openContainer = () => {}; return ( - - + )} hasUnpublishedChanges={publishStatus !== PublishStatus.Published} diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 778a6621bd..fcfb6732a7 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1,5 +1,5 @@ @import "./component-info/ComponentPreview"; -@import "./components/BaseComponentCard"; +@import "./components/BaseCard"; @import "./generic"; @import "./LibraryAuthoringPage";