Compare commits

...

19 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
fca319ba36 fix: saving alert doesn't disappear even its saved
saving alert doesn't disappear even if its saved and keep showing loading status
2025-11-13 15:49:39 +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
133 changed files with 1430 additions and 488 deletions

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.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"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

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

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

@@ -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,3 +1,4 @@
/* istanbul ignore file */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-unused-vars */
/* eslint-disable import/extensions */
@@ -13,7 +14,7 @@ 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 EditorContainer from '../EditorContainer';
// This 'module' self-import hack enables mocking during tests.
@@ -30,7 +31,7 @@ export const hooks = {
}),
};
export const thumbEditor = ({
export const ThumbEditor = ({
onClose,
// redux
blockValue,
@@ -38,44 +39,46 @@ export const thumbEditor = ({
blockFailed,
blockFinished,
initializeEditor,
// eslint-disable-next-line react/prop-types
exampleValue,
// inject
intl,
}) => (
<EditorContainer
getContent={module.hooks.getContent}
onClose={onClose}
>
<div>
{exampleValue}
</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')}
/>
</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>
</EditorContainer>
);
thumbEditor.defaultProps = {
}) => {
const intl = useIntl();
return (
<EditorContainer
getContent={module.hooks.getContent}
onClose={onClose}
>
<div>
{exampleValue}
</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')}
/>
</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>
</EditorContainer>
);
};
ThumbEditor.defaultProps = {
blockValue: null,
lmsEndpointUrl: null,
};
thumbEditor.propTypes = {
ThumbEditor.propTypes = {
onClose: PropTypes.func.isRequired,
// redux
blockValue: PropTypes.shape({
@@ -85,8 +88,6 @@ thumbEditor.propTypes = {
blockFailed: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
initializeEditor: PropTypes.func.isRequired,
// inject
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -103,4 +104,4 @@ export const mapDispatchToProps = {
// TODO fill with dispatches here if needed
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));
export default connect(mapStateToProps, mapDispatchToProps)(ThumbEditor);

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

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

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

@@ -87,7 +87,7 @@ const GradingSettings = ({ courseId }) => {
setIsQueryPending(!isQueryPending);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [savePending]);
}, [savePending, savingStatus]);
if (isLoadingDenied) {
return (

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>

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: 'Team Access', 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: 'Team Access',
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: 'Team Access',
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: 'Team Access',
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

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

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

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,11 @@ 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';
const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
const {
@@ -48,6 +50,7 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
<CreateCollectionModal />
<CreateContainerModal />
<ComponentEditorModal />
<LibraryTeamModal />
</SidebarProvider>
</LibraryProvider>
);
@@ -90,6 +93,10 @@ const LibraryLayout = () => (
path={ROUTES.BACKUP}
Component={LibraryBackupPage}
/>
<Route
path={ROUTES.IMPORT}
Component={CourseImportHomePage}
/>
</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));
}
};

View File

@@ -128,7 +128,7 @@ const render = (
});
};
let axiosMock: MockAdapter;
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: any; };
[
{

View File

@@ -30,7 +30,7 @@ export const ContainerPublisher = ({
try {
await publishContainer.mutateAsync();
showToast(intl.formatMessage(messages.publishContainerSuccess));
} catch (error) {
} catch {
showToast(intl.formatMessage(messages.publishContainerFailed));
}
handleClose();

View File

@@ -0,0 +1,73 @@
import userEvent from '@testing-library/user-event';
import type MockAdapter from 'axios-mock-adapter';
import {
initializeMocks,
render,
screen,
waitFor,
} from '@src/testUtils';
import { ToastProvider } from '@src/generic/toast-context';
import {
getLibraryContainerChildrenApiUrl,
} from '../data/api';
import {
mockContentLibrary,
mockGetContainerChildren,
} from '../data/api.mocks';
import ContainerRemover from './ContainerRemover';
import { LibraryProvider } from '../common/context/LibraryContext';
let axiosMock: MockAdapter;
mockGetContainerChildren.applyMock();
mockContentLibrary.applyMock();
const mockClose = jest.fn();
const { libraryId } = mockContentLibrary;
const renderModal = (element: React.ReactNode) => {
render(
<ToastProvider>
<LibraryProvider libraryId={libraryId}>
{element}
</LibraryProvider>
</ToastProvider>,
);
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockImplementation(() => ({
containerId: mockGetContainerChildren.unitIdWithDuplicate,
})),
}));
describe('<ContainerRemover />', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
it('triggers update container children api call when duplicates are present', async () => {
const user = userEvent.setup();
const url = getLibraryContainerChildrenApiUrl(mockGetContainerChildren.unitIdWithDuplicate);
axiosMock.onPatch(url).reply(200);
const result = await mockGetContainerChildren(mockGetContainerChildren.unitIdWithDuplicate);
const resultIds = result.map((obj) => obj.id);
renderModal(<ContainerRemover
close={mockClose}
containerKey={result[0].id}
displayName="Title"
index={0}
/>);
const btn = await screen.findByRole('button', { name: 'Remove' });
await user.click(btn);
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
// Only the first element is removed even though the last element has the same id.
expect(JSON.parse(axiosMock.history.patch[0].data).usage_keys).toEqual(resultIds.slice(1));
expect(mockClose).toHaveBeenCalled();
});
});

View File

@@ -7,32 +7,42 @@ import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { getBlockType } from '@src/generic/key-utils';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
import messages from '../components/messages';
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import {
useContainer, useContainerChildren, useRemoveContainerChildren, useUpdateContainerChildren,
} from '@src/library-authoring/data/apiHooks';
import messages from '@src/library-authoring/components/messages';
import { Container } from '@src/library-authoring/data/api';
type ContainerRemoverProps = {
close: () => void,
containerKey: string,
displayName: string,
index?: number,
};
const ContainerRemover = ({
close,
containerKey,
displayName,
index,
}: ContainerRemoverProps) => {
const intl = useIntl();
const {
sidebarItemInfo,
closeLibrarySidebar,
} = useSidebarContext();
const { containerId } = useLibraryContext();
const { containerId, showOnlyPublished } = useLibraryContext();
const { showToast } = useContext(ToastContext);
const removeContainerMutation = useRemoveContainerChildren(containerId);
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
const { data: container, isPending } = useContainer(containerId);
// Use update api for children if duplicates are present to avoid removing all instances of the child
const { data: children } = useContainerChildren<Container>(containerId, showOnlyPublished);
const childrenUsageIds = children?.map((child) => child.id);
const hasDuplicates = (childrenUsageIds?.filter((child) => child === containerKey).length || 0) > 1;
const itemType = getBlockType(containerKey);
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
@@ -50,12 +60,22 @@ const ContainerRemover = ({
const onRemove = useCallback(async () => {
try {
await removeContainerMutation.mutateAsync([containerKey]);
if (sidebarItemInfo?.id === containerKey) {
closeLibrarySidebar();
if (hasDuplicates && childrenUsageIds && typeof index !== 'undefined') {
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== containerKey || idx !== index);
await updateContainerChildrenMutation.mutateAsync(updatedKeys);
// istanbul ignore if
if (sidebarItemInfo?.id === containerKey && sidebarItemInfo?.index === index) {
closeLibrarySidebar();
}
} else {
await removeContainerMutation.mutateAsync([containerKey]);
// istanbul ignore if
if (sidebarItemInfo?.id === containerKey) {
closeLibrarySidebar();
}
}
showToast(removeSuccess);
} catch (e) {
} catch {
showToast(removeError);
} finally {
close();
@@ -63,12 +83,16 @@ const ContainerRemover = ({
}, [
containerKey,
removeContainerMutation,
updateContainerChildrenMutation,
sidebarItemInfo,
closeLibrarySidebar,
showToast,
removeSuccess,
removeError,
close,
hasDuplicates,
childrenUsageIds,
index,
]);
// istanbul ignore if: loading state

View File

@@ -94,7 +94,7 @@ const CreateContainerModal = () => {
}
showToast(labels.successMsg);
} catch (error) {
} catch {
showToast(labels.errorMsg);
} finally {
handleClose();

View File

@@ -603,6 +603,7 @@ mockGetContainerMetadata.applyMock = () => {
export async function mockGetContainerChildren(containerId: string): Promise<api.LibraryBlockMetadata[]> {
let numChildren: number;
let blockType = 'html';
let addDuplicate = false;
switch (containerId) {
case mockGetContainerMetadata.unitId:
case mockGetContainerMetadata.sectionId:
@@ -615,6 +616,10 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
case mockGetContainerChildren.sixChildren:
numChildren = 6;
break;
case mockGetContainerChildren.unitIdWithDuplicate:
numChildren = 3;
addDuplicate = true;
break;
default:
numChildren = 0;
break;
@@ -630,19 +635,22 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
name = blockType;
typeNamespace = 'lct';
}
return Promise.resolve(
Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
displayName: `${name} block ${idx}`,
publishedDisplayName: `${name} block published ${idx}`,
blockType,
}
)),
);
let result = Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
displayName: `${name} block ${idx}`,
publishedDisplayName: `${name} block published ${idx}`,
blockType,
}
));
if (addDuplicate) {
result = [...result, result[0]];
}
return Promise.resolve(result);
}
mockGetContainerChildren.unitIdWithDuplicate = 'lct:org1:Demo_Course:unit:unit-duplicate';
mockGetContainerChildren.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5';
mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
mockGetContainerChildren.childTemplate = {
@@ -1064,3 +1072,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);
export async function mockGetCourseImports(libraryId: string): ReturnType<typeof api.getCourseImports> {
switch (libraryId) {
case mockContentLibrary.libraryId:
return [
mockGetCourseImports.succeedImport,
mockGetCourseImports.succeedImportWithCollection,
mockGetCourseImports.failImport,
mockGetCourseImports.inProgressImport,
];
case mockGetCourseImports.emptyLibraryId:
return [];
default:
throw new Error(`mockGetCourseImports doesn't know how to mock ${JSON.stringify(libraryId)}`);
}
}
mockGetCourseImports.libraryId = mockContentLibrary.libraryId;
mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2;
mockGetCourseImports.succeedImport = {
source: {
key: 'course-v1:edX+DemoX+2025_T1',
displayName: 'DemoX 2025 T1',
},
targetCollection: null,
state: 'Succeeded',
progress: 1,
} satisfies api.CourseImport;
mockGetCourseImports.succeedImportWithCollection = {
source: {
key: 'course-v1:edX+DemoX+2025_T2',
displayName: 'DemoX 2025 T2',
},
targetCollection: {
key: 'sample-collection',
title: 'DemoX 2025 T1 (2)',
},
state: 'Succeeded',
progress: 1,
} satisfies api.CourseImport;
mockGetCourseImports.failImport = {
source: {
key: 'course-v1:edX+DemoX+2025_T3',
displayName: 'DemoX 2025 T3',
},
targetCollection: null,
state: 'Failed',
progress: 0.30,
} satisfies api.CourseImport;
mockGetCourseImports.inProgressImport = {
source: {
key: 'course-v1:edX+DemoX+2025_T4',
displayName: 'DemoX 2025 T4',
},
targetCollection: null,
state: 'In Progress',
progress: 0.5012,
} satisfies api.CourseImport;
mockGetCourseImports.applyMock = () => jest.spyOn(
api,
'getCourseImports',
).mockImplementation(mockGetCourseImports);

View File

@@ -157,6 +157,10 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr
* Get the URL for the API endpoint to copy a single container.
*/
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
/**
* Get the url for the API endpoint to list library course imports.
*/
export const getCourseImportsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
export interface ContentLibrary {
id: string;
@@ -702,10 +706,10 @@ export async function restoreContainer(containerId: string) {
/**
* Fetch a library container's children's metadata.
*/
export async function getLibraryContainerChildren(
export async function getLibraryContainerChildren<ChildType = LibraryBlockMetadata | Container>(
containerId: string,
published: boolean = false,
): Promise<LibraryBlockMetadata[] | Container[]> {
): Promise<ChildType[]> {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerChildrenApiUrl(containerId, published),
);
@@ -784,3 +788,24 @@ export async function getLibraryContainerHierarchy(
export async function publishContainer(containerId: string) {
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
}
export interface CourseImport {
source: {
key: string;
displayName: string;
};
targetCollection: {
key: string;
title: string;
} | null;
state: 'Succeeded' | 'Failed' | 'In Progress';
progress: number;
}
/**
* Returns the course imports which had this library as destination.
*/
export async function getCourseImports(libraryId: string): Promise<CourseImport[]> {
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId));
return camelCaseObject(data);
}

View File

@@ -329,10 +329,11 @@ describe('library api hooks', () => {
// Keys should be invalidated:
// 1. library
// 2. containerChildren
// 3. containerHierarchy
// 4 & 5. subsections
// 6 all hierarchies
expect(spy).toHaveBeenCalledTimes(6);
// 3. container
// 4. containerHierarchy
// 5 & 6. subsections
// 7 all hierarchies
expect(spy).toHaveBeenCalledTimes(7);
});
describe('publishContainer', () => {

View File

@@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = {
}
return ['hierarchy'];
},
courseImports: (libraryId: string) => [
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
'courseImports',
],
};
export const xblockQueryKeys = {
@@ -736,32 +740,35 @@ export const useRestoreContainer = (containerId: string) => {
/**
* Get the metadata and children for a container in a library
*/
export const useContainerChildren = (containerId?: string, published: boolean = false) => (
useQuery({
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
queryFn: () => api.getLibraryContainerChildren(containerId!, published),
structuralSharing: (
oldData: api.LibraryBlockMetadata[] | api.Container[],
newData: api.LibraryBlockMetadata[] | api.Container[],
) => {
export const useContainerChildren = <ChildType extends {
id: string;
isNew?: boolean;
} = api.LibraryBlockMetadata | api.Container>(
containerId?: string,
published: boolean = false,
) => (
useQuery({
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
queryFn: () => api.getLibraryContainerChildren<ChildType>(containerId!, published),
structuralSharing: (oldData: ChildType[], newData: ChildType[]) => {
// This just sets `isNew` flag to new children components
if (oldData) {
const oldDataIds = oldData.map((obj) => obj.id);
// eslint-disable-next-line no-param-reassign
newData = newData.map((newObj) => {
if (!oldDataIds.includes(newObj.id)) {
if (oldData) {
const oldDataIds = oldData.map((obj) => obj.id);
// eslint-disable-next-line no-param-reassign
newData = newData.map((newObj) => {
if (!oldDataIds.includes(newObj.id)) {
// Set isNew = true if we have new child on refetch
// eslint-disable-next-line no-param-reassign
newObj.isNew = true;
}
return newObj;
});
}
return replaceEqualDeep(oldData, newData);
},
})
);
newObj.isNew = true;
}
return newObj;
});
}
return replaceEqualDeep(oldData, newData);
},
})
);
/**
* If you work with `useContentFromSearchIndex`, you can use this
@@ -814,6 +821,8 @@ export const useAddItemsToContainer = (containerId?: string) => {
// It would be complex to bring the entire hierarchy and only update the items within that hierarchy.
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(undefined) });
// Invalidate the container to update its publish status
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
const containerType = getBlockType(containerId);
if (containerType === 'section') {
@@ -946,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => {
skipBlockTypeFetch: true,
});
};
/**
* Returns the course imports which had this library as destination.
*/
export const useCourseImports = (libraryId: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.courseImports(libraryId),
queryFn: () => api.getCourseImports(libraryId),
})
);

View File

@@ -45,7 +45,7 @@ describe('<ManageCollections />', () => {
// 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,

View File

@@ -0,0 +1,56 @@
import {
initializeMocks,
render as testRender,
screen,
} from '@src/testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import {
mockContentLibrary,
mockGetCourseImports,
} from '../data/api.mocks';
import { CourseImportHomePage } from './CourseImportHomePage';
mockContentLibrary.applyMock();
mockGetCourseImports.applyMock();
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const render = (libraryId: string) => (
testRender(
<CourseImportHomePage />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import-course',
params: { libraryId },
},
)
);
describe('<CourseImportHomePage>', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the library course import home page', async () => {
render(mockGetCourseImports.libraryId);
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4);
});
it('should render the empty state', async () => {
render(mockGetCourseImports.emptyLibraryId);
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument();
expect(screen.getByText('You have not imported any courses into this library.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import {
Button,
Card,
Container,
Layout,
Stack,
} from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { Helmet } from 'react-helmet';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import Loading from '@src/generic/Loading';
import SubHeader from '@src/generic/sub-header/SubHeader';
import Header from '@src/header';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useCourseImports } from '../data/apiHooks';
import { HelpSidebar } from './HelpSidebar';
import { ImportedCourseCard } from './ImportedCourseCard';
import messages from './messages';
const EmptyState = () => (
<Container size="md" className="py-6">
<Card>
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
<FormattedMessage {...messages.emptyStateText} />
<Button iconBefore={Add} disabled>
<FormattedMessage {...messages.emptyStateButtonText} />
</Button>
</Stack>
</Card>
</Container>
);
export const CourseImportHomePage = () => {
const intl = useIntl();
const { libraryId, libraryData, readOnly } = useLibraryContext();
const { data: courseImports } = useCourseImports(libraryId);
if (!courseImports || !libraryData) {
return <Loading />;
}
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
readOnly={readOnly}
containerProps={{
size: undefined,
}}
/>
<Container className="mt-4 mb-5">
<div className="px-4 bg-light-200 border-bottom">
<SubHeader
title={intl.formatMessage(messages.pageTitle)}
subtitle={intl.formatMessage(messages.pageSubtitle)}
hideBorder
/>
</div>
<Layout xs={[{ span: 9 }, { span: 3 }]}>
<Layout.Element>
{courseImports.length ? (
<Stack gap={3} className="pl-4 mt-4">
<h3>
<FormattedMessage {...messages.courseImportPreviousImports} />
</h3>
{courseImports.map((courseImport) => (
<ImportedCourseCard
key={courseImport.source.key}
courseImport={courseImport}
/>
))}
</Stack>
) : (<EmptyState />)}
</Layout.Element>
<Layout.Element>
<HelpSidebar />
</Layout.Element>
</Layout>
</Container>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Icon, Stack } from '@openedx/paragon';
import { Question } from '@openedx/paragon/icons';
import { Paragraph } from '@src/utils';
import messages from './messages';
export const HelpSidebar = () => (
<div className="pt-3 border-left h-100">
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
<Icon src={Question} />
<span>
<FormattedMessage {...messages.helpAndSupportTitle} />
</span>
</Stack>
<hr />
<Stack className="pl-4 pr-4">
<Stack>
<span className="h5">
<FormattedMessage {...messages.helpAndSupportFirstQuestionTitle} />
</span>
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportFirstQuestionBody}
values={{ p: Paragraph }}
/>
</span>
</Stack>
<hr />
<Stack>
<span className="h5">
<FormattedMessage {...messages.helpAndSupportSecondQuestionTitle} />
</span>
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportSecondQuestionBody}
values={{ p: Paragraph }}
/>
</span>
</Stack>
</Stack>
<hr className="w-100" />
</div>
);

View File

@@ -0,0 +1,94 @@
import userEvent from '@testing-library/user-event';
import {
initializeMocks,
render as testRender,
screen,
} from '@src/testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import {
mockContentLibrary,
mockGetCourseImports,
} from '../data/api.mocks';
import { type CourseImport } from '../data/api';
import { ImportedCourseCard } from './ImportedCourseCard';
mockContentLibrary.applyMock();
const { libraryId } = mockContentLibrary;
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const render = (courseImport: CourseImport) => (
testRender(
<ImportedCourseCard courseImport={courseImport} />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={mockContentLibrary.libraryId}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import-course',
params: { libraryId },
},
)
);
describe('<ImportedCourseCard>', () => {
beforeEach(() => {
initializeMocks();
});
it('should render a card for a successful import', () => {
const { succeedImport } = mockGetCourseImports;
render(succeedImport);
expect(screen.getByText(succeedImport.source.displayName)).toBeInTheDocument();
expect(screen.getByText(/100% Imported/)).toBeInTheDocument();
const courseLink = screen.getByRole('link', { name: succeedImport.source.displayName });
expect(courseLink).toHaveAttribute('href', `/course/${succeedImport.source.key}`);
});
it('should render a card for a successful import with a collection', async () => {
const { succeedImportWithCollection } = mockGetCourseImports;
render(succeedImportWithCollection);
expect(screen.getByText(succeedImportWithCollection.source.displayName)).toBeInTheDocument();
expect(screen.getByText(/100% Imported/)).toBeInTheDocument();
const courseLink = screen.getByRole('link', { name: succeedImportWithCollection.source.displayName });
expect(courseLink).toHaveAttribute('href', `/course/${succeedImportWithCollection.source.key}`);
const collectionLink = await screen.findByText(succeedImportWithCollection.targetCollection.title);
await userEvent.click(collectionLink);
expect(mockNavigate).toHaveBeenCalledWith(
{
pathname: `/library/${libraryId}/collection/${succeedImportWithCollection.targetCollection.key}`,
search: '',
},
);
});
it('should render a card for a failed import', () => {
const { failImport } = mockGetCourseImports;
render(failImport);
expect(screen.getByText(failImport.source.displayName)).toBeInTheDocument();
expect(screen.getByText('Import Failed')).toBeInTheDocument();
const courseLink = screen.getByRole('link', { name: failImport.source.displayName });
expect(courseLink).toHaveAttribute('href', `/course/${failImport.source.key}`);
});
it('should render a card for an in-progress import', () => {
const { inProgressImport } = mockGetCourseImports;
render(inProgressImport);
expect(screen.getByText(inProgressImport.source.displayName)).toBeInTheDocument();
expect(screen.getByText(/50% Imported/)).toBeInTheDocument();
const courseLink = screen.getByRole('link', { name: inProgressImport.source.displayName });
expect(courseLink).toHaveAttribute('href', `/course/${inProgressImport.source.key}`);
});
});

View File

@@ -0,0 +1,100 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Card,
Icon,
} from '@openedx/paragon';
import {
ArrowForwardIos,
Check,
Error,
Folder,
IncompleteCircle,
Warning,
} from '@openedx/paragon/icons';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { type CourseImport } from '../data/api';
import { useLibraryRoutes } from '../routes';
import messages from './messages';
interface ImportedCourseCardProps {
courseImport: CourseImport;
}
const BORDER_CLASS = {
Succeeded: 'status-border-imported',
Failed: 'status-border-failed',
Partial: 'status-border-partial',
'In Progress': 'status-border-in-progress',
};
const STATE_ICON = {
Succeeded: Check,
Failed: Error,
Partial: Warning,
'In Progress': IncompleteCircle,
};
const STATE_ICON_COLOR_CLASS = {
Succeeded: undefined,
Failed: 'text-danger-500',
Partial: 'text-warning-500',
'In Progress': undefined,
};
const StateIcon = ({ state }: { state: CourseImport['state'] }) => (
<Icon
src={STATE_ICON[state]}
size="sm"
className={classNames('mr-2', STATE_ICON_COLOR_CLASS[state])}
/>
);
export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => {
const intl = useIntl();
const { navigateTo } = useLibraryRoutes();
return (
<Card className={BORDER_CLASS[courseImport.state]}>
<Card.Section className="d-flex flex-row">
<div>
<Link to={`/course/${courseImport.source.key}`} target="_blank">
<h4>{courseImport.source.displayName}</h4>
</Link>
<div className="d-inline-flex small align-items-center">
<StateIcon state={courseImport.state} />
{courseImport.state === 'Failed' ? (
<FormattedMessage {...messages.courseImportTextFailed} />
) : (
<>
{Math.round(courseImport.progress * 100)}
<FormattedMessage {...messages.courseImportTextProgress} />
</>
)}
{courseImport.targetCollection && (
<Button
iconBefore={Folder}
variant="link"
className="ml-4 text-black text-decoration-underline"
onClick={() => navigateTo({ collectionId: courseImport.targetCollection!.key })}
>
{courseImport.targetCollection.title}
</Button>
)}
</div>
</div>
<div className="d-flex align-items-center ml-auto">
<Link
to={`/course/${courseImport.source.key}`}
aria-label={intl.formatMessage(messages.courseImportNavigateAlt)}
className="text-primary-500"
>
<Icon src={ArrowForwardIos} />
</Link>
</div>
</Card.Section>
</Card>
);
};

Some files were not shown because too many files have changed in this diff Show More