Compare commits
6 Commits
fix/saving
...
feat/add_g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9610f0791f | ||
|
|
0f58329cb4 | ||
|
|
54cfbeb756 | ||
|
|
7cf01de84c | ||
|
|
a1abd43a11 | ||
|
|
8f06263e27 |
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"
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -11067,9 +11067,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001754",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
|
||||
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
|
||||
"version": "1.0.30001755",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
|
||||
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,13 +10,13 @@ const UnitButton = ({
|
||||
unitId,
|
||||
className,
|
||||
showTitle,
|
||||
isActive, // passed from parent (SequenceNavigationTabs)
|
||||
}) => {
|
||||
const courseId = useSelector(getCourseId);
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
|
||||
const unit = useSelector((state) => state.models.units[unitId]);
|
||||
|
||||
const { title, contentType, isActive } = unit || {};
|
||||
const { title, contentType } = unit || {};
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -37,11 +37,13 @@ UnitButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
showTitle: PropTypes.bool,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
showTitle: false,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
export default UnitButton;
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
/* istanbul ignore file */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-unused-vars */
|
||||
/* eslint-disable import/extensions */
|
||||
/* eslint-disable import/no-unresolved */
|
||||
/**
|
||||
* This is an example component for an xblock Editor
|
||||
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
|
||||
* To use run npm run-script addXblock <your>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
Form,
|
||||
Spinner,
|
||||
Collapsible,
|
||||
Icon,
|
||||
IconButton,
|
||||
Dropdown,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
DeleteOutline,
|
||||
Add,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
InsertPhoto,
|
||||
MoreHoriz,
|
||||
Check,
|
||||
} from '@openedx/paragon/icons';
|
||||
import {
|
||||
actions,
|
||||
selectors,
|
||||
} from '../../data/redux';
|
||||
import {
|
||||
RequestKeys,
|
||||
} from '../../data/constants/requests';
|
||||
import './index.scss';
|
||||
import EditorContainer from '../EditorContainer';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
||||
// should be re-thought and cleaned up to avoid this pattern.
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import * as module from '.';
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
|
||||
import Button from '../../sharedComponents/Button';
|
||||
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
|
||||
import messages from './messages';
|
||||
|
||||
export const hooks = {
|
||||
getContent: () => ({
|
||||
@@ -31,77 +40,498 @@ export const hooks = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const ThumbEditor = ({
|
||||
export const GameEditor = ({
|
||||
onClose,
|
||||
// redux
|
||||
blockValue,
|
||||
lmsEndpointUrl,
|
||||
blockFailed,
|
||||
blockFinished,
|
||||
initializeEditor,
|
||||
// eslint-disable-next-line react/prop-types
|
||||
exampleValue,
|
||||
|
||||
// settings
|
||||
settings,
|
||||
shuffleTrue,
|
||||
shuffleFalse,
|
||||
timerTrue,
|
||||
timerFalse,
|
||||
type,
|
||||
updateType,
|
||||
|
||||
// list
|
||||
list,
|
||||
updateTerm,
|
||||
updateTermImage,
|
||||
updateDefinition,
|
||||
updateDefinitionImage,
|
||||
toggleOpen,
|
||||
setList,
|
||||
addCard,
|
||||
removeCard,
|
||||
|
||||
isDirty,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
// State for list
|
||||
const [state, setState] = React.useState(list);
|
||||
React.useEffect(() => { setState(list); }, [list]);
|
||||
|
||||
// Non-reducer functions go here
|
||||
const getDescriptionHeader = () => {
|
||||
// Function to determine what the header will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Flashcard terms';
|
||||
case 'matching':
|
||||
return 'Matching terms';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
// Function to determine what the description will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Enter your terms and definitions below. Learners will review each card by viewing the term, then flipping to reveal the definition.';
|
||||
case 'matching':
|
||||
return 'Enter your terms and definitions below. Learners must match each term with the correct definition.';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const saveTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateTermImage({ index, termImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateTermImage({ index, termImage: '' });
|
||||
};
|
||||
|
||||
const saveDefinitionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateDefinitionImage({ index, definitionImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDefintionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateDefinitionImage({ index, definitionImage: '' });
|
||||
};
|
||||
|
||||
const moveCardUp = (index) => {
|
||||
if (index === 0) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index], temp[index - 1]] = [temp[index - 1], temp[index]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const moveCardDown = (index) => {
|
||||
if (index === state.length - 1) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index + 1], temp[index]] = [temp[index], temp[index + 1]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const loading = (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
screenreadertext={intl.formatMessage(messages.loadingSpinner)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const termImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.term_image} alt="TERM_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeTermImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const termImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`term_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const definitionImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.definition_image} alt="DEF_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeDefintionImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const definitionImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`definition_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const timerSettingsOption = (
|
||||
<SettingsOption
|
||||
className="sidebar-timer d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Timer"
|
||||
summary={settings.timer ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Measure the time it takes learners to match all terms and definitions. Used to calculate a learner's score.</div>
|
||||
<Button
|
||||
onClick={() => timerFalse()}
|
||||
variant={!settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => timerTrue()}
|
||||
variant={settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
);
|
||||
|
||||
const page = (
|
||||
<div className="page-body d-flex align-items-start">
|
||||
<div className="terms d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description-header">
|
||||
{getDescriptionHeader()}
|
||||
</div>
|
||||
<div className="description-body align-self-stretch">
|
||||
{getDescription()}
|
||||
</div>
|
||||
</div>
|
||||
<DraggableList
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
itemList={state}
|
||||
setState={setState}
|
||||
updateOrder={() => (newList) => setList(newList)}
|
||||
>
|
||||
{
|
||||
state.map((card, index) => (
|
||||
<SortableItem
|
||||
id={card.id}
|
||||
key={card.id}
|
||||
buttonClassName="draggable-button"
|
||||
componentStyle={{
|
||||
background: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '24px',
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0px 1px 5px #ADADAD',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
flexFlow: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Collapsible.Advanced
|
||||
className="card"
|
||||
defaultOpen
|
||||
onOpen={() => toggleOpen({ index, isOpen: true })}
|
||||
onClose={() => toggleOpen({ index, isOpen: false })}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id={`term_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveTermImage(index)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id={`definition_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveDefinitionImage(index)}
|
||||
/>
|
||||
<Collapsible.Trigger className="card-heading">
|
||||
<div className="drag-spacer" />
|
||||
<div className="card-heading d-flex align-items-center align-self-stretch">
|
||||
<div className="card-number">{index + 1}</div>
|
||||
{!card.editorOpen ? (
|
||||
<div className="preview-block position-relative w-100">
|
||||
<span className="align-middle">
|
||||
<span className="preview-term">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.term_image !== ''
|
||||
? <img className="img-preview" src={card.term_image} alt="TERM_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.term !== '' ? card.term : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
<span className="preview-definition">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.definition_image !== ''
|
||||
? <img className="img-preview" src={card.definition_image} alt="DEF_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.definition !== '' ? card.definition : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: <div className="card-spacer d-flex align-self-stretch" />}
|
||||
<Dropdown onToggle={(isOpen, e) => e.stopPropagation()}>
|
||||
<Dropdown.Toggle
|
||||
className="card-dropdown"
|
||||
as={IconButton}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => moveCardUp(index)}>Move up</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => moveCardDown(index)}>Move down</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={() => removeCard({ index })}>Delete</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandMore}
|
||||
iconAs={Icon}
|
||||
alt="EXPAND"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandLess}
|
||||
iconAs={Icon}
|
||||
alt="COLLAPSE"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<div className="card-body p-0">
|
||||
<Collapsible.Body>
|
||||
<div className="card-body-divider">
|
||||
<div className="card-divider" />
|
||||
</div>
|
||||
<div className="card-term d-flex flex-column align-items-start align-self-stretch">
|
||||
Term
|
||||
{(type !== 'matching' && card.term_image !== '') && termImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`term|${index}`}
|
||||
placeholder="Enter your term"
|
||||
value={card.term}
|
||||
onChange={(e) => updateTerm({ index, term: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && termImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-divider" />
|
||||
<div className="card-definition d-flex flex-column align-items-start align-self-stretch">
|
||||
Definition
|
||||
{(type !== 'matching' && card.definition_image !== '') && definitionImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`definition|${index}`}
|
||||
placeholder="Enter your definition"
|
||||
value={card.definition}
|
||||
onChange={(e) => updateDefinition({ index, definition: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && definitionImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Body>
|
||||
</div>
|
||||
</Collapsible.Advanced>
|
||||
</SortableItem>
|
||||
))
|
||||
}
|
||||
</DraggableList>
|
||||
<Button
|
||||
className="add-button"
|
||||
onClick={() => addCard()}
|
||||
>
|
||||
<IconButton
|
||||
src={Add}
|
||||
iconAs={Icon}
|
||||
alt="ADD"
|
||||
variant="primary"
|
||||
/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sidebar d-flex flex-column align-items-start flex-shrink-0">
|
||||
<SettingsOption
|
||||
className="sidebar-type d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Type"
|
||||
summary={type.substr(0, 1).toUpperCase() + type.substr(1)}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<Button
|
||||
onClick={() => updateType('flashcards')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Flashcards</span>
|
||||
<span hidden={type !== 'flashcards'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
<div className="card-divider" />
|
||||
<Button
|
||||
onClick={() => updateType('matching')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Matching</span>
|
||||
<span hidden={type !== 'matching'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
</SettingsOption>
|
||||
<SettingsOption
|
||||
className="sidebar-shuffle d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Shuffle"
|
||||
summary={settings.shuffle ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Shuffle the order of terms shown to learners when reviewing.</div>
|
||||
<Button
|
||||
onClick={() => shuffleFalse()}
|
||||
variant={!settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => shuffleTrue()}
|
||||
variant={settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
{type === 'matching' && timerSettingsOption}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Page content goes here
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={module.hooks.getContent}
|
||||
getContent={hooks.getContent}
|
||||
onClose={onClose}
|
||||
isDirty={() => isDirty}
|
||||
>
|
||||
<div>
|
||||
{exampleValue}
|
||||
</div>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
// Use a messages.js file for intl messages.
|
||||
screenreadertext={intl.formatMessage('Loading Spinner')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p>
|
||||
Your Editor Goes here.
|
||||
You can get at the xblock data with the blockValue field.
|
||||
here is what is in your xblock: {JSON.stringify(blockValue)}
|
||||
</p>
|
||||
)}
|
||||
{!blockFinished ? loading : page}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
ThumbEditor.defaultProps = {
|
||||
blockValue: null,
|
||||
lmsEndpointUrl: null,
|
||||
};
|
||||
ThumbEditor.propTypes = {
|
||||
|
||||
GameEditor.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
blockValue: PropTypes.shape({
|
||||
data: PropTypes.shape({ data: PropTypes.string }),
|
||||
}),
|
||||
lmsEndpointUrl: PropTypes.string,
|
||||
blockFailed: PropTypes.bool.isRequired,
|
||||
blockFinished: PropTypes.bool.isRequired,
|
||||
initializeEditor: PropTypes.func.isRequired,
|
||||
list: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
updateTerm: PropTypes.func.isRequired,
|
||||
updateTermImage: PropTypes.func.isRequired,
|
||||
updateDefinition: PropTypes.func.isRequired,
|
||||
updateDefinitionImage: PropTypes.func.isRequired,
|
||||
toggleOpen: PropTypes.func.isRequired,
|
||||
setList: PropTypes.func.isRequired,
|
||||
addCard: PropTypes.func.isRequired,
|
||||
removeCard: PropTypes.func.isRequired,
|
||||
settings: PropTypes.shape({
|
||||
shuffle: PropTypes.bool.isRequired,
|
||||
timer: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
shuffleTrue: PropTypes.func.isRequired,
|
||||
shuffleFalse: PropTypes.func.isRequired,
|
||||
timerTrue: PropTypes.func.isRequired,
|
||||
timerFalse: PropTypes.func.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
updateType: PropTypes.func.isRequired,
|
||||
|
||||
isDirty: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
blockValue: selectors.app.blockValue(state),
|
||||
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
|
||||
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
// TODO fill with redux state here if needed
|
||||
exampleValue: selectors.game.exampleValue(state),
|
||||
settings: selectors.game.settings(state),
|
||||
type: selectors.game.type(state),
|
||||
list: selectors.game.list(state),
|
||||
isDirty: selectors.game.isDirty(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
initializeEditor: actions.app.initializeEditor,
|
||||
// TODO fill with dispatches here if needed
|
||||
|
||||
// shuffle
|
||||
shuffleTrue: actions.game.shuffleTrue,
|
||||
shuffleFalse: actions.game.shuffleFalse,
|
||||
|
||||
// timer
|
||||
timerTrue: actions.game.timerTrue,
|
||||
timerFalse: actions.game.timerFalse,
|
||||
|
||||
// type
|
||||
updateType: actions.game.updateType,
|
||||
|
||||
// list
|
||||
updateTerm: actions.game.updateTerm,
|
||||
updateTermImage: actions.game.updateTermImage,
|
||||
updateDefinition: actions.game.updateDefinition,
|
||||
updateDefinitionImage: actions.game.updateDefinitionImage,
|
||||
toggleOpen: actions.game.toggleOpen,
|
||||
setList: actions.game.setList,
|
||||
addCard: actions.game.addCard,
|
||||
removeCard: actions.game.removeCard,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ThumbEditor);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GameEditor);
|
||||
|
||||
275
src/editors/containers/GameEditor/index.scss
Normal file
275
src/editors/containers/GameEditor/index.scss
Normal file
@@ -0,0 +1,275 @@
|
||||
/* Basic styles to support GameEditor layout and classes used in JSX */
|
||||
.editor-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
padding: 8px 0 0 24px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 0;
|
||||
gap: 16px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.terms > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
padding: 8px 24px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.description-header {
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.draggable-button {
|
||||
cursor: grab;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: #EEF1F5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
max-height: 32px;
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
.card-image-area {
|
||||
display: flex;
|
||||
padding: 0 24px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: var(--light-400, #EAE6E5);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-right: 8px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.preview-term {
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.preview-block {
|
||||
margin-right: 8px;
|
||||
bottom: 35%;
|
||||
}
|
||||
|
||||
.preview-definition {
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.description-body {
|
||||
align-self: stretch;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
border: var(--extras-white, #FFFFFF);
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-spacer {
|
||||
flex: 1 0 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.card-delete-button, .card-image-button, .image-delete-button {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 44px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-body-divider {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.card-term, .card-definition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
gap: 16px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.card-input-line {
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1 0 0;
|
||||
border: 1px solid var(--gray-500, #707070);
|
||||
background: #FFFFFF;
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.sidebar-type, .sidebar-shuffle, .sidebar-timer {
|
||||
gap: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--light-700, #D7D3D1);
|
||||
background: #FFFFFF;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drag-spacer {
|
||||
width: 20px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.check {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
.card-dropdown {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
padding-bottom: 16px;
|
||||
color: #51565C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
11
src/editors/containers/GameEditor/messages.ts
Normal file
11
src/editors/containers/GameEditor/messages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingSpinner: {
|
||||
id: 'GameEditor.loadingSpinner',
|
||||
defaultMessage: 'Loading Spinner',
|
||||
description: 'Loading message for spinner screenreader text.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -6,5 +6,5 @@ export const blockTypes = StrictDict({
|
||||
problem: 'problem',
|
||||
// ADDED_EDITORS GO BELOW
|
||||
video_upload: 'video_upload',
|
||||
game: 'game',
|
||||
game: 'games',
|
||||
});
|
||||
|
||||
@@ -1,22 +1,136 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
const generateId = () => `card-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
const initialState = {
|
||||
settings: {},
|
||||
// TODO fill in with mock state
|
||||
exampleValue: 'this is an example value from the redux state',
|
||||
settings: {
|
||||
shuffle: false,
|
||||
timer: false,
|
||||
},
|
||||
type: 'flashcards',
|
||||
list: [
|
||||
{
|
||||
id: generateId(),
|
||||
term: '',
|
||||
term_image: '',
|
||||
definition: '',
|
||||
definition_image: '',
|
||||
editorOpen: true,
|
||||
},
|
||||
],
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const game = createSlice({
|
||||
name: 'game',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateField: (state, { payload }) => ({
|
||||
// settings
|
||||
shuffleTrue: (state) => ({
|
||||
...state,
|
||||
...payload,
|
||||
settings: {
|
||||
...state.settings,
|
||||
shuffle: true,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
shuffleFalse: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
shuffle: false,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
timerTrue: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
timer: true,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
timerFalse: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
timer: false,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
// type
|
||||
updateType: (state, { payload }) => ({
|
||||
...state,
|
||||
type: payload,
|
||||
isDirty: true,
|
||||
}),
|
||||
// list operations
|
||||
updateTerm: (state, { payload }) => {
|
||||
const { index, term } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, term } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateTermImage: (state, { payload }) => {
|
||||
const { index, termImage } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, term_image: termImage } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateDefinition: (state, { payload }) => {
|
||||
const { index, definition } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, definition } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateDefinitionImage: (state, { payload }) => {
|
||||
const { index, definitionImage } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map(
|
||||
(item, idx) => (idx === index ? { ...item, definition_image: definitionImage } : item),
|
||||
);
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
toggleOpen: (state, { payload }) => {
|
||||
const { index, isOpen } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, editorOpen: !!isOpen } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
setList: (state, { payload }) => ({
|
||||
...state,
|
||||
list: payload,
|
||||
isDirty: true,
|
||||
}),
|
||||
addCard: (state) => ({
|
||||
...state,
|
||||
list: [
|
||||
...state.list,
|
||||
{
|
||||
id: generateId(),
|
||||
term: '',
|
||||
term_image: '',
|
||||
definition: '',
|
||||
definition_image: '',
|
||||
editorOpen: true,
|
||||
},
|
||||
],
|
||||
isDirty: true,
|
||||
}),
|
||||
removeCard: (state, { payload }) => {
|
||||
const { index } = payload;
|
||||
if (index < 0 || index >= state.list.length) { return state; }
|
||||
return {
|
||||
...state,
|
||||
list: state.list.filter((_, idx) => idx !== index),
|
||||
isDirty: true,
|
||||
};
|
||||
},
|
||||
setDirty: (state, { payload }) => ({
|
||||
...state,
|
||||
isDirty: payload,
|
||||
}),
|
||||
// TODO fill in reducers
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import * as module from './selectors';
|
||||
export const gameState = (state) => state.game;
|
||||
const mkSimpleSelector = (cb) => createSelector([module.gameState], cb);
|
||||
export const simpleSelectors = {
|
||||
exampleValue: mkSimpleSelector(gameData => gameData.exampleValue),
|
||||
settings: mkSimpleSelector(gameData => gameData.settings),
|
||||
type: mkSimpleSelector(gameData => gameData.type),
|
||||
list: mkSimpleSelector(gameData => gameData.list),
|
||||
isDirty: mkSimpleSelector(gameData => gameData.isDirty),
|
||||
completeState: mkSimpleSelector(gameData => gameData),
|
||||
// TODO fill in with selectors as needed
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import TextEditor from './containers/TextEditor';
|
||||
import VideoEditor from './containers/VideoEditor';
|
||||
import ProblemEditor from './containers/ProblemEditor';
|
||||
import VideoUploadEditor from './containers/VideoUploadEditor';
|
||||
import GameEditor from './containers/GameEditor';
|
||||
import GamesEditor from './containers/GameEditor';
|
||||
|
||||
// ADDED_EDITOR_IMPORTS GO HERE
|
||||
|
||||
@@ -14,7 +14,7 @@ const supportedEditors = {
|
||||
[blockTypes.problem]: ProblemEditor,
|
||||
[blockTypes.video_upload]: VideoUploadEditor,
|
||||
// ADDED_EDITORS GO BELOW
|
||||
[blockTypes.game]: GameEditor,
|
||||
[blockTypes.game]: GamesEditor,
|
||||
} as const;
|
||||
|
||||
export default supportedEditors;
|
||||
|
||||
@@ -42,7 +42,7 @@ const GradingSettings = ({ courseId }) => {
|
||||
} = useCourseSettings(courseId);
|
||||
const {
|
||||
mutate: updateGradingSettings,
|
||||
isLoading: savePending,
|
||||
isPending: savePending,
|
||||
isSuccess: savingStatus,
|
||||
isError: savingFailed,
|
||||
} = useGradingSettingUpdater(courseId);
|
||||
|
||||
@@ -39,6 +39,9 @@ describe('<GradingSettings />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// jsdom doesn't implement scrollTo; mock to avoid noisy console errors.
|
||||
Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true });
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -99,6 +102,26 @@ describe('<GradingSettings />', () => {
|
||||
testSaving();
|
||||
});
|
||||
|
||||
it('should show success alert and hide save prompt after successful save', async () => {
|
||||
// Trigger change to show save prompt
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[2];
|
||||
fireEvent.change(segmentInput, { target: { value: 'PatchTest' } });
|
||||
// Click save and verify pending state appears
|
||||
const saveBtnInitial = screen.getByText(messages.buttonSaveText.defaultMessage);
|
||||
fireEvent.click(saveBtnInitial);
|
||||
expect(screen.getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
|
||||
// Wait for success alert to appear (mutation success)
|
||||
const successAlert = await screen.findByText(messages.alertSuccess.defaultMessage);
|
||||
expect(successAlert).toBeVisible();
|
||||
// Pending label should disappear and save prompt should be hidden (button removed)
|
||||
expect(screen.queryByText(messages.buttonSavingText.defaultMessage)).toBeNull();
|
||||
const saveAlert = screen.queryByTestId('grading-settings-save-alert');
|
||||
expect(saveAlert).toBeNull();
|
||||
// Ensure original save button text is no longer present because the prompt closed
|
||||
expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle being offline gracefully', async () => {
|
||||
setOnlineStatus(false);
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('header utils', () => {
|
||||
describe('useLibrarySettingsMenuItems', () => {
|
||||
it('should contain team access url', () => {
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current;
|
||||
expect(items).toContainEqual({ title: 'Team Access', href: 'http://localhost/?sa=manage-team' });
|
||||
expect(items).toContainEqual({ title: 'Library Team', href: 'http://localhost/?sa=manage-team' });
|
||||
});
|
||||
it('should contain admin console url if set', () => {
|
||||
setConfig({
|
||||
@@ -152,7 +152,7 @@ describe('header utils', () => {
|
||||
});
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current;
|
||||
expect(items).toContainEqual({
|
||||
title: 'Team Access',
|
||||
title: 'Library Team',
|
||||
href: 'http://admin-console.com/authz/libraries/library-123',
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe('header utils', () => {
|
||||
});
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', true)).result.current;
|
||||
expect(items).toContainEqual({
|
||||
title: 'Team Access',
|
||||
title: 'Library Team',
|
||||
href: 'http://admin-console.com/authz/libraries/library-123',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'header.menu.teamAccess': {
|
||||
id: 'header.links.teamAccess',
|
||||
defaultMessage: 'Team Access',
|
||||
defaultMessage: 'Library Team',
|
||||
description: 'Menu item to open team access popup',
|
||||
},
|
||||
'header.links.optimizer': {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,21 +314,21 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Manage Access" button in Library Info that opens the Library Team modal', async () => {
|
||||
it('should show Library Team button in Library Info that opens the Library Team modal', async () => {
|
||||
await renderLibraryPage();
|
||||
const manageAccess = screen.getByRole('button', { name: /manage access/i });
|
||||
const manageAccess = await screen.findByRole('button', { name: /Library Team/i });
|
||||
|
||||
expect(manageAccess).not.toBeDisabled();
|
||||
fireEvent.click(manageAccess);
|
||||
|
||||
expect(await screen.findByText('Library Team')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: 'Library Team' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "Manage Access" button in Library Info to users who cannot edit the library', async () => {
|
||||
it('should not show "Library Team" button in Library Info to users who cannot edit the library', async () => {
|
||||
const libraryId = mockContentLibrary.libraryIdReadOnly;
|
||||
render(<LibraryLayout />, { path, params: { libraryId } });
|
||||
|
||||
const manageAccess = screen.queryByRole('button', { name: /manage access/i });
|
||||
const manageAccess = screen.queryByRole('button', { name: /Library Team/i });
|
||||
expect(manageAccess).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -276,7 +276,7 @@ describe('<LibraryInfo />', () => {
|
||||
const ADMIN_CONSOLE_URL = 'http://localhost:2025/admin-console';
|
||||
mergeConfig({ ADMIN_CONSOLE_URL });
|
||||
render();
|
||||
const manageTeam = await screen.getByText('Manage Access');
|
||||
const manageTeam = await screen.findByText('Library Team');
|
||||
expect(manageTeam).toBeInTheDocument();
|
||||
expect(manageTeam).toHaveAttribute('href', `${ADMIN_CONSOLE_URL}/authz/libraries/${libraryData.id}`);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ const messages = defineMessages({
|
||||
},
|
||||
libraryTeamButtonTitle: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.library-team.button.title',
|
||||
defaultMessage: 'Manage Access',
|
||||
defaultMessage: 'Library Team',
|
||||
description: 'Title to use for the button that allows viewing/editing the Library Team user access.',
|
||||
},
|
||||
libraryHistorySectionTitle: {
|
||||
|
||||
@@ -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