diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.ts b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.ts index 055cd52418..aa787f6f2a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.ts +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants.ts @@ -13,4 +13,4 @@ export const ToleranceTypes = { type: 'None', message: messages.typesNone, }, -}; +} as const; diff --git a/src/editors/containers/ProblemEditor/index.test.tsx b/src/editors/containers/ProblemEditor/index.test.tsx index ef31cdaac5..ab3d37095a 100644 --- a/src/editors/containers/ProblemEditor/index.test.tsx +++ b/src/editors/containers/ProblemEditor/index.test.tsx @@ -90,7 +90,7 @@ describe('ProblemEditor', () => { }); describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + const testState = { A: 'pple', B: 'anana', C: 'ucumber' } as any; test('blockValue from app.blockValue', () => { expect( mapStateToProps(testState).blockValue, diff --git a/src/editors/containers/ProblemEditor/index.tsx b/src/editors/containers/ProblemEditor/index.tsx index 293b7da6ac..d8154c7e87 100644 --- a/src/editors/containers/ProblemEditor/index.tsx +++ b/src/editors/containers/ProblemEditor/index.tsx @@ -18,7 +18,7 @@ export interface Props extends EditorComponent { /** null if this is a new problem */ problemType: ProblemType | null; initializeProblemEditor: (blockValue: any) => void; - blockValue: Record; + blockValue: Record | null; } const ProblemEditor: React.FC = ({ diff --git a/src/editors/data/redux/app/index.js b/src/editors/data/redux/app/index.ts similarity index 100% rename from src/editors/data/redux/app/index.js rename to src/editors/data/redux/app/index.ts diff --git a/src/editors/data/redux/app/reducer.js b/src/editors/data/redux/app/reducer.ts similarity index 96% rename from src/editors/data/redux/app/reducer.js rename to src/editors/data/redux/app/reducer.ts index 3e019c8a54..d53579250c 100644 --- a/src/editors/data/redux/app/reducer.js +++ b/src/editors/data/redux/app/reducer.ts @@ -1,8 +1,9 @@ import { createSlice } from '@reduxjs/toolkit'; +import type { EditorState } from '..'; import { StrictDict } from '../../../utils'; -const initialState = { +const initialState: EditorState['app'] = { blockValue: null, unitUrl: null, blockContent: null, diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.ts similarity index 66% rename from src/editors/data/redux/app/selectors.test.js rename to src/editors/data/redux/app/selectors.test.ts index 061a0b5dd4..b380deca4f 100644 --- a/src/editors/data/redux/app/selectors.test.js +++ b/src/editors/data/redux/app/selectors.test.ts @@ -1,11 +1,9 @@ -// import * in order to mock in-file references +import type { EditorState } from '..'; import { keyStore } from '../../../utils'; +// import * in order to mock in-file references import * as urls from '../../services/cms/urls'; import * as selectors from './selectors'; -jest.mock('reselect', () => ({ - createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), -})); jest.mock('../../services/cms/urls', () => ({ returnUrl: (args) => ({ returnUrl: args }), })); @@ -20,14 +18,14 @@ describe('app selectors unit tests', () => { } = selectors; describe('appSelector', () => { it('returns the app data', () => { - expect(appSelector({ ...testState, app: testValue })).toEqual(testValue); + expect(appSelector({ ...testState, app: testValue } as any as EditorState)).toEqual(testValue); }); }); describe('simpleSelectors', () => { const testSimpleSelector = (key) => { test(`${key} simpleSelector returns its value from the app store`, () => { - const { preSelectors, cb } = simpleSelectors[key]; - expect(preSelectors).toEqual([appSelector]); + const { dependencies, resultFunc: cb } = simpleSelectors[key]; + expect(dependencies).toEqual([appSelector]); expect(cb({ ...testState, [key]: testValue })).toEqual(testValue); }); }; @@ -55,7 +53,7 @@ describe('app selectors unit tests', () => { }); describe('returnUrl', () => { it('is memoized based on unitUrl and studioEndpointUrl', () => { - expect(selectors.returnUrl.preSelectors).toEqual([ + expect(selectors.returnUrl.dependencies).toEqual([ simpleSelectors.unitUrl, simpleSelectors.studioEndpointUrl, simpleSelectors.learningContextId, @@ -63,9 +61,16 @@ describe('app selectors unit tests', () => { ]); }); it('returns urls.returnUrl with the returnUrl', () => { - const { cb } = selectors.returnUrl; + const { resultFunc: cb } = selectors.returnUrl; const studioEndpointUrl = 'baseURL'; - const unitUrl = 'some unit url'; + const unitUrl = { + data: { + ancestors: [ + { + id: 'unit id', display_name: 'Unit', category: 'vertical' as const, has_children: true, + }], + }, + }; const learningContextId = 'some learning context'; const blockId = 'block-v1 some v1 block id'; expect( @@ -79,7 +84,7 @@ describe('app selectors unit tests', () => { }); describe('isInitialized selector', () => { it('is memoized based on editorInitialized, unitUrl, isLibrary and blockValue', () => { - expect(selectors.isInitialized.preSelectors).toEqual([ + expect(selectors.isInitialized.dependencies).toEqual([ simpleSelectors.unitUrl, simpleSelectors.blockValue, selectors.isLibrary, @@ -87,29 +92,29 @@ describe('app selectors unit tests', () => { }); describe('for library blocks', () => { it('returns true if blockValue, and editorInitialized are truthy', () => { - const { cb } = selectors.isInitialized; + const { resultFunc: cb } = selectors.isInitialized; const truthy = { blockValue: { block: 'value' }, }; [ - [[null, truthy.blockValue, true], true], - [[null, null, true], false], + [[null, truthy.blockValue, true] as [any, any, any], true] as const, + [[null, null, true] as [any, any, any], false] as const, ].map(([args, expected]) => expect(cb(...args)).toEqual(expected)); }); }); describe('for course blocks', () => { it('returns true if blockValue, unitUrl, and editorInitialized are truthy', () => { - const { cb } = selectors.isInitialized; + const { resultFunc: cb } = selectors.isInitialized; const truthy = { blockValue: { block: 'value' }, unitUrl: { url: 'data' }, }; [ - [[null, truthy.blockValue, false], false], - [[truthy.unitUrl, null, false], false], - [[truthy.unitUrl, truthy.blockValue, false], true], + [[null, truthy.blockValue, false] as [any, any, any], false] as const, + [[truthy.unitUrl, null, false] as [any, any, any], false] as const, + [[truthy.unitUrl, truthy.blockValue, false] as [any, any, any], true] as const, ].map(([args, expected]) => expect(cb(...args)).toEqual(expected)); }); }); @@ -117,23 +122,23 @@ describe('app selectors unit tests', () => { describe('displayTitle', () => { const title = 'tItLe'; it('is memoized based on blockType and blockTitle', () => { - expect(selectors.displayTitle.preSelectors).toEqual([ + expect(selectors.displayTitle.dependencies).toEqual([ simpleSelectors.blockType, simpleSelectors.blockTitle, ]); }); it('returns null if blockType is null', () => { - expect(selectors.displayTitle.cb(null, title)).toEqual(null); + expect(selectors.displayTitle.resultFunc(null, title)).toEqual(null); }); it('returns blockTitle if blockTitle is not null', () => { - expect(selectors.displayTitle.cb('html', title)).toEqual(title); + expect(selectors.displayTitle.resultFunc('html', title)).toEqual(title); }); it('returns Text if the blockType is html', () => { - expect(selectors.displayTitle.cb('html', null)).toEqual('Text'); + expect(selectors.displayTitle.resultFunc('html', null)).toEqual('Text'); }); it('returns the blockType capitalized if not html', () => { - expect(selectors.displayTitle.cb('video', null)).toEqual('Video'); - expect(selectors.displayTitle.cb('random', null)).toEqual('Random'); + expect(selectors.displayTitle.resultFunc('video', null)).toEqual('Video'); + expect(selectors.displayTitle.resultFunc('random', null)).toEqual('Random'); }); }); @@ -141,41 +146,41 @@ describe('app selectors unit tests', () => { const learningContextIdLibrary = 'library-v1:name'; const learningContextIdCourse = 'course-v1:name'; it('is memoized based on isLibrary', () => { - expect(selectors.isLibrary.preSelectors).toEqual([ + expect(selectors.isLibrary.dependencies).toEqual([ simpleSelectors.learningContextId, simpleSelectors.blockId, ]); }); describe('blockId is null', () => { it('should return false when learningContextId null', () => { - expect(selectors.isLibrary.cb(null, null)).toEqual(false); + expect(selectors.isLibrary.resultFunc(null, null)).toEqual(false); }); it('should return false when learningContextId defined', () => { - expect(selectors.isLibrary.cb(learningContextIdCourse, null)).toEqual(false); + expect(selectors.isLibrary.resultFunc(learningContextIdCourse, null)).toEqual(false); }); }); describe('blockId is a course block', () => { it('should return false when learningContextId null', () => { - expect(selectors.isLibrary.cb(null, 'block-v1:')).toEqual(false); + expect(selectors.isLibrary.resultFunc(null, 'block-v1:')).toEqual(false); }); it('should return false when learningContextId defined', () => { - expect(selectors.isLibrary.cb(learningContextIdCourse, 'block-v1:')).toEqual(false); + expect(selectors.isLibrary.resultFunc(learningContextIdCourse, 'block-v1:')).toEqual(false); }); }); describe('blockId is a v2 library block', () => { it('should return true when learningContextId null', () => { - expect(selectors.isLibrary.cb(null, 'lb:')).toEqual(true); + expect(selectors.isLibrary.resultFunc(null, 'lb:')).toEqual(true); }); it('should return false when learningContextId is a v1 library', () => { - expect(selectors.isLibrary.cb(learningContextIdLibrary, 'lb:')).toEqual(true); + expect(selectors.isLibrary.resultFunc(learningContextIdLibrary, 'lb:')).toEqual(true); }); }); describe('blockId is a v1 library block', () => { it('should return false when learningContextId null', () => { - expect(selectors.isLibrary.cb(null, 'library-v1')).toEqual(false); + expect(selectors.isLibrary.resultFunc(null, 'library-v1')).toEqual(false); }); it('should return true when learningContextId a v1 library', () => { - expect(selectors.isLibrary.cb(learningContextIdLibrary, 'library-v1')).toEqual(true); + expect(selectors.isLibrary.resultFunc(learningContextIdLibrary, 'library-v1')).toEqual(true); }); }); }); diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.ts similarity index 70% rename from src/editors/data/redux/app/selectors.js rename to src/editors/data/redux/app/selectors.ts index 91d880a2d6..3b7d151023 100644 --- a/src/editors/data/redux/app/selectors.js +++ b/src/editors/data/redux/app/selectors.ts @@ -1,16 +1,12 @@ import { createSelector } from 'reselect'; +import type { EditorState } from '..'; import { blockTypes } from '../../constants/app'; import { isLibraryV1Key } from '../../../../generic/key-utils'; import * as urls from '../../services/cms/urls'; -// 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 './selectors'; -export const appSelector = (state) => state.app; +export const appSelector = (state: EditorState) => state.app; -const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb); +const mkSimpleSelector = (cb: (appState: EditorState['app']) => T) => createSelector([appSelector], cb); // top-level app data selectors export const simpleSelectors = { @@ -19,6 +15,7 @@ export const simpleSelectors = { blockType: mkSimpleSelector(app => app.blockType), blockValue: mkSimpleSelector(app => app.blockValue), studioView: mkSimpleSelector(app => app.studioView), + /** @deprecated Get as `const { learningContextid } = useEditorContext()` instead */ learningContextId: mkSimpleSelector(app => app.learningContextId), editorInitialized: mkSimpleSelector(app => app.editorInitialized), saveResponse: mkSimpleSelector(app => app.saveResponse), @@ -32,8 +29,8 @@ export const simpleSelectors = { }; export const returnUrl = createSelector( - [module.simpleSelectors.unitUrl, module.simpleSelectors.studioEndpointUrl, module.simpleSelectors.learningContextId, - module.simpleSelectors.blockId], + [simpleSelectors.unitUrl, simpleSelectors.studioEndpointUrl, simpleSelectors.learningContextId, + simpleSelectors.blockId], (unitUrl, studioEndpointUrl, learningContextId, blockId) => ( urls.returnUrl({ studioEndpointUrl, unitUrl, learningContextId, blockId, @@ -43,8 +40,8 @@ export const returnUrl = createSelector( export const isLibrary = createSelector( [ - module.simpleSelectors.learningContextId, - module.simpleSelectors.blockId, + simpleSelectors.learningContextId, + simpleSelectors.blockId, ], (learningContextId, blockId) => { if (isLibraryV1Key(learningContextId)) { @@ -59,9 +56,9 @@ export const isLibrary = createSelector( export const isInitialized = createSelector( [ - module.simpleSelectors.unitUrl, - module.simpleSelectors.blockValue, - module.isLibrary, + simpleSelectors.unitUrl, + simpleSelectors.blockValue, + isLibrary, ], (unitUrl, blockValue, isLibraryBlock) => { if (isLibraryBlock) { @@ -74,8 +71,8 @@ export const isInitialized = createSelector( export const displayTitle = createSelector( [ - module.simpleSelectors.blockType, - module.simpleSelectors.blockTitle, + simpleSelectors.blockType, + simpleSelectors.blockTitle, ], (blockType, blockTitle) => { if (blockType === null) { @@ -92,9 +89,9 @@ export const displayTitle = createSelector( export const analytics = createSelector( [ - module.simpleSelectors.blockId, - module.simpleSelectors.blockType, - module.simpleSelectors.learningContextId, + simpleSelectors.blockId, + simpleSelectors.blockType, + simpleSelectors.learningContextId, ], (blockId, blockType, learningContextId) => ( { blockId, blockType, learningContextId } diff --git a/src/editors/data/redux/index.js b/src/editors/data/redux/index.js deleted file mode 100644 index e231759a92..0000000000 --- a/src/editors/data/redux/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import { combineReducers } from 'redux'; - -import { StrictDict } from '../../utils'; - -import * as app from './app'; -import * as requests from './requests'; -import * as video from './video'; -import * as problem from './problem'; -import * as game from './game'; - -export { default as thunkActions } from './thunkActions'; - -const modules = { - app, - requests, - video, - problem, - game, -}; - -const moduleProps = (propName) => Object.keys(modules).reduce( - (obj, moduleKey) => ({ ...obj, [moduleKey]: modules[moduleKey][propName] }), - /** @type {Record} */({}), -); - -const rootReducer = combineReducers(moduleProps('reducer')); - -const actions = StrictDict(moduleProps('actions')); - -const selectors = StrictDict(moduleProps('selectors')); - -export { actions, selectors }; - -export default rootReducer; diff --git a/src/editors/data/redux/index.ts b/src/editors/data/redux/index.ts new file mode 100644 index 0000000000..7758662965 --- /dev/null +++ b/src/editors/data/redux/index.ts @@ -0,0 +1,185 @@ +import type { AxiosResponse } from 'axios'; +import { combineReducers } from 'redux'; +import { StrictDict } from '../../utils'; + +import * as app from './app'; +import * as requests from './requests'; +import * as video from './video'; +import * as problem from './problem'; +import * as game from './game'; +import type { RequestKeys, RequestStates } from '../constants/requests'; +import { AdvancedProblemType, ProblemType } from '../constants/problem'; + +export { default as thunkActions } from './thunkActions'; + +const rootReducer = combineReducers({ + app: app.reducer, + requests: requests.reducer, + video: video.reducer, + problem: problem.reducer, + game: game.reducer, +}); + +const actions = StrictDict({ + app: app.actions, + requests: requests.actions, + video: video.actions, + problem: problem.actions, + game: game.actions, +}); + +const selectors = StrictDict({ + app: app.selectors, + requests: requests.selectors, + video: video.selectors, + problem: problem.selectors, + game: game.selectors, +}); + +export interface EditorState { + // TODO: move all the 'app' state into EditorContext, not redux. + // TODO: replace 'requests' state with React Query hooks + // TODO: instead of one big store, give each editor its own store just for that editor type (e.g. VideoStore) + app: { + blockId: string | null; // e.g. "block-v1:Org+TS100+24+type@html+block@12345" + blockTitle: string | null; // e.g. "A Text Component"; + blockType: string | null; + blockValue: null | { + data: { + id: string; // e.g. "block-v1:Org+TS100+24+type@html+block@12345" + display_name: string; // e.g. "A Text Component" + category?: string; // e.g. "html". Only for blocks from courses + data: string | Record; + metadata: Record; + [otherKey: string]: any; + }, + // There are other fields; this is an AxiosResponse object. But we don't want to rely on those details. + }, + unitUrl: null | { + data: { + ancestors: { + id: string; + display_name: string; + category: 'vertical' | 'sequential' | 'chapter' | 'course'; + has_children: boolean; + unit_level_discussions?: boolean; + }[]; + }, + // There are other fields; this is an AxiosResponse object. But we don't want to rely on those details. + }, + blockContent: null; + studioView: null; + saveResponse: null; + /** @deprecated Get as `const { learningContextid } = useEditorContext()` instead */ + learningContextId: string | null; // e.g. "course-v1:Org+TS100+24"; + editorInitialized: boolean; + /** e.g. "http://studio.local.openedx.io:8001" TODO: move to EditorContext */ + studioEndpointUrl: string | null; + /** e.g. "http://local.openedx.io:8000" TODO: move to EditorContext */ + lmsEndpointUrl: string | null; + images: Record; + imageCount: number; + videos: Record; + courseDetails: Record; + showRawEditor: boolean; + }, + requests: Record + video: { + videoSource: string; // default: "" + videoId: string; // default: "" + fallbackVideos: string[]; + allowVideoDownloads: boolean; + allowVideoSharing: { level: string; value: boolean }; + videoSharingEnabledForAll: boolean; + videoSharingEnabledForCourse: boolean; + videoSharingLearnMoreLink: string; + thumbnail: null | any; + transcripts: any[]; + selectedVideoTranscriptUrls: Record; + allowTranscriptDownloads: boolean; + duration: { + /** e.g. "00:00:00" */ + startTime: string; + stopTime: string; + total: string; + }, + showTranscriptByDefault: boolean; + handout: null, + licenseType: null, + licenseDetails: { + attribution: boolean; + noncommercial: boolean; + noDerivatives: boolean; + shareAlike: boolean; + }, + courseLicenseType: string | null; + courseLicenseDetails: { + attribution: boolean; + noncommercial: boolean; + noDerivatives: boolean; + shareAlike: boolean; + }, + allowThumbnailUpload: boolean | null; // TODO: why is this null? + allowTranscriptImport: boolean; + }, + problem: { + /** Has the user made changes to this problem since opening the editor? */ + isDirty: boolean; + rawOLX: string; + problemType: null | ProblemType | AdvancedProblemType; + question: string; + answers: any[]; + correctAnswerCount: number; + groupFeedbackList: any[]; + generalFeedback: string; + additionalAttributes: Record; + defaultSettings: Record; + settings: { + randomization: null | any; // Not sure what type this field has + scoring: { + weight: number; + attempts: { unlimited: boolean; number: number | null; } + }, + hints: any[]; + timeBetween: number; + showAnswer: { + on: string; + afterAttempts: number; + }, + showResetButton: boolean | null; + solutionExplanation: string; + tolerance: { + value: number | null; + type: 'Percent' | 'Number' | 'None'; + } + } + }, + game: { + settings: Record; + exampleValue: 'this is an example value from the redux state'; + } +} + +export { actions, selectors }; + +export default rootReducer; diff --git a/src/editors/data/redux/problem/index.js b/src/editors/data/redux/problem/index.ts similarity index 100% rename from src/editors/data/redux/problem/index.js rename to src/editors/data/redux/problem/index.ts diff --git a/src/editors/data/redux/problem/reducers.test.js b/src/editors/data/redux/problem/reducers.test.ts similarity index 94% rename from src/editors/data/redux/problem/reducers.test.js rename to src/editors/data/redux/problem/reducers.test.ts index 83421f4c71..7f2acb2c84 100644 --- a/src/editors/data/redux/problem/reducers.test.js +++ b/src/editors/data/redux/problem/reducers.test.ts @@ -8,7 +8,7 @@ const testingState = { describe('problem reducer', () => { it('has initial state', () => { - expect(reducer(undefined, {})).toEqual(initialState); + expect(reducer(undefined, {} as any)).toEqual(initialState); }); const testValue = 'roll for initiative'; @@ -26,7 +26,7 @@ describe('problem reducer', () => { }); }; [ - ['updateQuestion', 'question'], + ['updateQuestion', 'question'] as const, ].map(args => setterTest(...args)); describe('setEnableTypeSelection', () => { it('sets given problemType to null', () => { @@ -44,7 +44,7 @@ describe('problem reducer', () => { attempts: { number: 1, unlimited: false }, }, showAnswer: { ...testingState.settings.showAnswer, on: payload.showanswer }, - ...payload.showResetButton, + showResetButton: payload.showResetButton, }, problemType: null, }); @@ -52,7 +52,7 @@ describe('problem reducer', () => { }); describe('load', () => { it('sets answers', () => { - const answer = { + const blankAnswer = { id: 'A', correct: false, selectedFeedback: '', @@ -60,9 +60,9 @@ describe('problem reducer', () => { isAnswerRange: false, unselectedFeedback: '', }; - expect(reducer(testingState, actions.addAnswer(answer))).toEqual({ + expect(reducer(testingState, actions.addAnswer())).toEqual({ ...testingState, - answers: [answer], + answers: [blankAnswer], isDirty: true, }); }); @@ -354,10 +354,10 @@ describe('problem reducer', () => { }, actions.deleteAnswer(payload), ); - expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalled(); - expect(window.tinymce.editors['answer-A'].setContent).toHaveBeenCalledWith('editorAnsB'); - expect(window.tinymce.editors['selectedFeedback-A'].setContent).toHaveBeenCalledWith('editSelFB'); - expect(window.tinymce.editors['unselectedFeedback-A'].setContent).toHaveBeenCalledWith('editUnselFB'); + expect((window as any).tinymce.editors['answer-A'].setContent).toHaveBeenCalled(); + expect((window as any).tinymce.editors['answer-A'].setContent).toHaveBeenCalledWith('editorAnsB'); + expect((window as any).tinymce.editors['selectedFeedback-A'].setContent).toHaveBeenCalledWith('editSelFB'); + expect((window as any).tinymce.editors['unselectedFeedback-A'].setContent).toHaveBeenCalledWith('editUnselFB'); }); it('sets groupFeedbackList by removing the checked item in the groupFeedback', () => { windowSpy.mockImplementation(() => ({ diff --git a/src/editors/data/redux/problem/reducers.js b/src/editors/data/redux/problem/reducers.ts similarity index 95% rename from src/editors/data/redux/problem/reducers.js rename to src/editors/data/redux/problem/reducers.ts index 3a0a762819..32cd7ea8f9 100644 --- a/src/editors/data/redux/problem/reducers.js +++ b/src/editors/data/redux/problem/reducers.ts @@ -4,9 +4,11 @@ import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXPars import { StrictDict } from '../../../utils'; import { ProblemTypeKeys, RichTextProblems } from '../../constants/problem'; import { ToleranceTypes } from '../../../containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants'; +import type { EditorState } from '..'; -const nextAlphaId = (lastId) => String.fromCharCode(lastId.charCodeAt(0) + 1); -const initialState = { +const nextAlphaId = (lastId: string) => String.fromCharCode(lastId.charCodeAt(0) + 1); + +const initialState: EditorState['problem'] = { rawOLX: '', problemType: null, question: '', @@ -41,7 +43,6 @@ const initialState = { }, }; -// eslint-disable-next-line no-unused-vars const problem = createSlice({ name: 'problem', initialState, @@ -84,7 +85,7 @@ const problem = createSlice({ }, deleteAnswer: (state, { payload }) => { const { id, correct, editorState } = payload; - const EditorsArray = window.tinymce.editors; + const EditorsArray = (window as any).tinymce.editors; if (state.answers.length === 1) { return { ...state, @@ -111,7 +112,7 @@ const problem = createSlice({ selectedFeedback: editorState.selectedFeedback ? editorState.selectedFeedback[answer.id] : '', unselectedFeedback: editorState.unselectedFeedback ? editorState.unselectedFeedback[answer.id] : '', }; - if (RichTextProblems.includes(state.problemType)) { + if (RichTextProblems.includes(state.problemType as any)) { newAnswer = { ...newAnswer, title: editorState.answers[answer.id], @@ -222,7 +223,7 @@ const problem = createSlice({ ...state.settings, scoring: { ...state.settings.scoring, attempts }, showAnswer: { ...state.settings.showAnswer, on: showanswer }, - ...showResetButton, + showResetButton, }, problemType: null, }; diff --git a/src/editors/data/redux/problem/selectors.test.js b/src/editors/data/redux/problem/selectors.test.ts similarity index 73% rename from src/editors/data/redux/problem/selectors.test.js rename to src/editors/data/redux/problem/selectors.test.ts index 72214ac08a..974bd67bc6 100644 --- a/src/editors/data/redux/problem/selectors.test.js +++ b/src/editors/data/redux/problem/selectors.test.ts @@ -2,10 +2,6 @@ import { keyStore } from '../../../utils'; import * as selectors from './selectors'; -jest.mock('reselect', () => ({ - createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), -})); - const testState = { some: 'arbitraryValue' }; const testValue = 'my VALUE'; @@ -16,14 +12,14 @@ describe('problem selectors unit tests', () => { } = selectors; describe('problemState', () => { it('returns the problem data', () => { - expect(problemState({ ...testState, problem: testValue })).toEqual(testValue); + expect(problemState({ ...testState, problem: testValue } as any)).toEqual(testValue); }); }); describe('simpleSelectors', () => { const testSimpleSelector = (key) => { test(`${key} simpleSelector returns its value from the problem store`, () => { - const { preSelectors, cb } = simpleSelectors[key]; - expect(preSelectors).toEqual([problemState]); + const { dependencies, resultFunc: cb } = simpleSelectors[key]; + expect(dependencies).toEqual([problemState]); expect(cb({ ...testState, [key]: testValue })).toEqual(testValue); }); }; @@ -39,12 +35,12 @@ describe('problem selectors unit tests', () => { ].map(testSimpleSelector); }); test('simple selector completeState equals the entire state', () => { - const { preSelectors, cb } = simpleSelectors[simpleKeys.completeState]; - expect(preSelectors).toEqual([problemState]); + const { dependencies, resultFunc: cb } = simpleSelectors[simpleKeys.completeState]; + expect(dependencies).toEqual([problemState]); expect(cb({ ...testState, [simpleKeys.completeState]: testValue, - })).toEqual({ + } as any)).toEqual({ ...testState, [simpleKeys.completeState]: testValue, }); diff --git a/src/editors/data/redux/problem/selectors.js b/src/editors/data/redux/problem/selectors.ts similarity index 61% rename from src/editors/data/redux/problem/selectors.js rename to src/editors/data/redux/problem/selectors.ts index fcd36b37aa..b6cb5ef60f 100644 --- a/src/editors/data/redux/problem/selectors.js +++ b/src/editors/data/redux/problem/selectors.ts @@ -1,12 +1,10 @@ import { createSelector } from 'reselect'; -// 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 './selectors'; +import type { EditorState } from '..'; + +export const problemState = (state: EditorState) => state.problem; + +const mkSimpleSelector = (cb: (problemState: EditorState['problem']) => T) => createSelector([problemState], cb); -export const problemState = (state) => state.problem; -const mkSimpleSelector = (cb) => createSelector([module.problemState], cb); export const simpleSelectors = { problemType: mkSimpleSelector(problemData => problemData.problemType), generalFeedback: mkSimpleSelector(problemData => problemData.generalFeedback), @@ -20,6 +18,4 @@ export const simpleSelectors = { isDirty: mkSimpleSelector(problemData => problemData.isDirty), }; -export default { - ...simpleSelectors, -}; +export default simpleSelectors; diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index 873ad0e8a9..6ccc95b295 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -75,26 +75,26 @@ describe('problem thunkActions', () => { fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onSuccess({ data: { key: 'test', max_attempts: 1 } }); - expect(dispatch).toHaveBeenCalledWith(actions.problem.load()); + expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); it('calls loadProblem on failure', () => { dispatch.mockClear(); fetchAdvancedSettings({ rawOLX, rawSettings })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onFailure(); - expect(dispatch).toHaveBeenCalledWith(actions.problem.load()); + expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); }); describe('loadProblem', () => { test('initializeProblem advanced Problem', () => { rawOLX = advancedProblemOlX.rawOLX; loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch); - expect(dispatch).toHaveBeenCalledWith(actions.problem.load()); + expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); test('initializeProblem blank Problem', () => { rawOLX = blankProblemOLX.rawOLX; loadProblem({ rawOLX, rawSettings, defaultSettings })(dispatch); - expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection()); + expect(dispatch).toHaveBeenCalledWith(actions.problem.setEnableTypeSelection(undefined)); }); }); }); diff --git a/src/editors/data/store.js b/src/editors/data/store.ts similarity index 68% rename from src/editors/data/store.js rename to src/editors/data/store.ts index a10b1ba28f..deee26bf46 100755 --- a/src/editors/data/store.js +++ b/src/editors/data/store.ts @@ -3,15 +3,15 @@ import thunkMiddleware from 'redux-thunk'; import { composeWithDevToolsLogOnlyInProduction } from '@redux-devtools/extension'; import { createLogger } from 'redux-logger'; -import reducer, { actions, selectors } from './redux'; +import reducer, { actions, selectors, type EditorState } from './redux'; export const createStore = () => { const loggerMiddleware = createLogger(); const middleware = [thunkMiddleware, loggerMiddleware]; - const store = redux.createStore( - reducer, + const store = redux.createStore( + reducer as any, composeWithDevToolsLogOnlyInProduction(redux.applyMiddleware(...middleware)), ); @@ -19,9 +19,9 @@ export const createStore = () => { * Dev tools for redux work */ if (process.env.NODE_ENV === 'development') { - window.store = store; - window.actions = actions; - window.selectors = selectors; + (window as any).store = store; + (window as any).actions = actions; + (window as any).selectors = selectors; } return store; diff --git a/src/editors/utils/keyStore.js b/src/editors/utils/keyStore.ts similarity index 57% rename from src/editors/utils/keyStore.js rename to src/editors/utils/keyStore.ts index a670f436f6..560a6f7800 100644 --- a/src/editors/utils/keyStore.js +++ b/src/editors/utils/keyStore.ts @@ -1,10 +1,10 @@ import StrictDict from './StrictDict'; -const keyStore = (collection) => StrictDict( +const keyStore = >(collection: Obj): { [K in keyof Obj]: K } => StrictDict( Object.keys(collection).reduce( (obj, key) => ({ ...obj, [key]: key }), {}, ), -); +) as any; export default keyStore; diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index fa689e89fe..6334171197 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -30,12 +30,12 @@ export function getLibraryId(usageKey: string): string { } /** Check if this is a V2 library key. */ -export function isLibraryKey(learningContextKey: string | undefined): learningContextKey is string { +export function isLibraryKey(learningContextKey: string | undefined | null): learningContextKey is string { return typeof learningContextKey === 'string' && learningContextKey.startsWith('lib:'); } /** Check if this is a V1 library key. */ -export function isLibraryV1Key(learningContextKey: string | undefined): learningContextKey is string { +export function isLibraryV1Key(learningContextKey: string | undefined | null): learningContextKey is string { return typeof learningContextKey === 'string' && learningContextKey.startsWith('library-v1:'); }