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
150 changed files with 1094 additions and 838 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

@@ -1,16 +0,0 @@
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};

View File

@@ -1,41 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
PermissionValidationAnswer,
PermissionValidationQuery,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from '@src/authz/types';
import { getApiUrl } from './utils';
export const validateUserPermissions = async (
query: PermissionValidationQuery,
): Promise<PermissionValidationAnswer> => {
// Convert the validations query object into an array for the API request
const request: PermissionValidationRequestItem[] = Object.values(query);
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/permissions/validate/me'),
request,
);
// Convert the API response back into the expected answer format
const result: PermissionValidationAnswer = {};
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
const key = Object.keys(query).find(
(k) => query[k].action === item.action
&& query[k].scope === item.scope,
);
if (key) {
result[key] = item.allowed;
}
});
// Fill any missing keys with false
Object.keys(query).forEach((key) => {
if (!(key in result)) {
result[key] = false;
}
});
return result;
};

View File

@@ -1,168 +0,0 @@
import { act, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useUserPermissions } from './apiHooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const singlePermission = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
};
const mockValidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
];
const mockEmptyPermissions = [
// No permissions returned
];
const multiplePermissions = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
canWrite: {
action: 'example.write',
scope: 'lib:example-org:test-lib',
},
};
const mockValidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
];
describe('useUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns allowed true when permission is valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
});
it('returns allowed false when permission is invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('returns allowed true when multiple permissions are valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
expect(result.current.data!.canWrite).toBe(true);
});
it('returns allowed false when multiple permissions are invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
expect(result.current.data!.canWrite).toBe(false);
});
it('returns allowed false when the permission is not included in the server response', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('handles error when the API call fails', async () => {
const mockError = new Error('API Error');
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockRejectedValue(new Error('API Error')),
});
try {
act(() => {
renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
});
} catch (error) {
expect(error).toEqual(mockError); // Check for the expected error
}
});
});

View File

@@ -1,36 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';
const adminConsoleQueryKeys = {
all: ['authz'],
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
};
/**
* React Query hook to validate if the current user has permissions over a certain object in the instance.
* It helps to:
* - Determine whether the current user can access certain object.
* - Provide role-based rendering logic for UI components.
*
* @param permissions - A key/value map of objects and actions to validate.
* The key is an arbitrary string to identify the permission check,
* and the value is an object containing the action and optional scope.
*
* @example
* const { isLoading, data } = useUserPermissions({
* canRead: {
* action: "content_libraries.view_library",
* scope: "lib:OpenedX:CSPROB"
* }
* });
* if (data.canRead) { ... }
*
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
retry: false,
});

View File

@@ -1,4 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;

View File

@@ -1,16 +0,0 @@
export interface PermissionValidationRequestItem {
action: string;
scope?: string;
}
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
allowed: boolean;
}
export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}
export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}

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

@@ -10,13 +10,13 @@ const UnitButton = ({
unitId,
className,
showTitle,
isActive, // passed from parent (SequenceNavigationTabs)
}) => {
const courseId = useSelector(getCourseId);
const sequenceId = useSelector(getSequenceId);
const unit = useSelector((state) => state.models.units[unitId]);
const { title, contentType } = unit || {};
const { title, contentType, isActive } = unit || {};
return (
<Button
@@ -37,13 +37,11 @@ UnitButton.propTypes = {
className: PropTypes.string,
showTitle: PropTypes.bool,
unitId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
};
UnitButton.defaultProps = {
className: undefined,
showTitle: false,
isActive: false,
};
export default UnitButton;

View File

@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
localStorage.removeItem('staticFileNotices');
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch (error) {
} catch {
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
dispatch(fetchSequenceFailure({ sequenceId }));
return false;
@@ -204,7 +204,7 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
}
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
} catch {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.FAILED }));
}
};

View File

@@ -24,7 +24,6 @@ import {
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
updateCourseUnitSidebar,
} from './data/thunk';
import {
getCanEdit,
@@ -232,7 +231,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(updateCourseUnitSidebar(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
localStorage.removeItem(event.key);
}
};

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

@@ -2,7 +2,7 @@
// lint is disabled for this file due to strict spacing
export const checkboxesOLXWithFeedbackAndHintsOLX = {
rawOLX: `<problem url_name="this_should_be_ignored" copied_from_version="2" copied_from_block="some-block">
rawOLX: `<problem url_name="this_should_be_ignored">
<choiceresponse>
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.</p>
<label>Add the question text, or prompt, here. This text is required.</label>

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

@@ -380,8 +380,6 @@ export const ignoredOlxAttributes = [
'@_url_name',
'@_x-is-pointer-node',
'@_markdown_edited',
'@_copied_from_block',
'@_copied_from_version',
] as const;
// Useful for the block creation workflow.

View File

@@ -126,9 +126,9 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
onSuccess: (response) => {
dispatch(actions.app.setSaveResponse(response));
const parsedData = JSON.parse(response.config.data);
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
if (parsedData?.has_changes) {
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
sessionStorage.setItem(storageKey, Date.now());
localStorage.setItem(storageKey, Date.now());
window.dispatchEvent(new StorageEvent('storage', {
key: storageKey,

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

@@ -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',
@@ -103,8 +108,13 @@ const messages = defineMessages({
},
'header.links.exportLibrary': {
id: 'header.links.exportLibrary',
defaultMessage: 'Back up to local archive',
description: 'Link to Studio Library Backup page',
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',

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,7 +74,6 @@ export const ConfirmationView = ({
{...messages.confirmationViewAlert}
values={{
count: legacyLibraries.length,
libraryName: destination.title,
b: BoldText,
}}
/>

View File

@@ -185,7 +185,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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();
@@ -211,7 +211,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to this new library/i)).toBeInTheDocument();
// The next button is disabled
expect(nextButton).toBeDisabled();
@@ -237,7 +237,7 @@ describe('<LegacyLibMigrationPage />', () => {
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 });
await user.click(radioButton);
@@ -245,7 +245,7 @@ describe('<LegacyLibMigrationPage />', () => {
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/,
/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 });
@@ -274,7 +274,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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();
@@ -344,7 +344,7 @@ describe('<LegacyLibMigrationPage />', () => {
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 });
await user.click(radioButton);
@@ -354,7 +354,7 @@ describe('<LegacyLibMigrationPage />', () => {
// Should show alert of ConfirmationView
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
/All content from the 3 legacy libraries you selected will be migrated to/,
/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();
@@ -401,7 +401,7 @@ describe('<LegacyLibMigrationPage />', () => {
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 });
await user.click(radioButton);
@@ -411,7 +411,7 @@ describe('<LegacyLibMigrationPage />', () => {
// Should show alert of ConfirmationView
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
/All content from the 3 legacy libraries you selected will be migrated to/,
/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();

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

@@ -65,24 +65,24 @@ const messages = defineMessages({
id: 'legacy-libraries-migration.select-destination.alert.text',
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.',
+ ' 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: 'All content from the'
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
+ ' be migrated to <b>{libraryName}</b> and 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.',
+ ' 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: {
id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text',
defaultMessage: 'Previously migrated library. Any problem bank links were already'
+ ' moved will be migrated to <b>{libraryName}</b>',
+ ' moved will be migrated to <b>{libraryName}</b>',
description: 'Alert text when the legacy library is already migrated.',
},
helpAndSupportTitle: {
@@ -98,8 +98,8 @@ const messages = defineMessages({
helpAndSupportFirstQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q1.body',
defaultMessage: 'In the new Content Libraries experience, you can author sections,'
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
description: 'Body of the first question in the Help & Support sidebar',
},
helpAndSupportSecondQuestionTitle: {
@@ -110,9 +110,9 @@ const messages = defineMessages({
helpAndSupportSecondQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q2.body',
defaultMessage: 'All legacy library content is supported in the new experience.'
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
description: 'Body of the second question in the Help & Support sidebar',
},
helpAndSupportThirdQuestionTitle: {
@@ -123,18 +123,18 @@ const messages = defineMessages({
helpAndSupportThirdQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q3.body.2',
defaultMessage: '<p>There are three steps to migrating legacy libraries:</p>'
+ '<p><div>1 - Select Legacy Libraries</div>'
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
+ ' v2 Content Library per migration.</p>'
+ '<p><div>2 - Select Destination</div>'
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
+ ' All your content will be migrated, and kept organized in collections.</p>'
+ '<p><div>3 - Confirm</div>'
+ 'In this step, review your migration. Once you confirm, migration will begin.'
+ ' It may take some time to complete.</p>',
+ '<p><div>1 - Select Legacy Libraries</div>'
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
+ ' v2 Content Library per migration.</p>'
+ '<p><div>2 - Select Destination</div>'
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
+ ' All your content will be migrated, and kept organized in collections.</p>'
+ '<p><div>3 - Confirm</div>'
+ 'In this step, review your migration. Once you confirm, migration will begin.'
+ ' It may take some time to complete.</p>',
description: 'Part 2 of the Body of the third question in the Help & Support sidebar',
},
migrationInProgress: {

View File

@@ -13,7 +13,7 @@
.library-authoring-sidebar {
z-index: 1000; // same as header
flex: 530px 0 0;
flex: 500px 0 0;
position: sticky;
top: 0;
right: 0;

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,8 +7,6 @@ import {
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';
import { ContainerType } from '../../../generic/key-utils';
import type { ComponentPicker } from '../../component-picker';
@@ -27,7 +25,6 @@ export type LibraryContextData = {
libraryId: string;
libraryData?: ContentLibrary;
readOnly: boolean;
canPublish: boolean;
isLoadingLibraryData: boolean;
/** The ID of the current collection/container, on the sidebar OR page */
collectionId: string | undefined;
@@ -110,13 +107,6 @@ export const LibraryProvider = ({
componentPickerMode,
} = useComponentPickerContext();
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canPublish: {
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
scope: libraryId,
},
});
const canPublish = userPermissions?.canPublish || false;
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
// Parse the initial collectionId and/or container ID(s) from the current URL params
@@ -141,8 +131,7 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
canPublish,
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
isLoadingLibraryData,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
@@ -165,9 +154,7 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
canPublish,
isLoadingLibraryData,
isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,

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

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

@@ -64,7 +64,7 @@ const BaseCard = ({
<Card.Header
className={`library-item-header ${getComponentStyleColor(itemType)}`}
title={
<Icon src={itemIcon} className="library-item-header-icon my-2" />
<Icon src={itemIcon} className="library-item-header-icon" />
}
actions={(
<div

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

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

@@ -8,11 +8,10 @@ import {
Icon,
IconButton,
useToggle,
Alert,
} from '@openedx/paragon';
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { InfoOutline, MoreVert } from '@openedx/paragon/icons';
import { MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '@src/generic/clipboard';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
@@ -150,15 +149,6 @@ const ContainerActions = ({
);
};
/* istanbul ignore next */
/* istanbul ignore next */
const ContainerSettings = () => (
<Alert icon={InfoOutline} variant="info">
<p>
<FormattedMessage {...messages.containerSettingsMsg} />
</p>
</Alert>
);
const ContainerInfo = () => {
const intl = useIntl();
const {
@@ -232,7 +222,7 @@ const ContainerInfo = () => {
{renderTab(
CONTAINER_INFO_TABS.Settings,
intl.formatMessage(messages.settingsTabTitle),
<ContainerSettings />,
// TODO: container settings component
)}
</Tabs>
</Stack>

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

@@ -75,7 +75,7 @@ const ContainerRemover = ({
}
}
showToast(removeSuccess);
} catch (e) {
} catch {
showToast(removeError);
} finally {
close();

View File

@@ -66,11 +66,6 @@ const messages = defineMessages({
defaultMessage: 'Container actions menu',
description: 'Alt/title text for the container card menu button.',
},
containerSettingsMsg: {
id: 'course-authoring.library-authoring.container.settings.alert.message',
defaultMessage: 'Section settings cannot be configured within Libraries and must be set within a course. In a future release, Libraries may support configuring some settings.',
description: 'Temporary message for settings tab being',
},
menuOpen: {
id: 'course-authoring.library-authoring.menu.open',
defaultMessage: 'Open',

View File

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

View File

@@ -435,7 +435,7 @@ describe('<CreateLibrary />', () => {
sections: 8,
subsections: 12,
units: 20,
createdOnServer: 'test.com',
createdOnServer: '2025-01-01T10:00:00Z',
createdAt: '2025-01-01T10:00:00Z',
createdBy: {
username: 'testuser',
@@ -478,67 +478,7 @@ describe('<CreateLibrary />', () => {
await waitFor(() => {
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
// Testing the archive details summary
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
expect(screen.getByText(/Created on instance test.com/i)).toBeInTheDocument();
expect(screen.getByText(/by user test@example.com/i)).toBeInTheDocument();
});
});
test('shows success state without instance and user email information', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
const mockResult = {
learningPackageId: 123,
title: 'Test Archive Library',
org: 'TestOrg',
slug: 'test-archive',
key: 'TestOrg/test-archive',
archiveKey: 'archive-key',
containers: 5,
components: 15,
collections: 3,
sections: 8,
subsections: 12,
units: 20,
createdOnServer: null,
createdAt: '2025-01-01T10:00:00Z',
createdBy: null,
};
// Pre-set the restore status to succeeded
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: mockResult,
error: null,
errorLog: null,
};
// Mock the restore mutation to return a task ID
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-123' });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a file to trigger the restore process
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByRole('presentation', { hidden: true });
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(input, file);
// Wait for the restore to complete and archive details to be shown
await waitFor(() => {
// Testing the archive details summary without instance and user email
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
expect(screen.queryByText(/Created on instance/i)).not.toBeInTheDocument();
expect(screen.queryByText(/by user/i)).not.toBeInTheDocument();
expect(screen.getByText(/Contains 15 Components/i)).toBeInTheDocument();
});
});

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