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
This commit is contained in:
@@ -7,32 +7,29 @@ import EditorPage from './EditorPage';
|
|||||||
interface Props {
|
interface Props {
|
||||||
/** Course ID or Library ID */
|
/** Course ID or Library ID */
|
||||||
learningContextId: string;
|
learningContextId: string;
|
||||||
/** Event handler for when user cancels out of the editor page */
|
/** Event handler sometimes called when user cancels out of the editor page */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/** Event handler called after when user saves their changes using an editor */
|
/**
|
||||||
afterSave?: () => (newData: Record<string, any>) => void;
|
* Event handler called after when user saves their changes using an editor
|
||||||
|
* and sometimes called when user cancels the editor, instead of onClose.
|
||||||
|
* If changes are saved, newData will be present, and if it was cancellation,
|
||||||
|
* newData will be undefined.
|
||||||
|
* TODO: clean this up so there are separate onCancel and onSave callbacks,
|
||||||
|
* and they are used consistently instead of this mess.
|
||||||
|
*/
|
||||||
|
returnFunction?: () => (newData: Record<string, any> | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorContainer: React.FC<Props> = ({
|
const EditorContainer: React.FC<Props> = ({
|
||||||
learningContextId,
|
learningContextId,
|
||||||
onClose,
|
onClose,
|
||||||
afterSave,
|
returnFunction,
|
||||||
}) => {
|
}) => {
|
||||||
const { blockType, blockId } = useParams();
|
const { blockType, blockId } = useParams();
|
||||||
if (blockType === undefined || blockId === undefined) {
|
if (blockType === undefined || blockId === undefined) {
|
||||||
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
|
||||||
return <div>Error: missing URL parameters</div>;
|
return <div>Error: missing URL parameters</div>;
|
||||||
}
|
}
|
||||||
if (!!onClose !== !!afterSave) {
|
|
||||||
/* istanbul ignore next */
|
|
||||||
throw new Error('You must specify both onClose and afterSave or neither.');
|
|
||||||
// These parameters are a bit messy so I'm trying to help make it more
|
|
||||||
// consistent here. For example, if you specify onClose, then returnFunction
|
|
||||||
// is only called if the save is successful. But if you leave onClose
|
|
||||||
// undefined, then returnFunction is called in either case, and with
|
|
||||||
// different arguments. The underlying EditorPage should be refactored to
|
|
||||||
// have more clear events like onCancel and onSaveSuccess
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-page">
|
<div className="editor-page">
|
||||||
<EditorPage
|
<EditorPage
|
||||||
@@ -42,7 +39,7 @@ const EditorContainer: React.FC<Props> = ({
|
|||||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
returnFunction={afterSave}
|
returnFunction={returnFunction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const RequestStates = StrictDict({
|
|||||||
pending: 'pending',
|
pending: 'pending',
|
||||||
completed: 'completed',
|
completed: 'completed',
|
||||||
failed: 'failed',
|
failed: 'failed',
|
||||||
});
|
} as const);
|
||||||
|
|
||||||
export const RequestKeys = StrictDict({
|
export const RequestKeys = StrictDict({
|
||||||
fetchVideos: 'fetchVideos',
|
fetchVideos: 'fetchVideos',
|
||||||
@@ -27,4 +27,4 @@ export const RequestKeys = StrictDict({
|
|||||||
uploadAsset: 'uploadAsset',
|
uploadAsset: 'uploadAsset',
|
||||||
fetchAdvancedSettings: 'fetchAdvancedSettings',
|
fetchAdvancedSettings: 'fetchAdvancedSettings',
|
||||||
fetchVideoFeatures: 'fetchVideoFeatures',
|
fetchVideoFeatures: 'fetchVideoFeatures',
|
||||||
});
|
} as const);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { blockTypes } from '../../constants/app';
|
import { blockTypes } from '../../constants/app';
|
||||||
|
import { isLibraryV1Key } from '../../../../generic/key-utils';
|
||||||
import * as urls from '../../services/cms/urls';
|
import * as urls from '../../services/cms/urls';
|
||||||
// This 'module' self-import hack enables mocking during tests.
|
// 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
|
// 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(
|
|||||||
module.simpleSelectors.blockId,
|
module.simpleSelectors.blockId,
|
||||||
],
|
],
|
||||||
(learningContextId, blockId) => {
|
(learningContextId, blockId) => {
|
||||||
if (learningContextId && learningContextId.startsWith('library-v1')) {
|
if (isLibraryV1Key(learningContextId)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (blockId && blockId.startsWith('lb:')) {
|
if (blockId && blockId.startsWith('lb:')) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StrictDict, camelizeKeys } from '../../../utils';
|
import { StrictDict, camelizeKeys } from '../../../utils';
|
||||||
|
import { isLibraryKey } from '../../../../generic/key-utils';
|
||||||
import * as requests from './requests';
|
import * as requests from './requests';
|
||||||
// This 'module' self-import hack enables mocking during tests.
|
// 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
|
// 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) => {
|
|||||||
dispatch(module.fetchCourseDetails());
|
dispatch(module.fetchCourseDetails());
|
||||||
break;
|
break;
|
||||||
case 'html':
|
case 'html':
|
||||||
if (data.learningContextId?.startsWith('lib:')) {
|
if (isLibraryKey(data.learningContextId)) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('Not fetching image assets - not implemented yet for content libraries.');
|
console.log('Not fetching image assets - not implemented yet for content libraries.');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ describe('problem thunkActions', () => {
|
|||||||
getState = jest.fn(() => ({
|
getState = jest.fn(() => ({
|
||||||
problem: {
|
problem: {
|
||||||
},
|
},
|
||||||
|
app: {
|
||||||
|
learningContextId: 'course-v1:org+course+run',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ describe('problem thunkActions', () => {
|
|||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
test('initializeProblem visual Problem :', () => {
|
test('initializeProblem visual Problem :', () => {
|
||||||
initializeProblem(blockValue)(dispatch);
|
initializeProblem(blockValue)(dispatch, getState);
|
||||||
expect(dispatch).toHaveBeenCalled();
|
expect(dispatch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
test('switchToAdvancedEditor visual Problem', () => {
|
test('switchToAdvancedEditor visual Problem', () => {
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { actions as problemActions } from '../problem';
|
import { actions as problemActions } from '../problem';
|
||||||
|
import { actions as requestActions } from '../requests';
|
||||||
|
import { selectors as appSelectors } from '../app';
|
||||||
import * as requests from './requests';
|
import * as requests from './requests';
|
||||||
|
import { isLibraryKey } from '../../../../generic/key-utils';
|
||||||
import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
|
import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';
|
||||||
import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
|
import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser';
|
||||||
import { ProblemTypeKeys } from '../../constants/problem';
|
import { ProblemTypeKeys } from '../../constants/problem';
|
||||||
import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
|
import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser';
|
||||||
import { blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
|
|
||||||
import { camelizeKeys } from '../../../utils';
|
import { camelizeKeys } from '../../../utils';
|
||||||
import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
|
import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks';
|
||||||
|
import { RequestKeys } from '../../constants/requests';
|
||||||
|
|
||||||
// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
|
// Similar to `import { actions, selectors } from '..';` but avoid circular imports:
|
||||||
const actions = { problem: problemActions };
|
const actions = { problem: problemActions, requests: requestActions };
|
||||||
|
const selectors = { app: appSelectors };
|
||||||
|
|
||||||
export const switchToAdvancedEditor = () => (dispatch, getState) => {
|
export const switchToAdvancedEditor = () => (dispatch, getState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
@@ -21,7 +25,7 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isBlankProblem = ({ rawOLX }) => {
|
export const isBlankProblem = ({ rawOLX }) => {
|
||||||
if (rawOLX.replace(/\s/g, '') === blankProblemOLX.rawOLX) {
|
if (['<problem></problem>', '<problem/>'].includes(rawOLX.replace(/\s/g, ''))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -67,7 +71,7 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
|
|||||||
dispatch(requests.fetchAdvancedSettings({
|
dispatch(requests.fetchAdvancedSettings({
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
const defaultSettings = {};
|
const defaultSettings = {};
|
||||||
Object.entries(response.data).forEach(([key, value]) => {
|
Object.entries(response.data as Record<string, any>).forEach(([key, value]) => {
|
||||||
if (advancedProblemSettingKeys.includes(key)) {
|
if (advancedProblemSettingKeys.includes(key)) {
|
||||||
defaultSettings[key] = value.value;
|
defaultSettings[key] = value.value;
|
||||||
}
|
}
|
||||||
@@ -79,10 +83,20 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initializeProblem = (blockValue) => (dispatch) => {
|
export const initializeProblem = (blockValue) => (dispatch, getState) => {
|
||||||
const rawOLX = _.get(blockValue, 'data.data', {});
|
const rawOLX = _.get(blockValue, 'data.data', {});
|
||||||
const rawSettings = _.get(blockValue, 'data.metadata', {});
|
const rawSettings = _.get(blockValue, 'data.metadata', {});
|
||||||
dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
|
const learningContextId = selectors.app.learningContextId(getState());
|
||||||
|
if (isLibraryKey(learningContextId)) {
|
||||||
|
// Content libraries don't yet support defaults for fields like max_attempts, showanswer, etc.
|
||||||
|
// So proceed with loading the problem.
|
||||||
|
// Though first we need to fake the request or else the problem type selection UI won't display:
|
||||||
|
dispatch(actions.requests.completeRequest({ requestKey: RequestKeys.fetchAdvancedSettings, response: {} }));
|
||||||
|
dispatch(loadProblem({ rawOLX, rawSettings, defaultSettings: {} }));
|
||||||
|
} else {
|
||||||
|
// Load the defaults (for max_attempts, etc.) from the course's advanced settings, then proceed:
|
||||||
|
dispatch(fetchAdvancedSettings({ rawOLX, rawSettings }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings };
|
export default { initializeProblem, switchToAdvancedEditor, fetchAdvancedSettings };
|
||||||
@@ -1,19 +1,7 @@
|
|||||||
/* eslint-disable no-import-assign */
|
|
||||||
import * as utils from '../../../utils';
|
|
||||||
import * as api from './api';
|
import * as api from './api';
|
||||||
import * as mockApi from './mockApi';
|
|
||||||
import * as urls from './urls';
|
import * as urls from './urls';
|
||||||
import { get, post, deleteObject } from './utils';
|
import { get, post, deleteObject } from './utils';
|
||||||
|
|
||||||
jest.mock('../../../utils', () => {
|
|
||||||
const camelizeMap = (obj) => ({ ...obj, camelized: true });
|
|
||||||
return {
|
|
||||||
...jest.requireActual('../../../utils'),
|
|
||||||
camelize: camelizeMap,
|
|
||||||
camelizeKeys: (list) => list.map(camelizeMap),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('./urls', () => ({
|
jest.mock('./urls', () => ({
|
||||||
block: jest.fn().mockReturnValue('urls.block'),
|
block: jest.fn().mockReturnValue('urls.block'),
|
||||||
blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'),
|
blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'),
|
||||||
@@ -40,8 +28,6 @@ jest.mock('./utils', () => ({
|
|||||||
deleteObject: jest.fn().mockName('deleteObject'),
|
deleteObject: jest.fn().mockName('deleteObject'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { camelize } = utils;
|
|
||||||
|
|
||||||
const { apiMethods } = api;
|
const { apiMethods } = api;
|
||||||
|
|
||||||
const blockId = 'block-v1-coursev1:2uX@4345432';
|
const blockId = 'block-v1-coursev1:2uX@4345432';
|
||||||
@@ -200,7 +186,7 @@ describe('cms api', () => {
|
|||||||
licenseType: 'LiCeNsETYpe',
|
licenseType: 'LiCeNsETYpe',
|
||||||
licenseDetails: 'liCENSeDetAIls',
|
licenseDetails: 'liCENSeDetAIls',
|
||||||
};
|
};
|
||||||
const html5Sources = 'hTML5souRCES';
|
const html5Sources = ['hTML5souRCES'];
|
||||||
const edxVideoId = 'eDXviDEOid';
|
const edxVideoId = 'eDXviDEOid';
|
||||||
const youtubeId = 'yOUtUBeid';
|
const youtubeId = 'yOUtUBeid';
|
||||||
const license = 'LiCEnsE';
|
const license = 'LiCEnsE';
|
||||||
@@ -241,6 +227,7 @@ describe('cms api', () => {
|
|||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
test('throw error for invalid blockType', () => {
|
test('throw error for invalid blockType', () => {
|
||||||
|
// @ts-expect-error because we're not passing 'blockId' or other parameters
|
||||||
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
|
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
|
||||||
.toThrow(TypeError);
|
.toThrow(TypeError);
|
||||||
});
|
});
|
||||||
@@ -271,7 +258,7 @@ describe('cms api', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('uploadAsset', () => {
|
describe('uploadAsset', () => {
|
||||||
const asset = { photo: 'dAta' };
|
const asset = new Blob(['data'], { type: 'image/jpeg' });
|
||||||
it('should call post with urls.courseAssets and imgdata', () => {
|
it('should call post with urls.courseAssets and imgdata', () => {
|
||||||
const mockFormdata = new FormData();
|
const mockFormdata = new FormData();
|
||||||
mockFormdata.append('file', asset);
|
mockFormdata.append('file', asset);
|
||||||
@@ -318,18 +305,17 @@ describe('cms api', () => {
|
|||||||
{ id: ids[0], some: 'data' },
|
{ id: ids[0], some: 'data' },
|
||||||
{ id: ids[1], other: 'data' },
|
{ id: ids[1], other: 'data' },
|
||||||
{ id: ids[2], some: 'DATA' },
|
{ id: ids[2], some: 'DATA' },
|
||||||
{ id: ids[3], other: 'DATA' },
|
{ id: ids[3], other_data: 'DATA' },
|
||||||
];
|
];
|
||||||
const oldLoadImage = api.loadImage;
|
const spy = jest.spyOn(api, 'loadImage').mockImplementation((imageData) => ({ loadImage: imageData }));
|
||||||
api.loadImage = (imageData) => ({ loadImage: imageData });
|
|
||||||
const out = api.loadImages(testData);
|
const out = api.loadImages(testData);
|
||||||
expect(out).toEqual({
|
expect(out).toEqual({
|
||||||
[ids[0]]: api.loadImage(camelize(testData[0])),
|
[ids[0]]: api.loadImage(testData[0]),
|
||||||
[ids[1]]: api.loadImage(camelize(testData[1])),
|
[ids[1]]: api.loadImage(testData[1]),
|
||||||
[ids[2]]: api.loadImage(camelize(testData[2])),
|
[ids[2]]: api.loadImage(testData[2]),
|
||||||
[ids[3]]: api.loadImage(camelize(testData[3])),
|
[ids[3]]: api.loadImage({ id: ids[3], otherData: 'DATA' }), // Verify its 'other_data' key was camelized
|
||||||
});
|
});
|
||||||
api.loadImage = oldLoadImage;
|
spy.mockClear();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('uploadThumbnail', () => {
|
describe('uploadThumbnail', () => {
|
||||||
@@ -382,7 +368,7 @@ describe('cms api', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('uploadTranscript', () => {
|
describe('uploadTranscript', () => {
|
||||||
const transcript = { transcript: 'dAta' };
|
const transcript = new Blob(['dAta']);
|
||||||
it('should call post with urls.videoTranscripts and transcript data', () => {
|
it('should call post with urls.videoTranscripts and transcript data', () => {
|
||||||
const mockFormdata = new FormData();
|
const mockFormdata = new FormData();
|
||||||
mockFormdata.append('file', transcript);
|
mockFormdata.append('file', transcript);
|
||||||
@@ -606,31 +592,6 @@ describe('cms api', () => {
|
|||||||
expect(api.processLicense(licenseType, licenseDetails)).toEqual('all-rights-reserved');
|
expect(api.processLicense(licenseType, licenseDetails)).toEqual('all-rights-reserved');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('checkMockApi', () => {
|
|
||||||
const envTemp = process.env;
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetModules();
|
|
||||||
process.env = { ...envTemp };
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = envTemp;
|
|
||||||
});
|
|
||||||
describe('if REACT_APP_DEVGALLERY is true', () => {
|
|
||||||
it('should return the mockApi version of a call when it exists', () => {
|
|
||||||
process.env.REACT_APP_DEVGALLERY = true;
|
|
||||||
expect(api.checkMockApi('fetchBlockById')).toEqual(mockApi.fetchBlockById);
|
|
||||||
});
|
|
||||||
it('should return an empty mock when the call does not exist', () => {
|
|
||||||
process.env.REACT_APP_DEVGALLERY = true;
|
|
||||||
expect(api.checkMockApi('someRAnDomThINg')).toEqual(mockApi.emptyMock);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('if REACT_APP_DEVGALLERY is not true', () => {
|
|
||||||
it('should return the appropriate call', () => {
|
|
||||||
expect(api.checkMockApi('fetchBlockById')).toEqual(apiMethods.fetchBlockById);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('fetchVideoFeatures', () => {
|
describe('fetchVideoFeatures', () => {
|
||||||
it('should call get with url.videoFeatures', () => {
|
it('should call get with url.videoFeatures', () => {
|
||||||
const args = { studioEndpointUrl };
|
const args = { studioEndpointUrl };
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
import { camelizeKeys } from '../../../utils';
|
import { camelizeKeys } from '../../../utils';
|
||||||
|
import { isLibraryKey } from '../../../../generic/key-utils';
|
||||||
import * as urls from './urls';
|
import * as urls from './urls';
|
||||||
import { get, post, deleteObject } from './utils';
|
import { get, post, deleteObject } from './utils';
|
||||||
// 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 './api';
|
|
||||||
import * as mockApi from './mockApi';
|
|
||||||
import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
|
import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
|
||||||
|
|
||||||
const fetchByUnitIdOptions = {};
|
const fetchByUnitIdOptions: AxiosRequestConfig = {};
|
||||||
|
|
||||||
// For some reason, the local webpack-dev-server of library-authoring does not accept the normal Accept header.
|
// For some reason, the local webpack-dev-server of library-authoring does not accept the normal Accept header.
|
||||||
// This is a workaround only for that specific case; the idea is to only do this locally and only for library-authoring.
|
// This is a workaround only for that specific case; the idea is to only do this locally and only for library-authoring.
|
||||||
@@ -19,6 +15,87 @@ if (process.env.NODE_ENV === 'development' && process.env.MFE_NAME === 'frontend
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetResponse {
|
||||||
|
assets: Record<string, string>[]; // In the raw response here, these are NOT camel-cased yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadImage = (imageData) => ({
|
||||||
|
...imageData,
|
||||||
|
dateAdded: new Date(imageData.dateAdded.replace(' at', '')).getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
|
||||||
|
(obj, image) => ({ ...obj, [image.id]: loadImage(image) }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const parseYoutubeId = (src: string): string | null => {
|
||||||
|
const youtubeRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/;
|
||||||
|
const match = src.match(youtubeRegex);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return match[5];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processVideoIds = ({
|
||||||
|
videoId,
|
||||||
|
videoUrl,
|
||||||
|
fallbackVideos,
|
||||||
|
}: { videoId: string, videoUrl: string, fallbackVideos: string[] }) => {
|
||||||
|
let youtubeId: string | null = '';
|
||||||
|
const html5Sources: string[] = [];
|
||||||
|
|
||||||
|
if (videoUrl) {
|
||||||
|
if (parseYoutubeId(videoUrl)) {
|
||||||
|
youtubeId = parseYoutubeId(videoUrl);
|
||||||
|
} else {
|
||||||
|
html5Sources.push(videoUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackVideos) {
|
||||||
|
fallbackVideos.forEach((src) => (src ? html5Sources.push(src) : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
edxVideoId: videoId,
|
||||||
|
html5Sources,
|
||||||
|
youtubeId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isEdxVideo = (src: string): boolean => {
|
||||||
|
const uuid4Regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
|
||||||
|
if (src && src.match(uuid4Regex)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processLicense = (licenseType, licenseDetails) => {
|
||||||
|
if (licenseType === 'creative-commons') {
|
||||||
|
return 'creative-commons: ver=4.0'.concat(
|
||||||
|
(licenseDetails.attribution ? ' BY' : ''),
|
||||||
|
(licenseDetails.noncommercial ? ' NC' : ''),
|
||||||
|
(licenseDetails.noDerivatives ? ' ND' : ''),
|
||||||
|
(licenseDetails.shareAlike ? ' SA' : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (licenseType === 'all-rights-reserved') {
|
||||||
|
return 'all-rights-reserved';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
export const apiMethods = {
|
export const apiMethods = {
|
||||||
fetchBlockById: ({ blockId, studioEndpointUrl }) => get(
|
fetchBlockById: ({ blockId, studioEndpointUrl }) => get(
|
||||||
urls.block({ blockId, studioEndpointUrl }),
|
urls.block({ blockId, studioEndpointUrl }),
|
||||||
@@ -30,7 +107,19 @@ export const apiMethods = {
|
|||||||
fetchStudioView: ({ blockId, studioEndpointUrl }) => get(
|
fetchStudioView: ({ blockId, studioEndpointUrl }) => get(
|
||||||
urls.blockStudioView({ studioEndpointUrl, blockId }),
|
urls.blockStudioView({ studioEndpointUrl, blockId }),
|
||||||
),
|
),
|
||||||
fetchImages: ({ learningContextId, studioEndpointUrl, pageNumber }) => {
|
fetchImages: ({
|
||||||
|
learningContextId,
|
||||||
|
studioEndpointUrl,
|
||||||
|
pageNumber,
|
||||||
|
}): Promise<{ data: AssetResponse & Pagination }> => {
|
||||||
|
if (isLibraryKey(learningContextId)) {
|
||||||
|
// V2 content libraries don't support static assets yet:
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const params = {
|
const params = {
|
||||||
asset_type: 'Images',
|
asset_type: 'Images',
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
@@ -150,6 +239,12 @@ export const apiMethods = {
|
|||||||
content,
|
content,
|
||||||
learningContextId,
|
learningContextId,
|
||||||
title,
|
title,
|
||||||
|
}: {
|
||||||
|
blockId: string,
|
||||||
|
blockType: string,
|
||||||
|
content: any, // string for 'html' blocks, otherwise Record<string, any>
|
||||||
|
learningContextId: string,
|
||||||
|
title: string,
|
||||||
}) => {
|
}) => {
|
||||||
let response = {};
|
let response = {};
|
||||||
if (blockType === 'html') {
|
if (blockType === 'html') {
|
||||||
@@ -175,7 +270,7 @@ export const apiMethods = {
|
|||||||
html5Sources,
|
html5Sources,
|
||||||
edxVideoId,
|
edxVideoId,
|
||||||
youtubeId,
|
youtubeId,
|
||||||
} = module.processVideoIds({
|
} = processVideoIds({
|
||||||
videoId: content.videoId,
|
videoId: content.videoId,
|
||||||
videoUrl: content.videoSource,
|
videoUrl: content.videoSource,
|
||||||
fallbackVideos: content.fallbackVideos,
|
fallbackVideos: content.fallbackVideos,
|
||||||
@@ -199,7 +294,7 @@ export const apiMethods = {
|
|||||||
handout: content.handout,
|
handout: content.handout,
|
||||||
start_time: durationStringFromValue(content.duration.startTime),
|
start_time: durationStringFromValue(content.duration.startTime),
|
||||||
end_time: durationStringFromValue(content.duration.stopTime),
|
end_time: durationStringFromValue(content.duration.stopTime),
|
||||||
license: module.processLicense(content.licenseType, content.licenseDetails),
|
license: processLicense(content.licenseType, content.licenseDetails),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -216,7 +311,7 @@ export const apiMethods = {
|
|||||||
title,
|
title,
|
||||||
}) => post(
|
}) => post(
|
||||||
urls.block({ studioEndpointUrl, blockId }),
|
urls.block({ studioEndpointUrl, blockId }),
|
||||||
module.apiMethods.normalizeContent({
|
apiMethods.normalizeContent({
|
||||||
blockType,
|
blockType,
|
||||||
content,
|
content,
|
||||||
blockId,
|
blockId,
|
||||||
@@ -239,82 +334,4 @@ export const apiMethods = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadImage = (imageData) => ({
|
export default apiMethods;
|
||||||
...imageData,
|
|
||||||
dateAdded: new Date(imageData.dateAdded.replace(' at', '')).getTime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
|
|
||||||
(obj, image) => ({ ...obj, [image.id]: module.loadImage(image) }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const processVideoIds = ({
|
|
||||||
videoId,
|
|
||||||
videoUrl,
|
|
||||||
fallbackVideos,
|
|
||||||
}) => {
|
|
||||||
let youtubeId = '';
|
|
||||||
const html5Sources = [];
|
|
||||||
|
|
||||||
if (videoUrl) {
|
|
||||||
if (module.parseYoutubeId(videoUrl)) {
|
|
||||||
youtubeId = module.parseYoutubeId(videoUrl);
|
|
||||||
} else {
|
|
||||||
html5Sources.push(videoUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fallbackVideos) {
|
|
||||||
fallbackVideos.forEach((src) => (src ? html5Sources.push(src) : null));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
edxVideoId: videoId,
|
|
||||||
html5Sources,
|
|
||||||
youtubeId,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isEdxVideo = (src) => {
|
|
||||||
const uuid4Regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
|
|
||||||
if (src && src.match(uuid4Regex)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseYoutubeId = (src) => {
|
|
||||||
const youtubeRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/;
|
|
||||||
if (!src.match(youtubeRegex)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return src.match(youtubeRegex)[5];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processLicense = (licenseType, licenseDetails) => {
|
|
||||||
if (licenseType === 'creative-commons') {
|
|
||||||
return 'creative-commons: ver=4.0'.concat(
|
|
||||||
(licenseDetails.attribution ? ' BY' : ''),
|
|
||||||
(licenseDetails.noncommercial ? ' NC' : ''),
|
|
||||||
(licenseDetails.noDerivatives ? ' ND' : ''),
|
|
||||||
(licenseDetails.shareAlike ? ' SA' : ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (licenseType === 'all-rights-reserved') {
|
|
||||||
return 'all-rights-reserved';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkMockApi = (key) => {
|
|
||||||
if (process.env.REACT_APP_DEVGALLERY) {
|
|
||||||
return mockApi[key] ? mockApi[key] : mockApi.emptyMock;
|
|
||||||
}
|
|
||||||
return module.apiMethods[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Object.keys(apiMethods).reduce(
|
|
||||||
(obj, key) => ({ ...obj, [key]: checkMockApi(key) }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
/* istanbul ignore file */
|
|
||||||
import * as urls from './urls';
|
|
||||||
|
|
||||||
const mockPromise = (returnValue) => new Promise(resolve => { resolve(returnValue); });
|
|
||||||
|
|
||||||
// TODO: update to return block data appropriate per block ID, which will equal block type
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchBlockById = ({ blockId, studioEndpointUrl }) => {
|
|
||||||
let data = {};
|
|
||||||
if (blockId === 'html-block-id') {
|
|
||||||
data = {
|
|
||||||
data: `<problem>
|
|
||||||
</problem>`,
|
|
||||||
display_name: 'My Text Prompt',
|
|
||||||
metadata: {
|
|
||||||
display_name: 'Welcome!',
|
|
||||||
download_track: true,
|
|
||||||
download_video: true,
|
|
||||||
edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
|
|
||||||
html5_sources: [
|
|
||||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
||||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
||||||
],
|
|
||||||
show_captions: true,
|
|
||||||
sub: '',
|
|
||||||
track: '',
|
|
||||||
transcripts: {
|
|
||||||
en: { filename: 'my-transcript-url' },
|
|
||||||
},
|
|
||||||
xml_attributes: {
|
|
||||||
source: '',
|
|
||||||
},
|
|
||||||
youtube_id_1_0: 'dQw4w9WgXcQ',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (blockId === 'problem-block-id') {
|
|
||||||
data = {
|
|
||||||
data: `<problem>
|
|
||||||
</problem>`,
|
|
||||||
display_name: 'Dropdown',
|
|
||||||
metadata: {
|
|
||||||
markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.
|
|
||||||
>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
|
|
||||||
[[
|
|
||||||
an incorrect answer
|
|
||||||
(the correct answer)
|
|
||||||
an incorrect answer
|
|
||||||
]]`,
|
|
||||||
attempts_before_showanswer_button: 7,
|
|
||||||
max_attempts: 5,
|
|
||||||
show_reset_button: true,
|
|
||||||
showanswer: 'after_attempts',
|
|
||||||
submission_wait_seconds: 15,
|
|
||||||
weight: 29,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (blockId === 'game-block-id') {
|
|
||||||
data = {
|
|
||||||
display_name: 'Game Block',
|
|
||||||
// TODO: insert mock data from backend here
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return mockPromise({ data: { ...data } });
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: update to return block data appropriate per block ID, which will equal block type
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchByUnitId = ({ blockId, studioEndpointUrl }) => mockPromise({
|
|
||||||
data: { ancestors: [{ id: 'unitUrl' }] },
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchImages = ({ learningContextId, studioEndpointUrl }) => mockPromise({
|
|
||||||
data: {
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
displayName: 'shahrukh.jpg',
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
dateAdded: 'Jan 05, 2022 at 17:38 UTC',
|
|
||||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
|
||||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
|
||||||
portableUrl: '/static/shahrukh.jpg',
|
|
||||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@shahrukh.jpg',
|
|
||||||
locked: false,
|
|
||||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@shahrukh.jpg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'IMG_5899.jpg',
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
dateAdded: 'Nov 16, 2021 at 18:55 UTC',
|
|
||||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
|
||||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
|
||||||
portableUrl: '/static/IMG_5899.jpg',
|
|
||||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@IMG_5899.jpg',
|
|
||||||
locked: false,
|
|
||||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@IMG_5899.jpg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'ccexample.srt',
|
|
||||||
contentType: 'application/octet-stream',
|
|
||||||
dateAdded: 'Nov 01, 2021 at 15:42 UTC',
|
|
||||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
|
||||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
|
||||||
portableUrl: '/static/ccexample.srt',
|
|
||||||
thumbnail: null,
|
|
||||||
locked: false,
|
|
||||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@ccexample.srt',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Tennis Ball.jpeg',
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
dateAdded: 'Aug 04, 2021 at 16:52 UTC',
|
|
||||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
|
||||||
externalUrl: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
|
||||||
portableUrl: '/static/Tennis_Ball.jpeg',
|
|
||||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@Tennis_Ball-jpeg.jpg',
|
|
||||||
locked: false,
|
|
||||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@Tennis_Ball.jpeg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchCourseDetails = ({ studioEndpointUrl, learningContextId }) => mockPromise({
|
|
||||||
data: {
|
|
||||||
// license: "creative-commons: ver=4.0 BY NC",
|
|
||||||
license: 'all-rights-reserved',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const checkTranscripts = ({youTubeId, studioEndpointUrl, blockId, videoId}) => mockPromise({
|
|
||||||
data: {
|
|
||||||
command: 'import',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const importTranscript = ({youTubeId, studioEndpointUrl, blockId}) => mockPromise({
|
|
||||||
data: {
|
|
||||||
edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchAdvanceSettings = ({ studioEndpointUrl, learningContextId }) => mockPromise({
|
|
||||||
data: { allow_unsupported_xblocks: { value: true } },
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchVideoFeatures = ({ studioEndpointUrl }) => mockPromise({
|
|
||||||
data: {
|
|
||||||
allowThumbnailUpload: true,
|
|
||||||
videoSharingEnabledForCourse: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const normalizeContent = ({
|
|
||||||
blockId,
|
|
||||||
blockType,
|
|
||||||
content,
|
|
||||||
learningContextId,
|
|
||||||
title,
|
|
||||||
}) => {
|
|
||||||
let response = {};
|
|
||||||
if (blockType === 'html') {
|
|
||||||
response = {
|
|
||||||
category: blockType,
|
|
||||||
couseKey: learningContextId,
|
|
||||||
data: content,
|
|
||||||
has_changes: true,
|
|
||||||
id: blockId,
|
|
||||||
metadata: { display_name: title },
|
|
||||||
};
|
|
||||||
} else if (blockType === 'problem') {
|
|
||||||
response = {
|
|
||||||
data: content.olx,
|
|
||||||
category: blockType,
|
|
||||||
couseKey: learningContextId,
|
|
||||||
has_changes: true,
|
|
||||||
id: blockId,
|
|
||||||
metadata: { display_name: title, ...content.settings },
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
|
|
||||||
}
|
|
||||||
return { ...response };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveBlock = ({
|
|
||||||
blockId,
|
|
||||||
blockType,
|
|
||||||
content,
|
|
||||||
learningContextId,
|
|
||||||
studioEndpointUrl,
|
|
||||||
title,
|
|
||||||
}) => mockPromise({
|
|
||||||
url: urls.block({ studioEndpointUrl, blockId }),
|
|
||||||
content: normalizeContent({
|
|
||||||
blockType,
|
|
||||||
content,
|
|
||||||
blockId,
|
|
||||||
learningContextId,
|
|
||||||
title,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uploadAsset = ({
|
|
||||||
learningContextId,
|
|
||||||
studioEndpointUrl,
|
|
||||||
// image,
|
|
||||||
}) => mockPromise({
|
|
||||||
url: urls.courseAssets({ studioEndpointUrl, learningContextId }),
|
|
||||||
asset: {
|
|
||||||
asset: {
|
|
||||||
display_name: 'journey_escape.jpg',
|
|
||||||
content_type: 'image/jpeg',
|
|
||||||
date_added: 'Jan 05, 2022 at 21:26 UTC',
|
|
||||||
url: '/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
|
||||||
external_url: 'https://courses.edx.org/asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
|
||||||
portable_url: '/static/journey_escape.jpg',
|
|
||||||
thumbnail: '/asset-v1:edX+test101+2021_T1+type@thumbnail+block@journey_escape.jpg',
|
|
||||||
locked: false,
|
|
||||||
id: 'asset-v1:edX+test101+2021_T1+type@asset+block@journey_escape.jpg',
|
|
||||||
},
|
|
||||||
msg: 'Upload completed',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: update to return block data appropriate per block ID, which will equal block type
|
|
||||||
// eslint-disable-next-line
|
|
||||||
export const fetchStudioView = ({ blockId, studioEndpointUrl }) => {
|
|
||||||
let data = {};
|
|
||||||
if (blockId === 'html-block-id') {
|
|
||||||
data = {
|
|
||||||
data: '<p>Test prompt content</p>',
|
|
||||||
display_name: 'My Text Prompt',
|
|
||||||
metadata: {
|
|
||||||
display_name: 'Welcome!',
|
|
||||||
download_track: true,
|
|
||||||
download_video: true,
|
|
||||||
edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
|
|
||||||
html5_sources: [
|
|
||||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
||||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
||||||
],
|
|
||||||
show_captions: true,
|
|
||||||
sub: '',
|
|
||||||
track: '',
|
|
||||||
transcripts: {
|
|
||||||
en: { filename: 'my-transcript-url' },
|
|
||||||
},
|
|
||||||
xml_attributes: {
|
|
||||||
source: '',
|
|
||||||
},
|
|
||||||
youtube_id_1_0: 'dQw4w9WgXcQ',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (blockId === 'problem-block-id') {
|
|
||||||
data = {
|
|
||||||
data: `<problem>
|
|
||||||
<optionresponse>
|
|
||||||
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.</p>
|
|
||||||
<label>Add the question text, or prompt, here. This text is required.</label>
|
|
||||||
<description>You can add an optional tip or note related to the prompt like this. </description>
|
|
||||||
<optioninput>
|
|
||||||
<option correct="False">an incorrect answer</option>
|
|
||||||
<option correct="True">the correct answer</option>
|
|
||||||
<option correct="False">an incorrect answer</option>
|
|
||||||
</optioninput>
|
|
||||||
</optionresponse>
|
|
||||||
</problem>`,
|
|
||||||
display_name: 'Dropdown',
|
|
||||||
metadata: {
|
|
||||||
markdown: `You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.
|
|
||||||
>>Add the question text, or prompt, here. This text is required.||You can add an optional tip or note related to the prompt like this. <<
|
|
||||||
[[
|
|
||||||
an incorrect answer
|
|
||||||
(the correct answer)
|
|
||||||
an incorrect answer
|
|
||||||
]]`,
|
|
||||||
attempts_before_showanswer_button: 7,
|
|
||||||
max_attempts: 5,
|
|
||||||
rerandomize: 'per_student',
|
|
||||||
show_reset_button: true,
|
|
||||||
showanswer: 'after_attempts',
|
|
||||||
submission_wait_seconds: 15,
|
|
||||||
weight: 29,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockPromise({
|
|
||||||
data: {
|
|
||||||
// The following is sent for 'raw' editors.
|
|
||||||
html: blockId.includes('mockRaw') ? 'data-editor="raw"' : '',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyMock = () => mockPromise({});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ProblemTypes, ShowAnswerTypes } from '../../constants/problem';
|
|
||||||
|
|
||||||
|
/*
|
||||||
export const videoDataProps = {
|
export const videoDataProps = {
|
||||||
videoSource: PropTypes.string,
|
videoSource: PropTypes.string,
|
||||||
videoId: PropTypes.string,
|
videoId: PropTypes.string,
|
||||||
@@ -25,6 +25,7 @@ export const videoDataProps = {
|
|||||||
shareAlike: PropTypes.bool,
|
shareAlike: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
export const answerOptionProps = PropTypes.shape({
|
export const answerOptionProps = PropTypes.shape({
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
@@ -35,6 +36,7 @@ export const answerOptionProps = PropTypes.shape({
|
|||||||
unselectedFeedback: PropTypes.string,
|
unselectedFeedback: PropTypes.string,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
export const problemDataProps = {
|
export const problemDataProps = {
|
||||||
rawOLX: PropTypes.string,
|
rawOLX: PropTypes.string,
|
||||||
problemType: PropTypes.instanceOf(ProblemTypes),
|
problemType: PropTypes.instanceOf(ProblemTypes),
|
||||||
@@ -68,9 +70,8 @@ export const problemDataProps = {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
videoDataProps,
|
|
||||||
problemDataProps,
|
|
||||||
answerOptionProps,
|
answerOptionProps,
|
||||||
};
|
};
|
||||||
@@ -44,7 +44,9 @@ describe('cms url methods', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
it('returns the library page when given the v1 library', () => {
|
it('returns the library page when given the v1 library', () => {
|
||||||
expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryLearningContextId }))
|
expect(returnUrl({
|
||||||
|
studioEndpointUrl, unitUrl, learningContextId: libraryLearningContextId, blockId: libraryV1Id,
|
||||||
|
}))
|
||||||
.toEqual(`${studioEndpointUrl}/library/${libraryLearningContextId}`);
|
.toEqual(`${studioEndpointUrl}/library/${libraryLearningContextId}`);
|
||||||
});
|
});
|
||||||
// it('throws error when given the v2 library', () => {
|
// it('throws error when given the v2 library', () => {
|
||||||
@@ -52,8 +54,9 @@ describe('cms url methods', () => {
|
|||||||
// .toThrow('Return url not available (or needed) for V2 libraries');
|
// .toThrow('Return url not available (or needed) for V2 libraries');
|
||||||
// });
|
// });
|
||||||
it('returns empty url when given the v2 library', () => {
|
it('returns empty url when given the v2 library', () => {
|
||||||
expect(returnUrl({ studioEndpointUrl, unitUrl, learningContextId: libraryV2Id }))
|
expect(returnUrl({
|
||||||
.toEqual('');
|
studioEndpointUrl, unitUrl, learningContextId: libraryV2Id, blockId: libraryV2Id,
|
||||||
|
})).toEqual('');
|
||||||
});
|
});
|
||||||
it('returnUrl function should return url with studioEndpointUrl, unitUrl, and blockId', () => {
|
it('returnUrl function should return url with studioEndpointUrl, unitUrl, and blockId', () => {
|
||||||
expect(returnUrl({
|
expect(returnUrl({
|
||||||
@@ -68,7 +71,9 @@ describe('cms url methods', () => {
|
|||||||
.toEqual('');
|
.toEqual('');
|
||||||
});
|
});
|
||||||
it('throws error if no unit url', () => {
|
it('throws error if no unit url', () => {
|
||||||
expect(returnUrl({ studioEndpointUrl, unitUrl: null, learningContextId: courseId }))
|
expect(returnUrl({
|
||||||
|
studioEndpointUrl, unitUrl: null, learningContextId: courseId, blockId,
|
||||||
|
}))
|
||||||
.toEqual('');
|
.toEqual('');
|
||||||
});
|
});
|
||||||
it('returns the library page when given the library', () => {
|
it('returns the library page when given the library', () => {
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import { isLibraryKey, isLibraryV1Key } from '../../../../generic/key-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A little helper so we can write the types of these functions more compactly
|
||||||
|
* The main purpose of this is to indicate the params are all strings.
|
||||||
|
*/
|
||||||
|
type UrlFunction = (args: Record<string, string>) => string;
|
||||||
|
|
||||||
export const libraryV1 = ({ studioEndpointUrl, learningContextId }) => (
|
export const libraryV1 = ({ studioEndpointUrl, learningContextId }) => (
|
||||||
`${studioEndpointUrl}/library/${learningContextId}`
|
`${studioEndpointUrl}/library/${learningContextId}`
|
||||||
);
|
);
|
||||||
@@ -8,12 +16,14 @@ export const unit = ({ studioEndpointUrl, unitUrl, blockId }) => (
|
|||||||
|
|
||||||
export const returnUrl = ({
|
export const returnUrl = ({
|
||||||
studioEndpointUrl, unitUrl, learningContextId, blockId,
|
studioEndpointUrl, unitUrl, learningContextId, blockId,
|
||||||
}) => {
|
}): string => {
|
||||||
if (learningContextId && learningContextId.startsWith('library-v1')) {
|
// Is this a v1 library?
|
||||||
|
if (isLibraryV1Key(learningContextId)) {
|
||||||
// when the learning context is a v1 library, return to the library page
|
// when the learning context is a v1 library, return to the library page
|
||||||
return libraryV1({ studioEndpointUrl, learningContextId });
|
return libraryV1({ studioEndpointUrl, learningContextId });
|
||||||
}
|
}
|
||||||
if (learningContextId && learningContextId.startsWith('lib')) {
|
// Is this a v2 library?
|
||||||
|
if (isLibraryKey(learningContextId)) {
|
||||||
// when it's a v2 library, there will be no return url (instead a closed popup)
|
// when it's a v2 library, there will be no return url (instead a closed popup)
|
||||||
// (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise
|
// (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise
|
||||||
// the app will run
|
// the app will run
|
||||||
@@ -28,69 +38,69 @@ export const returnUrl = ({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const block = ({ studioEndpointUrl, blockId }) => (
|
export const block = (({ studioEndpointUrl, blockId }) => (
|
||||||
blockId.startsWith('lb:')
|
blockId.startsWith('lb:')
|
||||||
? `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/`
|
? `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/`
|
||||||
: `${studioEndpointUrl}/xblock/${blockId}`
|
: `${studioEndpointUrl}/xblock/${blockId}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const blockAncestor = ({ studioEndpointUrl, blockId }) => {
|
export const blockAncestor = (({ studioEndpointUrl, blockId }) => {
|
||||||
if (blockId.includes('block-v1')) {
|
if (blockId.includes('block-v1')) {
|
||||||
return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`;
|
return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`;
|
||||||
}
|
}
|
||||||
throw new Error('Block ancestor not available (and not needed) for V2 blocks');
|
throw new Error('Block ancestor not available (and not needed) for V2 blocks');
|
||||||
};
|
}) satisfies UrlFunction;
|
||||||
|
|
||||||
export const blockStudioView = ({ studioEndpointUrl, blockId }) => (
|
export const blockStudioView = (({ studioEndpointUrl, blockId }) => (
|
||||||
blockId.includes('block-v1')
|
blockId.includes('block-v1')
|
||||||
? `${block({ studioEndpointUrl, blockId })}/studio_view`
|
? `${block({ studioEndpointUrl, blockId })}/studio_view`
|
||||||
: `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/view/studio_view/`
|
: `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/view/studio_view/`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const courseAssets = ({ studioEndpointUrl, learningContextId }) => (
|
export const courseAssets = (({ studioEndpointUrl, learningContextId }) => (
|
||||||
`${studioEndpointUrl}/assets/${learningContextId}/`
|
`${studioEndpointUrl}/assets/${learningContextId}/`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const thumbnailUpload = ({ studioEndpointUrl, learningContextId, videoId }) => (
|
export const thumbnailUpload = (({ studioEndpointUrl, learningContextId, videoId }) => (
|
||||||
`${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`
|
`${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const videoTranscripts = ({ studioEndpointUrl, blockId }) => (
|
export const videoTranscripts = (({ studioEndpointUrl, blockId }) => (
|
||||||
`${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`
|
`${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const downloadVideoTranscriptURL = ({ studioEndpointUrl, blockId, language }) => (
|
export const downloadVideoTranscriptURL = (({ studioEndpointUrl, blockId, language }) => (
|
||||||
`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`
|
`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const mediaTranscriptURL = ({ studioEndpointUrl, transcriptUrl }) => (
|
export const mediaTranscriptURL = (({ studioEndpointUrl, transcriptUrl }) => (
|
||||||
`${studioEndpointUrl}${transcriptUrl}`
|
`${studioEndpointUrl}${transcriptUrl}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const downloadVideoHandoutUrl = ({ studioEndpointUrl, handout }) => (
|
export const downloadVideoHandoutUrl = (({ studioEndpointUrl, handout }) => (
|
||||||
`${studioEndpointUrl}${handout}`
|
`${studioEndpointUrl}${handout}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const courseDetailsUrl = ({ studioEndpointUrl, learningContextId }) => (
|
export const courseDetailsUrl = (({ studioEndpointUrl, learningContextId }) => (
|
||||||
`${studioEndpointUrl}/settings/details/${learningContextId}`
|
`${studioEndpointUrl}/settings/details/${learningContextId}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const checkTranscriptsForImport = ({ studioEndpointUrl, parameters }) => (
|
export const checkTranscriptsForImport = (({ studioEndpointUrl, parameters }) => (
|
||||||
`${studioEndpointUrl}/transcripts/check?data=${parameters}`
|
`${studioEndpointUrl}/transcripts/check?data=${parameters}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const replaceTranscript = ({ studioEndpointUrl, parameters }) => (
|
export const replaceTranscript = (({ studioEndpointUrl, parameters }) => (
|
||||||
`${studioEndpointUrl}/transcripts/replace?data=${parameters}`
|
`${studioEndpointUrl}/transcripts/replace?data=${parameters}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const courseAdvanceSettings = ({ studioEndpointUrl, learningContextId }) => (
|
export const courseAdvanceSettings = (({ studioEndpointUrl, learningContextId }) => (
|
||||||
`${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`
|
`${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const videoFeatures = ({ studioEndpointUrl }) => (
|
export const videoFeatures = (({ studioEndpointUrl }) => (
|
||||||
`${studioEndpointUrl}/video_features/`
|
`${studioEndpointUrl}/video_features/`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
|
|
||||||
export const courseVideos = ({ studioEndpointUrl, learningContextId }) => (
|
export const courseVideos = (({ studioEndpointUrl, learningContextId }) => (
|
||||||
`${studioEndpointUrl}/videos/${learningContextId}`
|
`${studioEndpointUrl}/videos/${learningContextId}`
|
||||||
);
|
)) satisfies UrlFunction;
|
||||||
@@ -10,7 +10,7 @@ describe('cms service utils', () => {
|
|||||||
it('forwards arguments to authenticatedHttpClient().get', () => {
|
it('forwards arguments to authenticatedHttpClient().get', () => {
|
||||||
const get = jest.fn((...args) => ({ get: args }));
|
const get = jest.fn((...args) => ({ get: args }));
|
||||||
getAuthenticatedHttpClient.mockReturnValue({ get });
|
getAuthenticatedHttpClient.mockReturnValue({ get });
|
||||||
const args = ['some', 'args', 'for', 'the', 'test'];
|
const args = ['url 1', { headers: {} }] as const;
|
||||||
expect(utils.get(...args)).toEqual(get(...args));
|
expect(utils.get(...args)).toEqual(get(...args));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -18,7 +18,7 @@ describe('cms service utils', () => {
|
|||||||
it('forwards arguments to authenticatedHttpClient().post', () => {
|
it('forwards arguments to authenticatedHttpClient().post', () => {
|
||||||
const post = jest.fn((...args) => ({ post: args }));
|
const post = jest.fn((...args) => ({ post: args }));
|
||||||
getAuthenticatedHttpClient.mockReturnValue({ post });
|
getAuthenticatedHttpClient.mockReturnValue({ post });
|
||||||
const args = ['some', 'args', 'for', 'the', 'test'];
|
const args = ['url 2', { headers: {} }] as const;
|
||||||
expect(utils.post(...args)).toEqual(post(...args));
|
expect(utils.post(...args)).toEqual(post(...args));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import type { Axios } from 'axios';
|
||||||
|
|
||||||
|
export const client: () => Axios = getAuthenticatedHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get(url)
|
* get(url)
|
||||||
* simple wrapper providing an authenticated Http client get action
|
* simple wrapper providing an authenticated Http client get action
|
||||||
* @param {string} url - target url
|
* @param {string} url - target url
|
||||||
*/
|
*/
|
||||||
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
|
export const get: Axios['get'] = (...args) => client().get(...args);
|
||||||
/**
|
/**
|
||||||
* post(url, data)
|
* post(url, data)
|
||||||
* simple wrapper providing an authenticated Http client post action
|
* simple wrapper providing an authenticated Http client post action
|
||||||
* @param {string} url - target url
|
* @param {string} url - target url
|
||||||
* @param {object|string} data - post payload
|
* @param {object|string} data - post payload
|
||||||
*/
|
*/
|
||||||
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
|
export const post: Axios['post'] = (...args) => client().post(...args);
|
||||||
/**
|
/**
|
||||||
* delete(url, data)
|
* delete(url, data)
|
||||||
* simple wrapper providing an authenticated Http client delete action
|
* simple wrapper providing an authenticated Http client delete action
|
||||||
* @param {string} url - target url
|
* @param {string} url - target url
|
||||||
* @param {object|string} data - delete payload
|
* @param {object|string} data - delete payload
|
||||||
*/
|
*/
|
||||||
export const deleteObject = (...args) => getAuthenticatedHttpClient().delete(...args);
|
export const deleteObject: Axios['delete'] = (...args) => client().delete(...args);
|
||||||
|
|
||||||
export const client = getAuthenticatedHttpClient;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
import { isLibraryV1Key } from '../../../generic/key-utils';
|
||||||
import { navigateTo } from '../../hooks';
|
import { navigateTo } from '../../hooks';
|
||||||
import { selectors } from '../../data/redux';
|
import { selectors } from '../../data/redux';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const ErrorPage = ({
|
|||||||
// injected
|
// injected
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
const outlineType = learningContextId?.startsWith('library-v1') ? 'library' : 'course';
|
const outlineType = isLibraryV1Key(learningContextId) ? 'library' : 'course';
|
||||||
const outlineUrl = `${studioEndpointUrl}/${outlineType}/${learningContextId}`;
|
const outlineUrl = `${studioEndpointUrl}/${outlineType}/${learningContextId}`;
|
||||||
const unitUrl = unitData?.data ? `${studioEndpointUrl}/container/${unitData?.data.ancestors[0].id}` : null;
|
const unitUrl = unitData?.data ? `${studioEndpointUrl}/container/${unitData?.data.ancestors[0].id}` : null;
|
||||||
|
|
||||||
|
|||||||
78
src/generic/key-utils.test.ts
Normal file
78
src/generic/key-utils.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
getBlockType,
|
||||||
|
getLibraryId,
|
||||||
|
isLibraryKey,
|
||||||
|
isLibraryV1Key,
|
||||||
|
} from './key-utils';
|
||||||
|
|
||||||
|
describe('component utils', () => {
|
||||||
|
describe('getBlockType', () => {
|
||||||
|
for (const [input, expected] of [
|
||||||
|
['lb:org:lib:html:id', 'html'],
|
||||||
|
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'],
|
||||||
|
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'],
|
||||||
|
]) {
|
||||||
|
it(`returns '${expected}' for usage key '${input}'`, () => {
|
||||||
|
expect(getBlockType(input)).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
||||||
|
it(`throws an exception for usage key '${input}'`, () => {
|
||||||
|
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLibraryId', () => {
|
||||||
|
for (const [input, expected] of [
|
||||||
|
['lb:org:lib:html:id', 'lib:org:lib'],
|
||||||
|
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:OpenCraftX:ALPHA'],
|
||||||
|
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'],
|
||||||
|
]) {
|
||||||
|
it(`returns '${expected}' for usage key '${input}'`, () => {
|
||||||
|
expect(getLibraryId(input)).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
||||||
|
it(`throws an exception for usage key '${input}'`, () => {
|
||||||
|
expect(() => getLibraryId(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLibraryKey', () => {
|
||||||
|
for (const [input, expected] of [
|
||||||
|
['lib:org:lib', true],
|
||||||
|
['lib:OpenCraftX:ALPHA', true],
|
||||||
|
['lb:org:lib:html:id', false],
|
||||||
|
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', false],
|
||||||
|
['library-v1:AximX+L1', false],
|
||||||
|
['course-v1:AximX+TS100+23', false],
|
||||||
|
['', false],
|
||||||
|
[undefined, false],
|
||||||
|
] as const) {
|
||||||
|
it(`returns '${expected}' for learning context key '${input}'`, () => {
|
||||||
|
expect(isLibraryKey(input)).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLibraryV1Key', () => {
|
||||||
|
for (const [input, expected] of [
|
||||||
|
['library-v1:AximX+L1', true],
|
||||||
|
['lib:org:lib', false],
|
||||||
|
['lib:OpenCraftX:ALPHA', false],
|
||||||
|
['lb:org:lib:html:id', false],
|
||||||
|
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', false],
|
||||||
|
['course-v1:AximX+TS100+23', false],
|
||||||
|
['', false],
|
||||||
|
[undefined, false],
|
||||||
|
] as const) {
|
||||||
|
it(`returns '${expected}' for learning context key '${input}'`, () => {
|
||||||
|
expect(isLibraryV1Key(input)).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/generic/key-utils.ts
Normal file
40
src/generic/key-utils.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Given a usage key like `lb:org:lib:html:id`, get the type (e.g. `html`)
|
||||||
|
* @param usageKey e.g. `lb:org:lib:html:id`
|
||||||
|
* @returns The block type as a string
|
||||||
|
*/
|
||||||
|
export function getBlockType(usageKey: string): string {
|
||||||
|
if (usageKey && usageKey.startsWith('lb:')) {
|
||||||
|
const blockType = usageKey.split(':')[3];
|
||||||
|
if (blockType) {
|
||||||
|
return blockType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a usage key like `lb:org:lib:html:id`, get the library key
|
||||||
|
* @param usageKey e.g. `lb:org:lib:html:id`
|
||||||
|
* @returns The library key, e.g. `lib:org:lib`
|
||||||
|
*/
|
||||||
|
export function getLibraryId(usageKey: string): string {
|
||||||
|
if (usageKey && usageKey.startsWith('lb:')) {
|
||||||
|
const org = usageKey.split(':')[1];
|
||||||
|
const lib = usageKey.split(':')[2];
|
||||||
|
if (org && lib) {
|
||||||
|
return `lib:${org}:${lib}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if this is a V2 library key. */
|
||||||
|
export function isLibraryKey(learningContextKey: string | undefined): 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 {
|
||||||
|
return typeof learningContextKey === 'string' && learningContextKey.startsWith('library-v1:');
|
||||||
|
}
|
||||||
@@ -27,21 +27,34 @@ const LibraryLayout = () => {
|
|||||||
const goBack = React.useCallback(() => {
|
const goBack = React.useCallback(() => {
|
||||||
// Go back to the library
|
// Go back to the library
|
||||||
navigate(`/library/${libraryId}`);
|
navigate(`/library/${libraryId}`);
|
||||||
// The following function is called only if changes are saved:
|
}, []);
|
||||||
return ({ id: usageKey }) => {
|
const returnFunction = React.useCallback(() => {
|
||||||
|
// When changes are cancelled, either onClose (goBack) or this returnFunction will be called.
|
||||||
|
// When changes are saved, this returnFunction is called.
|
||||||
|
goBack();
|
||||||
|
return (args) => {
|
||||||
|
if (args === undefined) {
|
||||||
|
return; // Do nothing - the user cancelled the changes
|
||||||
|
}
|
||||||
|
const { id: usageKey } = args;
|
||||||
// invalidate any queries that involve this XBlock:
|
// invalidate any queries that involve this XBlock:
|
||||||
invalidateComponentData(queryClient, libraryId, usageKey);
|
invalidateComponentData(queryClient, libraryId, usageKey);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [goBack]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryProvider>
|
<LibraryProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/*
|
||||||
|
TODO: we should be opening this editor as a modal, not making it a separate page/URL.
|
||||||
|
That will be a much nicer UX because users can just close the modal and be on the same page they were already
|
||||||
|
on, instead of always getting sent back to the library home.
|
||||||
|
*/}
|
||||||
<Route
|
<Route
|
||||||
path="editor/:blockType/:blockId?"
|
path="editor/:blockType/:blockId?"
|
||||||
element={(
|
element={(
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<EditorContainer learningContextId={libraryId} onClose={goBack} afterSave={goBack} />
|
<EditorContainer learningContextId={libraryId} onClose={goBack} returnFunction={returnFunction} />
|
||||||
</PageWrap>
|
</PageWrap>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ export default [
|
|||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
usageKey: 'lb:org:lib:html:1',
|
usageKey: 'lb:org:lib:html:1',
|
||||||
displayName: 'Text',
|
displayName: 'Text Component 1',
|
||||||
formatted: {
|
formatted: {
|
||||||
|
displayName: 'Text Component 1',
|
||||||
content: {
|
content: {
|
||||||
htmlContent: 'This is a text: ID=1',
|
htmlContent: 'This is a text: ID=1',
|
||||||
},
|
},
|
||||||
@@ -11,13 +12,14 @@ export default [
|
|||||||
tags: {
|
tags: {
|
||||||
level0: ['1', '2', '3'],
|
level0: ['1', '2', '3'],
|
||||||
},
|
},
|
||||||
blockType: 'text',
|
blockType: 'html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
usageKey: 'lb:org:lib:html:2',
|
usageKey: 'lb:org:lib:html:2',
|
||||||
displayName: 'Text',
|
displayName: 'Text Component 2',
|
||||||
formatted: {
|
formatted: {
|
||||||
|
displayName: 'Text Component 2',
|
||||||
content: {
|
content: {
|
||||||
htmlContent: 'This is a text: ID=2',
|
htmlContent: 'This is a text: ID=2',
|
||||||
},
|
},
|
||||||
@@ -25,15 +27,15 @@ export default [
|
|||||||
tags: {
|
tags: {
|
||||||
level0: ['1', '2', '3'],
|
level0: ['1', '2', '3'],
|
||||||
},
|
},
|
||||||
blockType: 'text',
|
blockType: 'html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
usageKey: 'lb:org:lib:video:3',
|
usageKey: 'lb:org:lib:video:3',
|
||||||
displayName: 'Video',
|
displayName: 'Video Component 3',
|
||||||
formatted: {
|
formatted: {
|
||||||
|
displayName: 'Video Component 3',
|
||||||
content: {
|
content: {
|
||||||
htmlContent: 'This is a video: ID=3',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
@@ -44,24 +46,24 @@ export default [
|
|||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
usageKey: 'lb:org:lib:video:4',
|
usageKey: 'lb:org:lib:video:4',
|
||||||
displayName: 'Video',
|
displayName: 'Video Component 4',
|
||||||
formatted: {
|
formatted: {
|
||||||
content: {
|
displayName: 'Video Component 4',
|
||||||
htmlContent: 'This is a video: ID=4',
|
content: {},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
level0: ['1', '2'],
|
level0: ['1', '2'],
|
||||||
},
|
},
|
||||||
blockType: 'text',
|
blockType: 'video',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
usageKey: 'lb:org:lib:problem:5',
|
usageKey: 'lb:org:lib:problem:5',
|
||||||
displayName: 'Problem',
|
displayName: 'Problem',
|
||||||
formatted: {
|
formatted: {
|
||||||
|
displayName: 'Problem',
|
||||||
content: {
|
content: {
|
||||||
htmlContent: 'This is a problem: ID=5',
|
capaContent: 'This is a problem: ID=5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
blockType: 'problem',
|
blockType: 'problem',
|
||||||
@@ -71,8 +73,9 @@ export default [
|
|||||||
usageKey: 'lb:org:lib:problem:6',
|
usageKey: 'lb:org:lib:problem:6',
|
||||||
displayName: 'Problem',
|
displayName: 'Problem',
|
||||||
formatted: {
|
formatted: {
|
||||||
|
displayName: 'Problem',
|
||||||
content: {
|
content: {
|
||||||
htmlContent: 'This is a problem: ID=6',
|
capaContent: 'This is a problem: ID=6',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
blockType: 'problem',
|
blockType: 'problem',
|
||||||
|
|||||||
@@ -85,6 +85,41 @@ describe('AddContentWorkflow test', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can create a Problem component', async () => {
|
it('can create a Problem component', async () => {
|
||||||
|
initializeMocks();
|
||||||
|
render(<LibraryLayout />, renderOpts);
|
||||||
|
|
||||||
|
// Click "New [Component]"
|
||||||
|
const newComponentButton = await screen.findByRole('button', { name: /New/ });
|
||||||
|
fireEvent.click(newComponentButton);
|
||||||
|
|
||||||
|
// Click "Problem" to create a capa problem component
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Problem/ }));
|
||||||
|
|
||||||
|
// Then the editor should open
|
||||||
|
expect(await screen.findByRole('heading', { name: /Select problem type/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Select the type: Numerical Input
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Numerical input' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: /Numerical input/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Enter an answer value:
|
||||||
|
const inputA = await screen.findByPlaceholderText('Enter an answer');
|
||||||
|
fireEvent.change(inputA, { target: { value: '123456' } });
|
||||||
|
|
||||||
|
// Mock the save() REST API method:
|
||||||
|
const saveSpy = jest.spyOn(editorCmsApi as any, 'saveBlock').mockImplementationOnce(async () => ({
|
||||||
|
status: 200, data: { id: mockXBlockFields.usageKeyNewProblem },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Click Save
|
||||||
|
const saveButton = screen.getByLabelText('Save changes and return to learning context');
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
expect(saveSpy).toHaveBeenCalledTimes(2); // TODO: why is this called twice?
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a Video component', async () => {
|
||||||
const { mockShowToast } = initializeMocks();
|
const { mockShowToast } = initializeMocks();
|
||||||
render(<LibraryLayout />, renderOpts);
|
render(<LibraryLayout />, renderOpts);
|
||||||
|
|
||||||
@@ -92,13 +127,13 @@ describe('AddContentWorkflow test', () => {
|
|||||||
const newComponentButton = await screen.findByRole('button', { name: /New/ });
|
const newComponentButton = await screen.findByRole('button', { name: /New/ });
|
||||||
fireEvent.click(newComponentButton);
|
fireEvent.click(newComponentButton);
|
||||||
|
|
||||||
// Pre-condition - this is NOT shown yet:
|
// Pre-condition - the success toast is NOT shown yet:
|
||||||
expect(screen.queryByText('Content created successfully.')).not.toBeInTheDocument();
|
expect(mockShowToast).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Click "Problem" to create a capa problem component
|
// Click "Video" to create a video component
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /Problem/ }));
|
fireEvent.click(await screen.findByRole('button', { name: /Video/ }));
|
||||||
|
|
||||||
// We haven't yet implemented the problem editor, so we expect only a toast to appear
|
// We haven't yet implemented the video editor, so we expect only a toast to appear
|
||||||
await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Content created successfully.'));
|
await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Content created successfully.'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const contentHit: ContentHit = {
|
|||||||
tags: {
|
tags: {
|
||||||
level0: ['1', '2', '3'],
|
level0: ['1', '2', '3'],
|
||||||
},
|
},
|
||||||
blockType: 'text',
|
blockType: 'html',
|
||||||
created: 1722434322294,
|
created: 1722434322294,
|
||||||
modified: 1722434322294,
|
modified: 1722434322294,
|
||||||
lastPublished: null,
|
lastPublished: null,
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
|||||||
tags,
|
tags,
|
||||||
usageKey,
|
usageKey,
|
||||||
} = contentHit;
|
} = contentHit;
|
||||||
const description = formatted?.content?.htmlContent ?? '';
|
const description: string = (/* eslint-disable */
|
||||||
|
blockType === 'html' ? formatted?.content?.htmlContent :
|
||||||
|
blockType === 'problem' ? formatted?.content?.capaContent :
|
||||||
|
undefined
|
||||||
|
) ?? '';/* eslint-enable */
|
||||||
const displayName = formatted?.displayName ?? '';
|
const displayName = formatted?.displayName ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ describe('<LibraryComponents />', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument();
|
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument();
|
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument();
|
expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument();
|
expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -189,8 +189,8 @@ describe('<LibraryComponents />', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a video: ID=3')).toBeInTheDocument();
|
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This is a video: ID=4')).toBeInTheDocument();
|
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument();
|
expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument();
|
expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,14 @@
|
|||||||
import { getBlockType, getEditUrl, getLibraryId } from './utils';
|
import { getEditUrl } from './utils';
|
||||||
|
|
||||||
describe('component utils', () => {
|
describe('component utils', () => {
|
||||||
describe('getBlockType', () => {
|
|
||||||
for (const [input, expected] of [
|
|
||||||
['lb:org:lib:html:id', 'html'],
|
|
||||||
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'],
|
|
||||||
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'],
|
|
||||||
]) {
|
|
||||||
it(`returns '${expected}' for usage key '${input}'`, () => {
|
|
||||||
expect(getBlockType(input)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
|
||||||
it(`throws an exception for usage key '${input}'`, () => {
|
|
||||||
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLibraryId', () => {
|
|
||||||
for (const [input, expected] of [
|
|
||||||
['lb:org:lib:html:id', 'lib:org:lib'],
|
|
||||||
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:OpenCraftX:ALPHA'],
|
|
||||||
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'],
|
|
||||||
]) {
|
|
||||||
it(`returns '${expected}' for usage key '${input}'`, () => {
|
|
||||||
expect(getLibraryId(input)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
|
|
||||||
it(`throws an exception for usage key '${input}'`, () => {
|
|
||||||
expect(() => getLibraryId(input as any)).toThrow(`Invalid usageKey: ${input}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getEditUrl', () => {
|
describe('getEditUrl', () => {
|
||||||
it('returns the right URL for an HTML (Text) block', () => {
|
it('returns the right URL for an HTML (Text) block', () => {
|
||||||
const usageKey = 'lb:org:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
const usageKey = 'lb:org:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||||
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:ALPHA/editor/html/${usageKey}`);
|
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:ALPHA/editor/html/${usageKey}`);
|
||||||
});
|
});
|
||||||
it('doesn\'t yet allow editing a problem block', () => {
|
it('returns the right URL for editing a Problem block', () => {
|
||||||
const usageKey = 'lb:org:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
const usageKey = 'lb:org:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||||
expect(getEditUrl(usageKey)).toBeUndefined();
|
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:beta/editor/problem/${usageKey}`);
|
||||||
});
|
});
|
||||||
it('doesn\'t yet allow editing a video block', () => {
|
it('doesn\'t yet allow editing a video block', () => {
|
||||||
const usageKey = 'lb:org:beta:video:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
const usageKey = 'lb:org:beta:video:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||||
|
|||||||
@@ -1,34 +1,6 @@
|
|||||||
/**
|
import { getBlockType, getLibraryId } from '../../generic/key-utils';
|
||||||
* Given a usage key like `lb:org:lib:html:id`, get the type (e.g. `html`)
|
|
||||||
* @param usageKey e.g. `lb:org:lib:html:id`
|
|
||||||
* @returns The block type as a string
|
|
||||||
*/
|
|
||||||
export function getBlockType(usageKey: string): string {
|
|
||||||
if (usageKey && usageKey.startsWith('lb:')) {
|
|
||||||
const blockType = usageKey.split(':')[3];
|
|
||||||
if (blockType) {
|
|
||||||
return blockType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a usage key like `lb:org:lib:html:id`, get the library key
|
|
||||||
* @param usageKey e.g. `lb:org:lib:html:id`
|
|
||||||
* @returns The library key, e.g. `lib:org:lib`
|
|
||||||
*/
|
|
||||||
export function getLibraryId(usageKey: string): string {
|
|
||||||
if (usageKey && usageKey.startsWith('lb:')) {
|
|
||||||
const org = usageKey.split(':')[1];
|
|
||||||
const lib = usageKey.split(':')[2];
|
|
||||||
if (org && lib) {
|
|
||||||
return `lib:${org}:${lib}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
export function getEditUrl(usageKey: string): string | undefined {
|
export function getEditUrl(usageKey: string): string | undefined {
|
||||||
let blockType: string;
|
let blockType: string;
|
||||||
let libraryId: string;
|
let libraryId: string;
|
||||||
@@ -39,7 +11,8 @@ export function getEditUrl(usageKey: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mfeEditorTypes = ['html'];
|
// Which XBlock/component types are supported by the 'editors' built in to this repo?
|
||||||
|
const mfeEditorTypes = ['html', 'problem'];
|
||||||
if (mfeEditorTypes.includes(blockType)) {
|
if (mfeEditorTypes.includes(blockType)) {
|
||||||
return `/library/${libraryId}/editor/${blockType}/${usageKey}`;
|
return `/library/${libraryId}/editor/${blockType}/${usageKey}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,11 +112,14 @@ mockContentLibrary.applyMock = () => jest.spyOn(api, 'getContentLibrary').mockIm
|
|||||||
export async function mockCreateLibraryBlock(
|
export async function mockCreateLibraryBlock(
|
||||||
args: api.CreateBlockDataRequest,
|
args: api.CreateBlockDataRequest,
|
||||||
): ReturnType<typeof api.createLibraryBlock> {
|
): ReturnType<typeof api.createLibraryBlock> {
|
||||||
if (args.blockType === 'html' && args.libraryId === mockContentLibrary.libraryId) {
|
if (args.libraryId === mockContentLibrary.libraryId) {
|
||||||
return mockCreateLibraryBlock.newHtmlData;
|
switch (args.blockType) {
|
||||||
}
|
case 'html': return mockCreateLibraryBlock.newHtmlData;
|
||||||
if (args.blockType === 'problem' && args.libraryId === mockContentLibrary.libraryId) {
|
case 'problem': return mockCreateLibraryBlock.newProblemData;
|
||||||
return mockCreateLibraryBlock.newProblemData;
|
case 'video': return mockCreateLibraryBlock.newVideoData;
|
||||||
|
default:
|
||||||
|
// Continue to error handling below.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`mockCreateLibraryBlock doesn't know how to mock ${JSON.stringify(args)}`);
|
throw new Error(`mockCreateLibraryBlock doesn't know how to mock ${JSON.stringify(args)}`);
|
||||||
}
|
}
|
||||||
@@ -146,6 +149,19 @@ mockCreateLibraryBlock.newProblemData = {
|
|||||||
created: '2024-07-22T21:37:49Z',
|
created: '2024-07-22T21:37:49Z',
|
||||||
tagsCount: 0,
|
tagsCount: 0,
|
||||||
} satisfies api.LibraryBlockMetadata;
|
} satisfies api.LibraryBlockMetadata;
|
||||||
|
mockCreateLibraryBlock.newVideoData = {
|
||||||
|
id: 'lb:Axim:TEST:video:prob1',
|
||||||
|
defKey: 'video1',
|
||||||
|
blockType: 'video',
|
||||||
|
displayName: 'New Video',
|
||||||
|
hasUnpublishedChanges: true,
|
||||||
|
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
|
||||||
|
publishedBy: null, // or e.g. 'test_author',
|
||||||
|
lastDraftCreated: '2024-07-22T21:37:49Z',
|
||||||
|
lastDraftCreatedBy: null,
|
||||||
|
created: '2024-07-22T21:37:49Z',
|
||||||
|
tagsCount: 0,
|
||||||
|
} satisfies api.LibraryBlockMetadata;
|
||||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||||
mockCreateLibraryBlock.applyMock = () => (
|
mockCreateLibraryBlock.applyMock = () => (
|
||||||
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
|
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
|
||||||
@@ -163,6 +179,7 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
|
|||||||
switch (usageKey) {
|
switch (usageKey) {
|
||||||
case thisMock.usageKeyHtml: return thisMock.dataHtml;
|
case thisMock.usageKeyHtml: return thisMock.dataHtml;
|
||||||
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
|
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
|
||||||
|
case thisMock.usageKeyNewProblem: return thisMock.dataNewProblem;
|
||||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +197,13 @@ mockXBlockFields.dataNewHtml = {
|
|||||||
data: '',
|
data: '',
|
||||||
metadata: { displayName: 'New Text Component' },
|
metadata: { displayName: 'New Text Component' },
|
||||||
} satisfies api.XBlockFields;
|
} satisfies api.XBlockFields;
|
||||||
|
// Mock of a blank/new problem (CAPA) block:
|
||||||
|
mockXBlockFields.usageKeyNewProblem = 'lb:Axim:TEST:problem:prob1';
|
||||||
|
mockXBlockFields.dataNewProblem = {
|
||||||
|
displayName: 'New Problem Component',
|
||||||
|
data: '',
|
||||||
|
metadata: { displayName: 'New Problem Component' },
|
||||||
|
} satisfies api.XBlockFields;
|
||||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||||
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
|
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type QueryClient,
|
type QueryClient,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
import { getLibraryId } from '../components/utils';
|
import { getLibraryId } from '../../generic/key-utils';
|
||||||
import {
|
import {
|
||||||
type GetLibrariesV2CustomParams,
|
type GetLibrariesV2CustomParams,
|
||||||
type ContentLibrary,
|
type ContentLibrary,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { getItemIcon } from '../generic/block-type-utils';
|
import { getItemIcon } from '../generic/block-type-utils';
|
||||||
|
import { isLibraryKey } from '../generic/key-utils';
|
||||||
import { useSearchContext, type ContentHit, Highlight } from '../search-manager';
|
import { useSearchContext, type ContentHit, Highlight } from '../search-manager';
|
||||||
import { getStudioHomeData } from '../studio-home/data/selectors';
|
import { getStudioHomeData } from '../studio-home/data/selectors';
|
||||||
import { constructLibraryAuthoringURL } from '../utils';
|
import { constructLibraryAuthoringURL } from '../utils';
|
||||||
@@ -116,7 +117,7 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
|
|||||||
return `/${urlSuffix}`;
|
return `/${urlSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextKey.startsWith('lib:')) {
|
if (isLibraryKey(contextKey)) {
|
||||||
const urlSuffix = getLibraryComponentUrlSuffix(hit);
|
const urlSuffix = getLibraryComponentUrlSuffix(hit);
|
||||||
if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) {
|
if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) {
|
||||||
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix);
|
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix);
|
||||||
|
|||||||
Reference in New Issue
Block a user