diff --git a/src/generic/unlink-modal/UnlinkModal.tsx b/src/generic/unlink-modal/UnlinkModal.tsx index 55a513064f..ca66e738f4 100644 --- a/src/generic/unlink-modal/UnlinkModal.tsx +++ b/src/generic/unlink-modal/UnlinkModal.tsx @@ -5,12 +5,11 @@ import { } from '@openedx/paragon'; import { Warning } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { BoldText } from '@src/utils'; import messages from './messages'; import LoadingButton from '../loading-button'; -const BoldText = (chunk: string[]) => {chunk}; - type UnlinkModalPropsContainer = { displayName?: string; category?: string; diff --git a/src/index.jsx b/src/index.jsx index 219fbd004c..43e2ab210e 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -35,6 +35,7 @@ import { ContentType } from './library-authoring/routes'; import 'react-datepicker/dist/react-datepicker.css'; import './index.scss'; +import { LegacyLibMigrationPage } from './legacy-libraries-migration/LegacyLibMigrationPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -65,6 +66,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> div > div.highlight) { diff --git a/src/legacy-libraries-migration/ConfirmationView.tsx b/src/legacy-libraries-migration/ConfirmationView.tsx new file mode 100644 index 0000000000..6cb8e025ec --- /dev/null +++ b/src/legacy-libraries-migration/ConfirmationView.tsx @@ -0,0 +1,89 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Alert, + Card, + Container, + Icon, + Stack, +} from '@openedx/paragon'; +import { + AccessTime, + Folder, + SubdirectoryArrowRight, +} from '@openedx/paragon/icons'; + +import type { ContentLibrary } from '@src/library-authoring/data/api'; +import { LibraryV1Data } from '@src/studio-home/data/api'; +import { BoldText } from '@src/utils'; + +import messages from './messages'; + +interface ConfirmationCardProps { + legacyLib: LibraryV1Data; + destinationName: string; +} + +const ConfirmationCard = ({ + legacyLib, + destinationName, +}: ConfirmationCardProps) => ( + + + + {legacyLib.displayName} + + )} + subtitle={( + + + {destinationName} + + )} + /> + {legacyLib.isMigrated && ( + + + + + + + )} + +); + +interface ConfirmationViewProps { + destination: ContentLibrary; + legacyLibraries: LibraryV1Data[]; +} + +export const ConfirmationView = ({ + destination, + legacyLibraries, +}: ConfirmationViewProps) => ( + + + + + {legacyLibraries.map((legacyLib) => ( + + ))} + +); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx new file mode 100644 index 0000000000..17d4cbbbb0 --- /dev/null +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx @@ -0,0 +1,290 @@ +import type MockAdapter from 'axios-mock-adapter'; +import userEvent from '@testing-library/user-event'; + +import { + initializeMocks, + render, + screen, + waitFor, +} from '@src/testUtils'; +import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; +import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks'; +import { mockGetStudioHomeLibraries } from '@src/studio-home/data/api.mocks'; +import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-library/data/api'; +import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; + +import { LegacyLibMigrationPage } from './LegacyLibMigrationPage'; + +const path = '/libraries-v1/migrate/*'; +let axiosMock: MockAdapter; + +mockGetStudioHomeLibraries.applyMock(); +mockGetContentLibraryV2List.applyMock(); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('@src/generic/data/apiHooks', () => ({ + ...jest.requireActual('@src/generic/data/apiHooks'), + useOrganizationListData: () => ({ + data: ['org1', 'org2', 'org3', 'org4', 'org5'], + isLoading: false, + }), +})); + +const renderPage = () => ( + render(, { path }) +); + +describe('', () => { + beforeEach(() => { + axiosMock = initializeMocks().axiosMock; + }); + + it('should render legacy library migration page', async () => { + renderPage(); + // Should render the title + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + // Should render the Migration Steps Viewer + expect(screen.getByText(/select legacy libraries/i)).toBeInTheDocument(); + expect(screen.getByText(/select destination/i)).toBeInTheDocument(); + expect(screen.getByText(/confirm/i)).toBeInTheDocument(); + }); + + it('should cancel the migration', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + cancelButton.click(); + + // Should show exit confirmation modal + expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); + + // Close exit confirmation modal + const continueButton = screen.getByRole('button', { name: /continue migrating/i }); + continueButton.click(); + expect(mockNavigate).not.toHaveBeenCalled(); + + cancelButton.click(); + + // Should navigate to legacy libraries tab on studio home + expect(await screen.findByText('Exit Migration?')).toBeInTheDocument(); + const exitButton = screen.getByRole('button', { name: /exit/i }); + exitButton.click(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1'); + }); + }); + + it('should select legacy libraries', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + // The next button is disabled + expect(nextButton).toBeDisabled(); + + expect(await screen.findByText('MBA')).toBeInTheDocument(); + expect(await screen.findByText('Legacy library 1')).toBeInTheDocument(); + expect(await screen.findByText('MBA 1')).toBeInTheDocument(); + + const library1 = screen.getByRole('checkbox', { name: 'MBA' }); + const library2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + + expect(library1).not.toBeChecked(); + expect(library2).not.toBeChecked(); + + library1.click(); + + expect(library1).toBeChecked(); + expect(library2).not.toBeChecked(); + expect(nextButton).not.toBeDisabled(); + + library2.click(); + expect(library1).toBeChecked(); + expect(library2).toBeChecked(); + expect(nextButton).not.toBeDisabled(); + + library2.click(); + expect(library1).toBeChecked(); + expect(library2).not.toBeChecked(); + expect(nextButton).not.toBeDisabled(); + }); + + it('should back to select legacy libraries', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument(); + + const backButton = screen.getByRole('button', { name: /back/i }); + backButton.click(); + + expect(await screen.findByText('MBA')).toBeInTheDocument(); + }); + + it('should select a library destination', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument(); + + // The next button is disabled + expect(nextButton).toBeDisabled(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + radioButton.click(); + + expect(radioButton).toBeChecked(); + expect(nextButton).not.toBeDisabled(); + }); + + it('should back to select library destination', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument(); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + radioButton.click(); + + nextButton.click(); + expect(await screen.findByText(/these 1 legacy library will be migrated to/i)).toBeInTheDocument(); + + const backButton = screen.getByRole('button', { name: /back/i }); + backButton.click(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + }); + + it('should open the create new library modal', async () => { + const user = userEvent.setup(); + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, { + id: 'lib:SampleTaxonomyOrg1:TL1', + }); + + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' }); + legacyLibrary.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument(); + + const createButton = await screen.findByRole('button', { name: /create new library/i }); + expect(createButton).toBeInTheDocument(); + createButton.click(); + + // Should open the create library modal + expect(await screen.findByText('Create new library')).toBeInTheDocument(); + + // Cancel and close the create library modal + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + cancelButton.click(); + await waitFor(() => { + expect(screen.queryByText('Create new library')).not.toBeInTheDocument(); + }); + + // Open the modal again and create a new library + createButton.click(); + const titleInput = await screen.findByRole('textbox', { name: /library name/i }); + await user.click(titleInput); + await user.type(titleInput, 'Test Library Name'); + + const orgInput = await screen.findByRole('combobox', { name: /organization/i }); + await user.click(orgInput); + await user.type(orgInput, 'org1'); + await user.tab(); + + const slugInput = await screen.findByRole('textbox', { name: /library id/i }); + await user.click(slugInput); + await user.type(slugInput, 'test_library_slug'); + + const confirmButton = await screen.findByRole('button', { name: 'Create' }); + confirmButton.click(); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].data).toBe( + '{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}', + ); + + // The library should be checked + expect(screen.getByRole('radio', { name: /test library 1/i })).toBeChecked(); + }); + + it('should confirm migration', async () => { + renderPage(); + expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); + expect(await screen.findByText('MBA')).toBeInTheDocument(); + + const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' }); + const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i }); + const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' }); + + legacyLibrary1.click(); + legacyLibrary2.click(); + legacyLibrary3.click(); + + const nextButton = screen.getByRole('button', { name: /next/i }); + nextButton.click(); + + // Should show alert of SelectDestinationView + expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument(); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + const radioButton = screen.getByRole('radio', { name: /test library 1/i }); + radioButton.click(); + + nextButton.click(); + + // Should show alert of ConfirmationView + expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument(); + expect(screen.getByText('MBA')).toBeInTheDocument(); + expect(screen.getByText('Legacy library 1')).toBeInTheDocument(); + expect(screen.getByText('MBA 1')).toBeInTheDocument(); + expect(screen.getByText( + /Previously migrated library. Any problem bank links were already moved will be migrated to/i, + )).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { name: /confirm/i }); + confirmButton.click(); + + // TODO: expect call migrate API + }); +}); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx new file mode 100644 index 0000000000..c24504d5f2 --- /dev/null +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx @@ -0,0 +1,215 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { useNavigate } from 'react-router-dom'; + +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + Container, + ModalDialog, + StatefulButton, + Stepper, + useToggle, +} from '@openedx/paragon'; +import Header from '@src/header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import type { ContentLibrary } from '@src/library-authoring/data/api'; +import type { LibraryV1Data } from '@src/studio-home/data/api'; +import LibrariesList from '@src/studio-home/tabs-section/libraries-tab'; + +import messages from './messages'; +import { SelectDestinationView } from './SelectDestinationView'; +import { ConfirmationView } from './ConfirmationView'; + +export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view'; + +const ExitModal = ({ + isExitModalOpen, + closeExitModal, +}: { + isExitModalOpen: boolean, + closeExitModal: () => void, +}) => { + const intl = useIntl(); + const navigate = useNavigate(); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export const LegacyLibMigrationPage = () => { + const intl = useIntl(); + const [currentStep, setCurrentStep] = useState('select-libraries'); + const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false); + const [legacyLibraries, setLegacyLibraries] = useState([]); + const [destinationLibrary, setDestination] = useState(); + const [confirmationButtonState, setConfirmationButtonState] = useState('default'); + + const handleNext = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + setCurrentStep('select-destination'); + break; + case 'select-destination': + setCurrentStep('confirmation-view'); + break; + case 'confirmation-view': + setConfirmationButtonState('pending'); + // TODO Call migration API + break; + default: + /* istanbul ignore next */ + break; + } + }, [currentStep, setCurrentStep]); + + const handleBack = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + openExitModal(); + break; + case 'select-destination': + setDestination(undefined); + setCurrentStep('select-libraries'); + break; + case 'confirmation-view': + setCurrentStep('select-destination'); + break; + default: + /* istanbul ignore next */ + break; + } + }, [currentStep, setCurrentStep]); + + const isNextDisabled = useCallback(() => { + switch (currentStep) { + case 'select-libraries': + return legacyLibraries.length === 0; + case 'select-destination': + return destinationLibrary === undefined; + case 'confirmation-view': + /* istanbul ignore next */ + return false; + default: + /* istanbul ignore next */ + return true; + } + }, [legacyLibraries, currentStep, destinationLibrary]); + + const handleUpdateLegacyLibraries = useCallback((library: LibraryV1Data, action: 'add' | 'remove') => { + if (action === 'add') { + setLegacyLibraries([...legacyLibraries, library]); + } else { + setLegacyLibraries(legacyLibraries.filter(item => item.libraryKey !== library.libraryKey)); + } + }, [legacyLibraries, setLegacyLibraries]); + + const legacyLibrariesIds = useMemo(() => legacyLibraries.map(item => item.libraryKey), [legacyLibraries]); + + return ( + <> +
+ + + {intl.formatMessage(messages.siteTitle)} + + +
+ +
+ + + + + + + + + + + {destinationLibrary && ( + + )} + + +
+
+ + {currentStep !== 'confirmation-view' ? ( + + ) : ( + + )} +
+
+
+ + + ); +}; diff --git a/src/legacy-libraries-migration/SelectDestinationView.tsx b/src/legacy-libraries-migration/SelectDestinationView.tsx new file mode 100644 index 0000000000..92805a284c --- /dev/null +++ b/src/legacy-libraries-migration/SelectDestinationView.tsx @@ -0,0 +1,33 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert, Container } from '@openedx/paragon'; + +import LibrariesV2List from '@src/studio-home/tabs-section/libraries-v2-tab'; +import type { ContentLibrary } from '@src/library-authoring/data/api'; + +import messages from './messages'; + +interface SelectDestinationViewProps { + destinationId: string | undefined; + setDestinationId: (library: ContentLibrary) => void; + legacyLibCount: number; +} + +export const SelectDestinationView = ({ + destinationId, + setDestinationId, + legacyLibCount, +}: SelectDestinationViewProps) => ( + + + + + + +); diff --git a/src/legacy-libraries-migration/index.scss b/src/legacy-libraries-migration/index.scss new file mode 100644 index 0000000000..9af18213ab --- /dev/null +++ b/src/legacy-libraries-migration/index.scss @@ -0,0 +1,20 @@ +.legacy-library-migration-page { + .migration-container { + // Calculate all the screen size subtracting the height of the header and top/bottom margins + min-height: calc(calc(100vh - 60px) - calc(var(--pgn-spacing-spacer-base) * 6)); + + .courses-tab-container { + min-height: auto; + } + + .migration-content { + flex: 1; + } + } + + .confirmation-view { + .pgn__card-header-content { + margin-top: calc(var(--pgn-spacing-spacer-base)) !important;; + } + } +} diff --git a/src/legacy-libraries-migration/messages.ts b/src/legacy-libraries-migration/messages.ts new file mode 100644 index 0000000000..b0d5b4e8b9 --- /dev/null +++ b/src/legacy-libraries-migration/messages.ts @@ -0,0 +1,87 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + siteTitle: { + id: 'legacy-libraries-migration.site-title', + defaultMessage: 'Migrate Legacy Libraries', + description: 'Title for the page to migrate legacy libraries.', + }, + cancel: { + id: 'legacy-libraries-migration.button.cancel', + defaultMessage: 'Cancel', + description: 'Text of the button to cancel the migration.', + }, + next: { + id: 'legacy-libraries-migration.button.next', + defaultMessage: 'Next', + description: 'Text of the button to go to the next step of the migration.', + }, + back: { + id: 'legacy-libraries-migration.button.back', + defaultMessage: 'Back', + description: 'Text of the button to go back to the previous step of the migration.', + }, + confirm: { + id: 'legacy-libraries-migration.button.confirm', + defaultMessage: 'Confirm', + description: 'Text of the button to confirm the migration.', + }, + selectLegacyLibrariesStepTitle: { + id: 'legacy-libraries-migration.select-legacy-libraries-step.title', + defaultMessage: 'Select Legacy Libraries', + description: 'Title of the Select Legacy Libraries step', + }, + selectDestinationStepTitle: { + id: 'legacy-libraries-migration.select-destination-step.title', + defaultMessage: 'Select Destination', + description: 'Title of the Select Destination step', + }, + confirmStepTitle: { + id: 'legacy-libraries-migration.confirm-step.title', + defaultMessage: 'Confirm', + description: 'Title of the Confirm step', + }, + exitModalTitle: { + id: 'legacy-libraries-migration.exit-modal.title', + defaultMessage: 'Exit Migration?', + description: 'Title of the modal to confirm exit the migration.', + }, + exitModalBodyText: { + id: 'legacy-libraries-migration.exit-modal.body', + defaultMessage: 'By exiting, all changes will be lost and no libraries will be migrated.', + description: 'Body text of the modal to confirm exit the migration.', + }, + exitModalCancelText: { + id: 'legacy-libraries-migration.exit-modal.button.cancel.text', + defaultMessage: 'Continue Migrating', + description: 'Text for the button to close the modal to confirm exit the migration.', + }, + exitModalConfirmText: { + id: 'legacy-libraries-migration.exit-modal.button.confirm.text', + defaultMessage: 'Exit', + description: 'Text for the button to confirm exit the migration.', + }, + selectDestinationAlert: { + id: 'legacy-libraries-migration.select-destination.alert.text', + defaultMessage: 'All content from the' + + ' {count, plural, one {{count} legacy library} other {{count} legacy libraries}} you selected will' + + ' be migrated to this new library, organized into collections. Any legacy libraries that are used in' + + ' problem banks will maintain their link with migrated content the first time they are migrated.', + description: 'Alert text in the select destination step of the legacy libraries migration page.', + }, + confirmationViewAlert: { + id: 'legacy-libraries-migration.select-destination.alert.text', + defaultMessage: 'These {count, plural, one {{count} legacy library} other {{count} legacy libraries}}' + + ' will be migrated to {libraryName} and organized as collections. Any legacy libraries that are used in' + + ' problem banks will maintain their link with migrated content the first time they are migrated.', + description: 'Alert text in the confirmation step of the legacy libraries migration page.', + }, + previouslyMigratedAlert: { + id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text', + defaultMessage: 'Previously migrated library. Any problem bank links were already' + + ' moved will be migrated to {libraryName}', + description: 'Alert text when the legacy library is already migrated.', + }, +}); + +export default messages; diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx index eb45d80073..cf73083982 100644 --- a/src/library-authoring/create-library/CreateLibrary.test.tsx +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -23,8 +23,8 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); -jest.mock('../../generic/data/apiHooks', () => ({ - ...jest.requireActual('../../generic/data/apiHooks'), +jest.mock('@src/generic/data/apiHooks', () => ({ + ...jest.requireActual('@src/generic/data/apiHooks'), useOrganizationListData: () => ({ data: ['org1', 'org2', 'org3', 'org4', 'org5'], isLoading: false, diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index ada04427eb..676d02d9a4 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -10,19 +10,37 @@ import { import { Formik } from 'formik'; import { useNavigate } from 'react-router-dom'; import * as Yup from 'yup'; +import classNames from 'classnames'; + +import { REGEX_RULES } from '@src/constants'; +import { useOrganizationListData } from '@src/generic/data/apiHooks'; +import { useStudioHome } from '@src/studio-home/hooks'; +import Header from '@src/header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import FormikControl from '@src/generic/FormikControl'; +import FormikErrorFeedback from '@src/generic/FormikErrorFeedback'; +import AlertError from '@src/generic/alert-error'; -import { REGEX_RULES } from '../../constants'; -import Header from '../../header'; -import FormikControl from '../../generic/FormikControl'; -import FormikErrorFeedback from '../../generic/FormikErrorFeedback'; -import AlertError from '../../generic/alert-error'; -import { useOrganizationListData } from '../../generic/data/apiHooks'; -import SubHeader from '../../generic/sub-header/SubHeader'; -import { useStudioHome } from '../../studio-home/hooks'; import { useCreateLibraryV2 } from './data/apiHooks'; import messages from './messages'; +import type { ContentLibrary } from '../data/api'; -const CreateLibrary = () => { +/** + * Renders the form and logic to create a new library. + * + * Use `showInModal` to render this component in a way that can be + * used in a modal. Currently this component is used in a modal in the + * legacy libraries migration flow. + */ +export const CreateLibrary = ({ + showInModal = false, + handleCancel, + handlePostCreate, +}: { + showInModal?: boolean, + handleCancel?: () => void, + handlePostCreate?: (library: ContentLibrary) => void, +}) => { const intl = useIntl(); const navigate = useNavigate(); @@ -56,20 +74,30 @@ const CreateLibrary = () => { ) || []; const handleOnClickCancel = () => { - navigate('/libraries'); + if (handleCancel) { + handleCancel(); + } else { + navigate('/libraries'); + } }; if (data) { - navigate(`/library/${data.id}`); + if (handlePostCreate) { + handlePostCreate(data); + } else { + navigate(`/library/${data.id}`); + } } return ( <> -
+ {!showInModal && (
)} - + {!showInModal && ( + + )} { className="" controlClasses="pb-2" /> - + +export const MigrateLegacyLibrariesAlert = () => { + const navigate = useNavigate(); + + return ( + + + + +
+
+ +
+
+ +
- -
-); + + ); +}; diff --git a/src/studio-home/tabs-section/libraries-tab/index.tsx b/src/studio-home/tabs-section/libraries-tab/index.tsx index 5ac5c9d26b..952946bb90 100644 --- a/src/studio-home/tabs-section/libraries-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-tab/index.tsx @@ -1,3 +1,4 @@ +import { useCallback, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Form, Icon, Menu, MenuItem, Pagination, Row, SearchField, @@ -9,11 +10,52 @@ import { LoadingSpinner } from '@src/generic/Loading'; import AlertMessage from '@src/generic/alert-message'; import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; import CardItem from '@src/studio-home/card-item'; -import { useCallback, useState } from 'react'; import SearchFilterWidget from '@src/search-manager/SearchFilterWidget'; +import type { LibraryV1Data } from '@src/studio-home/data/api'; + import messages from '../messages'; import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert'; +const CardList = ({ + data, + inSelectMode, +}: { + data: LibraryV1Data[], + inSelectMode: boolean, +}) => ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + { + data?.map(({ + displayName, + org, + number, + url, + isMigrated, + migratedToKey, + migratedToTitle, + migratedToCollectionKey, + libraryKey, + }) => ( + + )) + } + +); + function findInValues(arr: T[] | undefined, searchValue: string) { return arr?.filter(o => Object.values(o).some(value => String(value).toLowerCase().includes( String(searchValue).toLowerCase().trim(), @@ -96,7 +138,17 @@ const MigrationFilter = ({ filters, setFilters }: MigrationFilterProps) => { ); }; -const LibrariesTab = () => { +interface LibrariesListProps { + selectedIds?: string[]; + handleCheck?: (library: LibraryV1Data, action: 'add' | 'remove') => void; + hideMigationAlert?: boolean; +} + +const LibrariesList = ({ + selectedIds, + handleCheck, + hideMigationAlert = false, +}: LibrariesListProps) => { const intl = useIntl(); const { isPending, data, isError } = useLibrariesV1Data(); const [currentPage, setCurrentPage] = useState(1); @@ -111,6 +163,21 @@ const LibrariesTab = () => { const perPage = 10; const totalPages = Math.ceil(filteredData.length / perPage); const currentPageData = filteredData.slice((currentPage - 1) * perPage, currentPage * perPage); + const inSelectMode = handleCheck !== undefined; + + const handleChangeCheckboxSet = useCallback((event) => { + if (handleCheck) { + const libraryId = event.target.value; + const library = currentPageData.find((item) => item.libraryKey === libraryId); + if (library) { + if (event.target.checked) { + handleCheck(library, 'add'); + } else { + handleCheck(library, 'remove'); + } + } + } + }, [handleCheck, currentPageData]); if (isPending) { return ( @@ -136,7 +203,7 @@ const LibrariesTab = () => { return ( <> - {getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && ()} + {!hideMigationAlert && getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && ()}
{ )} - {currentPageData?.map(({ - displayName, org, number, url, isMigrated, migratedToKey, migratedToTitle, migratedToCollectionKey, - }) => ( - + + + ) : ( + - ))} + )} { totalPages > 1 && ( @@ -192,4 +260,4 @@ const LibrariesTab = () => { ); }; -export default LibrariesTab; +export default LibrariesList; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx index 1719a1377f..cc71d732ae 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/WelcomeLibrariesV2Alert.tsx @@ -1,5 +1,6 @@ import { Alert, Button, Hyperlink } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useNavigate } from 'react-router-dom'; import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks'; @@ -17,6 +18,7 @@ const libraryDocsLink = ( export const WelcomeLibrariesV2Alert = () => { const { data, isPending, isError } = useLibrariesV1Data(); + const navigate = useNavigate(); // Does not show the alert if we are still loading or if there was an error fetching libraries if (isPending || isError) { @@ -37,7 +39,7 @@ export const WelcomeLibrariesV2Alert = () => {
-
diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx index de7a56e68a..dd348139be 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -1,32 +1,104 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Icon, Row, Pagination, Alert, Button, + Form, + Stack, + useToggle, } from '@openedx/paragon'; -import { getConfig } from '@edx/frontend-platform'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Error } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Add, Error } from '@openedx/paragon/icons'; -import { useContentLibraryV2List } from '@src/library-authoring'; +import { CreateLibraryModal, useContentLibraryV2List } from '@src/library-authoring'; import { LoadingSpinner } from '@src/generic/Loading'; import AlertMessage from '@src/generic/alert-message'; +import type { ContentLibrary, LibrariesV2Response } from '@src/library-authoring/data/api'; + import CardItem from '../../card-item'; import messages from '../messages'; import LibrariesV2Filters from './libraries-v2-filters'; -import { WelcomeLibrariesV2Alert } from './WelcomeLibrariesV2Alert'; -type Props = Record; +interface CardListProps { + hasV2Libraries: boolean; + selectMode?: 'single' | 'multiple'; + isFiltered: boolean; + isLoading: boolean; + data: LibrariesV2Response; + handleClearFilters: () => void; +} -const LibrariesV2Tab: React.FC = () => { +const CardList: React.FC = ({ + hasV2Libraries, + selectMode, + isFiltered, + isLoading, + data, + handleClearFilters, +}) => { + if (hasV2Libraries) { + return ( + <> + { + data!.results.map(({ + id, org, slug, title, + }) => ( + + )) + } + + ); + } + + // Empty alert + if (isFiltered && !isLoading) { + return ( + + + + +

+ +

+ +
+ ); + } + return null; +}; + +interface Props { + selectedLibraryId?: string; + handleSelect?: (library: ContentLibrary) => void; + showCreateLibrary?: boolean; +} + +const LibrariesV2List: React.FC = ({ + selectedLibraryId, + handleSelect, + showCreateLibrary = false, +}) => { const intl = useIntl(); const [currentPage, setCurrentPage] = useState(1); const [filterParams, setFilterParams] = useState({}); + const [isCreateLibraryOpen, openCreateLibrary, closeCreateLibrary] = useToggle(false); const isFiltered = Object.keys(filterParams).length > 0; + const inSelectMode = handleSelect !== undefined; const handlePageSelect = (page: number) => { setCurrentPage(page); @@ -43,6 +115,22 @@ const LibrariesV2Tab: React.FC = () => { isError, } = useContentLibraryV2List({ page: currentPage, ...filterParams }); + const handlePostCreateLibrary = useCallback((library: ContentLibrary) => { + if (handleSelect) { + handleSelect(library); + closeCreateLibrary(); + } + }, [handleSelect, closeCreateLibrary]); + + const handleOnChangeRadioSet = useCallback((libraryId: string) => { + if (handleSelect && data) { + const library = data.results.find((item) => item.id === libraryId); + if (library) { + handleSelect(library); + } + } + }, [data, handleSelect]); + if (isPending && !isFiltered) { return ( @@ -54,22 +142,30 @@ const LibrariesV2Tab: React.FC = () => { const hasV2Libraries = !isPending && !isError && ((data!.results.length || 0) > 0); return ( - <> - {getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && ()} - - {isError ? ( - - - {intl.formatMessage(messages.librariesTabErrorMessage)} - - )} - /> - ) : ( -
-
+ isError ? ( + + + {intl.formatMessage(messages.librariesTabErrorMessage)} + + )} + /> + ) : ( +
+
+ + {showCreateLibrary && ( + + )} = () => { setFilterParams={setFilterParams} setCurrentPage={setCurrentPage} /> - {!isPending && !isError - && ( -

- {intl.formatMessage(messages.coursesPaginationInfo, { - length: data.results.length, - total: data.count, - })} -

- )} -
- - {hasV2Libraries - ? data!.results.map(({ - id, org, slug, title, - }) => ( - - )) : isFiltered && !isPending && ( - - - {intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertTitle)} - -

- {intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertMessage)} -

- -
- )} - - { - hasV2Libraries && (data!.numPages || 0) > 1 - && ( - - ) - } + + {!isPending && !isError + && ( +

+ {intl.formatMessage(messages.coursesPaginationInfo, { + length: data!.results.length, + total: data!.count, + })} +

+ )}
- )} - + + {inSelectMode ? ( + handleOnChangeRadioSet(e.target.value)} + > + + + ) : ( + + )} + + { + hasV2Libraries && (data!.numPages || 0) > 1 + && ( + + ) + } + +
+ ) ); }; -export default LibrariesV2Tab; +export default LibrariesV2List; diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index 17303cb831..0563e25566 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -93,6 +93,11 @@ const messages = defineMessages({ defaultMessage: 'Review Legacy Libraries', description: 'Label for the button to review legacy libraries', }, + createLibraryButton: { + id: 'studio-home.legacy-libraries.migrate.create-button', + defaultMessage: 'Create New Library', + description: 'Label for the button to create a new library in the library list view.', + }, librariesV1TabMigrationFilterLabel: { id: 'course-authoring.studio-home.libraries.tab.migration.filter.label', description: 'Label text for migration filter in legacy libraries tab', diff --git a/src/utils.test.js b/src/utils.test.tsx similarity index 99% rename from src/utils.test.js rename to src/utils.test.tsx index d4fa59d373..9b59013edf 100644 --- a/src/utils.test.js +++ b/src/utils.test.tsx @@ -120,6 +120,7 @@ describe('FilesAndUploads utils', () => { const dateStr = '2023-10-01T12:00:00Z'; const date = convertToDateFromString(dateStr); expect(date).toBeInstanceOf(Date); + // @ts-ignore expect(date.toISOString()).toBe('2023-10-01T12:00:00.000Z'); }); diff --git a/src/utils.ts b/src/utils.tsx similarity index 99% rename from src/utils.ts rename to src/utils.tsx index 7e68e996c7..1432a2ce19 100644 --- a/src/utils.ts +++ b/src/utils.tsx @@ -347,3 +347,5 @@ export const skipIfUnwantedTarget = ( } onClick(e); }; + +export const BoldText = (chunk: string[]) => {chunk};