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:
Chris Chávez
2025-11-14 13:07:00 -05:00
committed by GitHub
parent 7cf01de84c
commit 54cfbeb756
32 changed files with 1059 additions and 285 deletions

1
.env
View File

@@ -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

View File

@@ -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=''

View File

@@ -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"

View File

@@ -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

View File

@@ -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,
})
);

View File

@@ -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;

View File

@@ -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',

View File

@@ -13,10 +13,6 @@
.card-item {
margin: 0 0 16px !important;
&.selected {
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
}
}
}

View File

@@ -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>
);

View File

@@ -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',
}],
}),
),
};

View File

@@ -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);
}

View File

@@ -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),
})
);

View File

@@ -29,7 +29,7 @@ const render = (libraryId: string) => (
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import-course',
path: '/libraries/:libraryId/import',
params: { libraryId },
},
)

View File

@@ -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 }]}>

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);

View File

@@ -49,6 +49,8 @@ export const ROUTES = {
BACKUP: '/backup',
// LibraryImportPage route:
IMPORT: '/import',
// ImportStepperPage route:
IMPORT_COURSE: '/import/courses',
};
export enum ContentType {

View File

@@ -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);

View File

@@ -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;

View File

@@ -1,4 +1,3 @@
// @ts-check
import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,3 +1,10 @@
.courses-tab-container {
min-height: 80vh;
.previously-migrated-chip {
.pgn__chip {
border: 0;
background-color: var(--pgn-color-warning-500);
}
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}
/>
);
})
}
</>
);

View File

@@ -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}

View File

@@ -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;