Compare commits

...

6 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
9610f0791f feat: add games xblock editor
With this Commit, games xblock editor is in place now!
- copy code from https://github.com/openedx-unsupported/frontend-lib-content-components/pull/371/files to authoring MFE
  - It includes refactoring in .scss files, useIntl, replacing deprecated dependencies, fixing reducers, fixed cancel/close editor button, fix dragging the cards, edit some styles and also removed duplicate styling etc.
2025-11-17 14:08:16 +05:00
edX requirements bot
0f58329cb4 chore: update browserslist DB (#2653)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-17 00:22:55 +00:00
Chris Chávez
54cfbeb756 feat: import course in library stepper [FC-0112] (#2567)
- Implemented the course import stepper described in https://github.com/openedx/frontend-app-authoring/issues/2524
- Adds the new `ENABLE_COURSE_IMPORT_IN_LIBRARY` flag
2025-11-14 13:07:00 -05:00
Muhammad Anas
7cf01de84c fix: grading settings save button stuck in pending state (#2614) 2025-11-14 08:54:57 -05:00
Navin Karkera
a1abd43a11 refactor: rename team access navbar and sidebar (#2644) 2025-11-13 20:27:06 -05:00
Muhammad Anas
8f06263e27 fix: unit button active state (#2617) 2025-11-13 12:08:18 -05:00
48 changed files with 2011 additions and 380 deletions

1
.env
View File

@@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6

View File

@@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''

View File

@@ -33,6 +33,7 @@ ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"

6
package-lock.json generated
View File

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

View File

@@ -1,7 +1,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { XBlock } from '@src/data/types';
import { CourseOutline } from './types';
import { CourseOutline, CourseDetails } from './types';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = (
courseId: string,
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded,
@@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl
/**
* Get course outline index.
* @param {string} courseId
* @returns {Promise<courseOutline>}
* @returns {Promise<CourseOutline>}
*/
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
const { data } = await getAuthenticatedHttpClient()
@@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
return camelCaseObject(data);
}
/**
* Get course details.
* @param {string} courseId
* @returns {Promise<CourseDetails>}
*/
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseDetailsApiUrl(courseId));
return camelCaseObject(data);
}
/**
*
* @param courseId

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import { getCourseItem } from './api';
import { getCourseDetails, getCourseItem } from './api';
export const courseOutlineQueryKeys = {
all: ['courseOutline'],
@@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = {
*/
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
};
/**
@@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
enabled: enabled && itemId !== undefined,
})
);
export const useCourseDetails = (courseId?: string) => (
useQuery({
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
})
);

View File

@@ -24,6 +24,15 @@ export interface CourseOutline {
rerunNotificationId: null;
}
// TODO: This interface has only basic data, all the rest needs to be added.
export interface CourseDetails {
courseId: string;
title: string;
subtitle?: string;
org: string;
description?: string;
}
export interface CourseOutlineState {
loadingStatus: {
outlineIndexLoadingStatus: string;

View File

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

View File

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

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

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

View File

@@ -6,5 +6,5 @@ export const blockTypes = StrictDict({
problem: 'problem',
// ADDED_EDITORS GO BELOW
video_upload: 'video_upload',
game: 'game',
game: 'games',
});

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ const GradingSettings = ({ courseId }) => {
} = useCourseSettings(courseId);
const {
mutate: updateGradingSettings,
isLoading: savePending,
isPending: savePending,
isSuccess: savingStatus,
isError: savingFailed,
} = useGradingSettingUpdater(courseId);

View File

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

View File

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

View File

@@ -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': {

View File

@@ -173,6 +173,7 @@ initialize({
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false',
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import { ROUTES } from './routes';
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
import { LibraryUnitPage } from './units';
import { LibraryTeamModal } from './library-team';
import { ImportStepperPage } from './import-course/stepper/ImportStepperPage';
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
const {
@@ -97,6 +98,10 @@ const LibraryLayout = () => (
path={ROUTES.IMPORT}
Component={CourseImportHomePage}
/>
<Route
path={ROUTES.IMPORT_COURSE}
Component={ImportStepperPage}
/>
</Route>
</Routes>
);

View File

@@ -1133,3 +1133,17 @@ mockGetCourseImports.applyMock = () => jest.spyOn(
api,
'getCourseImports',
).mockImplementation(mockGetCourseImports);
export const mockGetMigrationInfo = {
applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue(
camelCaseObject({
'course-v1:HarvardX+123+2023': [{
sourceKey: 'course-v1:HarvardX+123+2023',
targetCollectionKey: 'ltc:org:coll-1',
targetCollectionTitle: 'Collection 1',
targetKey: mockContentLibrary.libraryId,
targetTitle: 'Library 1',
}],
}),
),
};

View File

@@ -809,3 +809,24 @@ export async function getCourseImports(libraryId: string): Promise<CourseImport[
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId));
return camelCaseObject(data);
}
export interface MigrationInfo {
sourceKey: string;
targetCollectionKey: string;
targetCollectionTitle: string;
targetKey: string;
targetTitle: string;
}
/**
* Get the migration info data for a list of source keys
*/
export async function getMigrationInfo(sourceKeys: string[]): Promise<Record<string, MigrationInfo[]>> {
const client = getAuthenticatedHttpClient();
const params = new URLSearchParams();
sourceKeys.forEach(key => params.append('source_keys', key));
const { data } = await client.get(`${getApiBaseUrl()}/api/modulestore_migrator/v1/migration_info/`, { params });
return camelCaseObject(data);
}

View File

@@ -93,6 +93,11 @@ export const libraryAuthoringQueryKeys = {
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
'courseImports',
],
migrationInfo: (sourceKeys: string[]) => [
...libraryAuthoringQueryKeys.all,
'migrationInfo',
...sourceKeys,
],
};
export const xblockQueryKeys = {
@@ -965,3 +970,13 @@ export const useCourseImports = (libraryId: string) => (
queryFn: () => api.getCourseImports(libraryId),
})
);
/**
* Returns the migration info of a given source list
*/
export const useMigrationInfo = (sourcesKeys: string[]) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.migrationInfo(sourcesKeys),
queryFn: () => api.getMigrationInfo(sourcesKeys),
})
);

View File

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

View File

@@ -1,3 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import {
Button,
Card,
@@ -6,8 +8,7 @@ import {
Stack,
} from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { Helmet } from 'react-helmet';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import Loading from '@src/generic/Loading';
import SubHeader from '@src/generic/sub-header/SubHeader';
@@ -19,14 +20,26 @@ import { HelpSidebar } from './HelpSidebar';
import { ImportedCourseCard } from './ImportedCourseCard';
import messages from './messages';
const ImportCourseButton = () => {
const navigate = useNavigate();
if (getConfig().ENABLE_COURSE_IMPORT_IN_LIBRARY === 'true') {
return (
<Button iconBefore={Add} onClick={() => navigate('courses')}>
<FormattedMessage {...messages.importCourseButton} />
</Button>
);
}
return null;
};
const EmptyState = () => (
<Container size="md" className="py-6">
<Card>
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
<FormattedMessage {...messages.emptyStateText} />
<Button iconBefore={Add} disabled>
<FormattedMessage {...messages.emptyStateButtonText} />
</Button>
<ImportCourseButton />
</Stack>
</Card>
</Container>
@@ -64,6 +77,7 @@ export const CourseImportHomePage = () => {
title={intl.formatMessage(messages.pageTitle)}
subtitle={intl.formatMessage(messages.pageSubtitle)}
hideBorder
headerActions={<ImportCourseButton />}
/>
</div>
<Layout xs={[{ span: 9 }, { span: 3 }]}>

View File

@@ -73,6 +73,97 @@ const messages = defineMessages({
+ '<p>For additional details you can review the Library Import documentation.</p>',
description: 'Body of the second question in the Help & Support sidebar',
},
importCourseStepperTitle: {
id: 'course-authoring.library-authoring.import-course.stepper.title',
defaultMessage: 'Import Course to Library',
description: 'Title for the modal to import a course into a library.',
},
importCourseButton: {
id: 'course-authoring.library-authoring.import-course.button.text',
defaultMessage: 'Import Course',
description: 'Label of the button to open the modal to import a course into a library.',
},
importCourseSelectCourseStep: {
id: 'course-authoring.library-authoring.import-course.stepper.select-course.title',
defaultMessage: 'Select Course',
description: 'Title for the step to select course in the modal to import a course into a library.',
},
importCourseReviewDetailsStep: {
id: 'course-authoring.library-authoring.import-course.stepper.review-details.title',
defaultMessage: 'Review Import Details',
description: 'Title for the step to review import details in the modal to import a course into a library.',
},
importCourseCalcel: {
id: 'course-authoring.library-authoring.import-course.stepper.cancel.text',
defaultMessage: 'Cancel',
description: 'Label of the button to cancel the course import.',
},
importCourseNext: {
id: 'course-authoring.library-authoring.import-course.stepper.next.text',
defaultMessage: 'Next step',
description: 'Label of the button go to the next step in the course import modal.',
},
importCourseBack: {
id: 'course-authoring.library-authoring.import-course.stepper.back.text',
defaultMessage: 'Back',
description: 'Label of the button to go to the previous step in the course import modal.',
},
importCourseInProgressStatusTitle: {
id: 'course-authoring.library-authoring.import-course.review-details.in-progress.title',
defaultMessage: 'Import Analysis in Progress',
description: 'Titile for the info card with the in-progress status in the course import modal.',
},
importCourseInProgressStatusBody: {
id: 'course-authoring.library-authoring.import-course.review-details.in-progress.body',
defaultMessage: '{courseName} is being analyzed for review prior to import. For large courses, this may take some time.'
+ ' Please remain on this page.',
description: 'Body of the info card with the in-progress status in the course import modal.',
},
importCourseAnalysisSummary: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.title',
defaultMessage: 'Analysis Summary',
description: 'Title of the card for the analysis summary of a imported course.',
},
importCourseTotalBlocks: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.total-blocks',
defaultMessage: 'Total Blocks',
description: 'Label title for the total blocks in the analysis summary of a imported course.',
},
importCourseSections: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.sections',
defaultMessage: 'Sections',
description: 'Label title for the number of sections in the analysis summary of a imported course.',
},
importCourseSubsections: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.subsections',
defaultMessage: 'Subsections',
description: 'Label title for the number of subsections in the analysis summary of a imported course.',
},
importCourseUnits: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.units',
defaultMessage: 'Units',
description: 'Label title for the number of units in the analysis summary of a imported course.',
},
importCourseComponents: {
id: 'course-authoring.library-authoring.import-course.review-details.analysis-symmary.components',
defaultMessage: 'Components',
description: 'Label title for the number of components in the analysis summary of a imported course.',
},
importCourseDetailsTitle: {
id: 'course-authoring.library-authoring.import-course.review-details.import-details.title',
defaultMessage: 'Import Details',
description: 'Title of the card for the import details of a imported course.',
},
importCourseDetailsLoadingBody: {
id: 'course-authoring.library-authoring.import-course.review-details.import-details.loading.body',
defaultMessage: 'The selected course is being analyzed for import and review',
description: 'Body of the card in loading state for the import details of a imported course.',
},
previouslyImported: {
id: 'course-authoring.library-authoring.import-course.course-list.card.previously-imported.text',
defaultMessage: 'Previously Imported',
description: 'Chip that indicates that the course has been previously imported.',
},
});
export default messages;

View File

@@ -0,0 +1,156 @@
import userEvent from '@testing-library/user-event';
import {
initializeMocks,
render,
screen,
fireEvent,
waitFor,
} from '@src/testUtils';
import { initialState } from '@src/studio-home/factories/mockApiResponses';
import { RequestStatus } from '@src/data/constants';
import { type DeprecatedReduxState } from '@src/store';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { getCourseDetailsApiUrl } from '@src/course-outline/data/api';
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks';
import { ImportStepperPage } from './ImportStepperPage';
let axiosMock;
mockGetMigrationInfo.applyMock();
mockContentLibrary.applyMock();
type StudioHomeState = DeprecatedReduxState['studioHome'];
const libraryKey = mockContentLibrary.libraryId;
const numPages = 1;
const coursesCount = studioHomeMock.courses.length;
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
// Generate a custom initial state based on studioHomeCoursesRequestParams
const customInitialState: Partial<DeprecatedReduxState> = {
...initialState,
studioHome: {
...initialState.studioHome,
studioHomeData: {
courses: studioHomeMock.courses,
numPages,
coursesCount,
},
loadingStatuses: {
...initialState.studioHome.loadingStatuses,
courseLoadingStatus: RequestStatus.SUCCESSFUL,
},
...studioHomeState,
},
};
// Initialize the store with the custom initial state
const newMocks = initializeMocks({ initialState: customInitialState });
const store = newMocks.reduxStore;
axiosMock = newMocks.axiosMock;
return {
...render(
<ImportStepperPage />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryKey}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import/course',
params: { libraryId: libraryKey },
},
),
store,
};
};
describe('<ImportStepperModal />', () => {
it('should render correctly', async () => {
renderComponent();
// Renders the stepper header
expect(await screen.findByText('Select Course')).toBeInTheDocument();
expect(await screen.findByText('Review Import Details')).toBeInTheDocument();
// Renders the course list and previously imported chip
expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument();
expect(screen.getByText(/run 0/i)).toBeInTheDocument();
expect(await screen.findByText('Previously Imported')).toBeInTheDocument();
// Renders cancel and next step buttons
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next step/i })).toBeInTheDocument();
});
it('should cancel the import', async () => {
const user = userEvent.setup();
renderComponent();
const cancelButon = await screen.findByRole('button', { name: /cancel/i });
await user.click(cancelButon);
expect(mockNavigate).toHaveBeenCalled();
});
it('should go to review import details step', async () => {
const user = userEvent.setup();
renderComponent();
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
courseId: 'course-v1:HarvardX+123+2023',
title: 'Managing Risk in the Information Age',
subtitle: '',
org: 'HarvardX',
description: 'This is a test course',
});
const nextButton = await screen.findByRole('button', { name: /next step/i });
expect(nextButton).toBeDisabled();
// Select a course
const courseCard = screen.getAllByRole('radio')[0];
await fireEvent.click(courseCard);
expect(courseCard).toBeChecked();
// Click next
expect(nextButton).toBeEnabled();
await user.click(nextButton);
await waitFor(async () => expect(await screen.findByText(
/managing risk in the information age is being analyzed for review prior to import/i,
)).toBeInTheDocument());
expect(screen.getByText('Analysis Summary')).toBeInTheDocument();
expect(screen.getByText('Import Details')).toBeInTheDocument();
// The import details is loading
expect(screen.getByText('The selected course is being analyzed for import and review')).toBeInTheDocument();
});
it('the course should remain selected on back', async () => {
const user = userEvent.setup();
renderComponent();
const nextButton = await screen.findByRole('button', { name: /next step/i });
expect(nextButton).toBeDisabled();
// Select a course
const courseCard = screen.getAllByRole('radio')[0];
await fireEvent.click(courseCard);
expect(courseCard).toBeChecked();
// Click next
expect(nextButton).toBeEnabled();
await user.click(nextButton);
const backButton = await screen.getByRole('button', { name: /back/i });
await user.click(backButton);
expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument();
expect(courseCard).toBeChecked();
});
});

View File

@@ -0,0 +1,158 @@
import { useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Chip, Container, Layout, Stepper,
} from '@openedx/paragon';
import { CoursesList, MigrationStatusProps } from '@src/studio-home/tabs-section/courses-tab';
import { useStudioHome } from '@src/studio-home/hooks';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import Loading from '@src/generic/Loading';
import Header from '@src/header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { ReviewImportDetails } from './ReviewImportDetails';
import messages from '../messages';
import { HelpSidebar } from '../HelpSidebar';
type MigrationStep = 'select-course' | 'review-details';
export const MigrationStatus = ({
courseId,
allVisibleCourseIds,
}: MigrationStatusProps) => {
const { libraryId } = useLibraryContext();
const {
data: migrationInfoData,
} = useMigrationInfo(allVisibleCourseIds);
const processedMigrationInfo = useMemo(() => {
const result = {};
if (migrationInfoData) {
for (const libraries of Object.values(migrationInfoData)) {
// The map key in `migrationInfoData` is in camelCase.
// In the processed map, we use the key in its original form.
result[libraries[0].sourceKey] = libraries.map(item => item.targetKey);
}
}
return result;
}, [migrationInfoData]);
const isPreviouslyMigrated = (
courseId in processedMigrationInfo && processedMigrationInfo[courseId].includes(libraryId)
);
if (!isPreviouslyMigrated) {
return null;
}
return (
<div
key={`${courseId}-${processedMigrationInfo[courseId].join('-')}`}
className="previously-migrated-chip"
>
<Chip>
<FormattedMessage {...messages.previouslyImported} />
</Chip>
</div>
);
};
export const ImportStepperPage = () => {
const intl = useIntl();
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-course');
const [selectedCourseId, setSelectedCourseId] = useState<string>();
const { libraryId, libraryData, readOnly } = useLibraryContext();
// Load the courses list
// The loading state is handled in `CoursesList`
useStudioHome();
if (!libraryData) {
return <Loading />;
}
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
readOnly={readOnly}
containerProps={{
size: undefined,
}}
/>
<Container className="mt-4 mb-5">
<div className="px-4 bg-light-200 border-bottom">
<SubHeader
title={intl.formatMessage(messages.importCourseStepperTitle)}
hideBorder
/>
</div>
<Layout xs={[{ span: 9 }, { span: 3 }]}>
<Layout.Element>
<Stepper activeKey={currentStep}>
<Stepper.Header />
<Stepper.Step
eventKey="select-course"
title={intl.formatMessage(messages.importCourseSelectCourseStep)}
>
<CoursesList
selectedCourseId={selectedCourseId}
handleSelect={setSelectedCourseId}
cardMigrationStatusWidget={MigrationStatus}
/>
</Stepper.Step>
<Stepper.Step
eventKey="review-details"
title={intl.formatMessage(messages.importCourseReviewDetailsStep)}
>
<ReviewImportDetails courseId={selectedCourseId} />
</Stepper.Step>
</Stepper>
<div className="mt-4">
{currentStep === 'select-course' ? (
<ActionRow className="d-flex justify-content-between">
<Button variant="outline-primary" onClick={() => navigate('../import')}>
<FormattedMessage {...messages.importCourseCalcel} />
</Button>
<Button
onClick={() => setCurrentStep('review-details')}
disabled={selectedCourseId === undefined}
>
<FormattedMessage {...messages.importCourseNext} />
</Button>
</ActionRow>
) : (
<ActionRow className="d-flex justify-content-between">
<Button onClick={() => setCurrentStep('select-course')} variant="tertiary">
<FormattedMessage {...messages.importCourseBack} />
</Button>
<Button disabled>
<FormattedMessage {...messages.importCourseButton} />
</Button>
</ActionRow>
)}
</div>
</Layout.Element>
<Layout.Element>
<HelpSidebar />
</Layout.Element>
</Layout>
</Container>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Card, Stack } from '@openedx/paragon';
import { LoadingSpinner } from '@src/generic/Loading';
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
import messages from '../messages';
import { SummaryCard } from './SummaryCard';
export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => {
const { data, isPending } = useCourseDetails(courseId);
return (
<Stack gap={4}>
<Card>
{data && !isPending ? (
<Card.Section>
<h4><FormattedMessage {...messages.importCourseInProgressStatusTitle} /></h4>
<p>
<FormattedMessage
{...messages.importCourseInProgressStatusBody}
values={{
courseName: data?.title || '',
}}
/>
</p>
</Card.Section>
) : (
<div className="text-center p-3">
<LoadingSpinner />
</div>
)}
</Card>
<h4><FormattedMessage {...messages.importCourseAnalysisSummary} /></h4>
<SummaryCard />
<h4><FormattedMessage {...messages.importCourseDetailsTitle} /></h4>
<Card className="p-6">
<Stack className="align-items-center" gap={3}>
<LoadingSpinner />
<FormattedMessage {...messages.importCourseDetailsLoadingBody} />
</Stack>
</Card>
</Stack>
);
};

View File

@@ -0,0 +1,51 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Card, Icon, Stack } from '@openedx/paragon';
import { Widgets } from '@openedx/paragon/icons';
import { LoadingSpinner } from '@src/generic/Loading';
import { getItemIcon } from '@src/generic/block-type-utils';
import messages from '../messages';
// TODO: The SummaryCard is always in loading state
export const SummaryCard = () => (
<Card>
<Card.Section>
<Stack direction="horizontal">
<Stack className="align-items-center" gap={2}>
<FormattedMessage {...messages.importCourseTotalBlocks} />
<LoadingSpinner />
</Stack>
<div className="border-light-400" style={{ borderLeft: '2px solid', height: '50px' }} />
<Stack className="align-items-center" gap={2}>
<FormattedMessage {...messages.importCourseSections} />
<Stack className="justify-content-center" direction="horizontal" gap={3}>
<Icon src={getItemIcon('section')} />
<LoadingSpinner />
</Stack>
</Stack>
<Stack className="align-items-center" gap={2}>
<FormattedMessage {...messages.importCourseSubsections} />
<Stack className="justify-content-center" direction="horizontal" gap={3}>
<Icon src={getItemIcon('subsection')} />
<LoadingSpinner />
</Stack>
</Stack>
<Stack className="align-items-center" gap={2}>
<FormattedMessage {...messages.importCourseUnits} />
<Stack className="justify-content-center" direction="horizontal" gap={3}>
<Icon src={getItemIcon('unit')} />
<LoadingSpinner />
</Stack>
</Stack>
<Stack className="align-items-center" gap={2}>
<FormattedMessage {...messages.importCourseComponents} />
<Stack className="justify-content-center" direction="horizontal" gap={3}>
<Icon src={Widgets} />
<LoadingSpinner />
</Stack>
</Stack>
</Stack>
</Card.Section>
</Card>
);

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import messages from '../messages';
import { trimSlashes } from './utils';
import CardItem from '.';
import { CardItem } from '.';
jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => studioHomeMock);

View File

@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, {
ReactElement, useCallback, useEffect, useRef,
} from 'react';
import { useSelector } from 'react-redux';
import {
Card,
@@ -8,19 +10,18 @@ import {
IconButton,
Stack,
} from '@openedx/paragon';
import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ArrowForward, MoreHoriz } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { Link } from 'react-router-dom';
import { useWaffleFlags } from '@src/data/apiHooks';
import { COURSE_CREATOR_STATES } from '@src/constants';
import { parseLibraryKey } from '@src/generic/key-utils';
import classNames from 'classnames';
import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';
const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => (
export const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => (
<Stack direction="horizontal" gap={2}>
<span>{from}</span>
{to
@@ -33,7 +34,7 @@ const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactN
</Stack>
);
const MakeLinkOrSpan = ({
export const MakeLinkOrSpan = ({
when, to, children, className,
}: {
when: boolean,
@@ -52,10 +53,8 @@ interface CardTitleProps {
selectMode?: 'single' | 'multiple';
destinationUrl: string;
title: string;
secondaryLink?: ReactElement | null;
itemId?: string;
isMigrated?: boolean;
migratedToKey?: string;
migratedToTitle?: string;
}
const CardTitle: React.FC<CardTitleProps> = ({
@@ -63,10 +62,8 @@ const CardTitle: React.FC<CardTitleProps> = ({
selectMode,
destinationUrl,
title,
secondaryLink,
itemId,
isMigrated,
migratedToTitle,
migratedToKey,
}) => {
const getTitle = useCallback(() => (
<div style={{ marginTop: selectMode ? '-3px' : '' }}>
@@ -80,24 +77,12 @@ const CardTitle: React.FC<CardTitleProps> = ({
{title}
</MakeLinkOrSpan>
)}
to={
isMigrated && migratedToTitle && (
<MakeLinkOrSpan
when={!readOnlyItem && !selectMode}
to={`/library/${migratedToKey}`}
className="card-item-title"
>
{migratedToTitle}
</MakeLinkOrSpan>
)
}
to={secondaryLink}
/>
</div>
), [
readOnlyItem,
isMigrated,
destinationUrl,
migratedToTitle,
title,
selectMode,
]);
@@ -128,8 +113,78 @@ const CardTitle: React.FC<CardTitleProps> = ({
return getTitle();
};
interface CardMenuProps {
showMenu: boolean;
isShowRerunLink?: boolean;
rerunLink: string | null;
lmsLink: string | null;
}
const CardMenu = ({
showMenu,
isShowRerunLink,
rerunLink,
lmsLink,
}: CardMenuProps) => {
const intl = useIntl();
if (!showMenu) {
return null;
}
return (
<Dropdown>
<Dropdown.Toggle
as={IconButton}
iconAs={MoreHoriz}
variant="primary"
aria-label={intl.formatMessage(messages.btnDropDownText)}
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item
as={Link}
to={rerunLink ?? ''}
>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}
<Dropdown.Item href={lmsLink}>
<FormattedMessage {...messages.viewLiveBtnText} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};
const SelectAction = ({
itemId,
title,
selectMode,
}: {
itemId: string,
title: string,
selectMode: 'single' | 'multiple';
}) => {
if (selectMode === 'single') {
return (
<Form.Radio
value={itemId}
aria-label={title}
name={`select-card-item-${itemId}`}
/>
);
}
// Multiple
return (
<Form.Checkbox value={itemId} aria-label={title} />
);
};
interface BaseProps {
displayName: string;
onClick?: () => void;
org: string;
number: string;
run?: string;
@@ -137,11 +192,12 @@ interface BaseProps {
rerunLink?: string | null;
courseKey?: string;
isLibraries?: boolean;
isMigrated?: boolean;
migratedToKey?: string;
migratedToTitle?: string;
migratedToCollectionKey?: string | null;
subtitleWrapper?: ((subtitle: JSX.Element) => ReactElement) | null; // Wrapper for the default subtitle element
subtitleBeforeWidget?: ReactElement | null; // Adds a widget before the default subtitle element
cardStatusWidget?: ReactElement | null;
titleSecondaryLink?: ReactElement | null;
selectMode?: 'single' | 'multiple';
selectPosition?: 'card' | 'title';
isSelected?: boolean;
itemId?: string;
scrollIntoView?: boolean;
@@ -160,8 +216,9 @@ type Props = BaseProps & (
/**
* A card on the Studio home page that represents a Course or a Library
*/
const CardItem: React.FC<Props> = ({
export const CardItem: React.FC<Props> = ({
displayName,
onClick,
lmsLink = '',
rerunLink = '',
org,
@@ -170,17 +227,17 @@ const CardItem: React.FC<Props> = ({
isLibraries = false,
courseKey = '',
selectMode,
selectPosition,
isSelected = false,
itemId = '',
path,
url,
isMigrated = false,
migratedToKey,
migratedToTitle,
migratedToCollectionKey,
subtitleWrapper,
subtitleBeforeWidget,
titleSecondaryLink,
cardStatusWidget,
scrollIntoView = false,
}) => {
const intl = useIntl();
const {
allowCourseReruns,
courseCreatorStatus,
@@ -195,7 +252,7 @@ const CardItem: React.FC<Props> = ({
: new URL(url, getConfig().STUDIO_BASE_URL).toString()
);
const readOnlyItem = !(lmsLink || rerunLink || url || path);
const showActions = !(readOnlyItem || isLibraries);
const showActionsMenu = !(readOnlyItem || isLibraries || selectMode !== undefined);
const isShowRerunLink = allowCourseReruns
&& rerunCreatorStatus
&& courseCreatorStatus === COURSE_CREATOR_STATES.granted;
@@ -203,25 +260,19 @@ const CardItem: React.FC<Props> = ({
const getSubtitle = useCallback(() => {
let subtitle = isLibraries ? <>{org} / {number}</> : <>{org} / {number} / {run}</>;
if (isMigrated && migratedToKey) {
const migratedToKeyObj = parseLibraryKey(migratedToKey);
if (subtitleWrapper) {
subtitle = subtitleWrapper(subtitle);
}
if (subtitleBeforeWidget) {
subtitle = (
<PrevToNextName
from={subtitle}
to={<>{migratedToKeyObj.org} / {migratedToKeyObj.lib}</>}
/>
<Stack direction="horizontal" gap={2}>
{subtitleBeforeWidget}
{subtitle}
</Stack>
);
}
return subtitle;
}, [isLibraries, org, number, run, migratedToKey, isMigrated]);
const collectionLink = () => {
let libUrl = `/library/${migratedToKey}`;
if (migratedToCollectionKey) {
libUrl += `/collection/${migratedToCollectionKey}`;
}
return libUrl;
};
}, [isLibraries, org, number, run]);
useEffect(() => {
/* istanbul ignore next */
@@ -232,70 +283,46 @@ const CardItem: React.FC<Props> = ({
return (
<div ref={cardRef} className="w-100">
<Card className={classNames('card-item', {
selected: isSelected,
})}
<Card
onClick={onClick}
className={classNames('card-item', {
selected: isSelected,
})}
>
<Card.Header
size="sm"
title={(
<CardTitle
readOnlyItem={readOnlyItem}
selectMode={selectMode}
readOnlyItem={readOnlyItem || selectMode !== undefined}
selectMode={selectPosition === 'title' ? selectMode : undefined}
destinationUrl={destinationUrl}
title={title}
itemId={itemId}
isMigrated={isMigrated}
migratedToTitle={migratedToTitle}
migratedToKey={migratedToKey}
secondaryLink={titleSecondaryLink}
/>
)}
subtitle={getSubtitle()}
actions={showActions && (
<Dropdown>
<Dropdown.Toggle
as={IconButton}
iconAs={MoreHoriz}
variant="primary"
aria-label={intl.formatMessage(messages.btnDropDownText)}
actions={(selectMode && selectPosition === 'card') ? (
<SelectAction
itemId={itemId}
selectMode={selectMode}
title={title}
/>
) : (
<CardMenu
showMenu={showActionsMenu}
isShowRerunLink={isShowRerunLink}
rerunLink={rerunLink}
lmsLink={lmsLink}
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item
as={Link}
to={rerunLink ?? ''}
>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}
<Dropdown.Item href={lmsLink}>
{intl.formatMessage(messages.viewLiveBtnText)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)}
/>
{isMigrated && migratedToKey
&& (
{cardStatusWidget && (
<Card.Status className="bg-white pt-0 text-gray-500">
<Stack direction="horizontal" gap={2}>
<Icon src={AccessTime} size="sm" className="mb-1" />
{intl.formatMessage(messages.libraryMigrationStatusText)}
<b>
<MakeLinkOrSpan
when={!readOnlyItem}
to={collectionLink()}
className="text-info-500"
>
{migratedToTitle}
</MakeLinkOrSpan>
</b>
</Stack>
{cardStatusWidget}
</Card.Status>
)}
)}
</Card>
</div>
);
};
export default CardItem;

View File

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

View File

@@ -73,11 +73,6 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.organization.input.no-options',
defaultMessage: 'No options',
},
libraryMigrationStatusText: {
id: 'course-authoring.studio-home.library-v1.card.status',
description: 'Status text in v1 library card in studio informing user of its migration status',
defaultMessage: 'Previously migrated library. Any problem bank links were already moved to',
},
});
export default messages;

View File

@@ -58,6 +58,10 @@
.card-item {
margin-bottom: 1.5rem;
&.selected {
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
}
.pgn__card-header {
padding: .9375rem 1.25rem;

View File

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

View File

@@ -7,17 +7,16 @@ import {
import { COURSE_CREATOR_STATES } from '@src/constants';
import { type DeprecatedReduxState } from '@src/store';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { RequestStatus } from '@src/data/constants';
import { initialState } from '../../factories/mockApiResponses';
import CoursesTab from '.';
import { CoursesList } from '.';
import { studioHomeCoursesRequestParamsDefault } from '../../data/slice';
type StudioHomeState = DeprecatedReduxState['studioHome'];
const onClickNewCourse = jest.fn();
const isShowProcessing = false;
const isLoading = false;
const isFailed = false;
const numPages = 1;
const coursesCount = studioHomeMock.courses.length;
const showNewCourseContainer = true;
@@ -28,6 +27,15 @@ const renderComponent = (overrideProps = {}, studioHomeState: Partial<StudioHome
...initialState,
studioHome: {
...initialState.studioHome,
studioHomeData: {
courses: studioHomeMock.courses,
numPages,
coursesCount,
},
loadingStatuses: {
...initialState.studioHome.loadingStatuses,
courseLoadingStatus: RequestStatus.SUCCESSFUL,
},
...studioHomeState,
},
};
@@ -37,15 +45,10 @@ const renderComponent = (overrideProps = {}, studioHomeState: Partial<StudioHome
return {
...render(
<CoursesTab
coursesDataItems={studioHomeMock.courses}
<CoursesList
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
isShowProcessing={isShowProcessing}
isLoading={isLoading}
isFailed={isFailed}
numPages={numPages}
coursesCount={coursesCount}
{...overrideProps}
/>,
),
@@ -67,30 +70,46 @@ describe('<CoursesTab />', () => {
});
it('should render loading spinner when isLoading is true and isFiltered is false', () => {
const props = { isLoading: true, coursesDataItems: [] };
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
renderComponent(props, customStoreData);
const customStoreData = {
loadingStatuses: {
...initialState.studioHome.loadingStatuses,
courseLoadingStatus: RequestStatus.IN_PROGRESS,
},
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
};
renderComponent({}, customStoreData);
const loadingSpinner = screen.getByRole('status');
expect(loadingSpinner).toBeInTheDocument();
});
it('should render an error message when something went wrong', () => {
const props = { isFailed: true };
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false } };
renderComponent(props, customStoreData);
const customStoreData = {
loadingStatuses: {
...initialState.studioHome.loadingStatuses,
courseLoadingStatus: RequestStatus.FAILED,
},
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: false },
};
renderComponent({}, customStoreData);
const alertErrorFailed = screen.queryByTestId('error-failed-message');
expect(alertErrorFailed).toBeInTheDocument();
});
it('should render an alert message when there is not courses found', () => {
const props = { isLoading: false, coursesDataItems: [] };
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
renderComponent(props, customStoreData);
const customStoreData = {
studioHomeData: {
courses: [],
numPages: 0,
coursesCount: 0,
},
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
};
renderComponent({}, customStoreData);
const alertCoursesNotFound = screen.queryByTestId('courses-not-found-alert');
expect(alertCoursesNotFound).toBeInTheDocument();
});
it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', () => {
it('should render processing courses component when isEnabledPagination is false and isShowProcessing is true', async () => {
const props = { isShowProcessing: true, isEnabledPagination: false };
const customStoreData = {
studioHomeData: {
@@ -102,7 +121,7 @@ describe('<CoursesTab />', () => {
},
};
renderComponent(props, customStoreData);
const alertCoursesNotFound = screen.queryByTestId('processing-courses-title');
const alertCoursesNotFound = await screen.findByTestId('processing-courses-title');
expect(alertCoursesNotFound).toBeInTheDocument();
});
@@ -120,9 +139,15 @@ describe('<CoursesTab />', () => {
});
it('should reset filters when in pressed the button to clean them', () => {
const props = { isLoading: false, coursesDataItems: [] };
const customStoreData = { studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true } };
const { store } = renderComponent(props, customStoreData);
const customStoreData = {
studioHomeData: {
courses: [],
numPages: 0,
coursesCount: 0,
},
studioHomeCoursesRequestParams: { currentPage: 1, isFiltered: true },
};
const { store } = renderComponent({}, customStoreData);
const cleanFiltersButton = screen.getByRole('button', { name: /clear filters/i });
expect(cleanFiltersButton).toBeInTheDocument();

View File

@@ -1,67 +1,179 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Icon,
Row,
Pagination,
Alert,
Button,
Form,
} from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import { COURSE_CREATOR_STATES } from '@src/constants';
import { getStudioHomeData, getStudioHomeCoursesParams } from '@src/studio-home/data/selectors';
import { getStudioHomeData, getStudioHomeCoursesParams, getLoadingStatuses } from '@src/studio-home/data/selectors';
import { resetStudioHomeCoursesCustomParams, updateStudioHomeCoursesCustomParams } from '@src/studio-home/data/slice';
import { fetchStudioHomeData } from '@src/studio-home/data/thunks';
import CardItem from '@src/studio-home/card-item';
import { CardItem } from '@src/studio-home/card-item';
import CollapsibleStateWithAction from '@src/studio-home/collapsible-state-with-action';
import ProcessingCourses from '@src/studio-home/processing-courses';
import { LoadingSpinner } from '@src/generic/Loading';
import AlertMessage from '@src/generic/alert-message';
import { RequestStatus } from '@src/data/constants';
import messages from '../messages';
import CoursesFilters from './courses-filters';
import ContactAdministrator from './contact-administrator';
import './index.scss';
interface Props {
coursesDataItems: {
courseKey: string;
displayName: string;
lmsLink: string | null;
number: string;
org: string;
rerunLink: string | null;
run: string;
url: string;
}[];
showNewCourseContainer: boolean;
onClickNewCourse: () => void;
isShowProcessing: boolean;
isLoading: boolean;
isFailed: boolean;
numPages: number;
coursesCount: number;
export interface MigrationStatusProps {
courseId: string;
allVisibleCourseIds: string[];
}
const CoursesTab: React.FC<Props> = ({
coursesDataItems,
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
interface CardListProps {
currentPage: number;
handlePageSelected: (page: any) => void;
handleCleanFilters: () => void;
onClickCard?: (courseId: string) => void;
isLoading: boolean;
isFiltered: boolean;
hasAbilityToCreateCourse?: boolean;
showNewCourseContainer?: boolean;
onClickNewCourse?: () => void;
inSelectMode?: boolean;
selectedCourseId?: string;
migrationStatusWidget?: React.ComponentType<MigrationStatusProps>;
}
const CardList = ({
currentPage,
handlePageSelected,
handleCleanFilters,
onClickCard,
isLoading,
isFailed,
numPages = 0,
coursesCount = 0,
isFiltered,
hasAbilityToCreateCourse = false,
showNewCourseContainer = false,
onClickNewCourse = () => {},
inSelectMode = false,
selectedCourseId,
migrationStatusWidget,
}: CardListProps) => {
const {
courses,
numPages,
optimizationEnabled,
} = useSelector(getStudioHomeData);
const isNotFilteringCourses = !isFiltered && !isLoading;
const hasCourses = courses?.length > 0;
const MigrationStatusWidget = migrationStatusWidget;
return (
<>
{hasCourses ? (
<>
{courses.map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
courseKey={courseKey}
onClick={() => onClickCard?.(courseKey)}
itemId={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
selectMode={inSelectMode ? 'single' : undefined}
selectPosition={inSelectMode ? 'card' : undefined}
isSelected={inSelectMode && selectedCourseId === courseKey}
subtitleBeforeWidget={MigrationStatusWidget && (
<MigrationStatusWidget
courseId={courseKey}
allVisibleCourseIds={courses?.map(item => item.courseKey) || []}
/>
)}
/>
),
)}
{numPages > 1 && (
<Pagination
className="d-flex justify-content-center w-100"
paginationLabel="pagination navigation"
pageCount={numPages}
currentPage={currentPage}
onPageSelect={handlePageSelected}
/>
)}
</>
) : (!optimizationEnabled && isNotFilteringCourses && (
<ContactAdministrator
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
/>
)
)}
{isFiltered && !hasCourses && !isLoading && (
<Alert className="mt-4">
<Alert.Heading>
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertTitle} />
</Alert.Heading>
<p data-testid="courses-not-found-alert">
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertMessage} />
</p>
<Button variant="primary" onClick={handleCleanFilters}>
<FormattedMessage {...messages.coursesTabCourseNotFoundAlertCleanFiltersButton} />
</Button>
</Alert>
)}
</>
);
};
interface Props {
showNewCourseContainer?: boolean;
onClickNewCourse?: () => void;
isShowProcessing?: boolean;
selectedCourseId?: string;
handleSelect?: (courseId: string) => void;
cardMigrationStatusWidget?: React.ComponentType<MigrationStatusProps>;
}
export const CoursesList: React.FC<Props> = ({
showNewCourseContainer = false,
onClickNewCourse = () => {},
isShowProcessing = false,
selectedCourseId,
handleSelect,
cardMigrationStatusWidget,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const location = useLocation();
const {
courses,
coursesCount,
courseCreatorStatus,
optimizationEnabled,
} = useSelector(getStudioHomeData);
const {
courseLoadingStatus,
} = useSelector(getLoadingStatuses);
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
const { currentPage, isFiltered } = studioHomeCoursesParams;
const hasAbilityToCreateCourse = courseCreatorStatus === COURSE_CREATOR_STATES.granted;
@@ -72,6 +184,10 @@ const CoursesTab: React.FC<Props> = ({
].includes(courseCreatorStatus as any);
const locationValue = location.search ?? '';
const isLoading = courseLoadingStatus === RequestStatus.IN_PROGRESS;
const isFailed = courseLoadingStatus === RequestStatus.FAILED;
const inSelectMode = handleSelect !== undefined;
const handlePageSelected = (page) => {
const {
search,
@@ -96,9 +212,6 @@ const CoursesTab: React.FC<Props> = ({
dispatch(fetchStudioHomeData(locationValue, false, { page: 1, order: 'display_name' }));
};
const isNotFilteringCourses = !isFiltered && !isLoading;
const hasCourses = coursesDataItems?.length > 0;
if (isLoading && !isFiltered) {
return (
<Row className="m-0 mt-4 justify-content-center">
@@ -125,70 +238,42 @@ const CoursesTab: React.FC<Props> = ({
<CoursesFilters dispatch={dispatch} locationValue={locationValue} isLoading={isLoading} />
<p data-testid="pagination-info" className="my-0">
{intl.formatMessage(messages.coursesPaginationInfo, {
length: coursesDataItems.length,
length: courses?.length,
total: coursesCount,
})}
</p>
</div>
{hasCourses ? (
<>
{coursesDataItems.map(
({
courseKey,
displayName,
lmsLink,
org,
rerunLink,
number,
run,
url,
}) => (
<CardItem
key={courseKey}
courseKey={courseKey}
displayName={displayName}
lmsLink={lmsLink}
rerunLink={rerunLink}
org={org}
number={number}
run={run}
url={url}
/>
),
)}
{numPages > 1 && (
<Pagination
className="d-flex justify-content-center"
paginationLabel="pagination navigation"
pageCount={numPages}
currentPage={currentPage}
onPageSelect={handlePageSelected}
/>
)}
</>
) : (!optimizationEnabled && isNotFilteringCourses && (
<ContactAdministrator
{inSelectMode ? (
<Form.RadioSet
name="select-courses"
value={selectedCourseId}
onChange={(e) => handleSelect(e.target.value)}
>
<CardList
currentPage={currentPage}
onClickCard={handleSelect}
handlePageSelected={handlePageSelected}
handleCleanFilters={handleCleanFilters}
isLoading={isLoading}
isFiltered={isFiltered || false}
inSelectMode
selectedCourseId={selectedCourseId}
migrationStatusWidget={cardMigrationStatusWidget}
/>
</Form.RadioSet>
) : (
<CardList
currentPage={currentPage}
handlePageSelected={handlePageSelected}
handleCleanFilters={handleCleanFilters}
isLoading={isLoading}
isFiltered={isFiltered || false}
hasAbilityToCreateCourse={hasAbilityToCreateCourse}
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
/>
)
)}
{isFiltered && !hasCourses && !isLoading && (
<Alert className="mt-4">
<Alert.Heading>
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertTitle)}
</Alert.Heading>
<p data-testid="courses-not-found-alert">
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertMessage)}
</p>
<Button variant="primary" onClick={handleCleanFilters}>
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertCleanFiltersButton)}
</Button>
</Alert>
)}
{showCollapsible && (
<CollapsibleStateWithAction
state={courseCreatorStatus!}
@@ -199,5 +284,3 @@ const CoursesTab: React.FC<Props> = ({
)
);
};
export default CoursesTab;

View File

@@ -1,6 +1,5 @@
import { useMemo, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Badge,
Stack,
@@ -9,23 +8,28 @@ import {
} from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate, useLocation } from 'react-router-dom';
import { RequestStatus } from '@src/data/constants';
import { getLoadingStatuses, getStudioHomeData } from '../data/selectors';
import messages from './messages';
import { BaseFilterState, Filter, LibrariesList } from './libraries-tab';
import LibrariesV2List from './libraries-v2-tab/index';
import CoursesTab from './courses-tab';
import { CoursesList } from './courses-tab';
import { WelcomeLibrariesV2Alert } from './libraries-v2-tab/WelcomeLibrariesV2Alert';
interface Props {
showNewCourseContainer: boolean;
onClickNewCourse: () => void;
isShowProcessing: boolean;
librariesV1Enabled?: boolean;
librariesV2Enabled?: boolean;
}
const TabsSection = ({
showNewCourseContainer,
onClickNewCourse,
isShowProcessing,
librariesV1Enabled,
librariesV2Enabled,
}) => {
}: Props) => {
const intl = useIntl();
const navigate = useNavigate();
const { pathname } = useLocation();
@@ -61,13 +65,6 @@ const TabsSection = ({
setTabKey(initTabKeyState(pathname));
}, [pathname]);
const { courses, numPages, coursesCount } = useSelector(getStudioHomeData);
const {
courseLoadingStatus,
} = useSelector(getLoadingStatuses);
const isLoadingCourses = courseLoadingStatus === RequestStatus.IN_PROGRESS;
const isFailedCoursesPage = courseLoadingStatus === RequestStatus.FAILED;
// Controlling the visibility of tabs when using conditional rendering is necessary for
// the correct operation of iterating over child elements inside the Paragon Tabs component.
const visibleTabs = useMemo(() => {
@@ -78,15 +75,10 @@ const TabsSection = ({
eventKey={TABS_LIST.courses}
title={intl.formatMessage(messages.coursesTabTitle)}
>
<CoursesTab
coursesDataItems={courses}
<CoursesList
showNewCourseContainer={showNewCourseContainer}
onClickNewCourse={onClickNewCourse}
isShowProcessing={isShowProcessing}
isLoading={isLoadingCourses}
isFailed={isFailedCoursesPage}
numPages={numPages}
coursesCount={coursesCount}
/>
</Tab>,
);
@@ -141,7 +133,7 @@ const TabsSection = ({
}
return tabs;
}, [showNewCourseContainer, isLoadingCourses, migrationFilter]);
}, [showNewCourseContainer, migrationFilter]);
const handleSelectTab = (tab: TabKeyType) => {
if (tab === TABS_LIST.courses) {
@@ -168,12 +160,4 @@ const TabsSection = ({
);
};
TabsSection.propTypes = {
showNewCourseContainer: PropTypes.bool.isRequired,
onClickNewCourse: PropTypes.func.isRequired,
isShowProcessing: PropTypes.bool.isRequired,
librariesV1Enabled: PropTypes.bool,
librariesV2Enabled: PropTypes.bool,
};
export default TabsSection;

View File

@@ -2,15 +2,17 @@ import { useCallback, useState } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Form, Icon, Menu, MenuItem, Pagination, Row, SearchField,
Stack,
} from '@openedx/paragon';
import { Error, FilterList } from '@openedx/paragon/icons';
import { Error, FilterList, AccessTime } from '@openedx/paragon/icons';
import { LoadingSpinner } from '@src/generic/Loading';
import AlertMessage from '@src/generic/alert-message';
import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks';
import CardItem from '@src/studio-home/card-item';
import { CardItem, MakeLinkOrSpan, PrevToNextName } from '@src/studio-home/card-item';
import SearchFilterWidget from '@src/search-manager/SearchFilterWidget';
import type { LibraryV1Data } from '@src/studio-home/data/api';
import { parseLibraryKey } from '@src/generic/key-utils';
import messages from '../messages';
import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert';
@@ -37,23 +39,64 @@ const CardList = ({
migratedToTitle,
migratedToCollectionKey,
libraryKey,
}) => (
<CardItem
key={`${org}+${number}`}
isLibraries
displayName={displayName}
org={org}
number={number}
url={url}
itemId={libraryKey}
selectMode={inSelectMode ? 'multiple' : undefined}
isSelected={selectedIds?.includes(libraryKey)}
isMigrated={isMigrated}
migratedToKey={migratedToKey}
migratedToTitle={migratedToTitle}
migratedToCollectionKey={migratedToCollectionKey}
/>
))
}) => {
const collectionLink = () => {
let libUrl = `/library/${migratedToKey}`;
if (migratedToCollectionKey) {
libUrl += `/collection/${migratedToCollectionKey}`;
}
return libUrl;
};
const migratedToKeyObj = migratedToKey ? parseLibraryKey(migratedToKey) : undefined;
const subtitleWrapper = (subtitle) => (
<PrevToNextName
from={subtitle}
to={<>{migratedToKeyObj?.org} / {migratedToKeyObj?.lib}</>}
/>
);
return (
<CardItem
key={`${org}+${number}`}
isLibraries
displayName={displayName}
org={org}
number={number}
url={url}
itemId={libraryKey}
selectMode={inSelectMode ? 'multiple' : undefined}
selectPosition={inSelectMode ? 'title' : undefined}
isSelected={selectedIds?.includes(libraryKey)}
subtitleWrapper={isMigrated ? subtitleWrapper : null}
titleSecondaryLink={(isMigrated && migratedToTitle) ? (
<MakeLinkOrSpan
when={!inSelectMode}
to={`/library/${migratedToKey}`}
className="card-item-title"
>
{migratedToTitle}
</MakeLinkOrSpan>
) : null}
cardStatusWidget={(isMigrated && migratedToKey) ? (
<Stack direction="horizontal" gap={2}>
<Icon src={AccessTime} size="sm" className="mb-1" />
<FormattedMessage {...messages.libraryMigrationStatusText} />
<b>
<MakeLinkOrSpan
when
to={collectionLink()}
className="text-info-500"
>
{migratedToTitle}
</MakeLinkOrSpan>
</b>
</Stack>
) : null}
/>
);
})
}
</>
);

View File

@@ -17,13 +17,13 @@ import { LoadingSpinner } from '@src/generic/Loading';
import AlertMessage from '@src/generic/alert-message';
import type { ContentLibrary, LibrariesV2Response } from '@src/library-authoring/data/api';
import CardItem from '../../card-item';
import { CardItem } from '../../card-item';
import messages from '../messages';
import LibrariesV2Filters from './libraries-v2-filters';
interface CardListProps {
hasV2Libraries: boolean;
selectMode?: 'single' | 'multiple';
inSelectMode?: boolean;
selectedLibraryId?: string;
isFiltered: boolean;
isLoading: boolean;
@@ -34,7 +34,7 @@ interface CardListProps {
const CardList: React.FC<CardListProps> = ({
hasV2Libraries,
selectMode,
inSelectMode,
selectedLibraryId,
isFiltered,
isLoading,
@@ -56,7 +56,8 @@ const CardList: React.FC<CardListProps> = ({
org={org}
number={slug}
path={`/library/${id}`}
selectMode={selectMode}
selectMode={inSelectMode ? 'single' : undefined}
selectPosition={inSelectMode ? 'title' : undefined}
isSelected={selectedLibraryId === id}
itemId={id}
scrollIntoView={scrollIntoView && selectedLibraryId === id}
@@ -202,7 +203,7 @@ const LibrariesV2List: React.FC<Props> = ({
>
<CardList
hasV2Libraries={hasV2Libraries}
selectMode={inSelectMode ? 'single' : undefined}
inSelectMode={inSelectMode}
selectedLibraryId={selectedLibraryId}
isFiltered={isFiltered}
isLoading={isPending}

View File

@@ -136,6 +136,11 @@ const messages = defineMessages({
defaultMessage: 'Select All',
description: 'Button to select all libraries when migrate legacy libraries.',
},
libraryMigrationStatusText: {
id: 'course-authoring.studio-home.library-v1.card.status',
description: 'Status text in v1 library card in studio informing user of its migration status',
defaultMessage: 'Previously migrated library. Any problem bank links were already moved to',
},
});
export default messages;