Compare commits

...

24 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
9610f0791f feat: add games xblock editor
With this Commit, games xblock editor is in place now!
- copy code from https://github.com/openedx-unsupported/frontend-lib-content-components/pull/371/files to authoring MFE
  - It includes refactoring in .scss files, useIntl, replacing deprecated dependencies, fixing reducers, fixed cancel/close editor button, fix dragging the cards, edit some styles and also removed duplicate styling etc.
2025-11-17 14:08:16 +05:00
edX requirements bot
0f58329cb4 chore: update browserslist DB (#2653)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-17 00:22:55 +00:00
Chris Chávez
54cfbeb756 feat: import course in library stepper [FC-0112] (#2567)
- Implemented the course import stepper described in https://github.com/openedx/frontend-app-authoring/issues/2524
- Adds the new `ENABLE_COURSE_IMPORT_IN_LIBRARY` flag
2025-11-14 13:07:00 -05:00
Muhammad Anas
7cf01de84c fix: grading settings save button stuck in pending state (#2614) 2025-11-14 08:54:57 -05:00
Navin Karkera
a1abd43a11 refactor: rename team access navbar and sidebar (#2644) 2025-11-13 20:27:06 -05:00
Muhammad Anas
8f06263e27 fix: unit button active state (#2617) 2025-11-13 12:08:18 -05:00
Braden MacDonald
e10ab270dd chore: don't name unused errors in catch expressions (#2591) 2025-11-12 18:11:22 -05:00
Braden MacDonald
a5d65abea2 chore: fix no-base-to-string (#2597) 2025-11-12 13:34:00 -05:00
Braden MacDonald
5ec00236cb chore: fix unnecessary 'undefined' types (#2589) 2025-11-12 13:26:01 -05:00
Devasia Joseph
2530b01b82 fix: restrict single select questions to one correct answer (#2618) 2025-11-12 17:43:42 +05:00
renovate[bot]
13c51ce5a8 chore(deps): update dependency @tanstack/react-query to v5.90.7 (#2611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:49:02 -05:00
edX requirements bot
b2cfafc00e chore: update browserslist DB (#2610)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-10 00:22:49 +00:00
Rômulo Penido
6d619b9c40 feat: add course import page [FC-0112] (#2580)
Adds the Library Import Home, which lists course migrations to the library
2025-11-07 16:32:11 -05:00
Muhammad Arslan
6afe6095a5 fix: self-closing script tag fixed for TinyMceEditor (#2510)
(not really valid HTML but preserves backwards compatibility)
2025-11-06 09:38:04 -08:00
Muhammad Arslan
1b357cb2b6 fix: broken Course Overview editor on Schedule & Details page (#2599) 2025-11-05 15:15:31 -08:00
Chris Chávez
2de987b254 style: Update some texts in legacy libraries migration flow (#2601) 2025-11-05 18:13:32 -05:00
renovate[bot]
4299bf16b4 chore(deps): update dependency @openedx/paragon to v23.16.0 (#2583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 15:07:54 -08:00
Navin Karkera
5cda284cdb fix: pass readOnly flag to Header in library authoring page (#2598) 2025-11-04 17:56:37 -05:00
Navin Karkera
436ac3155d feat: nav dropdowns in library authoring view (#2556)
Updates navbar in library authoring page to include `Team Access` and `Import` menu options. Clicking on `Team Access` button opens Team management modal.

As per this new PR: https://github.com/openedx/frontend-app-authoring/pull/2570, if admin console url is set, it should be used instead of team access modal. So updated this PR accordingly.
2025-11-03 17:06:50 -05:00
renovate[bot]
86a7e06a3c chore(deps): update dependency @tanstack/react-query to v5.90.6 (#2595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 09:31:38 -08:00
Navin Karkera
bd82c1d33d fix: publish status of container on adding new children (#2587)
Updates publish status of container when adding new child components to a unit or other containers.
2025-11-03 10:01:30 -05:00
Navin Karkera
75ae9d549c feat: handle duplicate children in container pages [FC-0112] (#2584)
If we have duplicate container or component in parent page in library, clicking on one of them selects both and removing any one from the parent blocks removes all instances.
This PR handles duplicates by including index/order_number of each child component in the url and using it to exclude a single instance and update parent structure.
2025-11-03 09:59:37 -05:00
edX requirements bot
cec074e6d4 chore: update browserslist DB (#2593)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-03 00:23:01 +00:00
Braden MacDonald
36c9eba66d refactor: remove remaining injectIntl(), ban it using eslint (#2585)
This finished the removal of `injectIntl` from this codebase, and configures a new eslint rule to ban it completely.
2025-10-30 20:10:52 -05:00
166 changed files with 3396 additions and 823 deletions

1
.env
View File

@@ -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

View File

@@ -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=''

View File

@@ -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"

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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' } });

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

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

View File

@@ -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', () => ({

View File

@@ -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();

View File

@@ -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

View File

@@ -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,
})
);

View File

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

View File

@@ -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;

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

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

View File

@@ -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(() => {

View File

@@ -177,7 +177,7 @@ export const PreviewLibraryXBlockChanges = ({
overrideCustomizations,
});
postChange(accept);
} catch (e) {
} catch {
showToast(intl.formatMessage(failureMsg));
} finally {
closeModal();

View File

@@ -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 },

View File

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

View File

@@ -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&apos;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);

View 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;
}

View 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;

View File

@@ -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 }) => {

View File

@@ -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);

View File

@@ -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)) {

View File

@@ -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';

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -6,5 +6,5 @@ export const blockTypes = StrictDict({
problem: 'problem',
// ADDED_EDITORS GO BELOW
video_upload: 'video_upload',
game: 'game',
game: 'games',
});

View File

@@ -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
},
});

View File

@@ -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
};

View File

@@ -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);

View File

@@ -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',

View File

@@ -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],
},
};
};

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -59,7 +59,7 @@ const LoadingButton: React.FC<LoadingButtonProps> = ({
setState('pending');
try {
await onClick(e);
} catch (err) {
} catch {
// Do nothing
} finally {
if (componentMounted.current) {

View File

@@ -106,7 +106,7 @@ const useModalDropzone = ({
onSavingStatus({ status: RequestStatus.SUCCESSFUL });
onClose();
}
} catch (error) {
} catch {
onSavingStatus({ status: RequestStatus.FAILED });
} finally {
setDisabledUploadBtn(true);

View File

@@ -9,7 +9,7 @@ const handleResponseErrors = (error, dispatch, savingStatusFunction) => {
} = error;
const parsedData = JSON.parse(httpErrorResponseData);
errorMessage = parsedData?.error || errorMessage;
} catch (err) {
} catch {
errorMessage = '';
}

View File

@@ -42,7 +42,7 @@ const GradingSettings = ({ courseId }) => {
} = useCourseSettings(courseId);
const {
mutate: updateGradingSettings,
isLoading: savePending,
isPending: savePending,
isSuccess: savingStatus,
isError: savingFailed,
} = useGradingSettingUpdater(courseId);

View File

@@ -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');

View File

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

View File

@@ -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();

View File

@@ -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}`;

View File

@@ -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' });
});
});
});

View File

@@ -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 nonread-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;
};

View File

@@ -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',

View File

@@ -14,7 +14,7 @@ export function fetchHelpUrls() {
dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
} catch {
dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.FAILED }));
return false;

View File

@@ -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>
);

View File

@@ -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',

View File

@@ -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}
/>

View File

@@ -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();

View File

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

View File

@@ -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>

View File

@@ -13,10 +13,6 @@
.card-item {
margin: 0 0 16px !important;
&.selected {
box-shadow: 0 0 0 2px var(--pgn-color-primary-700);
}
}
}

View File

@@ -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: {

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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(() => {

View File

@@ -297,7 +297,7 @@ export const parseErrorMsg = (
if (detail) {
return intl.formatMessage(detailedMessage, { detail });
}
} catch (_err) {
} catch {
// ignore
}
return intl.formatMessage(defaultMessage);

View File

@@ -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,

View File

@@ -32,7 +32,7 @@ const CollectionInfoHeader = () => {
title: newTitle,
});
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
} catch (err) {
} catch {
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
}
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -34,7 +34,7 @@ const ComponentInfoHeader = () => {
},
});
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
} catch (err) {
} catch {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
}
};

View File

@@ -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();

View File

@@ -63,7 +63,7 @@ const CollectionMenu = ({ hit } : CollectionMenuProps) => {
onClick: restoreCollection,
},
);
} catch (e) {
} catch {
showToast(intl.formatMessage(messages.deleteCollectionFailed));
} finally {
closeDeleteModal();

View File

@@ -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(() => {

View File

@@ -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));
}
}, []);

View File

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

View File

@@ -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"
/>

View File

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

View File

@@ -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,

View File

@@ -146,7 +146,7 @@ const ContainerDeleter = ({
try {
await restoreContainerMutation.mutateAsync();
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
} catch (e) {
} catch {
showToast(intl.formatMessage(messageMap.undoDeleteError));
}
}, [messageMap]);

View File

@@ -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