Skip to content

Commit 314dfa6

Browse files
feat: Enable capa problem editor for components in libraries (#1290)
* feat: enable the problem editor for library components * fix: don't try to load "advanced settings" when editing problem in library * fix: don't fetch images when editing problem in library * docs: add a note about plans for the editor modal * fix: choosing a problem type then cancelling resulted in an error * chore: remove unused mockApi, clean up problematic 'module' self import * test: update workflow test to test problem editor * feat: show capa content summary on cards in library search results * docs: fix comment typos found in code review * refactor: add 'key-utils' to consolidate opaque key logic
1 parent b010909 commit 314dfa6

29 files changed

+466
-617
lines changed

src/editors/EditorContainer.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,29 @@ import EditorPage from './EditorPage';
77
interface Props {
88
/** Course ID or Library ID */
99
learningContextId: string;
10-
/** Event handler for when user cancels out of the editor page */
10+
/** Event handler sometimes called when user cancels out of the editor page */
1111
onClose?: () => void;
12-
/** Event handler called after when user saves their changes using an editor */
13-
afterSave?: () => (newData: Record<string, any>) => void;
12+
/**
13+
* Event handler called after when user saves their changes using an editor
14+
* and sometimes called when user cancels the editor, instead of onClose.
15+
* If changes are saved, newData will be present, and if it was cancellation,
16+
* newData will be undefined.
17+
* TODO: clean this up so there are separate onCancel and onSave callbacks,
18+
* and they are used consistently instead of this mess.
19+
*/
20+
returnFunction?: () => (newData: Record<string, any> | undefined) => void;
1421
}
1522

1623
const EditorContainer: React.FC<Props> = ({
1724
learningContextId,
1825
onClose,
19-
afterSave,
26+
returnFunction,
2027
}) => {
2128
const { blockType, blockId } = useParams();
2229
if (blockType === undefined || blockId === undefined) {
2330
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
2431
return <div>Error: missing URL parameters</div>;
2532
}
26-
if (!!onClose !== !!afterSave) {
27-
/* istanbul ignore next */
28-
throw new Error('You must specify both onClose and afterSave or neither.');
29-
// These parameters are a bit messy so I'm trying to help make it more
30-
// consistent here. For example, if you specify onClose, then returnFunction
31-
// is only called if the save is successful. But if you leave onClose
32-
// undefined, then returnFunction is called in either case, and with
33-
// different arguments. The underlying EditorPage should be refactored to
34-
// have more clear events like onCancel and onSaveSuccess
35-
}
3633
return (
3734
<div className="editor-page">
3835
<EditorPage
@@ -42,7 +39,7 @@ const EditorContainer: React.FC<Props> = ({
4239
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
4340
lmsEndpointUrl={getConfig().LMS_BASE_URL}
4441
onClose={onClose}
45-
returnFunction={afterSave}
42+
returnFunction={returnFunction}
4643
/>
4744
</div>
4845
);

src/editors/data/constants/requests.js renamed to src/editors/data/constants/requests.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const RequestStates = StrictDict({
55
pending: 'pending',
66
completed: 'completed',
77
failed: 'failed',
8-
});
8+
} as const);
99

1010
export const RequestKeys = StrictDict({
1111
fetchVideos: 'fetchVideos',
@@ -27,4 +27,4 @@ export const RequestKeys = StrictDict({
2727
uploadAsset: 'uploadAsset',
2828
fetchAdvancedSettings: 'fetchAdvancedSettings',
2929
fetchVideoFeatures: 'fetchVideoFeatures',
30-
});
30+
} as const);

src/editors/data/redux/app/selectors.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createSelector } from 'reselect';
22
import { blockTypes } from '../../constants/app';
3+
import { isLibraryV1Key } from '../../../../generic/key-utils';
34
import * as urls from '../../services/cms/urls';
45
// This 'module' self-import hack enables mocking during tests.
56
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
@@ -46,7 +47,7 @@ export const isLibrary = createSelector(
4647
module.simpleSelectors.blockId,
4748
],
4849
(learningContextId, blockId) => {
49-
if (learningContextId && learningContextId.startsWith('library-v1')) {
50+
if (isLibraryV1Key(learningContextId)) {
5051
return true;
5152
}
5253
if (blockId && blockId.startsWith('lb:')) {

src/editors/data/redux/thunkActions/app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { StrictDict, camelizeKeys } from '../../../utils';
2+
import { isLibraryKey } from '../../../../generic/key-utils';
23
import * as requests from './requests';
34
// This 'module' self-import hack enables mocking during tests.
45
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
@@ -102,7 +103,7 @@ export const initialize = (data) => (dispatch) => {
102103
dispatch(module.fetchCourseDetails());
103104
break;
104105
case 'html':
105-
if (data.learningContextId?.startsWith('lib:')) {
106+
if (isLibraryKey(data.learningContextId)) {
106107
// eslint-disable-next-line no-console
107108
console.log('Not fetching image assets - not implemented yet for content libraries.');
108109
} else {

src/editors/data/redux/thunkActions/problem.test.js renamed to src/editors/data/redux/thunkActions/problem.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ describe('problem thunkActions', () => {
4545
getState = jest.fn(() => ({
4646
problem: {
4747
},
48+
app: {
49+
learningContextId: 'course-v1:org+course+run',
50+
},
4851
}));
4952
});
5053

5154
afterEach(() => {
5255
jest.restoreAllMocks();
5356
});
5457
test('initializeProblem visual Problem :', () => {
55-
initializeProblem(blockValue)(dispatch);
58+
initializeProblem(blockValue)(dispatch, getState);
5659
expect(dispatch).toHaveBeenCalled();
5760
});
5861
test('switchToAdvancedEditor visual Problem', () => {

src/editors/data/redux/thunkActions/problem.js renamed to src/editors/data/redux/thunkActions/problem.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import _ from 'lodash';
22
import { actions as problemActions } from '../problem';
3+
import { actions as requestActions } from '../requests';
4+
import { selectors as appSelectors } from '../app';
35
import * as requests from './requests';
6+
import { isLibraryKey } from '../../../../generic/key-utils';
47
import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
58
import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
69
import { ProblemTypeKeys } from '../../constants/problem';
710
import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
8-
import { blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
911
import { camelizeKeys } from '../../../utils';
1012
import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
13+
import { RequestKeys } from '../../constants/requests';
1114

1215
// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
13-
const actions = { problem: problemActions };
16+
const actions = { problem: problemActions, requests: requestActions };
17+
const selectors = { app: appSelectors };
1418

1519
export const switchToAdvancedEditor = () => (dispatch, getState) => {
1620
const state = getState();
@@ -21,7 +25,7 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => {
2125
};
2226

2327
export const isBlankProblem = ({ rawOLX }) => {
24-
if (rawOLX.replace(/\s/g, '') === blankProblemOLX.rawOLX) {
28+
if (['<problem></problem>', '<problem/>'].includes(rawOLX.replace(/\s/g, ''))) {
2529
return true;
2630
}
2731
return false;
@@ -67,7 +71,7 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
6771
dispatch(requests.fetchAdvancedSettings({
6872
onSuccess: (response) => {
6973
const defaultSettings = {};
70-
Object.entries(response.data).forEach(([key, value]) => {
74+
Object.entries(response.data as Record<string, any>).forEach(([key, value]) => {
7175
if (advancedProblemSettingKeys.includes(key)) {
7276
defaultSettings[key] = value.value;
7377
}
@@ -79,10 +83,20 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
7983
}));
8084
};
8185

82-
export const initializeProblem = (blockValue) => (dispatch) => {
86+
export const initializeProblem = (blockValue) => (dispatch, getState) => {
8387
const rawOLX = _.get(blockValue, 'data.data', {});
8488
const rawSettings = _.get(blockValue, 'data.metadata', {});
85-
dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
89+
const learningContextId = selectors.app.learningContextId(getState());
90+
if (isLibraryKey(learningContextId)) {
91+
// Content libraries don't yet support defaults for fields like max_attempts, showanswer, etc.
92+
// So proceed with loading the problem.
93+
// Though first we need to fake the request or else the problem type selection UI won't display:
94+
dispatch(actions.requests.completeRequest({ requestKey: RequestKeys.fetchAdvancedSettings, response: {} }));
95+
dispatch(loadProblem({ rawOLX, rawSettings, defaultSettings: {} }));
96+
} else {
97+
// Load the defaults (for max_attempts, etc.) from the course's advanced settings, then proceed:
98+
dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
99+
}
86100
};
87101

88102
export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings };

src/editors/data/services/cms/api.test.js renamed to src/editors/data/services/cms/api.test.ts

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
1-
/* eslint-disable no-import-assign */
2-
import * as utils from '../../../utils';
31
import * as api from './api';
4-
import * as mockApi from './mockApi';
52
import * as urls from './urls';
63
import { get, post, deleteObject } from './utils';
74

8-
jest.mock('../../../utils', () => {
9-
const camelizeMap = (obj) => ({ ...obj, camelized: true });
10-
return {
11-
...jest.requireActual('../../../utils'),
12-
camelize: camelizeMap,
13-
camelizeKeys: (list) => list.map(camelizeMap),
14-
};
15-
});
16-
175
jest.mock('./urls', () => ({
186
block: jest.fn().mockReturnValue('urls.block'),
197
blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'),
@@ -40,8 +28,6 @@ jest.mock('./utils', () => ({
4028
deleteObject: jest.fn().mockName('deleteObject'),
4129
}));
4230

43-
const { camelize } = utils;
44-
4531
const { apiMethods } = api;
4632

4733
const blockId = 'block-v1-coursev1:2uX@4345432';
@@ -200,7 +186,7 @@ describe('cms api', () => {
200186
licenseType: 'LiCeNsETYpe',
201187
licenseDetails: 'liCENSeDetAIls',
202188
};
203-
const html5Sources = 'hTML5souRCES';
189+
const html5Sources = ['hTML5souRCES'];
204190
const edxVideoId = 'eDXviDEOid';
205191
const youtubeId = 'yOUtUBeid';
206192
const license = 'LiCEnsE';
@@ -241,6 +227,7 @@ describe('cms api', () => {
241227
jest.restoreAllMocks();
242228
});
243229
test('throw error for invalid blockType', () => {
230+
// @ts-expect-error because we're not passing 'blockId' or other parameters
244231
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
245232
.toThrow(TypeError);
246233
});
@@ -271,7 +258,7 @@ describe('cms api', () => {
271258
});
272259

273260
describe('uploadAsset', () => {
274-
const asset = { photo: 'dAta' };
261+
const asset = new Blob(['data'], { type: 'image/jpeg' });
275262
it('should call post with urls.courseAssets and imgdata', () => {
276263
const mockFormdata = new FormData();
277264
mockFormdata.append('file', asset);
@@ -318,18 +305,17 @@ describe('cms api', () => {
318305
{ id: ids[0], some: 'data' },
319306
{ id: ids[1], other: 'data' },
320307
{ id: ids[2], some: 'DATA' },
321-
{ id: ids[3], other: 'DATA' },
308+
{ id: ids[3], other_data: 'DATA' },
322309
];
323-
const oldLoadImage = api.loadImage;
324-
api.loadImage = (imageData) => ({ loadImage: imageData });
310+
const spy = jest.spyOn(api, 'loadImage').mockImplementation((imageData) => ({ loadImage: imageData }));
325311
const out = api.loadImages(testData);
326312
expect(out).toEqual({
327-
[ids[0]]: api.loadImage(camelize(testData[0])),
328-
[ids[1]]: api.loadImage(camelize(testData[1])),
329-
[ids[2]]: api.loadImage(camelize(testData[2])),
330-
[ids[3]]: api.loadImage(camelize(testData[3])),
313+
[ids[0]]: api.loadImage(testData[0]),
314+
[ids[1]]: api.loadImage(testData[1]),
315+
[ids[2]]: api.loadImage(testData[2]),
316+
[ids[3]]: api.loadImage({ id: ids[3], otherData: 'DATA' }), // Verify its 'other_data' key was camelized
331317
});
332-
api.loadImage = oldLoadImage;
318+
spy.mockClear();
333319
});
334320
});
335321
describe('uploadThumbnail', () => {
@@ -382,7 +368,7 @@ describe('cms api', () => {
382368
});
383369
});
384370
describe('uploadTranscript', () => {
385-
const transcript = { transcript: 'dAta' };
371+
const transcript = new Blob(['dAta']);
386372
it('should call post with urls.videoTranscripts and transcript data', () => {
387373
const mockFormdata = new FormData();
388374
mockFormdata.append('file', transcript);
@@ -606,31 +592,6 @@ describe('cms api', () => {
606592
expect(api.processLicense(licenseType, licenseDetails)).toEqual('all-rights-reserved');
607593
});
608594
});
609-
describe('checkMockApi', () => {
610-
const envTemp = process.env;
611-
beforeEach(() => {
612-
jest.resetModules();
613-
process.env = { ...envTemp };
614-
});
615-
afterEach(() => {
616-
process.env = envTemp;
617-
});
618-
describe('if REACT_APP_DEVGALLERY is true', () => {
619-
it('should return the mockApi version of a call when it exists', () => {
620-
process.env.REACT_APP_DEVGALLERY = true;
621-
expect(api.checkMockApi('fetchBlockById')).toEqual(mockApi.fetchBlockById);
622-
});
623-
it('should return an empty mock when the call does not exist', () => {
624-
process.env.REACT_APP_DEVGALLERY = true;
625-
expect(api.checkMockApi('someRAnDomThINg')).toEqual(mockApi.emptyMock);
626-
});
627-
});
628-
describe('if REACT_APP_DEVGALLERY is not true', () => {
629-
it('should return the appropriate call', () => {
630-
expect(api.checkMockApi('fetchBlockById')).toEqual(apiMethods.fetchBlockById);
631-
});
632-
});
633-
});
634595
describe('fetchVideoFeatures', () => {
635596
it('should call get with url.videoFeatures', () => {
636597
const args = { studioEndpointUrl };

0 commit comments

Comments
 (0)