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.
This commit is contained in:
Muhammad Faraz Maqsood
2025-11-17 14:08:16 +05:00
parent 0f58329cb4
commit 9610f0791f
7 changed files with 910 additions and 78 deletions

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;