feat: course import filter in library and fix list order (#2773)
* Adds filter to show/hide previously imported courses in course import page in libraries. * Fix issue with ordering of courses listing.
This commit is contained in:
@@ -186,9 +186,17 @@ export const LibraryProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
export function useLibraryContext(): LibraryContextData {
|
||||
export function useLibraryContext(
|
||||
allowEmtpy?: false,
|
||||
): LibraryContextData; // never undefined
|
||||
export function useLibraryContext(
|
||||
allowEmtpy: true,
|
||||
): LibraryContextData | undefined; // may be undefined
|
||||
export function useLibraryContext(
|
||||
allowEmtpy?: boolean,
|
||||
): LibraryContextData | undefined {
|
||||
const ctx = useContext(LibraryContext);
|
||||
if (ctx === undefined) {
|
||||
if (!allowEmtpy && ctx === undefined) {
|
||||
/* istanbul ignore next */
|
||||
throw new Error('useLibraryContext() was used in a component without a <LibraryProvider> ancestor.');
|
||||
}
|
||||
|
||||
@@ -80,14 +80,27 @@ const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
|
||||
|
||||
describe('<ImportStepperModal />', () => {
|
||||
it('should render correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
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();
|
||||
// Renders the course list and hides previously imported courses
|
||||
expect(screen.queryByText(/run 0/i)).toBeInTheDocument(); // not imported before
|
||||
|
||||
// Hides previously imported courses.
|
||||
expect(screen.queryByText(/managing risk in the information age/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Previously Imported')).not.toBeInTheDocument();
|
||||
|
||||
// use filter modal to show previously imported courses.
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
// Renders previously imported courses and badge
|
||||
expect(await screen.findByText(/managing risk in the information age/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/run 0/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Previously Imported')).toBeInTheDocument();
|
||||
|
||||
// Renders cancel and next step buttons
|
||||
@@ -116,6 +129,11 @@ describe('<ImportStepperModal />', () => {
|
||||
description: 'This is a test course',
|
||||
});
|
||||
|
||||
// use filter modal to show previously imported courses.
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next step/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
@@ -137,7 +155,7 @@ describe('<ImportStepperModal />', () => {
|
||||
expect(await screen.findByText('Import Analysis in Progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the course should remain selected on back', async () => {
|
||||
it('the course should remain selected on back only for non-imported courses', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
@@ -145,7 +163,7 @@ describe('<ImportStepperModal />', () => {
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
// Select a course
|
||||
const courseCard = screen.getAllByRole('radio')[0];
|
||||
const courseCard = (await screen.findAllByRole('radio'))[0];
|
||||
await user.click(courseCard);
|
||||
expect(courseCard).toBeChecked();
|
||||
|
||||
@@ -156,8 +174,53 @@ describe('<ImportStepperModal />', () => {
|
||||
const backButton = await screen.findByRole('button', { name: /back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Run 0/i)).toBeInTheDocument();
|
||||
expect(courseCard).toBeChecked();
|
||||
expect(nextButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should hide previously imported courses on page change', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
// use filter modal to show previously imported courses.
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next step/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
|
||||
// Select a course
|
||||
const courseCard = (await screen.findAllByRole('radio'))[0];
|
||||
await user.click(courseCard);
|
||||
expect(courseCard).toBeChecked();
|
||||
|
||||
// Click next
|
||||
expect(nextButton).toBeEnabled();
|
||||
await user.click(nextButton);
|
||||
|
||||
const backButton = await screen.findByRole('button', { name: /back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
expect(screen.queryByText(/managing risk in the information age/i)).not.toBeInTheDocument();
|
||||
expect((await screen.findAllByRole('radio'))[0]).not.toBeChecked();
|
||||
|
||||
// should be disabled
|
||||
expect(await screen.findByRole('button', { name: /next step/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should reset checkbox if modal is closed or cancelled without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
// use filter modal to show previously imported courses.
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
const checkbox = await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should import selected course on button click', async () => {
|
||||
@@ -173,6 +236,12 @@ describe('<ImportStepperModal />', () => {
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
// use filter modal to show previously imported courses.
|
||||
await user.click(await screen.findByRole('button', { name: 'Filter settings' }));
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Show Previously Imported Courses' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200);
|
||||
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
|
||||
courseId: 'course-v1:HarvardX+123+2023',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -7,17 +7,19 @@ import {
|
||||
} 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 { useBulkModulestoreMigrate } from '@src/data/apiHooks';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
|
||||
import {
|
||||
CourseImportFilterProvider,
|
||||
useCourseImportFilter,
|
||||
} from '@src/studio-home/tabs-section/courses-tab/courses-filters/courses-imported-filter-modal/context';
|
||||
import { ReviewImportDetails } from './ReviewImportDetails';
|
||||
import messages from '../messages';
|
||||
import { HelpSidebar } from '../HelpSidebar';
|
||||
@@ -26,31 +28,11 @@ type MigrationStep = 'select-course' | 'review-details';
|
||||
|
||||
export const MigrationStatus = ({
|
||||
courseId,
|
||||
allVisibleCourseIds,
|
||||
}: MigrationStatusProps) => {
|
||||
const { libraryId } = useLibraryContext();
|
||||
const { processedMigrationInfo } = useCourseImportFilter() || {};
|
||||
|
||||
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.
|
||||
if (libraries.length !== 0) {
|
||||
result[libraries[0].sourceKey] = libraries.map(item => item.targetKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [migrationInfoData]);
|
||||
|
||||
const isPreviouslyMigrated = (
|
||||
courseId in processedMigrationInfo && processedMigrationInfo[courseId].includes(libraryId)
|
||||
);
|
||||
const isPreviouslyMigrated = processedMigrationInfo?.[courseId]?.includes(libraryId);
|
||||
|
||||
if (!isPreviouslyMigrated) {
|
||||
return null;
|
||||
@@ -58,7 +40,7 @@ export const MigrationStatus = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${courseId}-${processedMigrationInfo[courseId].join('-')}`}
|
||||
key={`${courseId}-${processedMigrationInfo?.[courseId].join('-')}`}
|
||||
className="previously-migrated-chip"
|
||||
>
|
||||
<Chip>
|
||||
@@ -72,7 +54,7 @@ export const ImportStepperPage = () => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-course');
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string>();
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string>('');
|
||||
const [analysisCompleted, setAnalysisCompleted] = useState<boolean>(false);
|
||||
const { data: courseData } = useCourseDetails(selectedCourseId);
|
||||
const { libraryId, libraryData, readOnly } = useLibraryContext();
|
||||
@@ -81,11 +63,8 @@ export const ImportStepperPage = () => {
|
||||
// TODO: Modify single migration API to allow create collection
|
||||
const migrate = useBulkModulestoreMigrate();
|
||||
|
||||
// Load the courses list
|
||||
// The loading state is handled in `CoursesList`
|
||||
useStudioHome();
|
||||
|
||||
const handleImportCourse = async () => {
|
||||
// istanbul ignore if: this can never happen, just for satisfying type checker.
|
||||
if (!selectedCourseId) {
|
||||
return;
|
||||
}
|
||||
@@ -142,11 +121,16 @@ export const ImportStepperPage = () => {
|
||||
eventKey="select-course"
|
||||
title={intl.formatMessage(messages.importCourseSelectCourseStep)}
|
||||
>
|
||||
<CoursesList
|
||||
<CourseImportFilterProvider
|
||||
selectedCourseId={selectedCourseId}
|
||||
handleSelect={setSelectedCourseId}
|
||||
cardMigrationStatusWidget={MigrationStatus}
|
||||
/>
|
||||
>
|
||||
<CoursesList
|
||||
selectedCourseId={selectedCourseId}
|
||||
handleSelect={setSelectedCourseId}
|
||||
cardMigrationStatusWidget={MigrationStatus}
|
||||
/>
|
||||
</CourseImportFilterProvider>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step
|
||||
eventKey="review-details"
|
||||
@@ -167,7 +151,7 @@ export const ImportStepperPage = () => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentStep('review-details')}
|
||||
disabled={selectedCourseId === undefined}
|
||||
disabled={!selectedCourseId}
|
||||
>
|
||||
<FormattedMessage {...messages.importCourseNext} />
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('updateStudioHomeCoursesCustomParams action', () => {
|
||||
studioHomeData: {},
|
||||
studioHomeCoursesRequestParams: {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
order: 'display_name',
|
||||
archivedOnly: undefined,
|
||||
@@ -30,6 +31,7 @@ describe('updateStudioHomeCoursesCustomParams action', () => {
|
||||
...initialState,
|
||||
studioHomeCoursesRequestParams: {
|
||||
currentPage: 2,
|
||||
pageSize: 10,
|
||||
search: 'test',
|
||||
order: 'display_name',
|
||||
archivedOnly: true,
|
||||
@@ -41,6 +43,7 @@ describe('updateStudioHomeCoursesCustomParams action', () => {
|
||||
|
||||
const payload = {
|
||||
currentPage: 2,
|
||||
pageSize: 10,
|
||||
search: 'test',
|
||||
order: 'display_name',
|
||||
archivedOnly: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RequestStatus, type RequestStatusType } from '@src/data/constants';
|
||||
|
||||
export interface Params {
|
||||
currentPage: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
order?: string;
|
||||
archivedOnly?: boolean;
|
||||
@@ -16,6 +17,7 @@ export interface Params {
|
||||
|
||||
export const studioHomeCoursesRequestParamsDefault: Params = {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
search: '',
|
||||
order: 'display_name',
|
||||
archivedOnly: undefined,
|
||||
|
||||
@@ -49,26 +49,30 @@ const useStudioHome = () => {
|
||||
}
|
||||
const courseListQueryString = courseListQuery.size ? `?${courseListQuery.toString()}` : '';
|
||||
useEffect(() => {
|
||||
dispatch(fetchStudioHomeData(courseListQueryString));
|
||||
dispatch(fetchStudioHomeData(courseListQueryString, false, studioHomeCoursesParams));
|
||||
setShowNewCourseContainer(false);
|
||||
}, [courseListQueryString]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstPage = 1;
|
||||
dispatch(fetchStudioHomeData(courseListQueryString, false, { page: firstPage, order: 'display_name' }));
|
||||
dispatch(fetchStudioHomeData(courseListQueryString, false, {
|
||||
...studioHomeCoursesParams,
|
||||
page: firstPage,
|
||||
order: 'display_name',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (courseCreatorSavingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));
|
||||
dispatch(fetchStudioHomeData());
|
||||
dispatch(fetchStudioHomeData(undefined, false, studioHomeCoursesParams));
|
||||
}
|
||||
}, [courseCreatorSavingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteNotificationSavingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatuses({ courseCreatorSavingStatus: '' }));
|
||||
dispatch(fetchStudioHomeData());
|
||||
dispatch(fetchStudioHomeData(undefined, false, studioHomeCoursesParams));
|
||||
} else if (deleteNotificationSavingStatus === RequestStatus.FAILED) {
|
||||
dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: '' }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
createContext, ReactNode, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
|
||||
import { getLoadingStatuses, getStudioHomeCoursesParams, getStudioHomeData } from '@src/studio-home/data/selectors';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { fetchStudioHomeData } from '@src/studio-home/data/thunks';
|
||||
import { updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
|
||||
export interface CourseImportFilterContextType {
|
||||
processedMigrationInfo: Record<string, string[]>;
|
||||
hidePreviouslyImportedCourses: boolean;
|
||||
setHidePreviouslyImportedCourses: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
filteredCourses: object[];
|
||||
}
|
||||
|
||||
export const CourseImportFilterContext = createContext<CourseImportFilterContextType | undefined>(undefined);
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
handleSelect?: (courseId: string) => void;
|
||||
selectedCourseId?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
export const CourseImportFilterProvider = ({ handleSelect, selectedCourseId, children }: Props) => {
|
||||
const { libraryId } = useLibraryContext(true) || {};
|
||||
const dispatch = useDispatch();
|
||||
const [hidePreviouslyImportedCourses, setHidePreviouslyImportedCourses] = useState(true);
|
||||
const { courses, numPages } = useSelector(getStudioHomeData);
|
||||
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
|
||||
const { currentPage } = studioHomeCoursesParams;
|
||||
const { courseLoadingStatus } = useSelector(getLoadingStatuses);
|
||||
const isCourseListLoading = courseLoadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const allVisibleCourseIds = courses?.map(item => item.courseKey) || [];
|
||||
const {
|
||||
data: migrationInfoData,
|
||||
isPending,
|
||||
} = useMigrationInfo(allVisibleCourseIds, allVisibleCourseIds.length > 0);
|
||||
|
||||
const processedMigrationInfo: Record<string, string[]> = 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.
|
||||
if (libraries.length !== 0) {
|
||||
result[libraries[0]?.sourceKey] = libraries.map(item => item.targetKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [migrationInfoData]);
|
||||
|
||||
const filteredCourses: any[] = useMemo(() => (hidePreviouslyImportedCourses && libraryId
|
||||
? courses?.filter(course => !processedMigrationInfo[course.courseKey]?.includes(libraryId))
|
||||
: courses), [hidePreviouslyImportedCourses, libraryId, processedMigrationInfo, courses]);
|
||||
|
||||
useEffect(() => {
|
||||
// Filter the courses based on selected course id and handle the selection change
|
||||
if (!isCourseListLoading && !isPending
|
||||
&& filteredCourses.findIndex(course => course.courseKey === selectedCourseId) === -1) {
|
||||
handleSelect?.('');
|
||||
}
|
||||
}, [filteredCourses, selectedCourseId, handleSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch all studio home data for initial load to avoid pagingation
|
||||
// This is required to avoid cases where we have very less number of non-imported courses per page
|
||||
dispatch(updateStudioHomeCoursesCustomParams({ ...studioHomeCoursesParams, pageSize: PAGE_SIZE }));
|
||||
dispatch(fetchStudioHomeData(undefined, false, { ...studioHomeCoursesParams, page: 1, pageSize: PAGE_SIZE }));
|
||||
}, []);
|
||||
|
||||
// istanbul ignore next
|
||||
useEffect(() => {
|
||||
// HACK: If there are no courses that were not imported in the current page, then we need to fetch
|
||||
// the next page of courses.
|
||||
// FIXME: This workaround causes page flicker when the next page has also has no courses that were not imported.
|
||||
if ((numPages > currentPage) && filteredCourses.length === 0 && !isCourseListLoading) {
|
||||
dispatch(fetchStudioHomeData(undefined, false, { ...studioHomeCoursesParams, page: currentPage + 1 }));
|
||||
dispatch(updateStudioHomeCoursesCustomParams({ ...studioHomeCoursesParams, currentPage: currentPage + 1 }));
|
||||
}
|
||||
}, [
|
||||
numPages,
|
||||
filteredCourses,
|
||||
courses,
|
||||
dispatch,
|
||||
courseLoadingStatus,
|
||||
studioHomeCoursesParams,
|
||||
]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
processedMigrationInfo,
|
||||
hidePreviouslyImportedCourses,
|
||||
setHidePreviouslyImportedCourses,
|
||||
filteredCourses,
|
||||
}), [processedMigrationInfo, hidePreviouslyImportedCourses, setHidePreviouslyImportedCourses]);
|
||||
|
||||
return (
|
||||
<CourseImportFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</CourseImportFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCourseImportFilter = () => useContext(CourseImportFilterContext);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Button, Form, IconButton, ModalDialog,
|
||||
} from '@openedx/paragon';
|
||||
import { Settings } from '@openedx/paragon/icons';
|
||||
import { useState } from 'react';
|
||||
import { useCourseImportFilter } from './context';
|
||||
import messages from './messages';
|
||||
|
||||
interface FilterModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
handleSave: (isChecked: boolean) => void;
|
||||
}
|
||||
|
||||
const FilterModal = ({ show, onClose, handleSave }: FilterModalProps) => {
|
||||
const intl = useIntl();
|
||||
const { hidePreviouslyImportedCourses } = useCourseImportFilter() || {};
|
||||
const [isChecked, setChecked] = useState(!hidePreviouslyImportedCourses);
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked);
|
||||
|
||||
const reset = () => setChecked(!hidePreviouslyImportedCourses);
|
||||
|
||||
const submit = () => {
|
||||
handleSave(isChecked);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
onClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages.modalTitle)}
|
||||
onClose={cancel}
|
||||
isOverflowVisible={false}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOpen={show}
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.modalTitle)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<Form.Checkbox checked={isChecked} onChange={handleChange}>
|
||||
{intl.formatMessage(messages.checkboxLabel)}
|
||||
</Form.Checkbox>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancelBtn)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button onClick={submit}>
|
||||
{intl.formatMessage(messages.saveBtn)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const CourseImportFilter = () => {
|
||||
const intl = useIntl();
|
||||
const [show, setShow] = useState(false);
|
||||
const importContext = useCourseImportFilter();
|
||||
const { setHidePreviouslyImportedCourses } = importContext || {};
|
||||
const handleSave = (isChecked: boolean) => {
|
||||
setHidePreviouslyImportedCourses?.(!isChecked);
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
// istanbul ignore if
|
||||
if (typeof importContext === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
src={Settings}
|
||||
alt={intl.formatMessage(messages.actionBtnAltText)}
|
||||
onClick={() => setShow((prev) => !prev)}
|
||||
/>
|
||||
<FilterModal handleSave={handleSave} show={show} onClose={() => setShow(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
actionBtnAltText: {
|
||||
id: 'course-authoring.studio-home.courses.import-filter.alt.text',
|
||||
defaultMessage: 'Filter settings',
|
||||
description: 'Alt text for settings icon to filter course list in course import in library.',
|
||||
},
|
||||
modalTitle: {
|
||||
id: 'course-authoring.studio-home.courses.import-filter.modal.title',
|
||||
defaultMessage: 'Show Imported Courses',
|
||||
description: 'Title text of modal that allows users to see previously imported courses.',
|
||||
},
|
||||
checkboxLabel: {
|
||||
id: 'course-authoring.studio-home.courses.import-filter.checkbox.label',
|
||||
defaultMessage: 'Show Previously Imported Courses',
|
||||
description: 'Label text of checkbox that allows users to see previously imported courses.',
|
||||
},
|
||||
cancelBtn: {
|
||||
id: 'course-authoring.studio-home.courses.import-filter.cancelBtn.text',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Cancel button text of modal that allows users to see previously imported courses.',
|
||||
},
|
||||
saveBtn: {
|
||||
id: 'course-authoring.studio-home.courses.import-filter.saveBtn.text',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Save button text of modal that allows users to see previously imported courses.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,30 +1,48 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchField } from '@openedx/paragon';
|
||||
import { debounce } from 'lodash';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
import { getStudioHomeCoursesParams } from '../../../data/selectors';
|
||||
import { updateStudioHomeCoursesCustomParams } from '../../../data/slice';
|
||||
import { fetchStudioHomeData } from '../../../data/thunks';
|
||||
import { LoadingSpinner } from '../../../../generic/Loading';
|
||||
import { getStudioHomeCoursesParams } from '@src/studio-home/data/selectors';
|
||||
import { updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice';
|
||||
import { fetchStudioHomeData } from '@src/studio-home/data/thunks';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import CoursesTypesFilterMenu from './courses-types-filter-menu';
|
||||
import CoursesOrderFilterMenu from './courses-order-filter-menu';
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
import { CourseImportFilter } from './courses-imported-filter-modal';
|
||||
|
||||
interface BaseFilter {
|
||||
currentPage: number;
|
||||
search: string | undefined;
|
||||
order: string | undefined;
|
||||
isFiltered: boolean;
|
||||
archivedOnly: boolean | undefined;
|
||||
activeOnly: boolean | undefined;
|
||||
cleanFilters: boolean;
|
||||
}
|
||||
|
||||
/* regex to check if a string has only whitespace
|
||||
example " "
|
||||
*/
|
||||
const regexOnlyWhiteSpaces = /^\s+$/;
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch<any>,
|
||||
locationValue: string,
|
||||
onSubmitSearchField?: () => void,
|
||||
isLoading?: boolean,
|
||||
}
|
||||
|
||||
const CoursesFilters = ({
|
||||
dispatch,
|
||||
locationValue,
|
||||
locationValue = '',
|
||||
onSubmitSearchField,
|
||||
isLoading,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
|
||||
const {
|
||||
order,
|
||||
@@ -37,7 +55,7 @@ const CoursesFilters = ({
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const getFilterTypeData = (baseFilters) => ({
|
||||
const getFilterTypeData = (baseFilters: BaseFilter) => ({
|
||||
archivedCourses: { ...baseFilters, archivedOnly: true, activeOnly: undefined },
|
||||
activeCourses: { ...baseFilters, activeOnly: true, archivedOnly: undefined },
|
||||
allCourses: { ...baseFilters, archivedOnly: undefined, activeOnly: undefined },
|
||||
@@ -47,8 +65,8 @@ const CoursesFilters = ({
|
||||
oldestCourses: { ...baseFilters, order: 'created' },
|
||||
});
|
||||
|
||||
const handleMenuFilterItemSelected = (filterType) => {
|
||||
const baseFilters = {
|
||||
const handleMenuFilterItemSelected = (filterType: string | number) => {
|
||||
const baseFilters: BaseFilter = {
|
||||
currentPage: 1,
|
||||
search,
|
||||
order,
|
||||
@@ -73,7 +91,7 @@ const CoursesFilters = ({
|
||||
dispatch(fetchStudioHomeData(locationValue, false, { page: 1, ...customParams }, true));
|
||||
};
|
||||
|
||||
const handleSearchCourses = (searchValueDebounced) => {
|
||||
const handleSearchCourses = (searchValueDebounced: string) => {
|
||||
const valueFormatted = searchValueDebounced.trim();
|
||||
const filterParams = {
|
||||
search: valueFormatted.length > 0 ? valueFormatted : '',
|
||||
@@ -122,21 +140,9 @@ const CoursesFilters = ({
|
||||
|
||||
<CoursesTypesFilterMenu onItemMenuSelected={handleMenuFilterItemSelected} />
|
||||
<CoursesOrderFilterMenu onItemMenuSelected={handleMenuFilterItemSelected} />
|
||||
<CourseImportFilter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursesFilters.defaultProps = {
|
||||
locationValue: '',
|
||||
onSubmitSearchField: () => {},
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
CoursesFilters.propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
locationValue: PropTypes.string,
|
||||
onSubmitSearchField: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CoursesFilters;
|
||||
@@ -26,10 +26,10 @@ import messages from '../messages';
|
||||
import CoursesFilters from './courses-filters';
|
||||
import ContactAdministrator from './contact-administrator';
|
||||
import './index.scss';
|
||||
import { useCourseImportFilter } from './courses-filters/courses-imported-filter-modal/context';
|
||||
|
||||
export interface MigrationStatusProps {
|
||||
courseId: string;
|
||||
allVisibleCourseIds: string[];
|
||||
}
|
||||
|
||||
interface CardListProps {
|
||||
@@ -62,10 +62,11 @@ const CardList = ({
|
||||
migrationStatusWidget,
|
||||
}: CardListProps) => {
|
||||
const {
|
||||
courses,
|
||||
courses: allCourses,
|
||||
numPages,
|
||||
optimizationEnabled,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const { filteredCourses: courses } = useCourseImportFilter() || { filteredCourses: allCourses };
|
||||
|
||||
const isNotFilteringCourses = !isFiltered && !isLoading;
|
||||
const hasCourses = courses?.length > 0;
|
||||
@@ -101,10 +102,7 @@ const CardList = ({
|
||||
selectPosition={inSelectMode ? 'card' : undefined}
|
||||
isSelected={inSelectMode && selectedCourseId === courseKey}
|
||||
subtitleBeforeWidget={MigrationStatusWidget && (
|
||||
<MigrationStatusWidget
|
||||
courseId={courseKey}
|
||||
allVisibleCourseIds={courses?.map(item => item.courseKey) || []}
|
||||
/>
|
||||
<MigrationStatusWidget courseId={courseKey} />
|
||||
)}
|
||||
/>
|
||||
),
|
||||
@@ -167,10 +165,11 @@ export const CoursesList: React.FC<Props> = ({
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
const {
|
||||
courses,
|
||||
courses: allCourses,
|
||||
coursesCount,
|
||||
courseCreatorStatus,
|
||||
} = useSelector(getStudioHomeData);
|
||||
const { filteredCourses: courses } = useCourseImportFilter() || { filteredCourses: allCourses };
|
||||
const {
|
||||
courseLoadingStatus,
|
||||
} = useSelector(getLoadingStatuses);
|
||||
@@ -189,22 +188,12 @@ export const CoursesList: React.FC<Props> = ({
|
||||
const inSelectMode = handleSelect !== undefined;
|
||||
|
||||
const handlePageSelected = (page) => {
|
||||
const {
|
||||
search,
|
||||
order,
|
||||
archivedOnly,
|
||||
activeOnly,
|
||||
} = studioHomeCoursesParams;
|
||||
|
||||
const customParams = {
|
||||
search,
|
||||
order,
|
||||
archivedOnly,
|
||||
activeOnly,
|
||||
};
|
||||
|
||||
dispatch(fetchStudioHomeData(locationValue, false, { page, ...customParams }));
|
||||
dispatch(updateStudioHomeCoursesCustomParams({ currentPage: page, isFiltered: true }));
|
||||
dispatch(fetchStudioHomeData(locationValue, false, { ...studioHomeCoursesParams, page }));
|
||||
dispatch(updateStudioHomeCoursesCustomParams({
|
||||
...studioHomeCoursesParams,
|
||||
currentPage: page,
|
||||
isFiltered: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCleanFilters = () => {
|
||||
|
||||
Reference in New Issue
Block a user