Compare commits
1 Commits
master
...
feat/add_g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9610f0791f |
@@ -1,29 +1,38 @@
|
|||||||
/* istanbul ignore file */
|
/* 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 React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Spinner } from '@openedx/paragon';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
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';
|
import EditorContainer from '../EditorContainer';
|
||||||
// This 'module' self-import hack enables mocking during tests.
|
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
|
||||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
import Button from '../../sharedComponents/Button';
|
||||||
// should be re-thought and cleaned up to avoid this pattern.
|
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
|
||||||
// eslint-disable-next-line import/no-self-import
|
import messages from './messages';
|
||||||
import * as module from '.';
|
|
||||||
import { actions, selectors } from '../../data/redux';
|
|
||||||
import { RequestKeys } from '../../data/constants/requests';
|
|
||||||
|
|
||||||
export const hooks = {
|
export const hooks = {
|
||||||
getContent: () => ({
|
getContent: () => ({
|
||||||
@@ -31,77 +40,498 @@ export const hooks = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThumbEditor = ({
|
export const GameEditor = ({
|
||||||
onClose,
|
onClose,
|
||||||
// redux
|
// redux
|
||||||
blockValue,
|
|
||||||
lmsEndpointUrl,
|
|
||||||
blockFailed,
|
|
||||||
blockFinished,
|
blockFinished,
|
||||||
initializeEditor,
|
|
||||||
// eslint-disable-next-line react/prop-types
|
// settings
|
||||||
exampleValue,
|
settings,
|
||||||
|
shuffleTrue,
|
||||||
|
shuffleFalse,
|
||||||
|
timerTrue,
|
||||||
|
timerFalse,
|
||||||
|
type,
|
||||||
|
updateType,
|
||||||
|
|
||||||
|
// list
|
||||||
|
list,
|
||||||
|
updateTerm,
|
||||||
|
updateTermImage,
|
||||||
|
updateDefinition,
|
||||||
|
updateDefinitionImage,
|
||||||
|
toggleOpen,
|
||||||
|
setList,
|
||||||
|
addCard,
|
||||||
|
removeCard,
|
||||||
|
|
||||||
|
isDirty,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
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 (
|
return (
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={module.hooks.getContent}
|
getContent={hooks.getContent}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
isDirty={() => isDirty}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
{exampleValue}
|
|
||||||
</div>
|
|
||||||
<div className="editor-body h-75 overflow-auto">
|
<div className="editor-body h-75 overflow-auto">
|
||||||
{!blockFinished
|
{!blockFinished ? loading : page}
|
||||||
? (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
ThumbEditor.defaultProps = {
|
|
||||||
blockValue: null,
|
GameEditor.propTypes = {
|
||||||
lmsEndpointUrl: null,
|
|
||||||
};
|
|
||||||
ThumbEditor.propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
|
|
||||||
// redux
|
// redux
|
||||||
blockValue: PropTypes.shape({
|
|
||||||
data: PropTypes.shape({ data: PropTypes.string }),
|
|
||||||
}),
|
|
||||||
lmsEndpointUrl: PropTypes.string,
|
|
||||||
blockFailed: PropTypes.bool.isRequired,
|
|
||||||
blockFinished: 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) => ({
|
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 }),
|
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
|
||||||
// TODO fill with redux state here if needed
|
settings: selectors.game.settings(state),
|
||||||
exampleValue: selectors.game.exampleValue(state),
|
type: selectors.game.type(state),
|
||||||
|
list: selectors.game.list(state),
|
||||||
|
isDirty: selectors.game.isDirty(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mapDispatchToProps = {
|
export const mapDispatchToProps = {
|
||||||
initializeEditor: actions.app.initializeEditor,
|
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',
|
problem: 'problem',
|
||||||
// ADDED_EDITORS GO BELOW
|
// ADDED_EDITORS GO BELOW
|
||||||
video_upload: 'video_upload',
|
video_upload: 'video_upload',
|
||||||
game: 'game',
|
game: 'games',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,136 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { StrictDict } from '../../../utils';
|
import { StrictDict } from '../../../utils';
|
||||||
|
|
||||||
|
const generateId = () => `card-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
settings: {},
|
settings: {
|
||||||
// TODO fill in with mock state
|
shuffle: false,
|
||||||
exampleValue: 'this is an example value from the redux state',
|
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({
|
const game = createSlice({
|
||||||
name: 'game',
|
name: 'game',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
updateField: (state, { payload }) => ({
|
// settings
|
||||||
|
shuffleTrue: (state) => ({
|
||||||
...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;
|
export const gameState = (state) => state.game;
|
||||||
const mkSimpleSelector = (cb) => createSelector([module.gameState], cb);
|
const mkSimpleSelector = (cb) => createSelector([module.gameState], cb);
|
||||||
export const simpleSelectors = {
|
export const simpleSelectors = {
|
||||||
exampleValue: mkSimpleSelector(gameData => gameData.exampleValue),
|
|
||||||
settings: mkSimpleSelector(gameData => gameData.settings),
|
settings: mkSimpleSelector(gameData => gameData.settings),
|
||||||
|
type: mkSimpleSelector(gameData => gameData.type),
|
||||||
|
list: mkSimpleSelector(gameData => gameData.list),
|
||||||
|
isDirty: mkSimpleSelector(gameData => gameData.isDirty),
|
||||||
completeState: mkSimpleSelector(gameData => gameData),
|
completeState: mkSimpleSelector(gameData => gameData),
|
||||||
// TODO fill in with selectors as needed
|
// TODO fill in with selectors as needed
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import TextEditor from './containers/TextEditor';
|
|||||||
import VideoEditor from './containers/VideoEditor';
|
import VideoEditor from './containers/VideoEditor';
|
||||||
import ProblemEditor from './containers/ProblemEditor';
|
import ProblemEditor from './containers/ProblemEditor';
|
||||||
import VideoUploadEditor from './containers/VideoUploadEditor';
|
import VideoUploadEditor from './containers/VideoUploadEditor';
|
||||||
import GameEditor from './containers/GameEditor';
|
import GamesEditor from './containers/GameEditor';
|
||||||
|
|
||||||
// ADDED_EDITOR_IMPORTS GO HERE
|
// ADDED_EDITOR_IMPORTS GO HERE
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const supportedEditors = {
|
|||||||
[blockTypes.problem]: ProblemEditor,
|
[blockTypes.problem]: ProblemEditor,
|
||||||
[blockTypes.video_upload]: VideoUploadEditor,
|
[blockTypes.video_upload]: VideoUploadEditor,
|
||||||
// ADDED_EDITORS GO BELOW
|
// ADDED_EDITORS GO BELOW
|
||||||
[blockTypes.game]: GameEditor,
|
[blockTypes.game]: GamesEditor,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default supportedEditors;
|
export default supportedEditors;
|
||||||
|
|||||||
Reference in New Issue
Block a user