Compare commits
24 Commits
release/ul
...
feat/add_g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9610f0791f | ||
|
|
0f58329cb4 | ||
|
|
54cfbeb756 | ||
|
|
7cf01de84c | ||
|
|
a1abd43a11 | ||
|
|
8f06263e27 | ||
|
|
e10ab270dd | ||
|
|
a5d65abea2 | ||
|
|
5ec00236cb | ||
|
|
2530b01b82 | ||
|
|
13c51ce5a8 | ||
|
|
b2cfafc00e | ||
|
|
6d619b9c40 | ||
|
|
6afe6095a5 | ||
|
|
1b357cb2b6 | ||
|
|
2de987b254 | ||
|
|
4299bf16b4 | ||
|
|
5cda284cdb | ||
|
|
436ac3155d | ||
|
|
86a7e06a3c | ||
|
|
bd82c1d33d | ||
|
|
75ae9d549c | ||
|
|
cec074e6d4 | ||
|
|
36c9eba66d |
1
.env
1
.env
@@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
|
||||
@@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
|
||||
@@ -33,6 +33,7 @@ ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
|
||||
@@ -14,6 +14,15 @@ module.exports = createConfig(
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['@edx/frontend-platform/i18n'],
|
||||
importNames: ['injectIntl'],
|
||||
message: "Use 'useIntl' hook instead of injectIntl.",
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -30,9 +30,9 @@ We're trying to move away from some deprecated patterns in this codebase. Please
|
||||
check if your PR meets these recommendations before asking for a review:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
|
||||
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
|
||||
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
|
||||
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
|
||||
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -41,7 +41,7 @@
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tanstack/react-query": "5.90.7",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
@@ -7306,9 +7306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon": {
|
||||
"version": "23.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.15.1.tgz",
|
||||
"integrity": "sha512-uqbKE5pfLLdEaTltd27nyyV/enjOjPkkINES/LRBZXwRgGWhQh+vH2xA+iXigwvGGeWdzuxnJ0lXyfiUR/R7Ig==",
|
||||
"version": "23.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.16.0.tgz",
|
||||
"integrity": "sha512-s3cq7lJJ6hppv41hSb+HrwPknI+USWmBO0+GApTXXL2Dvy3j9B9q2M2r5T0sG1xnPwo59z+C2UKnMlTbbGGy5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -8257,9 +8257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
|
||||
"integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==",
|
||||
"version": "5.90.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz",
|
||||
"integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -8267,12 +8267,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz",
|
||||
"integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==",
|
||||
"version": "5.90.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz",
|
||||
"integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.5"
|
||||
"@tanstack/query-core": "5.90.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -11067,9 +11067,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001755",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
|
||||
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tanstack/react-query": "5.90.7",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
|
||||
@@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
@@ -20,7 +20,6 @@ const defaultProps = {
|
||||
courseId,
|
||||
onClose: () => {},
|
||||
};
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
let store;
|
||||
|
||||
const intlWrapper = children => (
|
||||
@@ -102,7 +101,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Field dependencies', () => {
|
||||
beforeEach(async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
|
||||
@@ -152,7 +151,7 @@ describe('ProctoredExamSettings', () => {
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctored exams');
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||
@@ -409,7 +408,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
||||
});
|
||||
@@ -418,7 +417,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -428,7 +427,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const org = 'test-org';
|
||||
setupApp(isAdmin, org);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -437,7 +436,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -446,7 +445,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -457,7 +456,7 @@ describe('ProctoredExamSettings', () => {
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -470,7 +469,7 @@ describe('ProctoredExamSettings', () => {
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -483,7 +482,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -497,7 +496,7 @@ describe('ProctoredExamSettings', () => {
|
||||
EXAMS_BASE_URL: null,
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -516,7 +515,7 @@ describe('ProctoredExamSettings', () => {
|
||||
).reply(200, {
|
||||
provider: 'test_lti',
|
||||
});
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctoring provider');
|
||||
});
|
||||
@@ -529,14 +528,14 @@ describe('ProctoredExamSettings', () => {
|
||||
describe('Toggles field visibility based on user permissions', () => {
|
||||
it('Hides opting out and zendesk tickets for non edX staff', async () => {
|
||||
setupApp(false);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
});
|
||||
|
||||
it('Shows opting out and zendesk tickets for edX staff', async () => {
|
||||
setupApp(true);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
|
||||
});
|
||||
@@ -544,7 +543,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Connection states', () => {
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
@@ -554,7 +553,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -566,7 +565,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -578,7 +577,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(403);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const permissionError = screen.getByTestId('permissionDeniedAlert');
|
||||
expect(permissionError.textContent).toEqual(
|
||||
expect.stringContaining('You are not authorized to view this page'),
|
||||
@@ -597,7 +596,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
fireEvent.click(submitButton);
|
||||
@@ -607,7 +606,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
@@ -638,7 +637,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
|
||||
// make sure we have not selected proctortrack as the proctoring provider
|
||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||
@@ -665,7 +664,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
@@ -706,7 +705,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// update exam service config
|
||||
@@ -750,7 +749,7 @@ describe('ProctoredExamSettings', () => {
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// does not update exam service config
|
||||
@@ -780,7 +779,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -798,7 +797,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(500, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -816,7 +815,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(403, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -835,7 +834,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -868,7 +867,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
|
||||
@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertPluginConfigurable(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertSettings(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function updateCourseAppSetting(courseId, settings) {
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function fetchProctoringExamErrors(courseId) {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||
try {
|
||||
JSON.parse(settingValue);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
let targetSettingValue = settingValue;
|
||||
const firstNonWhite = settingValue.substring(0, 1);
|
||||
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
...prevEditedSettings,
|
||||
[settingName]: targetSettingValue,
|
||||
}));
|
||||
} catch (quotedE) { /* empty */ }
|
||||
} catch { /* empty */ }
|
||||
}
|
||||
|
||||
pushDataToErrorArray(settingName);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
|
||||
import { BoldText } from '@src/utils';
|
||||
|
||||
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||
import ChildrenPreview from './ChildrenPreview';
|
||||
import ContainerRow from './ContainerRow';
|
||||
import { useCourseContainerChildren } from './data/apiHooks';
|
||||
@@ -60,7 +61,7 @@ const CompareContainersWidgetInner = ({
|
||||
data: libData,
|
||||
isError: isLibError,
|
||||
error: libError,
|
||||
} = useContainerChildren(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||
const {
|
||||
data: containerData,
|
||||
isError: isContainerTitleError,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ModeComment } from '@openedx/paragon/icons';
|
||||
@@ -127,4 +127,4 @@ ChecklistItemComment.propTypes = {
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistItemComment);
|
||||
export default ChecklistItemComment;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
|
||||
dispatch(fetchBestPracticeChecklistSuccess({ data }));
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ mockUseLibBlockMetadata.applyMock();
|
||||
|
||||
const searchParamsGetMock = jest.fn();
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let mockShowToast: (message: string, action?: ToastActionData) => void;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
jest.mock('../studio-home/hooks', () => ({
|
||||
|
||||
@@ -224,7 +224,7 @@ const ItemReviewList = ({
|
||||
messages.updateSingleBlockSuccess,
|
||||
{ name: info.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
|
||||
}
|
||||
}, []);
|
||||
@@ -243,7 +243,7 @@ const ItemReviewList = ({
|
||||
messages.ignoreSingleBlockSuccess,
|
||||
{ name: blockData.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
|
||||
} finally {
|
||||
closeConfirmModal();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline } from './types';
|
||||
import { CourseOutline, CourseDetails } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = (
|
||||
courseId: string,
|
||||
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
@@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
* @returns {Promise<CourseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course details.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<CourseDetails>}
|
||||
*/
|
||||
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseDetailsApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param courseId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { getCourseItem } from './api';
|
||||
import { getCourseDetails, getCourseItem } from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
@@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = {
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
|
||||
|
||||
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
|
||||
enabled: enabled && itemId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
export const useCourseDetails = (courseId?: string) => (
|
||||
useQuery({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
|
||||
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -148,7 +148,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -165,7 +165,7 @@ export function enableCourseHighlightsEmailsQuery(courseId: string) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -182,7 +182,7 @@ export function setVideoSharingOptionQuery(courseId: string, option: string) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export function publishCourseItemQuery(itemId: string, sectionId: string) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export function configureCourseItemQuery(sectionId: string, configureFn: () => P
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -390,7 +390,7 @@ export function editCourseItemQuery(itemId: string, sectionId: string, displayNa
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -412,7 +412,7 @@ function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
|
||||
dispatch(deleteItemFn());
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -469,7 +469,7 @@ function duplicateCourseItemQuery(
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -541,7 +541,7 @@ function addNewCourseItemQuery(
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -612,7 +612,7 @@ export function addUnitFromLibrary(body: {
|
||||
callback(result.locator);
|
||||
}
|
||||
});
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
} catch /* istanbul ignore next */ {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -643,7 +643,7 @@ function setBlockOrderListQuery(
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
@@ -728,7 +728,7 @@ export function pasteClipboardContent(parentLocator: string, sectionId: string)
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -743,7 +743,7 @@ export function dismissNotificationQuery(url: string) {
|
||||
await dismissNotification(url).then(async () => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,6 +24,15 @@ export interface CourseOutline {
|
||||
rerunNotificationId: null;
|
||||
}
|
||||
|
||||
// TODO: This interface has only basic data, all the rest needs to be added.
|
||||
export interface CourseDetails {
|
||||
courseId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
org: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: string;
|
||||
|
||||
@@ -159,7 +159,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
data.shouldScroll = true;
|
||||
// Page should scroll to newly added subsection.
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
@@ -174,7 +174,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function changeRoleTeamUserQuery(courseId, email, role) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch ({ message }) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function deleteCourseTeamQuery(courseId, email) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ const UnitButton = ({
|
||||
unitId,
|
||||
className,
|
||||
showTitle,
|
||||
isActive, // passed from parent (SequenceNavigationTabs)
|
||||
}) => {
|
||||
const courseId = useSelector(getCourseId);
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
|
||||
const unit = useSelector((state) => state.models.units[unitId]);
|
||||
|
||||
const { title, contentType, isActive } = unit || {};
|
||||
const { title, contentType } = unit || {};
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -37,11 +37,13 @@ UnitButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
showTitle: PropTypes.bool,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
showTitle: false,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
export default UnitButton;
|
||||
|
||||
@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
return false;
|
||||
@@ -204,7 +204,7 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
|
||||
}
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
|
||||
};
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let mockShowToast: (message: string, action?: ToastActionData) => void;
|
||||
|
||||
describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -177,7 +177,7 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
overrideCustomizations,
|
||||
});
|
||||
postChange(accept);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(failureMsg));
|
||||
} finally {
|
||||
closeModal();
|
||||
|
||||
@@ -58,7 +58,7 @@ export function createCourseUpdateQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { creatingUpdate: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -80,7 +80,7 @@ export function editCourseUpdateQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -102,7 +102,7 @@ export function deleteCourseUpdateQuery(courseId, updateId) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { deletingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -150,7 +150,7 @@ export function editCourseHandoutsQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingHandouts: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
|
||||
@@ -132,7 +132,7 @@ export function updateCustomPageVisibility({ blockId, metadata }) {
|
||||
},
|
||||
}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export const updateSingleCustomPage = ({
|
||||
}));
|
||||
setCurrentPage(null);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-unused-vars */
|
||||
/* eslint-disable import/extensions */
|
||||
/* eslint-disable import/no-unresolved */
|
||||
/**
|
||||
* This is an example component for an xblock Editor
|
||||
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
|
||||
* To use run npm run-script addXblock <your>
|
||||
*/
|
||||
|
||||
/* istanbul ignore file */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form,
|
||||
Spinner,
|
||||
Collapsible,
|
||||
Icon,
|
||||
IconButton,
|
||||
Dropdown,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
DeleteOutline,
|
||||
Add,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
InsertPhoto,
|
||||
MoreHoriz,
|
||||
Check,
|
||||
} from '@openedx/paragon/icons';
|
||||
import {
|
||||
actions,
|
||||
selectors,
|
||||
} from '../../data/redux';
|
||||
import {
|
||||
RequestKeys,
|
||||
} from '../../data/constants/requests';
|
||||
import './index.scss';
|
||||
import EditorContainer from '../EditorContainer';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
||||
// should be re-thought and cleaned up to avoid this pattern.
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import * as module from '.';
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
|
||||
import Button from '../../sharedComponents/Button';
|
||||
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
|
||||
import messages from './messages';
|
||||
|
||||
export const hooks = {
|
||||
getContent: () => ({
|
||||
@@ -30,77 +40,498 @@ export const hooks = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const thumbEditor = ({
|
||||
export const GameEditor = ({
|
||||
onClose,
|
||||
// redux
|
||||
blockValue,
|
||||
lmsEndpointUrl,
|
||||
blockFailed,
|
||||
blockFinished,
|
||||
initializeEditor,
|
||||
exampleValue,
|
||||
// inject
|
||||
intl,
|
||||
}) => (
|
||||
<EditorContainer
|
||||
getContent={module.hooks.getContent}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div>
|
||||
{exampleValue}
|
||||
|
||||
// settings
|
||||
settings,
|
||||
shuffleTrue,
|
||||
shuffleFalse,
|
||||
timerTrue,
|
||||
timerFalse,
|
||||
type,
|
||||
updateType,
|
||||
|
||||
// list
|
||||
list,
|
||||
updateTerm,
|
||||
updateTermImage,
|
||||
updateDefinition,
|
||||
updateDefinitionImage,
|
||||
toggleOpen,
|
||||
setList,
|
||||
addCard,
|
||||
removeCard,
|
||||
|
||||
isDirty,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
// State for list
|
||||
const [state, setState] = React.useState(list);
|
||||
React.useEffect(() => { setState(list); }, [list]);
|
||||
|
||||
// Non-reducer functions go here
|
||||
const getDescriptionHeader = () => {
|
||||
// Function to determine what the header will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Flashcard terms';
|
||||
case 'matching':
|
||||
return 'Matching terms';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
// Function to determine what the description will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Enter your terms and definitions below. Learners will review each card by viewing the term, then flipping to reveal the definition.';
|
||||
case 'matching':
|
||||
return 'Enter your terms and definitions below. Learners must match each term with the correct definition.';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const saveTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateTermImage({ index, termImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateTermImage({ index, termImage: '' });
|
||||
};
|
||||
|
||||
const saveDefinitionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateDefinitionImage({ index, definitionImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDefintionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateDefinitionImage({ index, definitionImage: '' });
|
||||
};
|
||||
|
||||
const moveCardUp = (index) => {
|
||||
if (index === 0) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index], temp[index - 1]] = [temp[index - 1], temp[index]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const moveCardDown = (index) => {
|
||||
if (index === state.length - 1) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index + 1], temp[index]] = [temp[index], temp[index + 1]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const loading = (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
screenreadertext={intl.formatMessage(messages.loadingSpinner)}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
// Use a messages.js file for intl messages.
|
||||
screenreadertext={intl.formatMessage('Loading Spinner')}
|
||||
/>
|
||||
);
|
||||
|
||||
const termImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.term_image} alt="TERM_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeTermImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const termImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`term_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const definitionImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.definition_image} alt="DEF_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeDefintionImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const definitionImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`definition_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const timerSettingsOption = (
|
||||
<SettingsOption
|
||||
className="sidebar-timer d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Timer"
|
||||
summary={settings.timer ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Measure the time it takes learners to match all terms and definitions. Used to calculate a learner's score.</div>
|
||||
<Button
|
||||
onClick={() => timerFalse()}
|
||||
variant={!settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => timerTrue()}
|
||||
variant={settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
);
|
||||
|
||||
const page = (
|
||||
<div className="page-body d-flex align-items-start">
|
||||
<div className="terms d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description-header">
|
||||
{getDescriptionHeader()}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p>
|
||||
Your Editor Goes here.
|
||||
You can get at the xblock data with the blockValue field.
|
||||
here is what is in your xblock: {JSON.stringify(blockValue)}
|
||||
</p>
|
||||
)}
|
||||
<div className="description-body align-self-stretch">
|
||||
{getDescription()}
|
||||
</div>
|
||||
</div>
|
||||
<DraggableList
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
itemList={state}
|
||||
setState={setState}
|
||||
updateOrder={() => (newList) => setList(newList)}
|
||||
>
|
||||
{
|
||||
state.map((card, index) => (
|
||||
<SortableItem
|
||||
id={card.id}
|
||||
key={card.id}
|
||||
buttonClassName="draggable-button"
|
||||
componentStyle={{
|
||||
background: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '24px',
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0px 1px 5px #ADADAD',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
flexFlow: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Collapsible.Advanced
|
||||
className="card"
|
||||
defaultOpen
|
||||
onOpen={() => toggleOpen({ index, isOpen: true })}
|
||||
onClose={() => toggleOpen({ index, isOpen: false })}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id={`term_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveTermImage(index)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id={`definition_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveDefinitionImage(index)}
|
||||
/>
|
||||
<Collapsible.Trigger className="card-heading">
|
||||
<div className="drag-spacer" />
|
||||
<div className="card-heading d-flex align-items-center align-self-stretch">
|
||||
<div className="card-number">{index + 1}</div>
|
||||
{!card.editorOpen ? (
|
||||
<div className="preview-block position-relative w-100">
|
||||
<span className="align-middle">
|
||||
<span className="preview-term">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.term_image !== ''
|
||||
? <img className="img-preview" src={card.term_image} alt="TERM_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.term !== '' ? card.term : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
<span className="preview-definition">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.definition_image !== ''
|
||||
? <img className="img-preview" src={card.definition_image} alt="DEF_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.definition !== '' ? card.definition : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: <div className="card-spacer d-flex align-self-stretch" />}
|
||||
<Dropdown onToggle={(isOpen, e) => e.stopPropagation()}>
|
||||
<Dropdown.Toggle
|
||||
className="card-dropdown"
|
||||
as={IconButton}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => moveCardUp(index)}>Move up</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => moveCardDown(index)}>Move down</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={() => removeCard({ index })}>Delete</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandMore}
|
||||
iconAs={Icon}
|
||||
alt="EXPAND"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandLess}
|
||||
iconAs={Icon}
|
||||
alt="COLLAPSE"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<div className="card-body p-0">
|
||||
<Collapsible.Body>
|
||||
<div className="card-body-divider">
|
||||
<div className="card-divider" />
|
||||
</div>
|
||||
<div className="card-term d-flex flex-column align-items-start align-self-stretch">
|
||||
Term
|
||||
{(type !== 'matching' && card.term_image !== '') && termImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`term|${index}`}
|
||||
placeholder="Enter your term"
|
||||
value={card.term}
|
||||
onChange={(e) => updateTerm({ index, term: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && termImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-divider" />
|
||||
<div className="card-definition d-flex flex-column align-items-start align-self-stretch">
|
||||
Definition
|
||||
{(type !== 'matching' && card.definition_image !== '') && definitionImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`definition|${index}`}
|
||||
placeholder="Enter your definition"
|
||||
value={card.definition}
|
||||
onChange={(e) => updateDefinition({ index, definition: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && definitionImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Body>
|
||||
</div>
|
||||
</Collapsible.Advanced>
|
||||
</SortableItem>
|
||||
))
|
||||
}
|
||||
</DraggableList>
|
||||
<Button
|
||||
className="add-button"
|
||||
onClick={() => addCard()}
|
||||
>
|
||||
<IconButton
|
||||
src={Add}
|
||||
iconAs={Icon}
|
||||
alt="ADD"
|
||||
variant="primary"
|
||||
/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sidebar d-flex flex-column align-items-start flex-shrink-0">
|
||||
<SettingsOption
|
||||
className="sidebar-type d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Type"
|
||||
summary={type.substr(0, 1).toUpperCase() + type.substr(1)}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<Button
|
||||
onClick={() => updateType('flashcards')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Flashcards</span>
|
||||
<span hidden={type !== 'flashcards'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
<div className="card-divider" />
|
||||
<Button
|
||||
onClick={() => updateType('matching')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Matching</span>
|
||||
<span hidden={type !== 'matching'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
</SettingsOption>
|
||||
<SettingsOption
|
||||
className="sidebar-shuffle d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Shuffle"
|
||||
summary={settings.shuffle ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Shuffle the order of terms shown to learners when reviewing.</div>
|
||||
<Button
|
||||
onClick={() => shuffleFalse()}
|
||||
variant={!settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => shuffleTrue()}
|
||||
variant={settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
{type === 'matching' && timerSettingsOption}
|
||||
</div>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
thumbEditor.defaultProps = {
|
||||
blockValue: null,
|
||||
lmsEndpointUrl: null,
|
||||
);
|
||||
|
||||
// Page content goes here
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={hooks.getContent}
|
||||
onClose={onClose}
|
||||
isDirty={() => isDirty}
|
||||
>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished ? loading : page}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
thumbEditor.propTypes = {
|
||||
|
||||
GameEditor.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
blockValue: PropTypes.shape({
|
||||
data: PropTypes.shape({ data: PropTypes.string }),
|
||||
}),
|
||||
lmsEndpointUrl: PropTypes.string,
|
||||
blockFailed: PropTypes.bool.isRequired,
|
||||
blockFinished: PropTypes.bool.isRequired,
|
||||
initializeEditor: PropTypes.func.isRequired,
|
||||
// inject
|
||||
intl: intlShape.isRequired,
|
||||
list: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
updateTerm: PropTypes.func.isRequired,
|
||||
updateTermImage: PropTypes.func.isRequired,
|
||||
updateDefinition: PropTypes.func.isRequired,
|
||||
updateDefinitionImage: PropTypes.func.isRequired,
|
||||
toggleOpen: PropTypes.func.isRequired,
|
||||
setList: PropTypes.func.isRequired,
|
||||
addCard: PropTypes.func.isRequired,
|
||||
removeCard: PropTypes.func.isRequired,
|
||||
settings: PropTypes.shape({
|
||||
shuffle: PropTypes.bool.isRequired,
|
||||
timer: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
shuffleTrue: PropTypes.func.isRequired,
|
||||
shuffleFalse: PropTypes.func.isRequired,
|
||||
timerTrue: PropTypes.func.isRequired,
|
||||
timerFalse: PropTypes.func.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
updateType: PropTypes.func.isRequired,
|
||||
|
||||
isDirty: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
blockValue: selectors.app.blockValue(state),
|
||||
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
|
||||
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
// TODO fill with redux state here if needed
|
||||
exampleValue: selectors.game.exampleValue(state),
|
||||
settings: selectors.game.settings(state),
|
||||
type: selectors.game.type(state),
|
||||
list: selectors.game.list(state),
|
||||
isDirty: selectors.game.isDirty(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
initializeEditor: actions.app.initializeEditor,
|
||||
// TODO fill with dispatches here if needed
|
||||
|
||||
// shuffle
|
||||
shuffleTrue: actions.game.shuffleTrue,
|
||||
shuffleFalse: actions.game.shuffleFalse,
|
||||
|
||||
// timer
|
||||
timerTrue: actions.game.timerTrue,
|
||||
timerFalse: actions.game.timerFalse,
|
||||
|
||||
// type
|
||||
updateType: actions.game.updateType,
|
||||
|
||||
// list
|
||||
updateTerm: actions.game.updateTerm,
|
||||
updateTermImage: actions.game.updateTermImage,
|
||||
updateDefinition: actions.game.updateDefinition,
|
||||
updateDefinitionImage: actions.game.updateDefinitionImage,
|
||||
toggleOpen: actions.game.toggleOpen,
|
||||
setList: actions.game.setList,
|
||||
addCard: actions.game.addCard,
|
||||
removeCard: actions.game.removeCard,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GameEditor);
|
||||
|
||||
275
src/editors/containers/GameEditor/index.scss
Normal file
275
src/editors/containers/GameEditor/index.scss
Normal file
@@ -0,0 +1,275 @@
|
||||
/* Basic styles to support GameEditor layout and classes used in JSX */
|
||||
.editor-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
padding: 8px 0 0 24px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 0;
|
||||
gap: 16px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.terms > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
padding: 8px 24px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.description-header {
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.draggable-button {
|
||||
cursor: grab;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: #EEF1F5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
max-height: 32px;
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
.card-image-area {
|
||||
display: flex;
|
||||
padding: 0 24px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: var(--light-400, #EAE6E5);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-right: 8px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.preview-term {
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.preview-block {
|
||||
margin-right: 8px;
|
||||
bottom: 35%;
|
||||
}
|
||||
|
||||
.preview-definition {
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.description-body {
|
||||
align-self: stretch;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
border: var(--extras-white, #FFFFFF);
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-spacer {
|
||||
flex: 1 0 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.card-delete-button, .card-image-button, .image-delete-button {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 44px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-body-divider {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.card-term, .card-definition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
gap: 16px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.card-input-line {
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1 0 0;
|
||||
border: 1px solid var(--gray-500, #707070);
|
||||
background: #FFFFFF;
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.sidebar-type, .sidebar-shuffle, .sidebar-timer {
|
||||
gap: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--light-700, #D7D3D1);
|
||||
background: #FFFFFF;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drag-spacer {
|
||||
width: 20px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.check {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
.card-dropdown {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
padding-bottom: 16px;
|
||||
color: #51565C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
11
src/editors/containers/GameEditor/messages.ts
Normal file
11
src/editors/containers/GameEditor/messages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingSpinner: {
|
||||
id: 'GameEditor.loadingSpinner',
|
||||
defaultMessage: 'Loading Spinner',
|
||||
description: 'Loading message for spinner screenreader text.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -90,7 +90,7 @@ export const useFeedback = (answer) => {
|
||||
};
|
||||
|
||||
export const isSingleAnswerProblem = (problemType) => (
|
||||
problemType === ProblemTypeKeys.DROPDOWN
|
||||
problemType === ProblemTypeKeys.SINGLESELECT || problemType === ProblemTypeKeys.DROPDOWN
|
||||
);
|
||||
|
||||
export const useAnswerContainer = ({ answers, updateField }) => {
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('Answer Options Hooks', () => {
|
||||
});
|
||||
describe('isSingleAnswerProblem()', () => {
|
||||
test('singleSelect', () => {
|
||||
expect(module.isSingleAnswerProblem(ProblemTypeKeys.SINGLESELECT)).toBe(false);
|
||||
expect(module.isSingleAnswerProblem(ProblemTypeKeys.SINGLESELECT)).toBe(true);
|
||||
});
|
||||
test('multiSelect', () => {
|
||||
expect(module.isSingleAnswerProblem(ProblemTypeKeys.MULTISELECT)).toBe(false);
|
||||
|
||||
@@ -299,9 +299,9 @@ export const typeRowHooks = ({
|
||||
if (typeKey === ProblemTypeKeys.TEXTINPUT && RichTextProblems.includes(problemType)) {
|
||||
convertToPlainText();
|
||||
}
|
||||
// Dropdown problems can only have one correct answer. When there is more than one correct answer
|
||||
// Dropdown and single-select problems can only have one correct answer. When there is more than one correct answer
|
||||
// from a previous problem type, the correct attribute for selected answers need to be set to false.
|
||||
if (typeKey === ProblemTypeKeys.DROPDOWN) {
|
||||
if (typeKey === ProblemTypeKeys.DROPDOWN || typeKey === ProblemTypeKeys.SINGLESELECT) {
|
||||
if (correctAnswerCount > 1) {
|
||||
clearPreviouslySelectedAnswers();
|
||||
} else if (RichTextProblems.includes(problemType)) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import HintsCard from './settingsComponents/HintsCard';
|
||||
import ResetCard from './settingsComponents/ResetCard';
|
||||
import TimerCard from './settingsComponents/TimerCard';
|
||||
import TypeCard from './settingsComponents/TypeCard';
|
||||
import ToleranceCard from './settingsComponents/Tolerance';
|
||||
import { ToleranceCard } from './settingsComponents/Tolerance';
|
||||
import GroupFeedbackCard from './settingsComponents/GroupFeedback/index';
|
||||
import SwitchEditorCard from './settingsComponents/SwitchEditorCard';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Form } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import SettingsOption from '../../SettingsOption';
|
||||
@@ -46,14 +46,13 @@ export const getSummary = ({ tolerance, intl }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const ToleranceCard = ({
|
||||
export const ToleranceCard = ({
|
||||
tolerance,
|
||||
answers,
|
||||
updateSettings,
|
||||
correctAnswerCount,
|
||||
// inject
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const isAnswerRange = isAnswerRangeSet({ answers });
|
||||
const hasMultipleCorrectAnswers = correctAnswerCount > 1;
|
||||
let summary = getSummary({ tolerance, intl });
|
||||
@@ -141,8 +140,4 @@ ToleranceCard.propTypes = {
|
||||
unselectedFeedback: PropTypes.string,
|
||||
})).isRequired,
|
||||
updateSettings: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export const ToleranceCardInternal = ToleranceCard; // For testing only
|
||||
export default injectIntl(ToleranceCard);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
import React from 'react';
|
||||
import messages from './messages';
|
||||
import { ToleranceTypes } from './constants';
|
||||
import { ToleranceCardInternal as ToleranceCard } from './index';
|
||||
import { ToleranceCard } from './index';
|
||||
import { formatMessage } from '../../../../../../../testUtils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
@@ -13,6 +13,9 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
FormattedMessage: jest.fn(({ defaultMessage }) => (
|
||||
<div>{ defaultMessage }</div>
|
||||
)),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
|
||||
@@ -3,8 +3,7 @@ import { connect, useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Image,
|
||||
@@ -32,14 +31,13 @@ import { ErrorContext } from '../../../../hooks';
|
||||
* Collapsible Form widget controlling video thumbnail
|
||||
*/
|
||||
const ThumbnailWidget = ({
|
||||
// injected
|
||||
intl,
|
||||
// redux
|
||||
isLibrary,
|
||||
allowThumbnailUpload,
|
||||
thumbnail,
|
||||
videoId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [error] = React.useContext(ErrorContext).thumbnail;
|
||||
const imgRef = React.useRef();
|
||||
@@ -126,8 +124,6 @@ const ThumbnailWidget = ({
|
||||
};
|
||||
|
||||
ThumbnailWidget.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
isLibrary: PropTypes.bool.isRequired,
|
||||
allowThumbnailUpload: PropTypes.bool.isRequired,
|
||||
@@ -144,4 +140,4 @@ export const mapStateToProps = (state) => ({
|
||||
export const mapDispatchToProps = {};
|
||||
|
||||
export const ThumbnailWidgetInternal = ThumbnailWidget; // For testing only
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ThumbnailWidget));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ThumbnailWidget);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect, useDispatch } from 'react-redux';
|
||||
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown, Icon, IconButton } from '@openedx/paragon';
|
||||
import { MoreHoriz } from '@openedx/paragon/icons';
|
||||
|
||||
@@ -90,4 +90,4 @@ export const mapDispatchToProps = {
|
||||
};
|
||||
|
||||
export const TranscriptActionMenuInternal = TranscriptActionMenu; // For testing only
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu);
|
||||
|
||||
@@ -6,5 +6,5 @@ export const blockTypes = StrictDict({
|
||||
problem: 'problem',
|
||||
// ADDED_EDITORS GO BELOW
|
||||
video_upload: 'video_upload',
|
||||
game: 'game',
|
||||
game: 'games',
|
||||
});
|
||||
|
||||
@@ -1,22 +1,136 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { StrictDict } from '../../../utils';
|
||||
|
||||
const generateId = () => `card-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
const initialState = {
|
||||
settings: {},
|
||||
// TODO fill in with mock state
|
||||
exampleValue: 'this is an example value from the redux state',
|
||||
settings: {
|
||||
shuffle: false,
|
||||
timer: false,
|
||||
},
|
||||
type: 'flashcards',
|
||||
list: [
|
||||
{
|
||||
id: generateId(),
|
||||
term: '',
|
||||
term_image: '',
|
||||
definition: '',
|
||||
definition_image: '',
|
||||
editorOpen: true,
|
||||
},
|
||||
],
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const game = createSlice({
|
||||
name: 'game',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateField: (state, { payload }) => ({
|
||||
// settings
|
||||
shuffleTrue: (state) => ({
|
||||
...state,
|
||||
...payload,
|
||||
settings: {
|
||||
...state.settings,
|
||||
shuffle: true,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
shuffleFalse: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
shuffle: false,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
timerTrue: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
timer: true,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
timerFalse: (state) => ({
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
timer: false,
|
||||
},
|
||||
isDirty: true,
|
||||
}),
|
||||
// type
|
||||
updateType: (state, { payload }) => ({
|
||||
...state,
|
||||
type: payload,
|
||||
isDirty: true,
|
||||
}),
|
||||
// list operations
|
||||
updateTerm: (state, { payload }) => {
|
||||
const { index, term } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, term } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateTermImage: (state, { payload }) => {
|
||||
const { index, termImage } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, term_image: termImage } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateDefinition: (state, { payload }) => {
|
||||
const { index, definition } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, definition } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
updateDefinitionImage: (state, { payload }) => {
|
||||
const { index, definitionImage } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map(
|
||||
(item, idx) => (idx === index ? { ...item, definition_image: definitionImage } : item),
|
||||
);
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
toggleOpen: (state, { payload }) => {
|
||||
const { index, isOpen } = payload;
|
||||
if (!state.list[index]) { return state; }
|
||||
const newList = state.list.map((item, idx) => (idx === index ? { ...item, editorOpen: !!isOpen } : item));
|
||||
return { ...state, list: newList, isDirty: true };
|
||||
},
|
||||
setList: (state, { payload }) => ({
|
||||
...state,
|
||||
list: payload,
|
||||
isDirty: true,
|
||||
}),
|
||||
addCard: (state) => ({
|
||||
...state,
|
||||
list: [
|
||||
...state.list,
|
||||
{
|
||||
id: generateId(),
|
||||
term: '',
|
||||
term_image: '',
|
||||
definition: '',
|
||||
definition_image: '',
|
||||
editorOpen: true,
|
||||
},
|
||||
],
|
||||
isDirty: true,
|
||||
}),
|
||||
removeCard: (state, { payload }) => {
|
||||
const { index } = payload;
|
||||
if (index < 0 || index >= state.list.length) { return state; }
|
||||
return {
|
||||
...state,
|
||||
list: state.list.filter((_, idx) => idx !== index),
|
||||
isDirty: true,
|
||||
};
|
||||
},
|
||||
setDirty: (state, { payload }) => ({
|
||||
...state,
|
||||
isDirty: payload,
|
||||
}),
|
||||
// TODO fill in reducers
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import * as module from './selectors';
|
||||
export const gameState = (state) => state.game;
|
||||
const mkSimpleSelector = (cb) => createSelector([module.gameState], cb);
|
||||
export const simpleSelectors = {
|
||||
exampleValue: mkSimpleSelector(gameData => gameData.exampleValue),
|
||||
settings: mkSimpleSelector(gameData => gameData.settings),
|
||||
type: mkSimpleSelector(gameData => gameData.type),
|
||||
list: mkSimpleSelector(gameData => gameData.list),
|
||||
isDirty: mkSimpleSelector(gameData => gameData.isDirty),
|
||||
completeState: mkSimpleSelector(gameData => gameData),
|
||||
// TODO fill in with selectors as needed
|
||||
};
|
||||
|
||||
@@ -164,7 +164,7 @@ export const removeTemporalLink = (response, asset, content, resolve) => {
|
||||
const imagePath = `/${response.data.asset.portableUrl}`;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => {
|
||||
const imageBS64 = reader.result.toString();
|
||||
const imageBS64 = /** @type {string} */(reader.result);
|
||||
const parsedContent = typeof content === 'string' ? content.replace(imageBS64, imagePath) : { ...content, olx: content.olx.replace(imageBS64, imagePath) };
|
||||
URL.revokeObjectURL(asset);
|
||||
resolve(parsedContent);
|
||||
|
||||
@@ -15,13 +15,9 @@ import 'jest-canvas-mock';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
|
||||
const PropTypes = jest.requireActual('prop-types');
|
||||
return {
|
||||
...i18n,
|
||||
useIntl: () => ({ formatMessage: mockFormatMessage }),
|
||||
intlShape: PropTypes.shape({
|
||||
formatMessage: PropTypes.func,
|
||||
}),
|
||||
defineMessages: m => m,
|
||||
getLocale: () => 'getLocale',
|
||||
FormattedDate: () => 'FormattedDate',
|
||||
|
||||
@@ -457,6 +457,9 @@ export const editorConfig = ({
|
||||
valid_elements: '*[*]',
|
||||
// FIXME: this is passing 'utf-8', which is not a valid entity_encoding value. It should be 'named' etc.
|
||||
entity_encoding: 'utf-8' as any,
|
||||
// Protect self-closing <script /> tags from being mangled,
|
||||
// to preserve backwards compatibility with content that relied on this behavior
|
||||
protect: [/<script[^>]*\/>/g],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import TextEditor from './containers/TextEditor';
|
||||
import VideoEditor from './containers/VideoEditor';
|
||||
import ProblemEditor from './containers/ProblemEditor';
|
||||
import VideoUploadEditor from './containers/VideoUploadEditor';
|
||||
import GameEditor from './containers/GameEditor';
|
||||
import GamesEditor from './containers/GameEditor';
|
||||
|
||||
// ADDED_EDITOR_IMPORTS GO HERE
|
||||
|
||||
@@ -14,7 +14,7 @@ const supportedEditors = {
|
||||
[blockTypes.problem]: ProblemEditor,
|
||||
[blockTypes.video_upload]: VideoUploadEditor,
|
||||
// ADDED_EDITORS GO BELOW
|
||||
[blockTypes.game]: GameEditor,
|
||||
[blockTypes.game]: GamesEditor,
|
||||
} as const;
|
||||
|
||||
export default supportedEditors;
|
||||
|
||||
@@ -39,7 +39,9 @@ describe('StrictDict', () => {
|
||||
expect(Object.values(dict)).toEqual([value1, value2]);
|
||||
});
|
||||
it('allows stringification', () => {
|
||||
expect(dict.toString()).toEqual(rawDict.toString());
|
||||
// Note: StrictDict stringifies as '[object Object]' which isn't stringification in any meaningful sense.
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
expect(dict.toString()).toEqual('[object Object]');
|
||||
expect({ ...dict }).toEqual({ ...rawDict });
|
||||
});
|
||||
it('allows entry listing', () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function startExportingCourse(courseId) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function getDownload(selectedRows, courseId) {
|
||||
throw new Error();
|
||||
}
|
||||
return await res.blob();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
|
||||
return null;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export async function getDownload(selectedRows, courseId) {
|
||||
const asset = selectedRows[0].original;
|
||||
try {
|
||||
saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function fetchAdditionalAssets(courseId, totalCount) {
|
||||
}));
|
||||
remainingAssetCount -= 50;
|
||||
page += 1;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
remainingAssetCount = 0;
|
||||
dispatch(updateErrors({ error: 'loading', message: 'Failed to load remaining files.' }));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.PARTIAL_FAILURE }));
|
||||
@@ -101,7 +101,7 @@ export function deleteAssetFile(courseId, id) {
|
||||
dispatch(deleteAssetSuccess({ assetId: id }));
|
||||
dispatch(removeModel({ modelType: 'assets', id }));
|
||||
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` }));
|
||||
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export function validateAssetFiles(courseId, files) {
|
||||
dispatch(updateDuplicateFiles({ files: conflicts }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
files.forEach(file => dispatch(updateErrors({ error: 'add', message: `Failed to validate ${file.name}.` })));
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export function updateAssetLock({ assetId, courseId, locked }) {
|
||||
},
|
||||
}));
|
||||
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const lockStatus = locked ? 'lock' : 'unlock';
|
||||
dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` }));
|
||||
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED }));
|
||||
@@ -205,7 +205,7 @@ export function getUsagePaths({ asset, courseId }) {
|
||||
},
|
||||
}));
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` }));
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED }));
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function getDownload(selectedRows, courseId) {
|
||||
const url = video.downloadLink;
|
||||
const name = video.displayName;
|
||||
return { url, name };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName || 'video'}.`);
|
||||
return null;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export async function getDownload(selectedRows, courseId) {
|
||||
} else {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
downloadErrors.push('Failed to download video.');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -160,7 +160,7 @@ export function deleteVideoFile(courseId, id) {
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'delete',
|
||||
@@ -184,7 +184,7 @@ export function markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }
|
||||
'Upload failed',
|
||||
'upload_failed',
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to send "Failed" upload status for ${edxVideoId} onbeforeunload`);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ const addVideoToEdxVal = async (courseId, file, dispatch) => {
|
||||
createUrlResponse.data,
|
||||
).files;
|
||||
return { uploadUrl, edxVideoId };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(failAddVideo({ fileName: file.name }));
|
||||
return {};
|
||||
}
|
||||
@@ -471,7 +471,7 @@ export function deleteVideoTranscript({
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'transcript',
|
||||
@@ -515,7 +515,7 @@ export function downloadVideoTranscript({
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'transcript',
|
||||
@@ -638,7 +638,7 @@ export function getUsagePaths({ video, courseId }) {
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'usageMetrics',
|
||||
@@ -682,7 +682,7 @@ export function fetchVideoDownload({ selectedRows, courseId }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'download',
|
||||
@@ -717,7 +717,7 @@ export function clearAutomatedTranscript({ courseId }) {
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'transcript',
|
||||
@@ -756,7 +756,7 @@ export function updateTranscriptCredentials({ courseId, data }) {
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(
|
||||
updateErrors({
|
||||
error: 'transcript',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { DeleteOutline } from '@openedx/paragon/icons';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { isEmpty } from 'lodash';
|
||||
import LanguageSelect from './LanguageSelect';
|
||||
import TranscriptMenu from './TranscriptMenu';
|
||||
@@ -20,9 +20,8 @@ const Transcript = ({
|
||||
transcript,
|
||||
previousSelection,
|
||||
handleTranscript,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle();
|
||||
const [newLanguage, setNewLanguage] = useState(transcript);
|
||||
const language = transcript;
|
||||
@@ -122,8 +121,6 @@ Transcript.propTypes = {
|
||||
transcript: PropTypes.string.isRequired,
|
||||
previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleTranscript: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Transcript);
|
||||
export default Transcript;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form,
|
||||
Icon,
|
||||
@@ -18,9 +18,8 @@ const ThreePlayMediaForm = ({
|
||||
data,
|
||||
setData,
|
||||
transcriptionPlan,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
if (hasTranscriptCredentials) {
|
||||
const selectedLanguages = data.preferredLanguages ? data.preferredLanguages : [];
|
||||
const turnaroundOptions = transcriptionPlan.turnaround;
|
||||
@@ -133,8 +132,6 @@ ThreePlayMediaForm.propTypes = {
|
||||
translations: PropTypes.shape({}),
|
||||
languages: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ThreePlayMediaForm);
|
||||
export default ThreePlayMediaForm;
|
||||
|
||||
6
src/frontend-platform.d.ts
vendored
6
src/frontend-platform.d.ts
vendored
@@ -3,12 +3,6 @@
|
||||
// (whichever comes first).
|
||||
|
||||
declare module '@edx/frontend-platform/i18n' {
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { injectIntl as _injectIntl } from 'react-intl';
|
||||
/** @deprecated Use useIntl() hook instead. */
|
||||
export const injectIntl: typeof _injectIntl;
|
||||
/** @deprecated Use useIntl() hook instead. */
|
||||
export const intlShape: any;
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
export {
|
||||
|
||||
@@ -59,7 +59,7 @@ const useClipboard = (canEdit: boolean = true) => {
|
||||
// If we don't have new data, invalidate the query
|
||||
queryClient.invalidateQueries({ queryKey: CLIPBOARD_QUERY_KEY });
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function fetchOrganizationsQuery() {
|
||||
const organizations = await getOrganizations();
|
||||
dispatch(fetchOrganizations(organizations));
|
||||
dispatch(updateLoadingStatuses({ organizationLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateLoadingStatuses({ organizationLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export function fetchCourseRerunQuery(courseId) {
|
||||
const courseRerun = await getCourseRerun(courseId);
|
||||
dispatch(updateCourseRerunData(courseRerun));
|
||||
dispatch(updateLoadingStatuses({ courseRerunLoadingStatus: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateLoadingStatuses({ courseRerunLoadingStatus: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -47,7 +47,7 @@ export function updateCreateOrRerunCourseQuery(courseData) {
|
||||
dispatch(updatePostErrors('errMsg' in response ? response : {}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const LoadingButton: React.FC<LoadingButtonProps> = ({
|
||||
setState('pending');
|
||||
try {
|
||||
await onClick(e);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
if (componentMounted.current) {
|
||||
|
||||
@@ -106,7 +106,7 @@ const useModalDropzone = ({
|
||||
onSavingStatus({ status: RequestStatus.SUCCESSFUL });
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
onSavingStatus({ status: RequestStatus.FAILED });
|
||||
} finally {
|
||||
setDisabledUploadBtn(true);
|
||||
|
||||
@@ -9,7 +9,7 @@ const handleResponseErrors = (error, dispatch, savingStatusFunction) => {
|
||||
} = error;
|
||||
const parsedData = JSON.parse(httpErrorResponseData);
|
||||
errorMessage = parsedData?.error || errorMessage;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
errorMessage = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const GradingSettings = ({ courseId }) => {
|
||||
} = useCourseSettings(courseId);
|
||||
const {
|
||||
mutate: updateGradingSettings,
|
||||
isLoading: savePending,
|
||||
isPending: savePending,
|
||||
isSuccess: savingStatus,
|
||||
isError: savingFailed,
|
||||
} = useGradingSettingUpdater(courseId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -25,7 +25,7 @@ const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GradingSettings intl={injectIntl} courseId={courseId} />
|
||||
<GradingSettings courseId={courseId} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
@@ -39,6 +39,9 @@ describe('<GradingSettings />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// jsdom doesn't implement scrollTo; mock to avoid noisy console errors.
|
||||
Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true });
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -99,6 +102,26 @@ describe('<GradingSettings />', () => {
|
||||
testSaving();
|
||||
});
|
||||
|
||||
it('should show success alert and hide save prompt after successful save', async () => {
|
||||
// Trigger change to show save prompt
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[2];
|
||||
fireEvent.change(segmentInput, { target: { value: 'PatchTest' } });
|
||||
// Click save and verify pending state appears
|
||||
const saveBtnInitial = screen.getByText(messages.buttonSaveText.defaultMessage);
|
||||
fireEvent.click(saveBtnInitial);
|
||||
expect(screen.getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
|
||||
// Wait for success alert to appear (mutation success)
|
||||
const successAlert = await screen.findByText(messages.alertSuccess.defaultMessage);
|
||||
expect(successAlert).toBeVisible();
|
||||
// Pending label should disappear and save prompt should be hidden (button removed)
|
||||
expect(screen.queryByText(messages.buttonSavingText.defaultMessage)).toBeNull();
|
||||
const saveAlert = screen.queryByTestId('grading-settings-save-alert');
|
||||
expect(saveAlert).toBeNull();
|
||||
// Ensure original save button text is no longer present because the prompt closed
|
||||
expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle being offline gracefully', async () => {
|
||||
setOnlineStatus(false);
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
@@ -19,7 +19,6 @@ const sortedGrades = [
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingScale
|
||||
intl={injectIntl}
|
||||
gradeCutoffs={gradeCutoffs}
|
||||
gradeLetters={gradeLetters}
|
||||
sortedGrades={sortedGrades}
|
||||
@@ -103,7 +102,6 @@ describe('<GradingScale />', () => {
|
||||
const { getAllByTestId } = render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingScale
|
||||
intl={injectIntl}
|
||||
gradeCutoffs={shortGradeCutoffs}
|
||||
gradeLetters={['A']}
|
||||
sortedGrades={shortSortedGrades}
|
||||
@@ -128,7 +126,6 @@ describe('<GradingScale />', () => {
|
||||
const { getAllByTestId } = render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingScale
|
||||
intl={injectIntl}
|
||||
gradeCutoffs={gradeCutoffs}
|
||||
gradeLetters={gradeLetters}
|
||||
sortedGrades={sortedGrades}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
initializeMocks, render, screen,
|
||||
} from '../../testUtils';
|
||||
@@ -11,7 +10,7 @@ describe('<GradingSidebar />', () => {
|
||||
});
|
||||
|
||||
it('renders sidebar text content correctly', async () => {
|
||||
render(<GradingSidebar intl={injectIntl} courseId="123" />);
|
||||
render(<GradingSidebar courseId="123" />);
|
||||
expect(await screen.findByText(messages.gradingSidebarTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.gradingSidebarAbout1.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.gradingSidebarAbout2.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type Container, useToggle } from '@openedx/paragon';
|
||||
import { useWaffleFlags } from '../data/apiHooks';
|
||||
import { SearchModal } from '../search-modal';
|
||||
import {
|
||||
useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
|
||||
useContentMenuItems, useLibrarySettingsMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
|
||||
} from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -20,6 +20,7 @@ interface HeaderProps {
|
||||
isHiddenMainMenu?: boolean,
|
||||
isLibrary?: boolean,
|
||||
containerProps?: ContainerPropsType,
|
||||
readOnly?: boolean,
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
@@ -30,6 +31,7 @@ const Header = ({
|
||||
isHiddenMainMenu = false,
|
||||
isLibrary = false,
|
||||
containerProps = {},
|
||||
readOnly = false,
|
||||
}: HeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const waffleFlags = useWaffleFlags();
|
||||
@@ -43,7 +45,8 @@ const Header = ({
|
||||
const settingMenuItems = useSettingMenuItems(contextId);
|
||||
const toolsMenuItems = useToolsMenuItems(contextId);
|
||||
const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId);
|
||||
const mainMenuDropdowns = !isLibrary ? [
|
||||
const libraryToolsSettingsItems = useLibrarySettingsMenuItems(contextId, readOnly);
|
||||
let mainMenuDropdowns = !isLibrary ? [
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.content']),
|
||||
@@ -65,6 +68,18 @@ const Header = ({
|
||||
items: libraryToolsMenuItems,
|
||||
}];
|
||||
|
||||
// Include settings menu only if user is allowed to see them.
|
||||
if (isLibrary && libraryToolsSettingsItems.length > 0) {
|
||||
mainMenuDropdowns = [
|
||||
{
|
||||
id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`,
|
||||
buttonTitle: intl.formatMessage(messages['header.links.settings']),
|
||||
items: libraryToolsSettingsItems,
|
||||
},
|
||||
...mainMenuDropdowns,
|
||||
];
|
||||
}
|
||||
|
||||
const getOutlineLink = () => {
|
||||
if (isLibrary) {
|
||||
return `/library/${contextId}`;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useSelector } from 'react-redux';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import messages from './messages';
|
||||
import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks';
|
||||
import {
|
||||
useContentMenuItems, useToolsMenuItems, useSettingMenuItems, useLibrarySettingsMenuItems, useLibraryToolsMenuItems,
|
||||
} from './hooks';
|
||||
import { mockWaffleFlags } from '../data/apiHooks.mock';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
@@ -28,7 +30,7 @@ jest.mock('react-redux', () => ({
|
||||
describe('header utils', () => {
|
||||
describe('getContentMenuItems', () => {
|
||||
it('when video upload page enabled should include Video Uploads option', () => {
|
||||
useSelector.mockReturnValue({
|
||||
jest.mocked(useSelector).mockReturnValue({
|
||||
librariesV2Enabled: false,
|
||||
});
|
||||
setConfig({
|
||||
@@ -39,7 +41,7 @@ describe('header utils', () => {
|
||||
expect(actualItems).toHaveLength(5);
|
||||
});
|
||||
it('when video upload page disabled should not include Video Uploads option', () => {
|
||||
useSelector.mockReturnValue({
|
||||
jest.mocked(useSelector).mockReturnValue({
|
||||
librariesV2Enabled: false,
|
||||
});
|
||||
setConfig({
|
||||
@@ -50,7 +52,7 @@ describe('header utils', () => {
|
||||
expect(actualItems).toHaveLength(4);
|
||||
});
|
||||
it('adds course libraries link to content menu when libraries v2 is enabled', () => {
|
||||
useSelector.mockReturnValue({
|
||||
jest.mocked(useSelector).mockReturnValue({
|
||||
librariesV2Enabled: true,
|
||||
});
|
||||
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
|
||||
@@ -60,7 +62,7 @@ describe('header utils', () => {
|
||||
|
||||
describe('getSettingsMenuitems', () => {
|
||||
beforeEach(() => {
|
||||
useSelector.mockReturnValue({
|
||||
jest.mocked(useSelector).mockReturnValue({
|
||||
canAccessAdvancedSettings: true,
|
||||
});
|
||||
});
|
||||
@@ -86,7 +88,7 @@ describe('header utils', () => {
|
||||
expect(actualItemsTitle).toContain('Advanced Settings');
|
||||
});
|
||||
it('when user has no access to advanced settings should not include advanced settings option', () => {
|
||||
useSelector.mockReturnValue({ canAccessAdvancedSettings: false });
|
||||
jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: false });
|
||||
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
|
||||
expect(actualItemsTitle).not.toContain('Advanced Settings');
|
||||
});
|
||||
@@ -137,4 +139,44 @@ describe('header utils', () => {
|
||||
expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLibrarySettingsMenuItems', () => {
|
||||
it('should contain team access url', () => {
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current;
|
||||
expect(items).toContainEqual({ title: 'Library Team', href: 'http://localhost/?sa=manage-team' });
|
||||
});
|
||||
it('should contain admin console url if set', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ADMIN_CONSOLE_URL: 'http://admin-console.com',
|
||||
});
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current;
|
||||
expect(items).toContainEqual({
|
||||
title: 'Library Team',
|
||||
href: 'http://admin-console.com/authz/libraries/library-123',
|
||||
});
|
||||
});
|
||||
it('should contain admin console url if set and readOnly is true', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ADMIN_CONSOLE_URL: 'http://admin-console.com',
|
||||
});
|
||||
const items = renderHook(() => useLibrarySettingsMenuItems('library-123', true)).result.current;
|
||||
expect(items).toContainEqual({
|
||||
title: 'Library Team',
|
||||
href: 'http://admin-console.com/authz/libraries/library-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLibraryToolsMenuItems', () => {
|
||||
it('should contain backup and import url', () => {
|
||||
const items = renderHook(() => useLibraryToolsMenuItems('course-123')).result.current;
|
||||
expect(items).toContainEqual({
|
||||
href: '/library/course-123/backup',
|
||||
title: 'Backup to local archive',
|
||||
});
|
||||
expect(items).toContainEqual({ href: '/library/course-123/import', title: 'Import' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,15 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Badge } from '@openedx/paragon';
|
||||
|
||||
import { getPagePath } from '../utils';
|
||||
import { useWaffleFlags } from '../data/apiHooks';
|
||||
import { getStudioHomeData } from '../studio-home/data/selectors';
|
||||
import { getPagePath } from '@src/utils';
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { getStudioHomeData } from '@src/studio-home/data/selectors';
|
||||
import courseOptimizerMessages from '@src/optimizer-page/messages';
|
||||
import { SidebarActions } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { LibQueryParamKeys } from '@src/library-authoring/routes';
|
||||
import messages from './messages';
|
||||
import courseOptimizerMessages from '../optimizer-page/messages';
|
||||
|
||||
export const useContentMenuItems = courseId => {
|
||||
export const useContentMenuItems = (courseId: string) => {
|
||||
const intl = useIntl();
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const waffleFlags = useWaffleFlags();
|
||||
@@ -50,7 +52,7 @@ export const useContentMenuItems = courseId => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const useSettingMenuItems = courseId => {
|
||||
export const useSettingMenuItems = (courseId: string) => {
|
||||
const intl = useIntl();
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const { canAccessAdvancedSettings } = useSelector(getStudioHomeData);
|
||||
@@ -89,7 +91,7 @@ export const useSettingMenuItems = courseId => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const useToolsMenuItems = (courseId) => {
|
||||
export const useToolsMenuItems = (courseId: string) => {
|
||||
const intl = useIntl();
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const waffleFlags = useWaffleFlags();
|
||||
@@ -127,7 +129,7 @@ export const useToolsMenuItems = (courseId) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const useLibraryToolsMenuItems = itemId => {
|
||||
export const useLibraryToolsMenuItems = (itemId: string) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const items = [
|
||||
@@ -135,7 +137,49 @@ export const useLibraryToolsMenuItems = itemId => {
|
||||
href: `/library/${itemId}/backup`,
|
||||
title: intl.formatMessage(messages['header.links.exportLibrary']),
|
||||
},
|
||||
{
|
||||
href: `/library/${itemId}/import`,
|
||||
title: intl.formatMessage(messages['header.links.lib.import']),
|
||||
},
|
||||
];
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const useLibrarySettingsMenuItems = (itemId: string, readOnly: boolean) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const openTeamAccessModalUrl = () => {
|
||||
const adminConsoleUrl = getConfig().ADMIN_CONSOLE_URL;
|
||||
// always show link to admin console MFE if it is being used
|
||||
const shouldShowAdminConsoleLink = !!adminConsoleUrl;
|
||||
|
||||
// if the admin console MFE isn't being used, show team modal button for non–read-only users
|
||||
const shouldShowTeamModalButton = !adminConsoleUrl && !readOnly;
|
||||
if (shouldShowTeamModalButton) {
|
||||
if (!window.location.href) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
// Set ?sa=manage-team in url which in turn opens team access modal
|
||||
url.searchParams.set(LibQueryParamKeys.SidebarActions, SidebarActions.ManageTeam);
|
||||
return url.toString();
|
||||
}
|
||||
if (shouldShowAdminConsoleLink) {
|
||||
return `${adminConsoleUrl}/authz/libraries/${itemId}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const items: { title: string; href: string }[] = [];
|
||||
|
||||
const teamAccessUrl = openTeamAccessModalUrl();
|
||||
if (teamAccessUrl) {
|
||||
items.push({
|
||||
title: intl.formatMessage(messages['header.menu.teamAccess']),
|
||||
href: teamAccessUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
@@ -96,6 +96,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Import',
|
||||
description: 'Link to Studio Import page',
|
||||
},
|
||||
'header.links.lib.import': {
|
||||
id: 'header.links.lib.import',
|
||||
defaultMessage: 'Import',
|
||||
description: 'Link to Course Import page in library',
|
||||
},
|
||||
'header.links.exportCourse': {
|
||||
id: 'header.links.exportCourse',
|
||||
defaultMessage: 'Export Course',
|
||||
@@ -106,6 +111,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Backup to local archive',
|
||||
description: 'Link to Studio Backup Library page',
|
||||
},
|
||||
'header.menu.teamAccess': {
|
||||
id: 'header.links.teamAccess',
|
||||
defaultMessage: 'Library Team',
|
||||
description: 'Menu item to open team access popup',
|
||||
},
|
||||
'header.links.optimizer': {
|
||||
id: 'header.links.optimizer',
|
||||
defaultMessage: 'Course Optimizer',
|
||||
@@ -14,7 +14,7 @@ export function fetchHelpUrls() {
|
||||
|
||||
dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.FAILED }));
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
|
||||
@@ -14,7 +14,7 @@ const courseId = '123';
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<FileSection intl={injectIntl} courseId={courseId} />
|
||||
<FileSection courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -173,6 +173,7 @@ initialize({
|
||||
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
|
||||
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false',
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true',
|
||||
|
||||
@@ -74,13 +74,13 @@ export const ConfirmationView = ({
|
||||
{...messages.confirmationViewAlert}
|
||||
values={{
|
||||
count: legacyLibraries.length,
|
||||
libraryName: destination.title,
|
||||
b: BoldText,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
{legacyLibraries.map((legacyLib) => (
|
||||
<ConfirmationCard
|
||||
key={legacyLib.libraryKey}
|
||||
legacyLib={legacyLib}
|
||||
destinationName={destination.title}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@src/testUtils';
|
||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||
import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
|
||||
@@ -184,7 +185,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
backButton.click();
|
||||
@@ -210,7 +211,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
|
||||
// The next button is disabled
|
||||
expect(nextButton).toBeDisabled();
|
||||
@@ -224,27 +225,31 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
});
|
||||
|
||||
it('should back to select library destination', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
|
||||
expect(await screen.findByText('MBA')).toBeInTheDocument();
|
||||
|
||||
const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' });
|
||||
legacyLibrary.click();
|
||||
await user.click(legacyLibrary);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
const nextButton = await screen.findByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
expect(await screen.findByText(/these 1 legacy library will be migrated to/i)).toBeInTheDocument();
|
||||
await user.click(nextButton);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the legacy library you selected will be migrated to the Content Library you select/i,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
backButton.click();
|
||||
await user.click(backButton);
|
||||
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
// The selected v2 library remains checked
|
||||
@@ -269,7 +274,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
|
||||
const createButton = await screen.findByRole('button', { name: /create new library/i });
|
||||
expect(createButton).toBeInTheDocument();
|
||||
@@ -336,18 +341,21 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
legacyLibrary3.click();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of ConfirmationView
|
||||
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the 3 legacy libraries you selected will be migrated to the Content Library you select/i,
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||
@@ -390,18 +398,22 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
legacyLibrary3.click();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of ConfirmationView
|
||||
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the 3 legacy libraries you selected will be migrated to the Content Library you select/i,
|
||||
{ exact: false },
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||
|
||||
@@ -98,7 +98,7 @@ export const LegacyLibMigrationPage = () => {
|
||||
count: legacyLibraries.length,
|
||||
}));
|
||||
navigate(`/library/${destinationLibrary.id}?migration_task=${migrationTask.uuid}`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.migrationFailed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Stack } from '@openedx/paragon';
|
||||
import { Question } from '@openedx/paragon/icons';
|
||||
import { Div, Paragraph } from '@src/utils';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const SingleLineBreak = (chunk: string[]) => <div>{chunk}</div>;
|
||||
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;
|
||||
|
||||
export const LegacyMigrationHelpSidebar = () => (
|
||||
<div className="legacy-libraries-migration-help bg-white pt-3 mt-1">
|
||||
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
|
||||
@@ -42,7 +40,7 @@ export const LegacyMigrationHelpSidebar = () => (
|
||||
<span className="x-small">
|
||||
<FormattedMessage
|
||||
{...messages.helpAndSupportThirdQuestionBody}
|
||||
values={{ div: SingleLineBreak, p: Paragraph }}
|
||||
values={{ div: Div, p: Paragraph }}
|
||||
/>
|
||||
</span>
|
||||
</Stack>
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
|
||||
.card-item {
|
||||
margin: 0 0 16px !important;
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,17 +64,19 @@ const messages = defineMessages({
|
||||
selectDestinationAlert: {
|
||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||
defaultMessage: 'All content from the'
|
||||
+ ' {count, plural, one {{count} legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to this new library, organized into collections. Any legacy libraries that are used in'
|
||||
+ ' problem banks will maintain their link with migrated content the first time they are migrated.',
|
||||
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to this new library, organized into collections. Legacy library content used in courses will'
|
||||
+ ' continue to work as-is. To receive any future changes to migrated content, you must update these'
|
||||
+ ' references within your course.',
|
||||
description: 'Alert text in the select destination step of the legacy libraries migration page.',
|
||||
},
|
||||
confirmationViewAlert: {
|
||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||
defaultMessage: 'These {count, plural, one {{count} legacy library} other {{count} legacy libraries}}'
|
||||
+ ' will be migrated to <b>{libraryName}</b> and organized as collections. Legacy library content used'
|
||||
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
|
||||
+ ' you must update these references within your course.',
|
||||
defaultMessage: 'All content from the'
|
||||
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to the Content Library you select, organized into collections. Legacy library content used in courses will'
|
||||
+ ' continue to work as-is. To receive any future changes to migrated content, you must update these'
|
||||
+ ' references within your course.',
|
||||
description: 'Alert text in the confirmation step of the legacy libraries migration page.',
|
||||
},
|
||||
previouslyMigratedAlert: {
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.mockReset();
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const requestData = JSON.parse((req.body ?? '') as string);
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
// because otherwise Instantsearch will update the UI and change the query,
|
||||
@@ -314,21 +314,21 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Manage Access" button in Library Info that opens the Library Team modal', async () => {
|
||||
it('should show Library Team button in Library Info that opens the Library Team modal', async () => {
|
||||
await renderLibraryPage();
|
||||
const manageAccess = screen.getByRole('button', { name: /manage access/i });
|
||||
const manageAccess = await screen.findByRole('button', { name: /Library Team/i });
|
||||
|
||||
expect(manageAccess).not.toBeDisabled();
|
||||
fireEvent.click(manageAccess);
|
||||
|
||||
expect(await screen.findByText('Library Team')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: 'Library Team' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "Manage Access" button in Library Info to users who cannot edit the library', async () => {
|
||||
it('should not show "Library Team" button in Library Info to users who cannot edit the library', async () => {
|
||||
const libraryId = mockContentLibrary.libraryIdReadOnly;
|
||||
render(<LibraryLayout />, { path, params: { libraryId } });
|
||||
|
||||
const manageAccess = screen.queryByRole('button', { name: /manage access/i });
|
||||
const manageAccess = screen.queryByRole('button', { name: /Library Team/i });
|
||||
expect(manageAccess).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ const LibraryAuthoringPage = ({
|
||||
isLoadingLibraryData,
|
||||
showOnlyPublished,
|
||||
extraFilter: contextExtraFilter,
|
||||
readOnly,
|
||||
} = useLibraryContext();
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
@@ -342,6 +343,7 @@ const LibraryAuthoringPage = ({
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
readOnly={readOnly}
|
||||
isLibrary
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { LibraryBackupPage } from '@src/library-authoring/backup-restore';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { LibraryBackupPage } from './backup-restore';
|
||||
import LibraryCollectionPage from './collections/LibraryCollectionPage';
|
||||
import { LibraryProvider } from './common/context/LibraryContext';
|
||||
import { SidebarProvider } from './common/context/SidebarContext';
|
||||
@@ -15,9 +15,12 @@ import { ComponentPicker } from './component-picker';
|
||||
import { ComponentEditorModal } from './components/ComponentEditorModal';
|
||||
import { CreateCollectionModal } from './create-collection';
|
||||
import { CreateContainerModal } from './create-container';
|
||||
import { CourseImportHomePage } from './import-course';
|
||||
import { ROUTES } from './routes';
|
||||
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
|
||||
import { LibraryUnitPage } from './units';
|
||||
import { LibraryTeamModal } from './library-team';
|
||||
import { ImportStepperPage } from './import-course/stepper/ImportStepperPage';
|
||||
|
||||
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const {
|
||||
@@ -48,6 +51,7 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
|
||||
<CreateCollectionModal />
|
||||
<CreateContainerModal />
|
||||
<ComponentEditorModal />
|
||||
<LibraryTeamModal />
|
||||
</SidebarProvider>
|
||||
</LibraryProvider>
|
||||
);
|
||||
@@ -90,6 +94,14 @@ const LibraryLayout = () => (
|
||||
path={ROUTES.BACKUP}
|
||||
Component={LibraryBackupPage}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.IMPORT}
|
||||
Component={CourseImportHomePage}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.IMPORT_COURSE}
|
||||
Component={ImportStepperPage}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ const renderWithContainer = (containerId: string, containerType: 'unit' | 'secti
|
||||
};
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let mockShowToast: (message: string, action?: ToastActionData) => void;
|
||||
|
||||
describe('<AddContent />', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -297,7 +297,7 @@ export const parseErrorMsg = (
|
||||
if (detail) {
|
||||
return intl.formatMessage(detailedMessage, { detail });
|
||||
}
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return intl.formatMessage(defaultMessage);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useContentLibrary } from '@src/library-authoring/data/apiHooks';
|
||||
|
||||
export const LibraryBackupPage = () => {
|
||||
const intl = useIntl();
|
||||
const { libraryId } = useLibraryContext();
|
||||
const { libraryId, readOnly } = useLibraryContext();
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
const [isMutationInProgress, setIsMutationInProgress] = useState<boolean>(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -49,7 +49,7 @@ export const LibraryBackupPage = () => {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Fallback to window.location.href if the above fails
|
||||
window.location.href = url;
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export const LibraryBackupPage = () => {
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
readOnly={readOnly}
|
||||
isLibrary
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
|
||||
@@ -32,7 +32,7 @@ const CollectionInfoHeader = () => {
|
||||
title: newTitle,
|
||||
});
|
||||
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('<LibraryCollectionPage />', () => {
|
||||
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const requestData = JSON.parse((req.body ?? '') as string);
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
const mockResultCopy = cloneDeep(mockResult);
|
||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||
|
||||
@@ -107,6 +107,7 @@ const LibraryCollectionPage = () => {
|
||||
showOnlyPublished,
|
||||
extraFilter: contextExtraFilter,
|
||||
setCollectionId,
|
||||
readOnly,
|
||||
} = useLibraryContext();
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
@@ -194,6 +195,7 @@ const LibraryCollectionPage = () => {
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
readOnly={readOnly}
|
||||
isLibrary
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useStateWithUrlSearchParam } from '../../../hooks';
|
||||
import { useStateWithUrlSearchParam } from '@src/hooks';
|
||||
import { LibQueryParamKeys, useLibraryRoutes } from '@src/library-authoring/routes';
|
||||
import { useComponentPickerContext } from './ComponentPickerContext';
|
||||
import { useLibraryContext } from './LibraryContext';
|
||||
import { useLibraryRoutes } from '../../routes';
|
||||
|
||||
export enum SidebarBodyItemId {
|
||||
AddContent = 'add-content',
|
||||
@@ -72,6 +72,7 @@ export interface DefaultTabs {
|
||||
export interface SidebarItemInfo {
|
||||
type: SidebarBodyItemId;
|
||||
id: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export enum SidebarActions {
|
||||
@@ -88,7 +89,7 @@ export type SidebarContextData = {
|
||||
openCollectionInfoSidebar: (collectionId: string) => void;
|
||||
openComponentInfoSidebar: (usageKey: string) => void;
|
||||
openContainerInfoSidebar: (usageKey: string) => void;
|
||||
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId) => void;
|
||||
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId, index?: number) => void;
|
||||
sidebarItemInfo?: SidebarItemInfo;
|
||||
sidebarAction: SidebarActions;
|
||||
setSidebarAction: (action: SidebarActions) => void;
|
||||
@@ -129,14 +130,14 @@ export const SidebarProvider = ({
|
||||
|
||||
const [sidebarTab, setSidebarTab] = useStateWithUrlSearchParam<SidebarInfoTab>(
|
||||
defaultTab.component,
|
||||
'st',
|
||||
LibQueryParamKeys.SidebarTab,
|
||||
(value: string) => toSidebarInfoTab(value),
|
||||
(value: SidebarInfoTab) => value.toString(),
|
||||
);
|
||||
|
||||
const [sidebarAction, setSidebarAction] = useStateWithUrlSearchParam<SidebarActions>(
|
||||
SidebarActions.None,
|
||||
'sa',
|
||||
LibQueryParamKeys.SidebarActions,
|
||||
(value: string) => Object.values(SidebarActions).find((enumValue) => value === enumValue),
|
||||
(value: SidebarActions) => value.toString(),
|
||||
);
|
||||
@@ -154,35 +155,38 @@ export const SidebarProvider = ({
|
||||
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
|
||||
}, []);
|
||||
|
||||
const openComponentInfoSidebar = useCallback((usageKey: string) => {
|
||||
const openComponentInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: usageKey,
|
||||
type: SidebarBodyItemId.ComponentInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
|
||||
const openCollectionInfoSidebar = useCallback((newCollectionId: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: newCollectionId,
|
||||
type: SidebarBodyItemId.CollectionInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openContainerInfoSidebar = useCallback((usageKey: string) => {
|
||||
const openContainerInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: usageKey,
|
||||
type: SidebarBodyItemId.ContainerInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId) => {
|
||||
navigateTo({ selectedItemId });
|
||||
setSidebarItemInfo({ id: selectedItemId, type });
|
||||
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId, index?: number) => {
|
||||
navigateTo({ selectedItemId, index });
|
||||
setSidebarItemInfo({ id: selectedItemId, type, index });
|
||||
}, [navigateTo, setSidebarItemInfo]);
|
||||
|
||||
// Set the initial sidebar state based on the URL parameters and context.
|
||||
const { selectedItemId } = useParams();
|
||||
const { selectedItemId, index: indexParam } = useParams();
|
||||
const { collectionId, containerId } = useLibraryContext();
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
|
||||
@@ -198,12 +202,15 @@ export const SidebarProvider = ({
|
||||
|
||||
// Handle selected item id changes
|
||||
if (selectedItemId) {
|
||||
// if a item is selected that means we have list of items displayed
|
||||
// which means we can get the index from url and set it.
|
||||
const indexNumber = indexParam ? Number(indexParam) : undefined;
|
||||
if (selectedItemId.startsWith('lct:')) {
|
||||
openContainerInfoSidebar(selectedItemId);
|
||||
openContainerInfoSidebar(selectedItemId, indexNumber);
|
||||
} else if (selectedItemId.startsWith('lb:')) {
|
||||
openComponentInfoSidebar(selectedItemId);
|
||||
openComponentInfoSidebar(selectedItemId, indexNumber);
|
||||
} else {
|
||||
openCollectionInfoSidebar(selectedItemId);
|
||||
openCollectionInfoSidebar(selectedItemId, indexNumber);
|
||||
}
|
||||
} else if (collectionId) {
|
||||
openCollectionInfoSidebar(collectionId);
|
||||
|
||||
@@ -111,6 +111,8 @@ const ComponentActions = ({
|
||||
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
|
||||
const canEdit = canEditComponent(componentId);
|
||||
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
if (isPublisherOpen) {
|
||||
return (
|
||||
<ComponentPublisher
|
||||
@@ -141,7 +143,7 @@ const ComponentActions = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ComponentMenu usageKey={componentId} />
|
||||
<ComponentMenu usageKey={componentId} index={sidebarItemInfo?.index} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ const ComponentInfoHeader = () => {
|
||||
},
|
||||
});
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ComponentPublisher = ({
|
||||
try {
|
||||
await publishComponent.mutateAsync();
|
||||
showToast(intl.formatMessage(messages.publishSuccessMsg));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.publishErrorMsg));
|
||||
}
|
||||
handleClose();
|
||||
|
||||
@@ -63,7 +63,7 @@ const CollectionMenu = ({ hit } : CollectionMenuProps) => {
|
||||
onClick: restoreCollection,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.deleteCollectionFailed));
|
||||
} finally {
|
||||
closeDeleteModal();
|
||||
|
||||
@@ -35,7 +35,7 @@ const renderArgs = {
|
||||
),
|
||||
};
|
||||
|
||||
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
|
||||
let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: any; };
|
||||
|
||||
describe('<ComponentDeleter />', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -36,7 +36,7 @@ const ComponentDeleter = ({ usageKey, close }: Props) => {
|
||||
try {
|
||||
await restoreComponentMutation.mutateAsync({ usageKey });
|
||||
showToast(intl.formatMessage(messages.undoDeleteComponentToastSuccess));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.undoDeleteComponentToastFailed));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -12,18 +12,23 @@ import { useClipboard } from '@src/generic/clipboard';
|
||||
import { getBlockType } from '@src/generic/key-utils';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
|
||||
import containerMessages from '@src/library-authoring/containers/messages';
|
||||
import { useLibraryRoutes } from '@src/library-authoring/routes';
|
||||
import { useRunOnNextRender } from '@src/utils';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import ComponentRemover from './ComponentRemover';
|
||||
import messages from './messages';
|
||||
import containerMessages from '../containers/messages';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export const ComponentMenu = ({ usageKey, index }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId,
|
||||
@@ -135,6 +140,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
{isRemoveModalOpen && (
|
||||
<ComponentRemover
|
||||
usageKey={usageKey}
|
||||
index={index}
|
||||
close={closeRemoveModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,31 +4,38 @@ import { Warning } from '@openedx/paragon/icons';
|
||||
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import {
|
||||
useContainer,
|
||||
useRemoveContainerChildren,
|
||||
useAddItemsToContainer,
|
||||
useLibraryBlockMetadata,
|
||||
} from '../data/apiHooks';
|
||||
useContainerChildren,
|
||||
useUpdateContainerChildren,
|
||||
} from '@src/library-authoring/data/apiHooks';
|
||||
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
index?: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
const ComponentRemover = ({ usageKey, index, close }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const { containerId } = useLibraryContext();
|
||||
const { containerId, showOnlyPublished } = useLibraryContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
||||
const addItemToContainerMutation = useAddItemsToContainer(containerId);
|
||||
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
|
||||
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
|
||||
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
|
||||
// Use update api for children if duplicates are present to avoid removing all instances of the child
|
||||
const { data: children } = useContainerChildren<LibraryBlockMetadata>(containerId, showOnlyPublished);
|
||||
const childrenUsageIds = children?.map((child) => child.id);
|
||||
const hasDuplicates = (childrenUsageIds?.filter((child) => child === usageKey).length || 0) > 1;
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending || isPendingParentContainer) {
|
||||
@@ -36,28 +43,62 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const restoreComponent = () => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!childrenUsageIds) {
|
||||
return;
|
||||
}
|
||||
updateContainerChildrenMutation.mutateAsync(childrenUsageIds).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
|
||||
const showSuccessToast = () => {
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const showFailureToast = () => showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
|
||||
const removeFromContainer = () => {
|
||||
const restoreComponent = () => {
|
||||
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarItemInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
showSuccessToast();
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
showFailureToast();
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const excludeOneInstance = () => {
|
||||
if (!childrenUsageIds || typeof index === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== usageKey || idx !== index);
|
||||
updateContainerChildrenMutation.mutateAsync(updatedKeys).then(() => {
|
||||
// istanbul ignore if
|
||||
if (sidebarItemInfo?.id === usageKey && sidebarItemInfo?.index === index) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
// Already tested as part of removeFromContainer
|
||||
// istanbul ignore next
|
||||
showSuccessToast();
|
||||
}).catch(() => {
|
||||
// Already tested as part of removeFromContainer
|
||||
// istanbul ignore next
|
||||
showFailureToast();
|
||||
});
|
||||
|
||||
close();
|
||||
@@ -76,7 +117,7 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
title={intl.formatMessage(messages.removeComponentWarningTitle)}
|
||||
icon={Warning}
|
||||
description={removeText}
|
||||
onDeleteSubmit={removeFromContainer}
|
||||
onDeleteSubmit={hasDuplicates ? excludeOneInstance : removeFromContainer}
|
||||
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
|
||||
@@ -17,23 +17,24 @@ import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useRunOnNextRender } from '@src/utils';
|
||||
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
|
||||
import { useLibraryRoutes } from '@src/library-authoring/routes';
|
||||
import BaseCard from '@src/library-authoring/components/BaseCard';
|
||||
import AddComponentWidget from '@src/library-authoring/components/AddComponentWidget';
|
||||
import messages from './messages';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
import ContainerRemover from './ContainerRemover';
|
||||
import BaseCard from '../components/BaseCard';
|
||||
import AddComponentWidget from '../components/AddComponentWidget';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
containerKey: string;
|
||||
displayName: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
|
||||
export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId, collectionId, containerId } = useLibraryContext();
|
||||
const {
|
||||
@@ -144,6 +145,7 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
|
||||
close={cancelRemove}
|
||||
containerKey={containerKey}
|
||||
displayName={displayName}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -51,7 +51,7 @@ const renderArgs = {
|
||||
),
|
||||
};
|
||||
|
||||
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
|
||||
let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: any; };
|
||||
|
||||
[
|
||||
'unit' as const,
|
||||
|
||||
@@ -146,7 +146,7 @@ const ContainerDeleter = ({
|
||||
try {
|
||||
await restoreContainerMutation.mutateAsync();
|
||||
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messageMap.undoDeleteError));
|
||||
}
|
||||
}, [messageMap]);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ContainerEditableTitle = ({ containerId, textClassName }: EditableT
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user