feat: import course in library stepper [FC-0112] (#2567)
- Implemented the course import stepper described in https://github.com/openedx/frontend-app-authoring/issues/2524 - Adds the new `ENABLE_COURSE_IMPORT_IN_LIBRARY` flag
This commit is contained in:
1
.env
1
.env
@@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
|
||||
@@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
|
||||
@@ -33,6 +33,7 @@ ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline } from './types';
|
||||
import { CourseOutline, CourseDetails } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = (
|
||||
courseId: string,
|
||||
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
@@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
* @returns {Promise<CourseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course details.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<CourseDetails>}
|
||||
*/
|
||||
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseDetailsApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param courseId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { getCourseItem } from './api';
|
||||
import { getCourseDetails, getCourseItem } from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
@@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = {
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
|
||||
|
||||
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
|
||||
enabled: enabled && itemId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
export const useCourseDetails = (courseId?: string) => (
|
||||
useQuery({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
|
||||
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -24,6 +24,15 @@ export interface CourseOutline {
|
||||
rerunNotificationId: null;
|
||||
}
|
||||
|
||||
// TODO: This interface has only basic data, all the rest needs to be added.
|
||||
export interface CourseDetails {
|
||||
courseId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
org: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: string;
|
||||
|
||||
@@ -173,6 +173,7 @@ initialize({
|
||||
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
|
||||
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false',
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
|
||||
.card-item {
|
||||
margin: 0 0 16px !important;
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ROUTES } from './routes';
|
||||
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
|
||||
import { LibraryUnitPage } from './units';
|
||||
import { LibraryTeamModal } from './library-team';
|
||||
import { ImportStepperPage } from './import-course/stepper/ImportStepperPage';
|
||||
|
||||
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const {
|
||||
@@ -97,6 +98,10 @@ const LibraryLayout = () => (
|
||||
path={ROUTES.IMPORT}
|
||||
Component={CourseImportHomePage}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.IMPORT_COURSE}
|
||||
Component={ImportStepperPage}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1133,3 +1133,17 @@ mockGetCourseImports.applyMock = () => jest.spyOn(
|
||||
api,
|
||||
'getCourseImports',
|
||||
).mockImplementation(mockGetCourseImports);
|
||||
|
||||
export const mockGetMigrationInfo = {
|
||||
applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue(
|
||||
camelCaseObject({
|
||||
'course-v1:HarvardX+123+2023': [{
|
||||
sourceKey: 'course-v1:HarvardX+123+2023',
|
||||
targetCollectionKey: 'ltc:org:coll-1',
|
||||
targetCollectionTitle: 'Collection 1',
|
||||
targetKey: mockContentLibrary.libraryId,
|
||||
targetTitle: 'Library 1',
|
||||
}],
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -809,3 +809,24 @@ export async function getCourseImports(libraryId: string): Promise<CourseImport[
|
||||
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export interface MigrationInfo {
|
||||
sourceKey: string;
|
||||
targetCollectionKey: string;
|
||||
targetCollectionTitle: string;
|
||||
targetKey: string;
|
||||
targetTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration info data for a list of source keys
|
||||
*/
|
||||
export async function getMigrationInfo(sourceKeys: string[]): Promise<Record<string, MigrationInfo[]>> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
sourceKeys.forEach(key => params.append('source_keys', key));
|
||||
|
||||
const { data } = await client.get(`${getApiBaseUrl()}/api/modulestore_migrator/v1/migration_info/`, { params });
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ export const libraryAuthoringQueryKeys = {
|
||||
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
|
||||
'courseImports',
|
||||
],
|
||||
migrationInfo: (sourceKeys: string[]) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
'migrationInfo',
|
||||
...sourceKeys,
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -965,3 +970,13 @@ export const useCourseImports = (libraryId: string) => (
|
||||
queryFn: () => api.getCourseImports(libraryId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the migration info of a given source list
|
||||
*/
|
||||
export const useMigrationInfo = (sourcesKeys: string[]) => (
|
||||
useQuery({
|
||||
queryKey: libraryAuthoringQueryKeys.migrationInfo(sourcesKeys),
|
||||
queryFn: () => api.getMigrationInfo(sourcesKeys),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ const render = (libraryId: string) => (
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
path: '/libraries/:libraryId/import-course',
|
||||
path: '/libraries/:libraryId/import',
|
||||
params: { libraryId },
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,8 +8,7 @@ import {
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
@@ -19,14 +20,26 @@ import { HelpSidebar } from './HelpSidebar';
|
||||
import { ImportedCourseCard } from './ImportedCourseCard';
|
||||
import messages from './messages';
|
||||
|
||||
const ImportCourseButton = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true') {
|
||||
return (
|
||||
<Button iconBefore={Add} onClick={() => navigate('courses')}>
|
||||
<FormattedMessage {...messages.importCourseButton} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const EmptyState = () => (
|
||||
<Container size="md" className="py-6">
|
||||
<Card>
|
||||
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
|
||||
<FormattedMessage {...messages.emptyStateText} />
|
||||
<Button iconBefore={Add} disabled>
|
||||
<FormattedMessage {...messages.emptyStateButtonText} />
|
||||
</Button>
|
||||
<ImportCourseButton />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
@@ -64,6 +77,7 @@ export const CourseImportHomePage = () => {
|
||||
title={intl.formatMessage(messages.pageTitle)}
|
||||
subtitle={intl.formatMessage(messages.pageSubtitle)}
|
||||
hideBorder
|
||||
headerActions={<ImportCourseButton />}
|
||||
/>
|
||||
</div>
|
||||
<Layout xs={[{ span: 9 }, { span: 3 }]}>
|
||||
|
||||
@@ -73,6 +73,97 @@ const messages = defineMessages({
|
||||
+ '<p>For additional details you can review the Library Import documentation.</p>',
|
||||
description: 'Body of the second question in the Help & Support sidebar',
|
||||
},
|
||||
importCourseStepperTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.title',
|
||||
defaultMessage: 'Import Course to Library',
|
||||
description: 'Title for the modal to import a course into a library.',
|
||||
},
|
||||
importCourseButton: {
|
||||
id: 'course-authoring.library-authoring.import-course.button.text',
|
||||
defaultMessage: 'Import Course',
|
||||
description: 'Label of the button to open the modal to import a course into a library.',
|
||||
},
|
||||
importCourseSelectCourseStep: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.select-course.title',
|
||||
defaultMessage: 'Select Course',
|
||||
description: 'Title for the step to select course in the modal to import a course into a library.',
|
||||
},
|
||||
importCourseReviewDetailsStep: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.review-details.title',
|
||||
defaultMessage: 'Review Import Details',
|
||||
description: 'Title for the step to review import details in the modal to import a course into a library.',
|
||||
},
|
||||
importCourseCalcel: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.cancel.text',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label of the button to cancel the course import.',
|
||||
},
|
||||
importCourseNext: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.next.text',
|
||||
defaultMessage: 'Next step',
|
||||
description: 'Label of the button go to the next step in the course import modal.',
|
||||
},
|
||||
importCourseBack: {
|
||||
id: 'course-authoring.library-authoring.import-course.stepper.back.text',
|
||||
defaultMessage: 'Back',
|
||||
description: 'Label of the button to go to the previous step in the course import modal.',
|
||||
},
|
||||
importCourseInProgressStatusTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.in-progress.title',
|
||||
defaultMessage: 'Import Analysis in Progress',
|
||||
description: 'Titile for the info card with the in-progress status in the course import modal.',
|
||||
},
|
||||
importCourseInProgressStatusBody: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.in-progress.body',
|
||||
defaultMessage: '{courseName} is being analyzed for review prior to import. For large courses, this may take some time.'
|
||||
+ ' Please remain on this page.',
|
||||
description: 'Body of the info card with the in-progress status in the course import modal.',
|
||||
},
|
||||
importCourseAnalysisSummary: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.title',
|
||||
defaultMessage: 'Analysis Summary',
|
||||
description: 'Title of the card for the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseTotalBlocks: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.total-blocks',
|
||||
defaultMessage: 'Total Blocks',
|
||||
description: 'Label title for the total blocks in the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseSections: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.sections',
|
||||
defaultMessage: 'Sections',
|
||||
description: 'Label title for the number of sections in the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseSubsections: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.subsections',
|
||||
defaultMessage: 'Subsections',
|
||||
description: 'Label title for the number of subsections in the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseUnits: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.units',
|
||||
defaultMessage: 'Units',
|
||||
description: 'Label title for the number of units in the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseComponents: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.components',
|
||||
defaultMessage: 'Components',
|
||||
description: 'Label title for the number of components in the analysis summary of a imported course.',
|
||||
},
|
||||
importCourseDetailsTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.import-details.title',
|
||||
defaultMessage: 'Import Details',
|
||||
description: 'Title of the card for the import details of a imported course.',
|
||||
},
|
||||
importCourseDetailsLoadingBody: {
|
||||
id: 'course-authoring.library-authoring.import-course.review-details.import-details.loading.body',
|
||||
defaultMessage: 'The selected course is being analyzed for import and review',
|
||||
description: 'Body of the card in loading state for the import details of a imported course.',
|
||||
},
|
||||
previouslyImported: {
|
||||
id: 'course-authoring.library-authoring.import-course.course-list.card.previously-imported.text',
|
||||
defaultMessage: 'Previously Imported',
|
||||
description: 'Chip that indicates that the course has been previously imported.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { initialState } from '@src/studio-home/factories/mockApiResponses';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { type DeprecatedReduxState } from '@src/store';
|
||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||
import { getCourseDetailsApiUrl } from '@src/course-outline/data/api';
|
||||
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks';
|
||||
import { ImportStepperPage } from './ImportStepperPage';
|
||||
|
||||
let axiosMock;
|
||||
mockGetMigrationInfo.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
type StudioHomeState = DeprecatedReduxState['studioHome'];
|
||||
|
||||
const libraryKey = mockContentLibrary.libraryId;
|
||||
const numPages = 1;
|
||||
const coursesCount = studioHomeMock.courses.length;
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
|
||||
// Generate a custom initial state based on studioHomeCoursesRequestParams
|
||||
const customInitialState: Partial<DeprecatedReduxState> = {
|
||||
...initialState,
|
||||
studioHome: {
|
||||
...initialState.studioHome,
|
||||
studioHomeData: {
|
||||
courses: studioHomeMock.courses,
|
||||
numPages,
|
||||
coursesCount,
|
||||
},
|
||||
loadingStatuses: {
|
||||
...initialState.studioHome.loadingStatuses,
|
||||
courseLoadingStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
...studioHomeState,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize the store with the custom initial state
|
||||
const newMocks = initializeMocks({ initialState: customInitialState });
|
||||
const store = newMocks.reduxStore;
|
||||
axiosMock = newMocks.axiosMock;
|
||||
|
||||
return {
|
||||
...render(
|
||||
<ImportStepperPage />,
|
||||
{
|
||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<LibraryProvider libraryId={libraryKey}>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
path: '/libraries/:libraryId/import/course',
|
||||
params: { libraryId: libraryKey },
|
||||
},
|
||||
),
|
||||
store,
|
||||
};
|
||||
};
|
||||
|
||||
describe('<ImportStepperModal />', () => {
|
||||
it('should render correctly', async () => {
|
||||
renderComponent();
|
||||
// Renders the stepper header
|
||||
expect(await screen.findByText('Select Course')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Review Import Details')).toBeInTheDocument();
|
||||
|
||||
// Renders the course list and previously imported chip
|
||||
expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/run 0/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Previously Imported')).toBeInTheDocument();
|
||||
|
||||
// Renders cancel and next step buttons
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next step/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cancel the import', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const cancelButon = await screen.findByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButon);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should go to review import details step', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
|
||||
courseId: 'course-v1:HarvardX+123+2023',
|
||||
title: 'Managing Risk in the Information Age',
|
||||
subtitle: '',
|
||||
org: 'HarvardX',
|
||||
description: 'This is a test course',
|
||||
});
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next step/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
// Select a course
|
||||
const courseCard = screen.getAllByRole('radio')[0];
|
||||
await fireEvent.click(courseCard);
|
||||
expect(courseCard).toBeChecked();
|
||||
|
||||
// Click next
|
||||
expect(nextButton).toBeEnabled();
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(async () => expect(await screen.findByText(
|
||||
/managing risk in the information age is being analyzed for review prior to import/i,
|
||||
)).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText('Analysis Summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Import Details')).toBeInTheDocument();
|
||||
// The import details is loading
|
||||
expect(screen.getByText('The selected course is being analyzed for import and review')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the course should remain selected on back', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next step/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
// Select a course
|
||||
const courseCard = screen.getAllByRole('radio')[0];
|
||||
await fireEvent.click(courseCard);
|
||||
expect(courseCard).toBeChecked();
|
||||
|
||||
// Click next
|
||||
expect(nextButton).toBeEnabled();
|
||||
await user.click(nextButton);
|
||||
|
||||
const backButton = await screen.getByRole('button', { name: /back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument();
|
||||
expect(courseCard).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { 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, Chip, Container, Layout, Stepper,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { CoursesList, MigrationStatusProps } from '@src/studio-home/tabs-section/courses-tab';
|
||||
import { useStudioHome } from '@src/studio-home/hooks';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import Loading from '@src/generic/Loading';
|
||||
|
||||
import Header from '@src/header';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
|
||||
import { ReviewImportDetails } from './ReviewImportDetails';
|
||||
import messages from '../messages';
|
||||
import { HelpSidebar } from '../HelpSidebar';
|
||||
|
||||
type MigrationStep = 'select-course' | 'review-details';
|
||||
|
||||
export const MigrationStatus = ({
|
||||
courseId,
|
||||
allVisibleCourseIds,
|
||||
}: MigrationStatusProps) => {
|
||||
const { libraryId } = useLibraryContext();
|
||||
|
||||
const {
|
||||
data: migrationInfoData,
|
||||
} = useMigrationInfo(allVisibleCourseIds);
|
||||
|
||||
const processedMigrationInfo = useMemo(() => {
|
||||
const result = {};
|
||||
if (migrationInfoData) {
|
||||
for (const libraries of Object.values(migrationInfoData)) {
|
||||
// The map key in `migrationInfoData` is in camelCase.
|
||||
// In the processed map, we use the key in its original form.
|
||||
result[libraries[0].sourceKey] = libraries.map(item => item.targetKey);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [migrationInfoData]);
|
||||
|
||||
const isPreviouslyMigrated = (
|
||||
courseId in processedMigrationInfo && processedMigrationInfo[courseId].includes(libraryId)
|
||||
);
|
||||
|
||||
if (!isPreviouslyMigrated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${courseId}-${processedMigrationInfo[courseId].join('-')}`}
|
||||
className="previously-migrated-chip"
|
||||
>
|
||||
<Chip>
|
||||
<FormattedMessage {...messages.previouslyImported} />
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportStepperPage = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-course');
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string>();
|
||||
const { libraryId, libraryData, readOnly } = useLibraryContext();
|
||||
|
||||
// Load the courses list
|
||||
// The loading state is handled in `CoursesList`
|
||||
useStudioHome();
|
||||
|
||||
if (!libraryData) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
<Helmet>
|
||||
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
|
||||
</Helmet>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
readOnly={readOnly}
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
}}
|
||||
/>
|
||||
<Container className="mt-4 mb-5">
|
||||
<div className="px-4 bg-light-200 border-bottom">
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.importCourseStepperTitle)}
|
||||
hideBorder
|
||||
/>
|
||||
</div>
|
||||
<Layout xs={[{ span: 9 }, { span: 3 }]}>
|
||||
<Layout.Element>
|
||||
<Stepper activeKey={currentStep}>
|
||||
<Stepper.Header />
|
||||
<Stepper.Step
|
||||
eventKey="select-course"
|
||||
title={intl.formatMessage(messages.importCourseSelectCourseStep)}
|
||||
>
|
||||
<CoursesList
|
||||
selectedCourseId={selectedCourseId}
|
||||
handleSelect={setSelectedCourseId}
|
||||
cardMigrationStatusWidget={MigrationStatus}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step
|
||||
eventKey="review-details"
|
||||
title={intl.formatMessage(messages.importCourseReviewDetailsStep)}
|
||||
>
|
||||
<ReviewImportDetails courseId={selectedCourseId} />
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
<div className="mt-4">
|
||||
{currentStep === 'select-course' ? (
|
||||
<ActionRow className="d-flex justify-content-between">
|
||||
<Button variant="outline-primary" onClick={() => navigate('../import')}>
|
||||
<FormattedMessage {...messages.importCourseCalcel} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentStep('review-details')}
|
||||
disabled={selectedCourseId === undefined}
|
||||
>
|
||||
<FormattedMessage {...messages.importCourseNext} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
) : (
|
||||
<ActionRow className="d-flex justify-content-between">
|
||||
<Button onClick={() => setCurrentStep('select-course')} variant="tertiary">
|
||||
<FormattedMessage {...messages.importCourseBack} />
|
||||
</Button>
|
||||
<Button disabled>
|
||||
<FormattedMessage {...messages.importCourseButton} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
</div>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<HelpSidebar />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Card, Stack } from '@openedx/paragon';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
|
||||
|
||||
import messages from '../messages';
|
||||
import { SummaryCard } from './SummaryCard';
|
||||
|
||||
export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => {
|
||||
const { data, isPending } = useCourseDetails(courseId);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Card>
|
||||
{data && !isPending ? (
|
||||
<Card.Section>
|
||||
<h4><FormattedMessage {...messages.importCourseInProgressStatusTitle} /></h4>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseInProgressStatusBody}
|
||||
values={{
|
||||
courseName: data?.title || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Card.Section>
|
||||
) : (
|
||||
<div className="text-center p-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<h4><FormattedMessage {...messages.importCourseAnalysisSummary} /></h4>
|
||||
<SummaryCard />
|
||||
<h4><FormattedMessage {...messages.importCourseDetailsTitle} /></h4>
|
||||
<Card className="p-6">
|
||||
<Stack className="align-items-center" gap={3}>
|
||||
<LoadingSpinner />
|
||||
<FormattedMessage {...messages.importCourseDetailsLoadingBody} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
51
src/library-authoring/import-course/stepper/SummaryCard.tsx
Normal file
51
src/library-authoring/import-course/stepper/SummaryCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Card, Icon, Stack } from '@openedx/paragon';
|
||||
import { Widgets } from '@openedx/paragon/icons';
|
||||
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
// TODO: The SummaryCard is always in loading state
|
||||
export const SummaryCard = () => (
|
||||
<Card>
|
||||
<Card.Section>
|
||||
<Stack direction="horizontal">
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseTotalBlocks} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
<div className="border-light-400" style={{ borderLeft: '2px solid', height: '50px' }} />
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseSections} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('section')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseSubsections} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('subsection')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseUnits} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('unit')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseComponents} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={Widgets} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
@@ -49,6 +49,8 @@ export const ROUTES = {
|
||||
BACKUP: '/backup',
|
||||
// LibraryImportPage route:
|
||||
IMPORT: '/import',
|
||||
// ImportStepperPage route:
|
||||
IMPORT_COURSE: '/import/courses',
|
||||
};
|
||||
|
||||
export enum ContentType {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||
import messages from '../messages';
|
||||
import { trimSlashes } from './utils';
|
||||
import CardItem from '.';
|
||||
import { CardItem } from '.';
|
||||
|
||||
jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => studioHomeMock);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, {
|
||||
ReactElement, useCallback, useEffect, useRef,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
@@ -8,19 +10,18 @@ import {
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ArrowForward, MoreHoriz } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { COURSE_CREATOR_STATES } from '@src/constants';
|
||||
import { parseLibraryKey } from '@src/generic/key-utils';
|
||||
import classNames from 'classnames';
|
||||
import { getStudioHomeData } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
|
||||
const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => (
|
||||
export const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<span>{from}</span>
|
||||
{to
|
||||
@@ -33,7 +34,7 @@ const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactN
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const MakeLinkOrSpan = ({
|
||||
export const MakeLinkOrSpan = ({
|
||||
when, to, children, className,
|
||||
}: {
|
||||
when: boolean,
|
||||
@@ -52,10 +53,8 @@ interface CardTitleProps {
|
||||
selectMode?: 'single' | 'multiple';
|
||||
destinationUrl: string;
|
||||
title: string;
|
||||
secondaryLink?: ReactElement | null;
|
||||
itemId?: string;
|
||||
isMigrated?: boolean;
|
||||
migratedToKey?: string;
|
||||
migratedToTitle?: string;
|
||||
}
|
||||
|
||||
const CardTitle: React.FC<CardTitleProps> = ({
|
||||
@@ -63,10 +62,8 @@ const CardTitle: React.FC<CardTitleProps> = ({
|
||||
selectMode,
|
||||
destinationUrl,
|
||||
title,
|
||||
secondaryLink,
|
||||
itemId,
|
||||
isMigrated,
|
||||
migratedToTitle,
|
||||
migratedToKey,
|
||||
}) => {
|
||||
const getTitle = useCallback(() => (
|
||||
<div style={{ marginTop: selectMode ? '-3px' : '' }}>
|
||||
@@ -80,24 +77,12 @@ const CardTitle: React.FC<CardTitleProps> = ({
|
||||
{title}
|
||||
</MakeLinkOrSpan>
|
||||
)}
|
||||
to={
|
||||
isMigrated && migratedToTitle && (
|
||||
<MakeLinkOrSpan
|
||||
when={!readOnlyItem && !selectMode}
|
||||
to={`/library/${migratedToKey}`}
|
||||
className="card-item-title"
|
||||
>
|
||||
{migratedToTitle}
|
||||
</MakeLinkOrSpan>
|
||||
)
|
||||
}
|
||||
to={secondaryLink}
|
||||
/>
|
||||
</div>
|
||||
), [
|
||||
readOnlyItem,
|
||||
isMigrated,
|
||||
destinationUrl,
|
||||
migratedToTitle,
|
||||
title,
|
||||
selectMode,
|
||||
]);
|
||||
@@ -128,8 +113,78 @@ const CardTitle: React.FC<CardTitleProps> = ({
|
||||
return getTitle();
|
||||
};
|
||||
|
||||
interface CardMenuProps {
|
||||
showMenu: boolean;
|
||||
isShowRerunLink?: boolean;
|
||||
rerunLink: string | null;
|
||||
lmsLink: string | null;
|
||||
}
|
||||
|
||||
const CardMenu = ({
|
||||
showMenu,
|
||||
isShowRerunLink,
|
||||
rerunLink,
|
||||
lmsLink,
|
||||
}: CardMenuProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!showMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
as={IconButton}
|
||||
iconAs={MoreHoriz}
|
||||
variant="primary"
|
||||
aria-label={intl.formatMessage(messages.btnDropDownText)}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
{isShowRerunLink && (
|
||||
<Dropdown.Item
|
||||
as={Link}
|
||||
to={rerunLink ?? ''}
|
||||
>
|
||||
{messages.btnReRunText.defaultMessage}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={lmsLink}>
|
||||
<FormattedMessage {...messages.viewLiveBtnText} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectAction = ({
|
||||
itemId,
|
||||
title,
|
||||
selectMode,
|
||||
}: {
|
||||
itemId: string,
|
||||
title: string,
|
||||
selectMode: 'single' | 'multiple';
|
||||
}) => {
|
||||
if (selectMode === 'single') {
|
||||
return (
|
||||
<Form.Radio
|
||||
value={itemId}
|
||||
aria-label={title}
|
||||
name={`select-card-item-${itemId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple
|
||||
return (
|
||||
<Form.Checkbox value={itemId} aria-label={title} />
|
||||
);
|
||||
};
|
||||
|
||||
interface BaseProps {
|
||||
displayName: string;
|
||||
onClick?: () => void;
|
||||
org: string;
|
||||
number: string;
|
||||
run?: string;
|
||||
@@ -137,11 +192,12 @@ interface BaseProps {
|
||||
rerunLink?: string | null;
|
||||
courseKey?: string;
|
||||
isLibraries?: boolean;
|
||||
isMigrated?: boolean;
|
||||
migratedToKey?: string;
|
||||
migratedToTitle?: string;
|
||||
migratedToCollectionKey?: string | null;
|
||||
subtitleWrapper?: ((subtitle: JSX.Element) => ReactElement) | null; // Wrapper for the default subtitle element
|
||||
subtitleBeforeWidget?: ReactElement | null; // Adds a widget before the default subtitle element
|
||||
cardStatusWidget?: ReactElement | null;
|
||||
titleSecondaryLink?: ReactElement | null;
|
||||
selectMode?: 'single' | 'multiple';
|
||||
selectPosition?: 'card' | 'title';
|
||||
isSelected?: boolean;
|
||||
itemId?: string;
|
||||
scrollIntoView?: boolean;
|
||||
@@ -160,8 +216,9 @@ type Props = BaseProps & (
|
||||
/**
|
||||
* A card on the Studio home page that represents a Course or a Library
|
||||
*/
|
||||
const CardItem: React.FC<Props> = ({
|
||||
export const CardItem: React.FC<Props> = ({
|
||||
displayName,
|
||||
onClick,
|
||||
lmsLink = '',
|
||||
rerunLink = '',
|
||||
org,
|
||||
@@ -170,17 +227,17 @@ const CardItem: React.FC<Props> = ({
|
||||
isLibraries = false,
|
||||
courseKey = '',
|
||||
selectMode,
|
||||
selectPosition,
|
||||
isSelected = false,
|
||||
itemId = '',
|
||||
path,
|
||||
url,
|
||||
isMigrated = false,
|
||||
migratedToKey,
|
||||
migratedToTitle,
|
||||
migratedToCollectionKey,
|
||||
subtitleWrapper,
|
||||
subtitleBeforeWidget,
|
||||
titleSecondaryLink,
|
||||
cardStatusWidget,
|
||||
scrollIntoView = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
allowCourseReruns,
|
||||
courseCreatorStatus,
|
||||
@@ -195,7 +252,7 @@ const CardItem: React.FC<Props> = ({
|
||||
: new URL(url, getConfig().STUDIO_BASE_URL).toString()
|
||||
);
|
||||
const readOnlyItem = !(lmsLink || rerunLink || url || path);
|
||||
const showActions = !(readOnlyItem || isLibraries);
|
||||
const showActionsMenu = !(readOnlyItem || isLibraries || selectMode !== undefined);
|
||||
const isShowRerunLink = allowCourseReruns
|
||||
&& rerunCreatorStatus
|
||||
&& courseCreatorStatus === COURSE_CREATOR_STATES.granted;
|
||||
@@ -203,25 +260,19 @@ const CardItem: React.FC<Props> = ({
|
||||
|
||||
const getSubtitle = useCallback(() => {
|
||||
let subtitle = isLibraries ? <>{org} / {number}</> : <>{org} / {number} / {run}</>;
|
||||
if (isMigrated && migratedToKey) {
|
||||
const migratedToKeyObj = parseLibraryKey(migratedToKey);
|
||||
if (subtitleWrapper) {
|
||||
subtitle = subtitleWrapper(subtitle);
|
||||
}
|
||||
if (subtitleBeforeWidget) {
|
||||
subtitle = (
|
||||
<PrevToNextName
|
||||
from={subtitle}
|
||||
to={<>{migratedToKeyObj.org} / {migratedToKeyObj.lib}</>}
|
||||
/>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
{subtitleBeforeWidget}
|
||||
{subtitle}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return subtitle;
|
||||
}, [isLibraries, org, number, run, migratedToKey, isMigrated]);
|
||||
|
||||
const collectionLink = () => {
|
||||
let libUrl = `/library/${migratedToKey}`;
|
||||
if (migratedToCollectionKey) {
|
||||
libUrl += `/collection/${migratedToCollectionKey}`;
|
||||
}
|
||||
return libUrl;
|
||||
};
|
||||
}, [isLibraries, org, number, run]);
|
||||
|
||||
useEffect(() => {
|
||||
/* istanbul ignore next */
|
||||
@@ -232,70 +283,46 @@ const CardItem: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div ref={cardRef} className="w-100">
|
||||
<Card className={classNames('card-item', {
|
||||
selected: isSelected,
|
||||
})}
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={classNames('card-item', {
|
||||
selected: isSelected,
|
||||
})}
|
||||
>
|
||||
<Card.Header
|
||||
size="sm"
|
||||
title={(
|
||||
<CardTitle
|
||||
readOnlyItem={readOnlyItem}
|
||||
selectMode={selectMode}
|
||||
readOnlyItem={readOnlyItem || selectMode !== undefined}
|
||||
selectMode={selectPosition === 'title' ? selectMode : undefined}
|
||||
destinationUrl={destinationUrl}
|
||||
title={title}
|
||||
itemId={itemId}
|
||||
isMigrated={isMigrated}
|
||||
migratedToTitle={migratedToTitle}
|
||||
migratedToKey={migratedToKey}
|
||||
secondaryLink={titleSecondaryLink}
|
||||
/>
|
||||
)}
|
||||
subtitle={getSubtitle()}
|
||||
actions={showActions && (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
as={IconButton}
|
||||
iconAs={MoreHoriz}
|
||||
variant="primary"
|
||||
aria-label={intl.formatMessage(messages.btnDropDownText)}
|
||||
actions={(selectMode && selectPosition === 'card') ? (
|
||||
<SelectAction
|
||||
itemId={itemId}
|
||||
selectMode={selectMode}
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<CardMenu
|
||||
showMenu={showActionsMenu}
|
||||
isShowRerunLink={isShowRerunLink}
|
||||
rerunLink={rerunLink}
|
||||
lmsLink={lmsLink}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
{isShowRerunLink && (
|
||||
<Dropdown.Item
|
||||
as={Link}
|
||||
to={rerunLink ?? ''}
|
||||
>
|
||||
{messages.btnReRunText.defaultMessage}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={lmsLink}>
|
||||
{intl.formatMessage(messages.viewLiveBtnText)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
/>
|
||||
{isMigrated && migratedToKey
|
||||
&& (
|
||||
{cardStatusWidget && (
|
||||
<Card.Status className="bg-white pt-0 text-gray-500">
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={AccessTime} size="sm" className="mb-1" />
|
||||
{intl.formatMessage(messages.libraryMigrationStatusText)}
|
||||
<b>
|
||||
<MakeLinkOrSpan
|
||||
when={!readOnlyItem}
|
||||
to={collectionLink()}
|
||||
className="text-info-500"
|
||||
>
|
||||
{migratedToTitle}
|
||||
</MakeLinkOrSpan>
|
||||
</b>
|
||||
</Stack>
|
||||
{cardStatusWidget}
|
||||
</Card.Status>
|
||||
)}
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardItem;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
|
||||
@@ -73,11 +73,6 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.studio-home.organization.input.no-options',
|
||||
defaultMessage: 'No options',
|
||||
},
|
||||
libraryMigrationStatusText: {
|
||||
id: 'course-authoring.studio-home.library-v1.card.status',
|
||||
description: 'Status text in v1 library card in studio informing user of its migration status',
|
||||
defaultMessage: 'Previously migrated library. Any problem bank links were already moved to',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
.card-item {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
|
||||
}
|
||||
|
||||
.pgn__card-header {
|
||||
padding: .9375rem 1.25rem;
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.courses-tab-container {
|
||||
min-height: 80vh;
|
||||
|
||||
.previously-migrated-chip {
|
||||
.pgn__chip {
|
||||
border: 0;
|
||||
background-color: var(--pgn-color-warning-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,16 @@ import {
|
||||
import { COURSE_CREATOR_STATES } from '@src/constants';
|
||||
import { type DeprecatedReduxState } from '@src/store';
|
||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { initialState } from '../../factories/mockApiResponses';
|
||||
|
||||
import CoursesTab from '.';
|
||||
import { CoursesList } from '.';
|
||||
import { studioHomeCoursesRequestParamsDefault } from '../../data/slice';
|
||||
|
||||
type StudioHomeState = DeprecatedReduxState['studioHome'];
|
||||
|
||||
const onClickNewCourse = jest.fn();
|
||||
const isShowProcessing = false;
|
||||
const isLoading = false;
|
||||
const isFailed = false;
|
||||
const numPages = 1;
|
||||
const coursesCount = studioHomeMock.courses.length;
|
||||
const showNewCourseContainer = true;
|
||||
@@ -28,6 +27,15 @@ const renderComponent = (overrideProps = {}, studioHomeState: Partial<StudioHome
|
||||
...initialState,
|
||||
studioHome: {
|
||||
...initialState.studioHome,
|
||||
studioHomeData: {
|
||||
courses: studioHomeMock.courses,
|
||||
numPages,
|
||||
coursesCount,
|
||||
},
|
||||
loadingStatuses: {
|
||||
...initialState.studioHome.loadingStatuses,
|
||||
courseLoadingStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
...studioHomeState,
|
||||
},
|
||||
};
|
||||
@@ -37,15 +45,10 @@ const renderComponent = (overrideProps = {}, studioHomeState: Partial<StudioHome
|
||||
|
||||
return {
|
||||
...render(
|
||||
<CoursesTab
|
||||
coursesDataItems={studioHomeMock.courses}
|
||||
<CoursesList
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={onClickNewCourse}
|
||||
isShowProcessing={isShowProcessing}
|
||||
isLoading={isLoading}
|
||||
isFailed={isFailed}
|
||||
numPages={numPages}
|
||||
coursesCount={coursesCount}
|
||||
{...overrideProps}
|
||||
/>,
|
||||
),
|
||||
@@ -67,30 +70,46 @@ describe('<CoursesTab />', () => {
|
||||
});
|
||||
|
||||
it('should render loading spinner when isLoading is true and isFiltered is false', () => {
|
||||
const props = { isLoading: true, coursesDataItems: [] };
|
||||
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
|
||||
renderComponent(props, customStoreData);
|
||||
const customStoreData = {
|
||||
loadingStatuses: {
|
||||
...initialState.studioHome.loadingStatuses,
|
||||
courseLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
|
||||
};
|
||||
renderComponent({}, customStoreData);
|
||||
const loadingSpinner = screen.getByRole('status');
|
||||
expect(loadingSpinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an error message when something went wrong', () => {
|
||||
const props = { isFailed: true };
|
||||
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false } };
|
||||
renderComponent(props, customStoreData);
|
||||
const customStoreData = {
|
||||
loadingStatuses: {
|
||||
...initialState.studioHome.loadingStatuses,
|
||||
courseLoadingStatus: RequestStatus.FAILED,
|
||||
},
|
||||
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false },
|
||||
};
|
||||
renderComponent({}, customStoreData);
|
||||
const alertErrorFailed = screen.queryByTestId('error-failed-message');
|
||||
expect(alertErrorFailed).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an alert message when there is not courses found', () => {
|
||||
const props = { isLoading: false, coursesDataItems: [] };
|
||||
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
|
||||
renderComponent(props, customStoreData);
|
||||
const customStoreData = {
|
||||
studioHomeData: {
|
||||
courses: [],
|
||||
numPages: 0,
|
||||
coursesCount: 0,
|
||||
},
|
||||
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
|
||||
};
|
||||
renderComponent({}, customStoreData);
|
||||
const alertCoursesNotFound = screen.queryByTestId('courses-not-found-alert');
|
||||
expect(alertCoursesNotFound).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', () => {
|
||||
it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', async () => {
|
||||
const props = { isShowProcessing: true, isEnabledPagination: false };
|
||||
const customStoreData = {
|
||||
studioHomeData: {
|
||||
@@ -102,7 +121,7 @@ describe('<CoursesTab />', () => {
|
||||
},
|
||||
};
|
||||
renderComponent(props, customStoreData);
|
||||
const alertCoursesNotFound = screen.queryByTestId('processing-courses-title');
|
||||
const alertCoursesNotFound = await screen.findByTestId('processing-courses-title');
|
||||
expect(alertCoursesNotFound).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -120,9 +139,15 @@ describe('<CoursesTab />', () => {
|
||||
});
|
||||
|
||||
it('should reset filters when in pressed the button to clean them', () => {
|
||||
const props = { isLoading: false, coursesDataItems: [] };
|
||||
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
|
||||
const { store } = renderComponent(props, customStoreData);
|
||||
const customStoreData = {
|
||||
studioHomeData: {
|
||||
courses: [],
|
||||
numPages: 0,
|
||||
coursesCount: 0,
|
||||
},
|
||||
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
|
||||
};
|
||||
const { store } = renderComponent({}, customStoreData);
|
||||
const cleanFiltersButton = screen.getByRole('button', { name: /clear filters/i });
|
||||
expect(cleanFiltersButton).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,67 +1,179 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
Row,
|
||||
Pagination,
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
|
||||
import { COURSE_CREATOR_STATES } from '@src/constants';
|
||||
import { getStudioHomeData, getStudioHomeCoursesParams } from '@src/studio-home/data/selectors';
|
||||
import { getStudioHomeData, getStudioHomeCoursesParams, getLoadingStatuses } from '@src/studio-home/data/selectors';
|
||||
import { resetStudioHomeCoursesCustomParams, updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice';
|
||||
import { fetchStudioHomeData } from '@src/studio-home/data/thunks';
|
||||
import CardItem from '@src/studio-home/card-item';
|
||||
import { CardItem } from '@src/studio-home/card-item';
|
||||
import CollapsibleStateWithAction from '@src/studio-home/collapsible-state-with-action';
|
||||
import ProcessingCourses from '@src/studio-home/processing-courses';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import messages from '../messages';
|
||||
import CoursesFilters from './courses-filters';
|
||||
import ContactAdministrator from './contact-administrator';
|
||||
import './index.scss';
|
||||
|
||||
interface Props {
|
||||
coursesDataItems: {
|
||||
courseKey: string;
|
||||
displayName: string;
|
||||
lmsLink: string | null;
|
||||
number: string;
|
||||
org: string;
|
||||
rerunLink: string | null;
|
||||
run: string;
|
||||
url: string;
|
||||
}[];
|
||||
showNewCourseContainer: boolean;
|
||||
onClickNewCourse: () => void;
|
||||
isShowProcessing: boolean;
|
||||
isLoading: boolean;
|
||||
isFailed: boolean;
|
||||
numPages: number;
|
||||
coursesCount: number;
|
||||
export interface MigrationStatusProps {
|
||||
courseId: string;
|
||||
allVisibleCourseIds: string[];
|
||||
}
|
||||
|
||||
const CoursesTab: React.FC<Props> = ({
|
||||
coursesDataItems,
|
||||
showNewCourseContainer,
|
||||
onClickNewCourse,
|
||||
isShowProcessing,
|
||||
interface CardListProps {
|
||||
currentPage: number;
|
||||
handlePageSelected: (page: any) => void;
|
||||
handleCleanFilters: () => void;
|
||||
onClickCard?: (courseId: string) => void;
|
||||
isLoading: boolean;
|
||||
isFiltered: boolean;
|
||||
hasAbilityToCreateCourse?: boolean;
|
||||
showNewCourseContainer?: boolean;
|
||||
onClickNewCourse?: () => void;
|
||||
inSelectMode?: boolean;
|
||||
selectedCourseId?: string;
|
||||
migrationStatusWidget?: React.ComponentType<MigrationStatusProps>;
|
||||
}
|
||||
|
||||
const CardList = ({
|
||||
currentPage,
|
||||
handlePageSelected,
|
||||
handleCleanFilters,
|
||||
onClickCard,
|
||||
isLoading,
|
||||
isFailed,
|
||||
numPages = 0,
|
||||
coursesCount = 0,
|
||||
isFiltered,
|
||||
hasAbilityToCreateCourse = false,
|
||||
showNewCourseContainer = false,
|
||||
onClickNewCourse = () => {},
|
||||
inSelectMode = false,
|
||||
selectedCourseId,
|
||||
migrationStatusWidget,
|
||||
}: CardListProps) => {
|
||||
const {
|
||||
courses,
|
||||
numPages,
|
||||
optimizationEnabled,
|
||||
} = useSelector(getStudioHomeData);
|
||||
|
||||
const isNotFilteringCourses = !isFiltered && !isLoading;
|
||||
const hasCourses = courses?.length > 0;
|
||||
const MigrationStatusWidget = migrationStatusWidget;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasCourses ? (
|
||||
<>
|
||||
{courses.map(
|
||||
({
|
||||
courseKey,
|
||||
displayName,
|
||||
lmsLink,
|
||||
org,
|
||||
rerunLink,
|
||||
number,
|
||||
run,
|
||||
url,
|
||||
}) => (
|
||||
<CardItem
|
||||
courseKey={courseKey}
|
||||
onClick={() => onClickCard?.(courseKey)}
|
||||
itemId={courseKey}
|
||||
displayName={displayName}
|
||||
lmsLink={lmsLink}
|
||||
rerunLink={rerunLink}
|
||||
org={org}
|
||||
number={number}
|
||||
run={run}
|
||||
url={url}
|
||||
selectMode={inSelectMode ? 'single' : undefined}
|
||||
selectPosition={inSelectMode ? 'card' : undefined}
|
||||
isSelected={inSelectMode && selectedCourseId === courseKey}
|
||||
subtitleBeforeWidget={MigrationStatusWidget && (
|
||||
<MigrationStatusWidget
|
||||
courseId={courseKey}
|
||||
allVisibleCourseIds={courses?.map(item => item.courseKey) || []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{numPages > 1 && (
|
||||
<Pagination
|
||||
className="d-flex justify-content-center w-100"
|
||||
paginationLabel="pagination navigation"
|
||||
pageCount={numPages}
|
||||
currentPage={currentPage}
|
||||
onPageSelect={handlePageSelected}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (!optimizationEnabled && isNotFilteringCourses && (
|
||||
<ContactAdministrator
|
||||
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={onClickNewCourse}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isFiltered && !hasCourses && !isLoading && (
|
||||
<Alert className="mt-4">
|
||||
<Alert.Heading>
|
||||
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertTitle} />
|
||||
</Alert.Heading>
|
||||
<p data-testid="courses-not-found-alert">
|
||||
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertMessage} />
|
||||
</p>
|
||||
<Button variant="primary" onClick={handleCleanFilters}>
|
||||
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertCleanFiltersButton} />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
showNewCourseContainer?: boolean;
|
||||
onClickNewCourse?: () => void;
|
||||
isShowProcessing?: boolean;
|
||||
selectedCourseId?: string;
|
||||
handleSelect?: (courseId: string) => void;
|
||||
cardMigrationStatusWidget?: React.ComponentType<MigrationStatusProps>;
|
||||
}
|
||||
|
||||
export const CoursesList: React.FC<Props> = ({
|
||||
showNewCourseContainer = false,
|
||||
onClickNewCourse = () => {},
|
||||
isShowProcessing = false,
|
||||
selectedCourseId,
|
||||
handleSelect,
|
||||
cardMigrationStatusWidget,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
const {
|
||||
courses,
|
||||
coursesCount,
|
||||
courseCreatorStatus,
|
||||
optimizationEnabled,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const {
|
||||
courseLoadingStatus,
|
||||
} = useSelector(getLoadingStatuses);
|
||||
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
|
||||
const { currentPage, isFiltered } = studioHomeCoursesParams;
|
||||
const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
|
||||
@@ -72,6 +184,10 @@ const CoursesTab: React.FC<Props> = ({
|
||||
].includes(courseCreatorStatus as any);
|
||||
const locationValue = location.search ?? '';
|
||||
|
||||
const isLoading = courseLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isFailed = courseLoadingStatus === RequestStatus.FAILED;
|
||||
const inSelectMode = handleSelect !== undefined;
|
||||
|
||||
const handlePageSelected = (page) => {
|
||||
const {
|
||||
search,
|
||||
@@ -96,9 +212,6 @@ const CoursesTab: React.FC<Props> = ({
|
||||
dispatch(fetchStudioHomeData(locationValue, false, { page: 1, order: 'display_name' }));
|
||||
};
|
||||
|
||||
const isNotFilteringCourses = !isFiltered && !isLoading;
|
||||
const hasCourses = coursesDataItems?.length > 0;
|
||||
|
||||
if (isLoading && !isFiltered) {
|
||||
return (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
@@ -125,70 +238,42 @@ const CoursesTab: React.FC<Props> = ({
|
||||
<CoursesFilters dispatch={dispatch} locationValue={locationValue} isLoading={isLoading} />
|
||||
<p data-testid="pagination-info" className="my-0">
|
||||
{intl.formatMessage(messages.coursesPaginationInfo, {
|
||||
length: coursesDataItems.length,
|
||||
length: courses?.length,
|
||||
total: coursesCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{hasCourses ? (
|
||||
<>
|
||||
{coursesDataItems.map(
|
||||
({
|
||||
courseKey,
|
||||
displayName,
|
||||
lmsLink,
|
||||
org,
|
||||
rerunLink,
|
||||
number,
|
||||
run,
|
||||
url,
|
||||
}) => (
|
||||
<CardItem
|
||||
key={courseKey}
|
||||
courseKey={courseKey}
|
||||
displayName={displayName}
|
||||
lmsLink={lmsLink}
|
||||
rerunLink={rerunLink}
|
||||
org={org}
|
||||
number={number}
|
||||
run={run}
|
||||
url={url}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{numPages > 1 && (
|
||||
<Pagination
|
||||
className="d-flex justify-content-center"
|
||||
paginationLabel="pagination navigation"
|
||||
pageCount={numPages}
|
||||
currentPage={currentPage}
|
||||
onPageSelect={handlePageSelected}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (!optimizationEnabled && isNotFilteringCourses && (
|
||||
<ContactAdministrator
|
||||
{inSelectMode ? (
|
||||
<Form.RadioSet
|
||||
name="select-courses"
|
||||
value={selectedCourseId}
|
||||
onChange={(e) => handleSelect(e.target.value)}
|
||||
>
|
||||
<CardList
|
||||
currentPage={currentPage}
|
||||
onClickCard={handleSelect}
|
||||
handlePageSelected={handlePageSelected}
|
||||
handleCleanFilters={handleCleanFilters}
|
||||
isLoading={isLoading}
|
||||
isFiltered={isFiltered || false}
|
||||
inSelectMode
|
||||
selectedCourseId={selectedCourseId}
|
||||
migrationStatusWidget={cardMigrationStatusWidget}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
) : (
|
||||
<CardList
|
||||
currentPage={currentPage}
|
||||
handlePageSelected={handlePageSelected}
|
||||
handleCleanFilters={handleCleanFilters}
|
||||
isLoading={isLoading}
|
||||
isFiltered={isFiltered || false}
|
||||
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={onClickNewCourse}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isFiltered && !hasCourses && !isLoading && (
|
||||
<Alert className="mt-4">
|
||||
<Alert.Heading>
|
||||
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertTitle)}
|
||||
</Alert.Heading>
|
||||
<p data-testid="courses-not-found-alert">
|
||||
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertMessage)}
|
||||
</p>
|
||||
<Button variant="primary" onClick={handleCleanFilters}>
|
||||
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertCleanFiltersButton)}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{showCollapsible && (
|
||||
<CollapsibleStateWithAction
|
||||
state={courseCreatorStatus!}
|
||||
@@ -199,5 +284,3 @@ const CoursesTab: React.FC<Props> = ({
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesTab;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Stack,
|
||||
@@ -9,23 +8,28 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { getLoadingStatuses, getStudioHomeData } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import { BaseFilterState, Filter, LibrariesList } from './libraries-tab';
|
||||
import LibrariesV2List from './libraries-v2-tab/index';
|
||||
import CoursesTab from './courses-tab';
|
||||
import { CoursesList } from './courses-tab';
|
||||
import { WelcomeLibrariesV2Alert } from './libraries-v2-tab/WelcomeLibrariesV2Alert';
|
||||
|
||||
interface Props {
|
||||
showNewCourseContainer: boolean;
|
||||
onClickNewCourse: () => void;
|
||||
isShowProcessing: boolean;
|
||||
librariesV1Enabled?: boolean;
|
||||
librariesV2Enabled?: boolean;
|
||||
}
|
||||
|
||||
const TabsSection = ({
|
||||
showNewCourseContainer,
|
||||
onClickNewCourse,
|
||||
isShowProcessing,
|
||||
librariesV1Enabled,
|
||||
librariesV2Enabled,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
@@ -61,13 +65,6 @@ const TabsSection = ({
|
||||
setTabKey(initTabKeyState(pathname));
|
||||
}, [pathname]);
|
||||
|
||||
const { courses, numPages, coursesCount } = useSelector(getStudioHomeData);
|
||||
const {
|
||||
courseLoadingStatus,
|
||||
} = useSelector(getLoadingStatuses);
|
||||
const isLoadingCourses = courseLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isFailedCoursesPage = courseLoadingStatus === RequestStatus.FAILED;
|
||||
|
||||
// Controlling the visibility of tabs when using conditional rendering is necessary for
|
||||
// the correct operation of iterating over child elements inside the Paragon Tabs component.
|
||||
const visibleTabs = useMemo(() => {
|
||||
@@ -78,15 +75,10 @@ const TabsSection = ({
|
||||
eventKey={TABS_LIST.courses}
|
||||
title={intl.formatMessage(messages.coursesTabTitle)}
|
||||
>
|
||||
<CoursesTab
|
||||
coursesDataItems={courses}
|
||||
<CoursesList
|
||||
showNewCourseContainer={showNewCourseContainer}
|
||||
onClickNewCourse={onClickNewCourse}
|
||||
isShowProcessing={isShowProcessing}
|
||||
isLoading={isLoadingCourses}
|
||||
isFailed={isFailedCoursesPage}
|
||||
numPages={numPages}
|
||||
coursesCount={coursesCount}
|
||||
/>
|
||||
</Tab>,
|
||||
);
|
||||
@@ -141,7 +133,7 @@ const TabsSection = ({
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}, [showNewCourseContainer, isLoadingCourses, migrationFilter]);
|
||||
}, [showNewCourseContainer, migrationFilter]);
|
||||
|
||||
const handleSelectTab = (tab: TabKeyType) => {
|
||||
if (tab === TABS_LIST.courses) {
|
||||
@@ -168,12 +160,4 @@ const TabsSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
TabsSection.propTypes = {
|
||||
showNewCourseContainer: PropTypes.bool.isRequired,
|
||||
onClickNewCourse: PropTypes.func.isRequired,
|
||||
isShowProcessing: PropTypes.bool.isRequired,
|
||||
librariesV1Enabled: PropTypes.bool,
|
||||
librariesV2Enabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TabsSection;
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useCallback, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Form, Icon, Menu, MenuItem, Pagination, Row, SearchField,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Error, FilterList } from '@openedx/paragon/icons';
|
||||
import { Error, FilterList, AccessTime } from '@openedx/paragon/icons';
|
||||
|
||||
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 { CardItem, MakeLinkOrSpan, PrevToNextName } from '@src/studio-home/card-item';
|
||||
import SearchFilterWidget from '@src/search-manager/SearchFilterWidget';
|
||||
import type { LibraryV1Data } from '@src/studio-home/data/api';
|
||||
import { parseLibraryKey } from '@src/generic/key-utils';
|
||||
|
||||
import messages from '../messages';
|
||||
import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert';
|
||||
@@ -37,23 +39,64 @@ const CardList = ({
|
||||
migratedToTitle,
|
||||
migratedToCollectionKey,
|
||||
libraryKey,
|
||||
}) => (
|
||||
<CardItem
|
||||
key={`${org}+${number}`}
|
||||
isLibraries
|
||||
displayName={displayName}
|
||||
org={org}
|
||||
number={number}
|
||||
url={url}
|
||||
itemId={libraryKey}
|
||||
selectMode={inSelectMode ? 'multiple' : undefined}
|
||||
isSelected={selectedIds?.includes(libraryKey)}
|
||||
isMigrated={isMigrated}
|
||||
migratedToKey={migratedToKey}
|
||||
migratedToTitle={migratedToTitle}
|
||||
migratedToCollectionKey={migratedToCollectionKey}
|
||||
/>
|
||||
))
|
||||
}) => {
|
||||
const collectionLink = () => {
|
||||
let libUrl = `/library/${migratedToKey}`;
|
||||
if (migratedToCollectionKey) {
|
||||
libUrl += `/collection/${migratedToCollectionKey}`;
|
||||
}
|
||||
return libUrl;
|
||||
};
|
||||
|
||||
const migratedToKeyObj = migratedToKey ? parseLibraryKey(migratedToKey) : undefined;
|
||||
|
||||
const subtitleWrapper = (subtitle) => (
|
||||
<PrevToNextName
|
||||
from={subtitle}
|
||||
to={<>{migratedToKeyObj?.org} / {migratedToKeyObj?.lib}</>}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CardItem
|
||||
key={`${org}+${number}`}
|
||||
isLibraries
|
||||
displayName={displayName}
|
||||
org={org}
|
||||
number={number}
|
||||
url={url}
|
||||
itemId={libraryKey}
|
||||
selectMode={inSelectMode ? 'multiple' : undefined}
|
||||
selectPosition={inSelectMode ? 'title' : undefined}
|
||||
isSelected={selectedIds?.includes(libraryKey)}
|
||||
subtitleWrapper={isMigrated ? subtitleWrapper : null}
|
||||
titleSecondaryLink={(isMigrated && migratedToTitle) ? (
|
||||
<MakeLinkOrSpan
|
||||
when={!inSelectMode}
|
||||
to={`/library/${migratedToKey}`}
|
||||
className="card-item-title"
|
||||
>
|
||||
{migratedToTitle}
|
||||
</MakeLinkOrSpan>
|
||||
) : null}
|
||||
cardStatusWidget={(isMigrated && migratedToKey) ? (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={AccessTime} size="sm" className="mb-1" />
|
||||
<FormattedMessage {...messages.libraryMigrationStatusText} />
|
||||
<b>
|
||||
<MakeLinkOrSpan
|
||||
when
|
||||
to={collectionLink()}
|
||||
className="text-info-500"
|
||||
>
|
||||
{migratedToTitle}
|
||||
</MakeLinkOrSpan>
|
||||
</b>
|
||||
</Stack>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,13 +17,13 @@ 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 { CardItem } from '../../card-item';
|
||||
import messages from '../messages';
|
||||
import LibrariesV2Filters from './libraries-v2-filters';
|
||||
|
||||
interface CardListProps {
|
||||
hasV2Libraries: boolean;
|
||||
selectMode?: 'single' | 'multiple';
|
||||
inSelectMode?: boolean;
|
||||
selectedLibraryId?: string;
|
||||
isFiltered: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -34,7 +34,7 @@ interface CardListProps {
|
||||
|
||||
const CardList: React.FC<CardListProps> = ({
|
||||
hasV2Libraries,
|
||||
selectMode,
|
||||
inSelectMode,
|
||||
selectedLibraryId,
|
||||
isFiltered,
|
||||
isLoading,
|
||||
@@ -56,7 +56,8 @@ const CardList: React.FC<CardListProps> = ({
|
||||
org={org}
|
||||
number={slug}
|
||||
path={`/library/${id}`}
|
||||
selectMode={selectMode}
|
||||
selectMode={inSelectMode ? 'single' : undefined}
|
||||
selectPosition={inSelectMode ? 'title' : undefined}
|
||||
isSelected={selectedLibraryId === id}
|
||||
itemId={id}
|
||||
scrollIntoView={scrollIntoView && selectedLibraryId === id}
|
||||
@@ -202,7 +203,7 @@ const LibrariesV2List: React.FC<Props> = ({
|
||||
>
|
||||
<CardList
|
||||
hasV2Libraries={hasV2Libraries}
|
||||
selectMode={inSelectMode ? 'single' : undefined}
|
||||
inSelectMode={inSelectMode}
|
||||
selectedLibraryId={selectedLibraryId}
|
||||
isFiltered={isFiltered}
|
||||
isLoading={isPending}
|
||||
|
||||
@@ -136,6 +136,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Select All',
|
||||
description: 'Button to select all libraries when migrate legacy libraries.',
|
||||
},
|
||||
libraryMigrationStatusText: {
|
||||
id: 'course-authoring.studio-home.library-v1.card.status',
|
||||
description: 'Status text in v1 library card in studio informing user of its migration status',
|
||||
defaultMessage: 'Previously migrated library. Any problem bank links were already moved to',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user