Compare commits

..

14 Commits

Author SHA1 Message Date
Navin Karkera
fedb85577e feat: add temporary message alert in sections settings tab in libraries (#2734) (#2766)
- add temporary message alert in sections settings tab in libraries
- increase sidebar width to remove `More` option and display all tabs
together

(cherry picked from commit 3eeca244d7)
2025-12-19 16:36:31 -08:00
David Ormsbee
18e51db70a fix: support "in progress" status for lib upload
When uploading a library archive file during the creation of a new
library, the code prior to this commit did not properly handle the "In
Progress" state, which is when the celery task doing the archive
processing is actively running. Note that this is distinct from the
"Pending" state, which is when the task is waiting in the queue to be
run (which in practice should almost never happen unless there is an
operational issue).

Since celery tasks run in-process during local development, the task
was always finished by the time that the browser made a call to check
on the status. The problem only happened on slower sandboxes, where
processing truly runs asynchronously and might take a few seconds.
Because this case wasn't handled, the frontend would never poll for
updates either, so the upload was basically lost as far as the user
was concerned.
2025-12-12 21:37:59 -05:00
Rodrigo Mendez
4a1d0a2716 feat: Implement querying openedx-authz for publish permissions (#2685) (#2733) 2025-12-08 15:58:35 -05:00
Daniel Wong
2ba6f96142 feat: add support for origin server and user info (#2663) (#2710)
* feat: add support for origin server and user info

* test: add coverage for restore archive summary

* test: increase coverage for restore archive summary

* fix: address comments
2025-12-04 13:24:06 -06:00
Rômulo Penido
28f0c9943d fix: migrate library alert text (#2727)
Backport of #2651
2025-12-04 09:41:52 -05:00
Asad Ali
067806a0e6 fix: do not reload multiple tabs on block save (#2600) (#2705) 2025-12-01 18:13:45 -05:00
Kyle McCormick
7ebf349789 fix: "Back up" is two words when used as a verb (#2706)
There is a new menu item "Backup to local archive". Backup is the correct
spelling when using it as a noun or adjective, but the menu item uses as a
verb, so it should be two words, back up, i.e. "Back up to local archive"

Backports 70c19a3ffb
2025-11-26 12:18:57 -05:00
Navin Karkera
7a1bc3931a fix: don't revert to advanced editor if block contains copied_from fields (#2661) (#2695)
(cherry picked from commit 2215fc53cc)
2025-11-25 16:17:03 -05:00
Kyle McCormick
9bea56b3ae fix: Rename builtin discussion providers, "edX" -> "Open edX" (#2662)
Backports 5fadccabe2 to Ulmo
2025-11-18 10:46:38 -05:00
Muhammad Anas
c7a84a1a9c fix: unit button active state (#2617) (#2650) (backport) 2025-11-13 12:24:20 -05:00
Muhammad Arslan
ad0e1ae570 fix: broken Course Overview editor on Schedule & Details page (#2604) (backport) 2025-11-13 11:10:32 -05:00
Muhammad Arslan
bd00c3b271 fix: self-closing script tag fixed for TinyMceEditor (#2608) (backport) 2025-11-07 09:42:32 -08:00
Chris Chávez
de8b4b460b style: Update some texts in legacy libraries migration flow (#2601) (#2603) 2025-11-05 18:46:32 -05:00
Navin Karkera
fa2bd8a604 chore: backport latest bug fixes (#2602)
Backport of https://github.com/openedx/frontend-app-authoring/pull/2584 and https://github.com/openedx/frontend-app-authoring/pull/2587
2025-11-05 17:23:28 -05:00
150 changed files with 838 additions and 1094 deletions

View File

@@ -14,15 +14,6 @@ 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`).
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used 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.
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
- [ ] Imports avoid using `../`. 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.7",
"@tanstack/react-query": "5.90.5",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
@@ -7306,9 +7306,9 @@
}
},
"node_modules/@openedx/paragon": {
"version": "23.16.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.16.0.tgz",
"integrity": "sha512-s3cq7lJJ6hppv41hSb+HrwPknI+USWmBO0+GApTXXL2Dvy3j9B9q2M2r5T0sG1xnPwo59z+C2UKnMlTbbGGy5Q==",
"version": "23.15.1",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.15.1.tgz",
"integrity": "sha512-uqbKE5pfLLdEaTltd27nyyV/enjOjPkkINES/LRBZXwRgGWhQh+vH2xA+iXigwvGGeWdzuxnJ0lXyfiUR/R7Ig==",
"license": "Apache-2.0",
"peer": true,
"workspaces": [
@@ -8257,9 +8257,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz",
"integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==",
"version": "5.90.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
"integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==",
"license": "MIT",
"funding": {
"type": "github",
@@ -8267,12 +8267,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz",
"integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==",
"version": "5.90.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz",
"integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.7"
"@tanstack/query-core": "5.90.5"
},
"funding": {
"type": "github",
@@ -11067,9 +11067,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"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.7",
"@tanstack/react-query": "5.90.5",
"@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 } from '@edx/frontend-platform/i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
@@ -20,6 +20,7 @@ const defaultProps = {
courseId,
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let store;
const intlWrapper = children => (
@@ -101,7 +102,7 @@ describe('ProctoredExamSettings', () => {
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
@@ -151,7 +152,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
@@ -224,7 +225,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
@@ -408,7 +409,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
@@ -417,7 +418,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -427,7 +428,7 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -436,7 +437,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -445,7 +446,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -456,7 +457,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -469,7 +470,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -482,7 +483,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -496,7 +497,7 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -515,7 +516,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
@@ -528,14 +529,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(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...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(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
@@ -543,7 +544,7 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
@@ -553,7 +554,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -565,7 +566,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -577,7 +578,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -596,7 +597,7 @@ describe('ProctoredExamSettings', () => {
});
it('Disable button while submitting', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -606,7 +607,7 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
@@ -637,7 +638,7 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
@@ -664,7 +665,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(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...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' } });
@@ -705,7 +706,7 @@ describe('ProctoredExamSettings', () => {
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// update exam service config
@@ -749,7 +750,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// does not update exam service config
@@ -779,7 +780,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -797,7 +798,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -815,7 +816,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -834,7 +835,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -867,7 +868,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...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 {
} catch (error) {
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 {
} catch (e) {
enabled = undefined;
}
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
try {
const { response } = await getXpertSettings(courseId);
enabled = response?.enabled;
} catch {
} catch (e) {
enabled = undefined;
}
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
}
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false;
} catch {
} catch (error) {
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 {
} catch (err) {
errorData = {};
}
@@ -77,7 +77,7 @@ export function fetchProctoringExamErrors(courseId) {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch {
} catch (error) {
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 {
} catch (e) {
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 { /* empty */ }
} catch (quotedE) { /* empty */ }
}
pushDataToErrorArray(settingName);

16
src/authz/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
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',
};

41
src/authz/data/api.ts Normal file
View File

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,36 @@
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,
});

4
src/authz/data/utils.ts Normal file
View File

@@ -0,0 +1,4 @@
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 || ''}`;

16
src/authz/types.ts Normal file
View File

@@ -0,0 +1,16 @@
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 { FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { injectIntl, 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 ChecklistItemComment;
export default injectIntl(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 {
} catch (error) {
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) => void;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({

View File

@@ -224,7 +224,7 @@ const ItemReviewList = ({
messages.updateSingleBlockSuccess,
{ name: info.displayName },
));
} catch {
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
}
}, []);
@@ -243,7 +243,7 @@ const ItemReviewList = ({
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
} catch {
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
} finally {
closeConfirmModal();

View File

@@ -148,7 +148,7 @@ export function fetchCourseBestPracticesQuery({
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
return true;
} catch {
} catch (error) {
return false;
}
};
@@ -165,7 +165,7 @@ export function enableCourseHighlightsEmailsQuery(courseId: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -182,7 +182,7 @@ export function setVideoSharingOptionQuery(courseId: string, option: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
dispatch(hideProcessingNotification());
}
@@ -260,7 +260,7 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -280,7 +280,7 @@ export function publishCourseItemQuery(itemId: string, sectionId: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -300,7 +300,7 @@ export function configureCourseItemQuery(sectionId: string, configureFn: () => P
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -469,7 +469,7 @@ function duplicateCourseItemQuery(
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -541,7 +541,7 @@ function addNewCourseItemQuery(
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -612,7 +612,7 @@ export function addUnitFromLibrary(body: {
callback(result.locator);
}
});
} catch /* istanbul ignore next */ {
} catch (error) /* istanbul ignore next */ {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -643,7 +643,7 @@ function setBlockOrderListQuery(
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
@@ -728,7 +728,7 @@ export function pasteClipboardContent(parentLocator: string, sectionId: string)
dispatch(setPasteFileNotices(result?.staticFileNotices));
}
});
} catch {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch ({ message }) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
@@ -83,7 +83,7 @@ export function deleteCourseTeamQuery(courseId, email) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}

View File

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

View File

@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
localStorage.removeItem('staticFileNotices');
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch {
} catch (error) {
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 {
} catch (error) {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.FAILED }));
}
};

View File

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

View File

@@ -48,7 +48,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<IframePreviewLibraryXBlockChanges />', () => {
beforeEach(() => {

View File

@@ -177,7 +177,7 @@ export const PreviewLibraryXBlockChanges = ({
overrideCustomizations,
});
postChange(accept);
} catch {
} catch (e) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -154,7 +154,7 @@ export const updateSingleCustomPage = ({
}));
setCurrentPage(null);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};

View File

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

View File

@@ -90,7 +90,7 @@ export const useFeedback = (answer) => {
};
export const isSingleAnswerProblem = (problemType) => (
problemType === ProblemTypeKeys.SINGLESELECT || problemType === ProblemTypeKeys.DROPDOWN
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(true);
expect(module.isSingleAnswerProblem(ProblemTypeKeys.SINGLESELECT)).toBe(false);
});
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 and single-select problems can only have one correct answer. When there is more than one correct answer
// Dropdown 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 || typeKey === ProblemTypeKeys.SINGLESELECT) {
if (typeKey === ProblemTypeKeys.DROPDOWN) {
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 { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Form } from '@openedx/paragon';
import PropTypes from 'prop-types';
import SettingsOption from '../../SettingsOption';
@@ -46,13 +46,14 @@ export const getSummary = ({ tolerance, intl }) => {
}
};
export const ToleranceCard = ({
const ToleranceCard = ({
tolerance,
answers,
updateSettings,
correctAnswerCount,
// inject
intl,
}) => {
const intl = useIntl();
const isAnswerRange = isAnswerRangeSet({ answers });
const hasMultipleCorrectAnswers = correctAnswerCount > 1;
let summary = getSummary({ tolerance, intl });
@@ -140,4 +141,8 @@ 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 { ToleranceCard } from './index';
import { ToleranceCardInternal as ToleranceCard } from './index';
import { formatMessage } from '../../../../../../../testUtils';
jest.mock('@edx/frontend-platform/i18n', () => ({
@@ -13,9 +13,6 @@ 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">
rawOLX: `<problem url_name="this_should_be_ignored" copied_from_version="2" copied_from_block="some-block">
<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,7 +3,8 @@ import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
FormattedMessage,
useIntl,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import {
Image,
@@ -31,13 +32,14 @@ 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();
@@ -124,6 +126,8 @@ const ThumbnailWidget = ({
};
ThumbnailWidget.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
isLibrary: PropTypes.bool.isRequired,
allowThumbnailUpload: PropTypes.bool.isRequired,
@@ -140,4 +144,4 @@ export const mapStateToProps = (state) => ({
export const mapDispatchToProps = {};
export const ThumbnailWidgetInternal = ThumbnailWidget; // For testing only
export default connect(mapStateToProps, mapDispatchToProps)(ThumbnailWidget);
export default injectIntl(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 } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl } 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 connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu));

View File

@@ -380,6 +380,8 @@ 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) {
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
localStorage.setItem(storageKey, Date.now());
sessionStorage.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 = /** @type {string} */(reader.result);
const imageBS64 = reader.result.toString();
const parsedContent = typeof content === 'string' ? content.replace(imageBS64, imagePath) : { ...content, olx: content.olx.replace(imageBS64, imagePath) };
URL.revokeObjectURL(asset);
resolve(parsedContent);

View File

@@ -15,9 +15,13 @@ 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,9 +39,7 @@ describe('StrictDict', () => {
expect(Object.values(dict)).toEqual([value1, value2]);
});
it('allows stringification', () => {
// 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.toString()).toEqual(rawDict.toString());
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
}
} else {

View File

@@ -48,7 +48,7 @@ export function fetchAdditionalAssets(courseId, totalCount) {
}));
remainingAssetCount -= 50;
page += 1;
} catch {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
downloadErrors.push('Failed to download video.');
}
} else {

View File

@@ -160,7 +160,7 @@ export function deleteVideoFile(courseId, id) {
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'delete',
@@ -184,7 +184,7 @@ export function markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }
'Upload failed',
'upload_failed',
);
} catch {
} catch (error) {
// 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 {
} catch (error) {
dispatch(failAddVideo({ fileName: file.name }));
return {};
}
@@ -471,7 +471,7 @@ export function deleteVideoTranscript({
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'transcript',
@@ -515,7 +515,7 @@ export function downloadVideoTranscript({
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'transcript',
@@ -638,7 +638,7 @@ export function getUsagePaths({ video, courseId }) {
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'usageMetrics',
@@ -682,7 +682,7 @@ export function fetchVideoDownload({ selectedRows, courseId }) {
}),
);
}
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'download',
@@ -717,7 +717,7 @@ export function clearAutomatedTranscript({ courseId }) {
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'transcript',
@@ -756,7 +756,7 @@ export function updateTranscriptCredentials({ courseId, data }) {
status: RequestStatus.SUCCESSFUL,
}),
);
} catch {
} catch (error) {
dispatch(
updateErrors({
error: 'transcript',

View File

@@ -8,7 +8,7 @@ import {
useToggle,
} from '@openedx/paragon';
import { DeleteOutline } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import { isEmpty } from 'lodash';
import LanguageSelect from './LanguageSelect';
import TranscriptMenu from './TranscriptMenu';
@@ -20,8 +20,9 @@ const Transcript = ({
transcript,
previousSelection,
handleTranscript,
// injected
intl,
}) => {
const intl = useIntl();
const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle();
const [newLanguage, setNewLanguage] = useState(transcript);
const language = transcript;
@@ -121,6 +122,8 @@ Transcript.propTypes = {
transcript: PropTypes.string.isRequired,
previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired,
handleTranscript: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export default Transcript;
export default injectIntl(Transcript);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { isEmpty } from 'lodash';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
import {
Form,
Icon,
@@ -18,8 +18,9 @@ const ThreePlayMediaForm = ({
data,
setData,
transcriptionPlan,
// injected
intl,
}) => {
const intl = useIntl();
if (hasTranscriptCredentials) {
const selectedLanguages = data.preferredLanguages ? data.preferredLanguages : [];
const turnaroundOptions = transcriptionPlan.turnaround;
@@ -132,6 +133,8 @@ ThreePlayMediaForm.propTypes = {
translations: PropTypes.shape({}),
languages: PropTypes.shape({}),
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default ThreePlayMediaForm;
export default injectIntl(ThreePlayMediaForm);

View File

@@ -3,6 +3,12 @@
// (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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (error) {
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 {
} catch (err) {
// Do nothing
} finally {
if (componentMounted.current) {

View File

@@ -106,7 +106,7 @@ const useModalDropzone = ({
onSavingStatus({ status: RequestStatus.SUCCESSFUL });
onClose();
}
} catch {
} catch (error) {
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 {
} catch (err) {
errorMessage = '';
}

View File

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

View File

@@ -1,6 +1,6 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { injectIntl, 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 courseId={courseId} />
<GradingSettings intl={injectIntl} courseId={courseId} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, waitFor, fireEvent } from '@testing-library/react';
@@ -19,6 +19,7 @@ const sortedGrades = [
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<GradingScale
intl={injectIntl}
gradeCutoffs={gradeCutoffs}
gradeLetters={gradeLetters}
sortedGrades={sortedGrades}
@@ -102,6 +103,7 @@ describe('<GradingScale />', () => {
const { getAllByTestId } = render(
<IntlProvider locale="en" messages={{}}>
<GradingScale
intl={injectIntl}
gradeCutoffs={shortGradeCutoffs}
gradeLetters={['A']}
sortedGrades={shortSortedGrades}
@@ -126,6 +128,7 @@ describe('<GradingScale />', () => {
const { getAllByTestId } = render(
<IntlProvider locale="en" messages={{}}>
<GradingScale
intl={injectIntl}
gradeCutoffs={gradeCutoffs}
gradeLetters={gradeLetters}
sortedGrades={sortedGrades}

View File

@@ -1,3 +1,4 @@
import { injectIntl } from '@edx/frontend-platform/i18n';
import {
initializeMocks, render, screen,
} from '../../testUtils';
@@ -10,7 +11,7 @@ describe('<GradingSidebar />', () => {
});
it('renders sidebar text content correctly', async () => {
render(<GradingSidebar courseId="123" />);
render(<GradingSidebar intl={injectIntl} 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, useLibrarySettingsMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems,
} from './hooks';
import messages from './messages';
@@ -20,7 +20,6 @@ interface HeaderProps {
isHiddenMainMenu?: boolean,
isLibrary?: boolean,
containerProps?: ContainerPropsType,
readOnly?: boolean,
}
const Header = ({
@@ -31,7 +30,6 @@ const Header = ({
isHiddenMainMenu = false,
isLibrary = false,
containerProps = {},
readOnly = false,
}: HeaderProps) => {
const intl = useIntl();
const waffleFlags = useWaffleFlags();
@@ -45,8 +43,7 @@ const Header = ({
const settingMenuItems = useSettingMenuItems(contextId);
const toolsMenuItems = useToolsMenuItems(contextId);
const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId);
const libraryToolsSettingsItems = useLibrarySettingsMenuItems(contextId, readOnly);
let mainMenuDropdowns = !isLibrary ? [
const mainMenuDropdowns = !isLibrary ? [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
buttonTitle: intl.formatMessage(messages['header.links.content']),
@@ -68,18 +65,6 @@ 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

@@ -3,15 +3,13 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { Badge } from '@openedx/paragon';
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 { getPagePath } from '../utils';
import { useWaffleFlags } from '../data/apiHooks';
import { getStudioHomeData } from '../studio-home/data/selectors';
import messages from './messages';
import courseOptimizerMessages from '../optimizer-page/messages';
export const useContentMenuItems = (courseId: string) => {
export const useContentMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useWaffleFlags();
@@ -52,7 +50,7 @@ export const useContentMenuItems = (courseId: string) => {
return items;
};
export const useSettingMenuItems = (courseId: string) => {
export const useSettingMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const { canAccessAdvancedSettings } = useSelector(getStudioHomeData);
@@ -91,7 +89,7 @@ export const useSettingMenuItems = (courseId: string) => {
return items;
};
export const useToolsMenuItems = (courseId: string) => {
export const useToolsMenuItems = (courseId) => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useWaffleFlags();
@@ -129,7 +127,7 @@ export const useToolsMenuItems = (courseId: string) => {
return items;
};
export const useLibraryToolsMenuItems = (itemId: string) => {
export const useLibraryToolsMenuItems = itemId => {
const intl = useIntl();
const items = [
@@ -137,49 +135,7 @@ export const useLibraryToolsMenuItems = (itemId: string) => {
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

@@ -2,9 +2,7 @@ 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, useLibrarySettingsMenuItems, useLibraryToolsMenuItems,
} from './hooks';
import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks';
import { mockWaffleFlags } from '../data/apiHooks.mock';
jest.mock('@edx/frontend-platform/i18n', () => ({
@@ -30,7 +28,7 @@ jest.mock('react-redux', () => ({
describe('header utils', () => {
describe('getContentMenuItems', () => {
it('when video upload page enabled should include Video Uploads option', () => {
jest.mocked(useSelector).mockReturnValue({
useSelector.mockReturnValue({
librariesV2Enabled: false,
});
setConfig({
@@ -41,7 +39,7 @@ describe('header utils', () => {
expect(actualItems).toHaveLength(5);
});
it('when video upload page disabled should not include Video Uploads option', () => {
jest.mocked(useSelector).mockReturnValue({
useSelector.mockReturnValue({
librariesV2Enabled: false,
});
setConfig({
@@ -52,7 +50,7 @@ describe('header utils', () => {
expect(actualItems).toHaveLength(4);
});
it('adds course libraries link to content menu when libraries v2 is enabled', () => {
jest.mocked(useSelector).mockReturnValue({
useSelector.mockReturnValue({
librariesV2Enabled: true,
});
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
@@ -62,7 +60,7 @@ describe('header utils', () => {
describe('getSettingsMenuitems', () => {
beforeEach(() => {
jest.mocked(useSelector).mockReturnValue({
useSelector.mockReturnValue({
canAccessAdvancedSettings: true,
});
});
@@ -88,7 +86,7 @@ describe('header utils', () => {
expect(actualItemsTitle).toContain('Advanced Settings');
});
it('when user has no access to advanced settings should not include advanced settings option', () => {
jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: false });
useSelector.mockReturnValue({ canAccessAdvancedSettings: false });
const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title);
expect(actualItemsTitle).not.toContain('Advanced Settings');
});
@@ -139,44 +137,4 @@ 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

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

View File

@@ -14,7 +14,7 @@ export function fetchHelpUrls() {
dispatch(updateLoadingHelpUrlsStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
} catch (error) {
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 } from '@edx/frontend-platform/i18n';
import { IntlProvider, injectIntl } 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 courseId={courseId} />
<FileSection intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);

View File

@@ -74,6 +74,7 @@ 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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 the Content Library you select/i,
/All content from the legacy library you selected will be migrated to/,
)).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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 the Content Library you select/i,
/All content from the 3 legacy libraries you selected will be migrated to/,
)).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 this new library/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).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 the Content Library you select/i,
/All content from the 3 legacy libraries you selected will be migrated to/,
{ 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 {
} catch (error) {
showToast(intl.formatMessage(messages.migrationFailed));
}
}

View File

@@ -1,10 +1,12 @@
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">
@@ -40,7 +42,7 @@ export const LegacyMigrationHelpSidebar = () => (
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportThirdQuestionBody}
values={{ div: Div, p: Paragraph }}
values={{ div: SingleLineBreak, 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 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.',
+ ' 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 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 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 <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.',
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: 500px 0 0;
flex: 530px 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 ?? '') as string);
const requestData = JSON.parse(req.body?.toString() ?? '');
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,7 +167,6 @@ const LibraryAuthoringPage = ({
isLoadingLibraryData,
showOnlyPublished,
extraFilter: contextExtraFilter,
readOnly,
} = useLibraryContext();
const { sidebarItemInfo } = useSidebarContext();
@@ -343,7 +342,6 @@ 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,11 +15,9 @@ 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 {
@@ -50,7 +48,6 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
<CreateCollectionModal />
<CreateContainerModal />
<ComponentEditorModal />
<LibraryTeamModal />
</SidebarProvider>
</LibraryProvider>
);
@@ -93,10 +90,6 @@ 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) => void;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<AddContent />', () => {
beforeEach(() => {

View File

@@ -297,7 +297,7 @@ export const parseErrorMsg = (
if (detail) {
return intl.formatMessage(detailedMessage, { detail });
}
} catch {
} catch (_err) {
// 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, readOnly } = useLibraryContext();
const { libraryId } = 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 {
} catch (error) {
// Fallback to window.location.href if the above fails
window.location.href = url;
}
@@ -144,7 +144,6 @@ 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 {
} catch (err) {
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 ?? '') as string);
const requestData = JSON.parse(req.body?.toString() ?? '');
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,7 +107,6 @@ const LibraryCollectionPage = () => {
showOnlyPublished,
extraFilter: contextExtraFilter,
setCollectionId,
readOnly,
} = useLibraryContext();
const { sidebarItemInfo } = useSidebarContext();
@@ -195,7 +194,6 @@ const LibraryCollectionPage = () => {
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
readOnly={readOnly}
isLibrary
containerProps={{
size: undefined,

View File

@@ -7,6 +7,8 @@ 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';
@@ -25,6 +27,7 @@ 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;
@@ -107,6 +110,13 @@ 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
@@ -131,7 +141,8 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
isLoadingLibraryData,
canPublish,
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
@@ -154,7 +165,9 @@ 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 '@src/hooks';
import { LibQueryParamKeys, useLibraryRoutes } from '@src/library-authoring/routes';
import { useStateWithUrlSearchParam } from '../../../hooks';
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,
LibQueryParamKeys.SidebarTab,
'st',
(value: string) => toSidebarInfoTab(value),
(value: SidebarInfoTab) => value.toString(),
);
const [sidebarAction, setSidebarAction] = useStateWithUrlSearchParam<SidebarActions>(
SidebarActions.None,
LibQueryParamKeys.SidebarActions,
'sa',
(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 {
} catch (err) {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
}
};

View File

@@ -30,7 +30,7 @@ export const ComponentPublisher = ({
try {
await publishComponent.mutateAsync();
showToast(intl.formatMessage(messages.publishSuccessMsg));
} catch {
} catch (error) {
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" />
<Icon src={itemIcon} className="library-item-header-icon my-2" />
}
actions={(
<div

View File

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

View File

@@ -35,7 +35,7 @@ const renderArgs = {
),
};
let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: any; };
let mockShowToast: { (message: string, action?: ToastActionData | undefined): 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 {
} catch (e) {
showToast(intl.formatMessage(messages.undoDeleteComponentToastFailed));
}
}, []);

View File

@@ -51,7 +51,7 @@ const renderArgs = {
),
};
let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: any; };
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
[
'unit' as const,

View File

@@ -146,7 +146,7 @@ const ContainerDeleter = ({
try {
await restoreContainerMutation.mutateAsync();
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
} catch {
} catch (e) {
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 {
} catch (err) {
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
}
};

View File

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

View File

@@ -8,10 +8,11 @@ import {
Icon,
IconButton,
useToggle,
Alert,
} from '@openedx/paragon';
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { MoreVert } from '@openedx/paragon/icons';
import { InfoOutline, MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '@src/generic/clipboard';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
@@ -149,6 +150,15 @@ 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 {
@@ -222,7 +232,7 @@ const ContainerInfo = () => {
{renderTab(
CONTAINER_INFO_TABS.Settings,
intl.formatMessage(messages.settingsTabTitle),
// TODO: container settings component
<ContainerSettings />,
)}
</Tabs>
</Stack>

View File

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

View File

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

View File

@@ -66,6 +66,11 @@ 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 {
} catch (error) {
showToast(labels.errorMsg);
} finally {
handleClose();

View File

@@ -435,7 +435,7 @@ describe('<CreateLibrary />', () => {
sections: 8,
subsections: 12,
units: 20,
createdOnServer: '2025-01-01T10:00:00Z',
createdOnServer: 'test.com',
createdAt: '2025-01-01T10:00:00Z',
createdBy: {
username: 'testuser',
@@ -478,7 +478,67 @@ describe('<CreateLibrary />', () => {
await waitFor(() => {
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
expect(screen.getByText(/Contains 15 Components/i)).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();
});
});

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