diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index 78297237f1..1c961855cb 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -187,6 +187,9 @@ const AddComponent = ({ showXBlockEditorModal(); }); break; + case COMPONENT_TYPES.games: + handleCreateNewCourseXBlock({ type, category: type, parentLocator: blockId }); + break; default: } }; diff --git a/src/editors/containers/GameEditor/index.jsx b/src/editors/containers/GameEditor/index.jsx index e14c3bad0f..c75b2c4653 100644 --- a/src/editors/containers/GameEditor/index.jsx +++ b/src/editors/containers/GameEditor/index.jsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-vars */ /* eslint-disable import/extensions */ /* eslint-disable import/no-unresolved */ @@ -11,96 +10,535 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; - -import { Spinner } from '@openedx/paragon'; -import { injectIntl, intlShape } 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 { getConfig } from '@edx/frontend-platform'; +import { + actions, + selectors, + thunkActions, +} 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: () => ({ - some: 'content', + getContent: ({ type, settings, list }) => ({ + gameType: type, + isShuffled: settings.shuffle, + hasTimer: settings.timer, + cards: list, }), }; -export const thumbEditor = ({ +export const GameEditor = ({ onClose, // redux - blockValue, - lmsEndpointUrl, - blockFailed, blockFinished, - initializeEditor, - exampleValue, - // inject - intl, -}) => ( - -
- {exampleValue} + blockId, + blockValue, + + // settings + settings, + shuffleTrue, + shuffleFalse, + timerTrue, + timerFalse, + type, + updateType, + + // list + list, + updateTerm, + updateTermImage, + updateDefinition, + updateDefinitionImage, + toggleOpen, + setList, + addCard, + removeCard, + + // thunks + uploadGameImage, + loadGamesSettings, + + isDirty, +}) => { + const intl = useIntl(); + // State for list + const [state, setState] = React.useState(list); + const [settingsLoaded, setSettingsLoaded] = React.useState(false); + React.useEffect(() => { setState(list); }, [list]); + + React.useEffect(() => { + if (blockFinished && blockId && blockValue && !settingsLoaded) { + loadGamesSettings(); + setSettingsLoaded(true); + } + }, [blockFinished, blockId, blockValue, settingsLoaded, loadGamesSettings]); + + // 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'; + } + }; + + // Unified image handling + const handleImageUpload = (index, imageType) => { + const id = `${imageType}_image_upload|${index}`; + const file = document.getElementById(id).files[0]; + if (file) { + uploadGameImage({ index, imageFile: file, imageType }); + } + }; + + const handleImageRemove = (index, imageType) => { + const id = `${imageType}_image_upload|${index}`; + document.getElementById(id).value = ''; + const updateAction = imageType === 'term' ? updateTermImage : updateDefinitionImage; + updateAction({ index, [imageType === 'term' ? 'termImage' : 'definitionImage']: '' }); + }; + + // Backward compatible wrappers + const saveTermImage = (index) => handleImageUpload(index, 'term'); + const saveDefinitionImage = (index) => handleImageUpload(index, 'definition'); + + 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 = ( +
+ +
+ ); + + // Unified image components + const renderImageDisplay = (imageUrl, index, imageType) => ( +
+ {`${imageType.toUpperCase()}_IMG`} + handleImageRemove(index, imageType)} + />
-
- {!blockFinished - ? ( -
- + ); + + const renderImageUploadButton = (index, imageType) => ( + document.getElementById(`${imageType}_image_upload|${index}`).click()} + /> + ); + + // Backward compatible wrappers for image components + const termImageDiv = (card, index) => renderImageDisplay(card.term_image, index, 'term'); + const termImageUploadButton = (card, index) => renderImageUploadButton(index, 'term'); + const definitionImageDiv = (card, index) => renderImageDisplay(card.definition_image, index, 'definition'); + const definitionImageUploadButton = (card, index) => renderImageUploadButton(index, 'definition'); + + 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()}
- ) - : ( -

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

- )} +
+ (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} +
- -); -thumbEditor.defaultProps = { - blockValue: null, - lmsEndpointUrl: null, + ); + + // Page content goes here + return ( + hooks.getContent({ type, settings, list })} + onClose={onClose} + isDirty={() => isDirty} + > +
+ {!blockFinished ? loading : page} +
+
+ ); }; -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, - // inject - intl: intlShape.isRequired, + blockId: PropTypes.string.isRequired, + blockValue: PropTypes.shape({}), + 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, + + // thunks + uploadGameImage: PropTypes.func.isRequired, + loadGamesSettings: 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), + blockId: selectors.app.blockId(state), + blockValue: selectors.app.blockValue(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, + + // thunks + loadGamesSettings: thunkActions.game.loadGamesSettings, + uploadGameImage: thunkActions.game.uploadGameImage, }; -export default injectIntl(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..abe5101a83 100644 --- a/src/editors/data/redux/game/reducers.js +++ b/src/editors/data/redux/game/reducers.js @@ -1,26 +1,109 @@ 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 }) => ({ + // Unified setting update + updateSetting: (state, { payload }) => { + const { key, value } = payload; + return { + ...state, + settings: { + ...state.settings, + [key]: value, + }, + isDirty: true, + }; + }, + // type + updateType: (state, { payload }) => ({ + ...state, + type: payload, + isDirty: true, + }), + // Unified card field update + updateCardField: (state, { payload }) => { + const { index, field, value } = payload; + if (!state.list[index]) { return state; } + const newList = state.list.map((item, idx) => (idx === index ? { ...item, [field]: value } : 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 }, }); -const actions = StrictDict(game.actions); +// Create backward-compatible action creators +const baseActions = game.actions; + +const actions = StrictDict({ + ...baseActions, + // Backward compatible wrappers for settings + shuffleTrue: () => baseActions.updateSetting({ key: 'shuffle', value: true }), + shuffleFalse: () => baseActions.updateSetting({ key: 'shuffle', value: false }), + timerTrue: () => baseActions.updateSetting({ key: 'timer', value: true }), + timerFalse: () => baseActions.updateSetting({ key: 'timer', value: false }), + // Backward compatible wrappers for card fields + updateTerm: ({ index, term }) => baseActions.updateCardField({ index, field: 'term', value: term }), + updateTermImage: ({ index, termImage }) => baseActions.updateCardField({ index, field: 'term_image', value: termImage }), + updateDefinition: ({ index, definition }) => baseActions.updateCardField({ index, field: 'definition', value: definition }), + updateDefinitionImage: ({ index, definitionImage }) => baseActions.updateCardField({ index, field: 'definition_image', value: definitionImage }), + toggleOpen: ({ index, isOpen }) => baseActions.updateCardField({ index, field: 'editorOpen', value: !!isOpen }), +}); const { reducer } = game; 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/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 4c5079a5a9..b39d18fd6f 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -117,23 +117,53 @@ export const initialize = (data) => (dispatch) => { }; /** - * @param {func} onSuccess + * Helper to trigger course refresh after save */ -export const saveBlock = (content, returnToUnit) => (dispatch) => { +const triggerCourseRefresh = () => { + const storageKey = 'courseRefreshTriggerOnComponentEditSave'; + localStorage.setItem(storageKey, Date.now()); + window.dispatchEvent(new StorageEvent('storage', { + key: storageKey, + newValue: Date.now().toString(), + })); +}; + +/** + * @param {func} returnToUnit - Callback function after save + */ +export const saveBlock = (content, returnToUnit) => (dispatch, getState) => { dispatch(actions.app.setBlockContent(content)); + + // Games block uses a custom handler for saving + const blockType = selectors.blockType(getState()); + if (blockType === 'games' && content.gameType) { + dispatch(requests.saveGamesSettings({ + gameType: content.gameType, + isShuffled: content.isShuffled, + hasTimer: content.hasTimer, + cards: content.cards, + onSuccess: (response) => { + triggerCourseRefresh(); + returnToUnit(response.data); + }, + onFailure: (error) => { + dispatch(actions.requests.failRequest({ + requestKey: RequestKeys.saveBlock, + error, + })); + }, + })); + return; + } + + // Standard save for other block types dispatch(requests.saveBlock({ content, onSuccess: (response) => { dispatch(actions.app.setSaveResponse(response)); const parsedData = JSON.parse(response.config.data); if (parsedData?.has_changes) { - const storageKey = 'courseRefreshTriggerOnComponentEditSave'; - localStorage.setItem(storageKey, Date.now()); - - window.dispatchEvent(new StorageEvent('storage', { - key: storageKey, - newValue: Date.now().toString(), - })); + triggerCourseRefresh(); } returnToUnit(response.data); }, diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 3f8dc10c9e..1bf0075384 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -36,7 +36,7 @@ describe('app thunkActions', () => { let getState; beforeEach(() => { dispatch = jest.fn((action) => ({ dispatch: action })); - getState = jest.fn().mockImplementation(() => ({ app: { blockId: 'blockId', images: {} } })); + getState = jest.fn().mockImplementation(() => ({ app: { blockId: 'blockId', blockType: 'html', images: {} } })); }); describe('fetchBlock', () => { beforeEach(() => { @@ -339,7 +339,7 @@ describe('app thunkActions', () => { let calls; beforeEach(() => { returnToUnit = jest.fn(); - thunkActions.saveBlock(testValue, returnToUnit)(dispatch); + thunkActions.saveBlock(testValue, returnToUnit)(dispatch, getState); calls = dispatch.mock.calls; }); it('dispatches actions.app.setBlockContent with content, before dispatching saveBlock', () => { diff --git a/src/editors/data/redux/thunkActions/game.js b/src/editors/data/redux/thunkActions/game.js new file mode 100644 index 0000000000..90926d86b4 --- /dev/null +++ b/src/editors/data/redux/thunkActions/game.js @@ -0,0 +1,93 @@ +import { StrictDict } from '../../../utils'; +import * as requests from './requests'; +import { actions as gameActions } from '../game'; +import { actions as requestsActions } from '../requests'; +import { RequestKeys } from '../../constants/requests'; + +const actions = { + game: gameActions, + requests: requestsActions, +}; + +/** + * Load existing game settings and populate the Redux store + */ +export const loadGamesSettings = () => (dispatch) => { + dispatch(requests.getGamesSettings({ + onSuccess: (response) => { + const { data } = response; + + if (data.game_type) { + dispatch(actions.game.updateType(data.game_type)); + } + + if (data.is_shuffled !== undefined) { + if (data.is_shuffled) { + dispatch(actions.game.shuffleTrue()); + } else { + dispatch(actions.game.shuffleFalse()); + } + } + + if (data.has_timer !== undefined) { + if (data.has_timer) { + dispatch(actions.game.timerTrue()); + } else { + dispatch(actions.game.timerFalse()); + } + } + + if (data.cards && data.cards.length > 0) { + const formattedCards = data.cards.map((card, index) => ({ + id: `card-${Date.now()}-${index}`, + term: card.term || '', + term_image: card.term_image || '', + definition: card.definition || '', + definition_image: card.definition_image || '', + editorOpen: false, + })); + dispatch(actions.game.setList(formattedCards)); + } + }, + onFailure: (error) => { + dispatch(actions.requests.failRequest({ + requestKey: RequestKeys.fetchBlock, + error, + })); + }, + })); +}; + +/** + * Upload an image for games xblock and update the state with the returned URL + * @param {number} index - The index of the card in the list + * @param {File} imageFile - The image file to upload + * @param {string} imageType - Either 'term' or 'definition' + */ +export const uploadGameImage = ({ index, imageFile, imageType }) => (dispatch) => { + dispatch(requests.uploadGamesImage({ + image: imageFile, + onSuccess: (response) => { + // Extract the URL from the response + // Response format: { success: true, url: "/media/games/...", filename: "..." } + const imageUrl = response.data?.url; + + if (imageType === 'term') { + dispatch(actions.game.updateTermImage({ index, termImage: imageUrl })); + } else if (imageType === 'definition') { + dispatch(actions.game.updateDefinitionImage({ index, definitionImage: imageUrl })); + } + }, + onFailure: (error) => { + dispatch(actions.requests.failRequest({ + requestKey: RequestKeys.uploadAsset, + error, + })); + }, + })); +}; + +export default StrictDict({ + loadGamesSettings, + uploadGameImage, +}); diff --git a/src/editors/data/redux/thunkActions/index.js b/src/editors/data/redux/thunkActions/index.js index b787e23cff..c656d56e4a 100644 --- a/src/editors/data/redux/thunkActions/index.js +++ b/src/editors/data/redux/thunkActions/index.js @@ -3,9 +3,11 @@ import { StrictDict } from '../../../utils'; import app from './app'; import video from './video'; import problem from './problem'; +import game from './game'; export default StrictDict({ app, video, problem, + game, }); diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index b4e49bf0fb..921e82853f 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -485,6 +485,50 @@ export const uploadVideo = ({ data, ...rest }) => (dispatch, getState) => { })); }; +export const uploadGamesImage = ({ image, ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.uploadAsset, + promise: api.uploadGamesImage({ + image, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + }), + ...rest, + })); +}; + +export const getGamesSettings = ({ ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.fetchBlock, + promise: api.getGamesSettings({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + }), + ...rest, + })); +}; + +export const saveGamesSettings = ({ + gameType, + isShuffled, + hasTimer, + cards, + ...rest +}) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.saveBlock, + promise: api.saveGamesSettings({ + gameType, + isShuffled, + hasTimer, + cards, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + }), + ...rest, + })); +}; + export default StrictDict({ fetchBlock, fetchStudioView, @@ -507,4 +551,7 @@ export default StrictDict({ fetchVideoFeatures, uploadVideo, getHandlerlUrl, + uploadGamesImage, + getGamesSettings, + saveGamesSettings, }); diff --git a/src/editors/data/services/cms/api.ts b/src/editors/data/services/cms/api.ts index ec4cd7d760..579fda23a2 100644 --- a/src/editors/data/services/cms/api.ts +++ b/src/editors/data/services/cms/api.ts @@ -316,6 +316,15 @@ export const apiMethods = { id: blockId, metadata: { display_name: title, ...content.settings }, }; + } else if (blockType === 'games') { + response = { + data: content, + category: blockType, + courseKey: learningContextId, + has_changes: true, + id: blockId, + metadata: { display_name: title, ...content.settings }, + }; } else if (blockType === 'video') { const { html5Sources, @@ -390,6 +399,67 @@ export const apiMethods = { }) => get( urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }), ), + uploadGamesImage: ({ + studioEndpointUrl, + blockId, + image, + }) => { + const data = new FormData(); + data.append('file', image); + return post( + urls.xblockHandler({ studioEndpointUrl, blockId, handlerName: 'upload_image' }), + data, + ); + }, + getGamesSettings: ({ + studioEndpointUrl, + blockId, + }) => post( + urls.xblockHandler({ studioEndpointUrl, blockId, handlerName: 'get_settings' }), + {}, + ), + saveGamesSettings: ({ + studioEndpointUrl, + blockId, + gameType, + isShuffled, + cards, + hasTimer, + }) => { + // Transform cards to include order and format properly + // For matching games, exclude image fields + const formattedCards = cards.map((card, index) => { + const baseCard = { + term: card.term || '', + definition: card.definition || '', + order: index + 1, + }; + if (gameType === 'flashcards') { + return { + ...baseCard, + term_image: card.term_image || '', + definition_image: card.definition_image || '', + }; + } + return baseCard; + }); + + const payload: any = { + game_type: gameType, + is_shuffled: isShuffled, + cards: formattedCards, + }; + + // Only include has_timer for matching game type + if (gameType === 'matching') { + payload.has_timer = hasTimer; + } + + return post( + urls.xblockHandler({ studioEndpointUrl, blockId, handlerName: 'save_settings' }), + payload, + ); + }, }; export default apiMethods; diff --git a/src/editors/data/services/cms/urls.ts b/src/editors/data/services/cms/urls.ts index 6da1e22038..2c8dedaac3 100644 --- a/src/editors/data/services/cms/urls.ts +++ b/src/editors/data/services/cms/urls.ts @@ -123,3 +123,7 @@ export const courseVideos = (({ studioEndpointUrl, learningContextId }) => ( export const handlerUrl = (({ studioEndpointUrl, blockId, handlerName }) => ( `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/handler_url/${handlerName}/` )) satisfies UrlFunction; + +export const xblockHandler = (({ studioEndpointUrl, blockId, handlerName }) => ( + `${studioEndpointUrl}/xblock/${blockId}/handler/${handlerName}` +)) satisfies UrlFunction; 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; diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index aedb48c14e..2c3afb3a93 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -34,6 +34,7 @@ export const COMPONENT_TYPES = { problem: 'problem', video: 'video', dragAndDrop: 'drag-and-drop-v2', + games: 'games', }; export const UNIT_TYPE_ICONS_MAP: Record = {