From 9610f0791f0e4178c387209f6587158d07855b1c Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Mon, 17 Nov 2025 14:08:16 +0500 Subject: [PATCH] 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. --- src/editors/containers/GameEditor/index.jsx | 564 +++++++++++++++--- src/editors/containers/GameEditor/index.scss | 275 +++++++++ src/editors/containers/GameEditor/messages.ts | 11 + src/editors/data/constants/app.ts | 2 +- src/editors/data/redux/game/reducers.js | 128 +++- src/editors/data/redux/game/selectors.js | 4 +- src/editors/supportedEditors.ts | 4 +- 7 files changed, 910 insertions(+), 78 deletions(-) create mode 100644 src/editors/containers/GameEditor/index.scss create mode 100644 src/editors/containers/GameEditor/messages.ts diff --git a/src/editors/containers/GameEditor/index.jsx b/src/editors/containers/GameEditor/index.jsx index c4f580d42a..625167e87e 100644 --- a/src/editors/containers/GameEditor/index.jsx +++ b/src/editors/containers/GameEditor/index.jsx @@ -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 - */ - 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 = ( +
+ +
+ ); + + const termImageDiv = (card, index) => ( +
+ TERM_IMG + removeTermImage(index)} + /> +
+ ); + + const termImageUploadButton = (card, index) => ( + document.getElementById(`term_image_upload|${index}`).click()} + /> + ); + + const definitionImageDiv = (card, index) => ( +
+ DEF_IMG + removeDefintionImage(index)} + /> +
+ ); + + const definitionImageUploadButton = (card, index) => ( + document.getElementById(`definition_image_upload|${index}`).click()} + /> + ); + + const timerSettingsOption = ( + + <> +
Measure the time it takes learners to match all terms and definitions. Used to calculate a learner's score.
+ + + +
+ ); + + const page = ( +
+
+
+
+ {getDescriptionHeader()} +
+
+ {getDescription()} +
+
+ (newList) => setList(newList)} + > + { + state.map((card, index) => ( + + toggleOpen({ index, isOpen: true })} + onClose={() => toggleOpen({ index, isOpen: false })} + > + saveTermImage(index)} + /> + saveDefinitionImage(index)} + /> + +
+
+
{index + 1}
+ {!card.editorOpen ? ( +
+ + + {type === 'flashcards' ? ( + + {card.term_image !== '' + ? TERM_IMG_PRV + : } + + ) + : ''} + {card.term !== '' ? card.term : No text} + + + {type === 'flashcards' ? ( + + {card.definition_image !== '' + ? DEF_IMG_PRV + : } + + ) + : ''} + {card.definition !== '' ? card.definition : No text} + + +
+ ) + :
} + e.stopPropagation()}> + + + moveCardUp(index)}>Move up + moveCardDown(index)}>Move down + + removeCard({ index })}>Delete + + +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ Term + {(type !== 'matching' && card.term_image !== '') && termImageDiv(card, index)} +
+ updateTerm({ index, term: e.target.value })} + /> + {type !== 'matching' && termImageUploadButton(card, index)} +
+
+
+
+ Definition + {(type !== 'matching' && card.definition_image !== '') && definitionImageDiv(card, index)} +
+ updateDefinition({ index, definition: e.target.value })} + /> + {type !== 'matching' && definitionImageUploadButton(card, index)} +
+
+ +
+ + + )) + } + + +
+
+ + +
+ + + + <> +
Shuffle the order of terms shown to learners when reviewing.
+ + + +
+ {type === 'matching' && timerSettingsOption} +
+
+ ); + + // Page content goes here return ( isDirty} > -
- {exampleValue} -
- {!blockFinished - ? ( -
- -
- ) - : ( -

- 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)} -

- )} + {!blockFinished ? loading : page}
); }; -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); diff --git a/src/editors/containers/GameEditor/index.scss b/src/editors/containers/GameEditor/index.scss new file mode 100644 index 0000000000..be51e28abd --- /dev/null +++ b/src/editors/containers/GameEditor/index.scss @@ -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; +} diff --git a/src/editors/containers/GameEditor/messages.ts b/src/editors/containers/GameEditor/messages.ts new file mode 100644 index 0000000000..9c5bd4c9e9 --- /dev/null +++ b/src/editors/containers/GameEditor/messages.ts @@ -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; diff --git a/src/editors/data/constants/app.ts b/src/editors/data/constants/app.ts index ac331dae17..0e42f77c7e 100644 --- a/src/editors/data/constants/app.ts +++ b/src/editors/data/constants/app.ts @@ -6,5 +6,5 @@ export const blockTypes = StrictDict({ problem: 'problem', // ADDED_EDITORS GO BELOW video_upload: 'video_upload', - game: 'game', + game: 'games', }); diff --git a/src/editors/data/redux/game/reducers.js b/src/editors/data/redux/game/reducers.js index 93d0f02ffe..f2648462ff 100644 --- a/src/editors/data/redux/game/reducers.js +++ b/src/editors/data/redux/game/reducers.js @@ -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, + 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, - ...payload, + isDirty: payload, }), - // TODO fill in reducers }, }); diff --git a/src/editors/data/redux/game/selectors.js b/src/editors/data/redux/game/selectors.js index 736d49f93b..4c888ec785 100644 --- a/src/editors/data/redux/game/selectors.js +++ b/src/editors/data/redux/game/selectors.js @@ -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 }; diff --git a/src/editors/supportedEditors.ts b/src/editors/supportedEditors.ts index 71d0bad737..c113ba5e8a 100644 --- a/src/editors/supportedEditors.ts +++ b/src/editors/supportedEditors.ts @@ -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;