Compare commits
110 Commits
chris/FAL-
...
feat/add_g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9610f0791f | ||
|
|
0f58329cb4 | ||
|
|
54cfbeb756 | ||
|
|
7cf01de84c | ||
|
|
a1abd43a11 | ||
|
|
8f06263e27 | ||
|
|
e10ab270dd | ||
|
|
a5d65abea2 | ||
|
|
5ec00236cb | ||
|
|
2530b01b82 | ||
|
|
13c51ce5a8 | ||
|
|
b2cfafc00e | ||
|
|
6d619b9c40 | ||
|
|
6afe6095a5 | ||
|
|
1b357cb2b6 | ||
|
|
2de987b254 | ||
|
|
4299bf16b4 | ||
|
|
5cda284cdb | ||
|
|
436ac3155d | ||
|
|
86a7e06a3c | ||
|
|
bd82c1d33d | ||
|
|
75ae9d549c | ||
|
|
cec074e6d4 | ||
|
|
36c9eba66d | ||
|
|
2dc087f87a | ||
|
|
9b77a40284 | ||
|
|
871d98828c | ||
|
|
f116740184 | ||
|
|
cd967a9878 | ||
|
|
d8805bf2b4 | ||
|
|
0972b7e62d | ||
|
|
15a728d0e7 | ||
|
|
157e2464aa | ||
|
|
c54c21e2b4 | ||
|
|
dc05ccfd16 | ||
|
|
106f22b3c2 | ||
|
|
76d8b2e03a | ||
|
|
5ce61fa5e5 | ||
|
|
46fa17ea83 | ||
|
|
9f1604110b | ||
|
|
82a3c2815b | ||
|
|
191be55b2e | ||
|
|
8f7e48421f | ||
|
|
b6de9a8883 | ||
|
|
7bfc73073b | ||
|
|
98009b3e6a | ||
|
|
e80930e06f | ||
|
|
66dad5ff32 | ||
|
|
e4ea69266f | ||
|
|
8b6a350808 | ||
|
|
a56faf8ca7 | ||
|
|
77215eeb5e | ||
|
|
311bef67ed | ||
|
|
195249ef26 | ||
|
|
4a26a86c90 | ||
|
|
411d4f053c | ||
|
|
9c0b545b2f | ||
|
|
cd36407457 | ||
|
|
46d2465064 | ||
|
|
6c829b9421 | ||
|
|
1d6fdc39fd | ||
|
|
04e9a253ba | ||
|
|
8470d7cd4d | ||
|
|
b774084a10 | ||
|
|
aadccc748c | ||
|
|
c4a439df47 | ||
|
|
8fe5fb6a20 | ||
|
|
0315c05e11 | ||
|
|
a5d51ce4f4 | ||
|
|
3a6378e569 | ||
|
|
e37f2e0071 | ||
|
|
835de77385 | ||
|
|
7f23e9b585 | ||
|
|
32ed2f183b | ||
|
|
292068af6e | ||
|
|
a975f3b716 | ||
|
|
1c7ad2f725 | ||
|
|
7ba3db0187 | ||
|
|
fdf98a1400 | ||
|
|
56d3eede64 | ||
|
|
c5de944d72 | ||
|
|
523dd1f389 | ||
|
|
cffc4d77c9 | ||
|
|
25160347b3 | ||
|
|
d63680083d | ||
|
|
39e5f89b45 | ||
|
|
7e81b52583 | ||
|
|
1efa94d410 | ||
|
|
d98a34ac3f | ||
|
|
8b530481de | ||
|
|
9f6a882e61 | ||
|
|
b95b3a60ad | ||
|
|
61c87fe6a6 | ||
|
|
c21b664a8b | ||
|
|
71376fa22b | ||
|
|
23b4f4731e | ||
|
|
ab645ad86b | ||
|
|
720b591add | ||
|
|
87239ab723 | ||
|
|
0117c1eae3 | ||
|
|
0f7c8de882 | ||
|
|
387c45a5b2 | ||
|
|
6377fbd896 | ||
|
|
67fab054ab | ||
|
|
a7860b8392 | ||
|
|
3082eca91c | ||
|
|
2fb04d670f | ||
|
|
f79b65c273 | ||
|
|
472d77823f | ||
|
|
09f4304daa |
2
.env
2
.env
@@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
@@ -48,3 +49,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
@@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
@@ -51,3 +52,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
@@ -33,10 +33,10 @@ ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
|
||||
@@ -14,6 +14,15 @@ module.exports = createConfig(
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['@edx/frontend-platform/i18n'],
|
||||
importNames: ['injectIntl'],
|
||||
message: "Use 'useIntl' hook instead of injectIntl.",
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -30,9 +30,9 @@ We're trying to move away from some deprecated patterns in this codebase. Please
|
||||
check if your PR meets these recommendations before asking for a review:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
|
||||
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
|
||||
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
|
||||
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
|
||||
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
|
||||
15
.github/workflows/add-to-cc-board.yml
vendored
Normal file
15
.github/workflows/add-to-cc-board.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Trigger to add Issue or PR to a Core Contributor project board
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
add-to-cc-board:
|
||||
if: github.event.label.name == 'Core Contributor assignee'
|
||||
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
|
||||
with:
|
||||
board_name: cc-frontend-apps
|
||||
secrets:
|
||||
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}
|
||||
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
@@ -27,9 +27,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: code-coverage-report
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -11,4 +11,5 @@ coverage:
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
||||
- "src/container-comparison/data/api.mock.ts"
|
||||
- "src/index.js"
|
||||
|
||||
9638
package-lock.json
generated
9638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -45,7 +45,7 @@
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.9.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-component-header": "^8.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -59,17 +59,17 @@
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.5.0",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.40.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"@tanstack/react-query": "5.90.7",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^4.0.10",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
@@ -97,7 +97,7 @@
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"universal-cookie": "^8.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.32.11"
|
||||
|
||||
@@ -125,10 +125,13 @@ describe('ORASettings', () => {
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
renderComponent();
|
||||
|
||||
waitFor(() => {
|
||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
@@ -20,7 +20,6 @@ const defaultProps = {
|
||||
courseId,
|
||||
onClose: () => {},
|
||||
};
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
let store;
|
||||
|
||||
const intlWrapper = children => (
|
||||
@@ -102,7 +101,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Field dependencies', () => {
|
||||
beforeEach(async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
|
||||
@@ -152,7 +151,7 @@ describe('ProctoredExamSettings', () => {
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctored exams');
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||
@@ -409,7 +408,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
||||
});
|
||||
@@ -418,7 +417,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -428,7 +427,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const org = 'test-org';
|
||||
setupApp(isAdmin, org);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -437,7 +436,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -446,7 +445,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
@@ -457,7 +456,7 @@ describe('ProctoredExamSettings', () => {
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -470,7 +469,7 @@ describe('ProctoredExamSettings', () => {
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -483,7 +482,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -497,7 +496,7 @@ describe('ProctoredExamSettings', () => {
|
||||
EXAMS_BASE_URL: null,
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
@@ -516,7 +515,7 @@ describe('ProctoredExamSettings', () => {
|
||||
).reply(200, {
|
||||
provider: 'test_lti',
|
||||
});
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctoring provider');
|
||||
});
|
||||
@@ -529,14 +528,14 @@ describe('ProctoredExamSettings', () => {
|
||||
describe('Toggles field visibility based on user permissions', () => {
|
||||
it('Hides opting out and zendesk tickets for non edX staff', async () => {
|
||||
setupApp(false);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
});
|
||||
|
||||
it('Shows opting out and zendesk tickets for edX staff', async () => {
|
||||
setupApp(true);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
|
||||
});
|
||||
@@ -544,7 +543,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Connection states', () => {
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
@@ -554,7 +553,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -566,7 +565,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
@@ -578,7 +577,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(403);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const permissionError = screen.getByTestId('permissionDeniedAlert');
|
||||
expect(permissionError.textContent).toEqual(
|
||||
expect.stringContaining('You are not authorized to view this page'),
|
||||
@@ -597,7 +596,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
fireEvent.click(submitButton);
|
||||
@@ -607,7 +606,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
@@ -638,7 +637,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
|
||||
// make sure we have not selected proctortrack as the proctoring provider
|
||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||
@@ -665,7 +664,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
@@ -706,7 +705,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// update exam service config
|
||||
@@ -750,7 +749,7 @@ describe('ProctoredExamSettings', () => {
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
// does not update exam service config
|
||||
@@ -780,7 +779,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -798,7 +797,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(500, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -816,7 +815,7 @@ describe('ProctoredExamSettings', () => {
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(403, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -835,7 +834,7 @@ describe('ProctoredExamSettings', () => {
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -868,7 +867,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'authoring.proctoring.alert.error': {
|
||||
id: 'authoring.proctoring.alert.error',
|
||||
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
|
||||
description: 'Alert message for proctoring settings save error.',
|
||||
},
|
||||
'authoring.proctoring.alert.forbidden': {
|
||||
id: 'authoring.proctoring.alert.forbidden',
|
||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
||||
|
||||
@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertPluginConfigurable(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
|
||||
try {
|
||||
const { response } = await getXpertSettings(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
|
||||
}
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
16
src/__mocks__/clipboardSection.ts
Normal file
16
src/__mocks__/clipboardSection.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'chapter',
|
||||
blockTypeDisplay: 'Section',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Chapter 1',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
export { default as clipboardSection } from './clipboardSection';
|
||||
@@ -58,7 +58,7 @@ export function updateCourseAppSetting(courseId, settings) {
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function fetchProctoringExamErrors(courseId) {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -22,7 +21,6 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
@@ -86,10 +84,10 @@ describe('<SettingCard />', () => {
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await (async () => {
|
||||
await user.tab(); // blur off of the input.
|
||||
await waitFor(() => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ const SettingCard = ({
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
@@ -118,7 +118,7 @@ SettingCard.propTypes = {
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||
try {
|
||||
JSON.parse(settingValue);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
let targetSettingValue = settingValue;
|
||||
const firstNonWhite = settingValue.substring(0, 1);
|
||||
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
|
||||
...prevEditedSettings,
|
||||
[settingName]: targetSettingValue,
|
||||
}));
|
||||
} catch (quotedE) { /* empty */ }
|
||||
} catch { /* empty */ }
|
||||
}
|
||||
|
||||
pushDataToErrorArray(settingName);
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('CertificateDetails', () => {
|
||||
|
||||
await user.type(input, newInputValue);
|
||||
|
||||
waitFor(() => {
|
||||
await waitFor(() => {
|
||||
expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('CertificateSignatories', () => {
|
||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
it.skip('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getAllByRole } = renderComponent(defaultProps);
|
||||
|
||||
@@ -105,7 +105,9 @@ describe('CertificateSignatories', () => {
|
||||
|
||||
await user.click(deleteIcons[0]);
|
||||
|
||||
waitFor(() => {
|
||||
// FIXME: this isn't called because the whole 'useEditSignatory' hook
|
||||
// which calls it is mocked out.
|
||||
await waitFor(() => {
|
||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -30,6 +30,7 @@ const initialState = {
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
index: 0,
|
||||
...signatoriesMock[0],
|
||||
showDeleteButton: true,
|
||||
isEdit: true,
|
||||
@@ -62,31 +63,36 @@ describe('Signatory Component', () => {
|
||||
it('handles input change', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = jest.fn();
|
||||
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
const newInputValue = 'Jane Doe';
|
||||
|
||||
await user.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
expect(input.value).not.toBe(newInputValue);
|
||||
await user.type(input, newInputValue);
|
||||
|
||||
waitFor(() => {
|
||||
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
||||
expect(input.value).toBe(newInputValue);
|
||||
await waitFor(() => {
|
||||
// This is not a great test; handleChange() gets called for each key press:
|
||||
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length);
|
||||
// And the input value never actually changes because it's a controlled component
|
||||
// and we pass the name in as a prop, which hasn't changed.
|
||||
// expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens image upload modal on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
||||
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
|
||||
const replaceButton = getByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||
);
|
||||
|
||||
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(replaceButton);
|
||||
|
||||
expect(getByRole('presentation')).toBeInTheDocument();
|
||||
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirm modal on delete icon click', async () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('HeaderButtons Component', () => {
|
||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||
await user.click(dropdownButton);
|
||||
|
||||
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
const verifiedMode = getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
await user.click(verifiedMode);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export const STATEFUL_BUTTON_STATES = {
|
||||
default: 'default',
|
||||
pending: 'pending',
|
||||
error: 'error',
|
||||
disable: 'disable',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
@@ -61,6 +62,8 @@ export const COURSE_BLOCK_NAMES = ({
|
||||
libraryContent: { id: 'library_content', name: 'Library content' },
|
||||
splitTest: { id: 'split_test', name: 'Split Test' },
|
||||
component: { id: 'component', name: 'Component' },
|
||||
itembank: { id: 'itembank', name: 'Problem Bank' },
|
||||
legacyLibraryContent: { id: 'library_content', name: 'Randomized Content Block' },
|
||||
});
|
||||
|
||||
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
|
||||
@@ -107,3 +110,9 @@ export const iframeMessageTypes = {
|
||||
xblockEvent: 'xblock-event',
|
||||
xblockScroll: 'xblock-scroll',
|
||||
};
|
||||
|
||||
export const BROKEN = 'broken';
|
||||
|
||||
export const LOCKED = 'locked';
|
||||
|
||||
export const MANUAL = 'manual';
|
||||
|
||||
26
src/container-comparison/ChildrenPreview.tsx
Normal file
26
src/container-comparison/ChildrenPreview.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
side: 'Before' | 'After';
|
||||
}
|
||||
|
||||
const ChildrenPreview = ({ title, children, side }: Props) => {
|
||||
const intl = useIntl();
|
||||
const sideTitle = side === 'Before'
|
||||
? intl.formatMessage(messages.diffBeforeTitle)
|
||||
: intl.formatMessage(messages.diffAfterTitle);
|
||||
|
||||
return (
|
||||
<Stack direction="vertical">
|
||||
<span className="text-center">{sideTitle}</span>
|
||||
<span className="mt-2 mb-3 text-md text-gray-800">{title}</span>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildrenPreview;
|
||||
162
src/container-comparison/CompareContainersWidget.test.tsx
Normal file
162
src/container-comparison/CompareContainersWidget.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { getLibraryContainerApiUrl } from '@src/library-authoring/data/api';
|
||||
import { mockGetContainerChildren, mockGetContainerMetadata } from '@src/library-authoring/data/api.mocks';
|
||||
import { initializeMocks, render, screen } from '@src/testUtils';
|
||||
import { CompareContainersWidget } from './CompareContainersWidget';
|
||||
import { mockGetCourseContainerChildren } from './data/api.mock';
|
||||
|
||||
mockGetCourseContainerChildren.applyMock();
|
||||
mockGetContainerChildren.applyMock();
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
describe('CompareContainersWidget', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock } = initializeMocks());
|
||||
});
|
||||
|
||||
test('renders the component with a title', async () => {
|
||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
||||
/>);
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
expect((await screen.findAllByText('subsection block 0')).length).toEqual(1);
|
||||
expect((await screen.findAllByText('subsection block 00')).length).toEqual(1);
|
||||
expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3);
|
||||
expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3);
|
||||
expect((await screen.findAllByText('subsection block 1')).length).toEqual(1);
|
||||
expect((await screen.findAllByText('subsection block 2')).length).toEqual(1);
|
||||
expect((await screen.findAllByText('subsection block 11')).length).toEqual(1);
|
||||
expect((await screen.findAllByText('subsection block 22')).length).toEqual(1);
|
||||
expect(screen.queryByText(
|
||||
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
|
||||
)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders loading spinner when data is pending', async () => {
|
||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionIdLoading);
|
||||
axiosMock.onGet(url).reply(() => new Promise(() => {}));
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionIdLoading}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading}
|
||||
/>);
|
||||
const spinner = await screen.findAllByRole('status');
|
||||
expect(spinner.length).toEqual(4);
|
||||
expect(spinner[0].textContent).toEqual('Loading...');
|
||||
expect(spinner[1].textContent).toEqual('Loading...');
|
||||
expect(spinner[2].textContent).toEqual('Loading...');
|
||||
expect(spinner[3].textContent).toEqual('Loading...');
|
||||
});
|
||||
|
||||
test('calls onRowClick when a row is clicked and updates diff view', async () => {
|
||||
// mocks title
|
||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
axiosMock.onGet(
|
||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
||||
/>);
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
// left i.e. before side block
|
||||
let block = await screen.findByText('subsection block 00');
|
||||
await user.click(block);
|
||||
// Breadcrumbs - shows old and new name
|
||||
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
|
||||
|
||||
// Back breadcrumb
|
||||
const backbtns = await screen.findAllByRole('button', { name: 'Back' });
|
||||
expect(backbtns.length).toEqual(2);
|
||||
|
||||
// Go back
|
||||
await user.click(backbtns[0]);
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
// right i.e. after side block
|
||||
block = await screen.findByText('subsection block 0');
|
||||
|
||||
// After side click also works
|
||||
await user.click(block);
|
||||
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show removed container diff state', async () => {
|
||||
// mocks title
|
||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
axiosMock.onGet(
|
||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
|
||||
/>);
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
// left i.e. before side block
|
||||
const block = await screen.findByText('subsection block 00');
|
||||
await user.click(block);
|
||||
|
||||
const removedRows = await screen.findAllByText('This unit was removed');
|
||||
await user.click(removedRows[0]);
|
||||
|
||||
expect(await screen.findByText('This unit has been removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show new added container diff state', async () => {
|
||||
// mocks title
|
||||
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
axiosMock.onGet(
|
||||
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
|
||||
).reply(200, { publishedDisplayName: 'subsection block 0' });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId="block-v1:UNIX+UX1+2025_T3+type@section+block@0-new"
|
||||
/>);
|
||||
const blocks = await screen.findAllByText('This subsection will be added in the new version');
|
||||
await user.click(blocks[0]);
|
||||
|
||||
expect(await screen.findByText(/this subsection is new/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show alert if the only change is a single text component with local overrides', async () => {
|
||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertSingleText}
|
||||
/>);
|
||||
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
|
||||
expect(screen.getByText(
|
||||
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Html block 11/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show alert if the only changes is multiple text components with local overrides', async () => {
|
||||
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
|
||||
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
|
||||
render(<CompareContainersWidget
|
||||
upstreamBlockId={mockGetContainerMetadata.sectionId}
|
||||
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertMultipleText}
|
||||
/>);
|
||||
|
||||
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
|
||||
|
||||
expect(screen.getByText(
|
||||
/the only change is to which have been edited in this course\. accepting will not remove local edits\./i,
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 text blocks/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
300
src/container-comparison/CompareContainersWidget.tsx
Normal file
300
src/container-comparison/CompareContainersWidget.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Breadcrumb, Button, Card, Icon, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { ArrowBack, Add, Delete } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
import ErrorAlert from '@src/generic/alert-error';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
|
||||
import { BoldText } from '@src/utils';
|
||||
|
||||
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||
import ChildrenPreview from './ChildrenPreview';
|
||||
import ContainerRow from './ContainerRow';
|
||||
import { useCourseContainerChildren } from './data/apiHooks';
|
||||
import {
|
||||
ContainerChild, ContainerChildBase, ContainerState, WithState,
|
||||
} from './types';
|
||||
import { diffPreviewContainerChildren, isRowClickable } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
interface ContainerInfoProps {
|
||||
upstreamBlockId: string;
|
||||
downstreamBlockId: string;
|
||||
isReadyToSyncIndividually?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends ContainerInfoProps {
|
||||
parent: ContainerInfoProps[];
|
||||
onRowClick: (row: WithState<ContainerChild>) => void;
|
||||
onBackBtnClick: () => void;
|
||||
state?: ContainerState;
|
||||
// This two props are used to show an alert for the changes to text components with local overrides.
|
||||
// They may be removed in the future.
|
||||
localUpdateAlertCount: number;
|
||||
localUpdateAlertBlockName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual implementation of the displaying diff between children of containers.
|
||||
*/
|
||||
const CompareContainersWidgetInner = ({
|
||||
upstreamBlockId,
|
||||
downstreamBlockId,
|
||||
parent,
|
||||
state,
|
||||
onRowClick,
|
||||
onBackBtnClick,
|
||||
localUpdateAlertCount,
|
||||
localUpdateAlertBlockName,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const { data, isError, error } = useCourseContainerChildren(downstreamBlockId, parent.length === 0);
|
||||
// There is the case in which the item is removed, but it still exists
|
||||
// in the library, for that case, we avoid bringing the children.
|
||||
const {
|
||||
data: libData,
|
||||
isError: isLibError,
|
||||
error: libError,
|
||||
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||
const {
|
||||
data: containerData,
|
||||
isError: isContainerTitleError,
|
||||
error: containerTitleError,
|
||||
} = useContainer(upstreamBlockId);
|
||||
|
||||
const result = useMemo(() => {
|
||||
if ((!data || !libData) && !['added', 'removed'].includes(state || '')) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
return diffPreviewContainerChildren(data?.children || [], libData as ContainerChildBase[] || []);
|
||||
}, [data, libData]);
|
||||
|
||||
const renderBeforeChildren = useCallback(() => {
|
||||
if (!result[0] && state !== 'added') {
|
||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
||||
}
|
||||
|
||||
if (state === 'added') {
|
||||
return (
|
||||
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
|
||||
<Icon src={Add} className="big-icon" />
|
||||
<FormattedMessage
|
||||
{...messages.newContainer}
|
||||
values={{
|
||||
containerType: getBlockType(upstreamBlockId),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return result[0]?.map((child) => (
|
||||
<ContainerRow
|
||||
key={child.id}
|
||||
title={child.name}
|
||||
containerType={child.blockType}
|
||||
state={child.state}
|
||||
originalName={child.originalName}
|
||||
side="Before"
|
||||
onClick={() => onRowClick(child)}
|
||||
/>
|
||||
));
|
||||
}, [result]);
|
||||
|
||||
const renderAfterChildren = useCallback(() => {
|
||||
if (!result[1] && state !== 'removed') {
|
||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
||||
}
|
||||
|
||||
if (state === 'removed') {
|
||||
return (
|
||||
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
|
||||
<Icon src={Delete} className="big-icon" />
|
||||
<FormattedMessage
|
||||
{...messages.deletedContainer}
|
||||
values={{
|
||||
containerType: getBlockType(upstreamBlockId),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return result[1]?.map((child) => (
|
||||
<ContainerRow
|
||||
key={child.id}
|
||||
title={child.name}
|
||||
containerType={child.blockType}
|
||||
state={child.state}
|
||||
side="After"
|
||||
onClick={() => onRowClick(child)}
|
||||
/>
|
||||
));
|
||||
}, [result]);
|
||||
|
||||
const getTitleComponent = useCallback((title?: string | null) => {
|
||||
if (!title) {
|
||||
return <div className="m-auto"><LoadingSpinner /></div>;
|
||||
}
|
||||
|
||||
if (parent.length === 0) {
|
||||
return title;
|
||||
}
|
||||
return (
|
||||
<Breadcrumb
|
||||
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
|
||||
links={[
|
||||
{
|
||||
// This raises failed prop-type error as label expects a string but it works without any issues
|
||||
label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>,
|
||||
onClick: onBackBtnClick,
|
||||
variant: 'link',
|
||||
className: 'px-0 text-gray-900',
|
||||
},
|
||||
{
|
||||
label: title,
|
||||
variant: 'link',
|
||||
className: 'px-0 text-gray-900',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
linkAs={Button}
|
||||
/>
|
||||
);
|
||||
}, [parent]);
|
||||
|
||||
let beforeTitle: string | undefined | null = data?.displayName;
|
||||
let afterTitle = containerData?.publishedDisplayName;
|
||||
if (!data && state === 'added') {
|
||||
beforeTitle = containerData?.publishedDisplayName;
|
||||
}
|
||||
if (!containerData && state === 'removed') {
|
||||
afterTitle = data?.displayName;
|
||||
}
|
||||
|
||||
if (isError || (isLibError && state !== 'removed') || (isContainerTitleError && state !== 'removed')) {
|
||||
return <ErrorAlert error={error || libError || containerTitleError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compare-changes-widget row justify-content-center">
|
||||
{localUpdateAlertCount > 0 && (
|
||||
<Alert variant="info">
|
||||
<FormattedMessage
|
||||
{...messages.localChangeInTextAlert}
|
||||
values={{
|
||||
blockName: localUpdateAlertBlockName,
|
||||
count: localUpdateAlertCount,
|
||||
b: BoldText,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="col col-6 p-1">
|
||||
<Card className="compare-card p-4">
|
||||
<ChildrenPreview title={getTitleComponent(beforeTitle)} side="Before">
|
||||
{renderBeforeChildren()}
|
||||
</ChildrenPreview>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col col-6 p-1">
|
||||
<Card className="compare-card p-4">
|
||||
<ChildrenPreview title={getTitleComponent(afterTitle)} side="After">
|
||||
{renderAfterChildren()}
|
||||
</ChildrenPreview>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CompareContainersWidget component. Displays a diff of set of child containers from two different sources
|
||||
* and allows the user to select the container to view. This is a wrapper component that maintains current
|
||||
* source state. Actual implementation of the diff view is done by CompareContainersWidgetInner.
|
||||
*/
|
||||
export const CompareContainersWidget = ({
|
||||
upstreamBlockId,
|
||||
downstreamBlockId,
|
||||
isReadyToSyncIndividually = false,
|
||||
}: ContainerInfoProps) => {
|
||||
const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & {
|
||||
state?: ContainerState;
|
||||
parent:(ContainerInfoProps & { state?: ContainerState })[];
|
||||
}>({
|
||||
upstreamBlockId,
|
||||
downstreamBlockId,
|
||||
parent: [],
|
||||
state: 'modified',
|
||||
});
|
||||
|
||||
const { data } = useCourseContainerChildren(downstreamBlockId, true);
|
||||
let localUpdateAlertBlockName = '';
|
||||
let localUpdateAlertCount = 0;
|
||||
|
||||
// Show this alert if the only change is text components with local overrides.
|
||||
// We decided not to put this in `CompareContainersWidgetInner` because if you enter a child,
|
||||
// the alert would disappear. By keeping this call in CompareContainersWidget,
|
||||
// the alert remains in the modal regardless of whether you navigate within the children.
|
||||
if (!isReadyToSyncIndividually && data?.upstreamReadyToSyncChildrenInfo
|
||||
&& data.upstreamReadyToSyncChildrenInfo.every(value => value.downstreamCustomized.length > 0 && value.blockType === 'html')
|
||||
) {
|
||||
localUpdateAlertCount = data.upstreamReadyToSyncChildrenInfo.length;
|
||||
if (localUpdateAlertCount === 1) {
|
||||
localUpdateAlertBlockName = data.upstreamReadyToSyncChildrenInfo[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
const onRowClick = (row: WithState<ContainerChild>) => {
|
||||
if (!isRowClickable(row.state, row.blockType as ContainerType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentContainerState((prev) => ({
|
||||
upstreamBlockId: row.id!,
|
||||
downstreamBlockId: row.downstreamId!,
|
||||
state: row.state,
|
||||
parent: [...prev.parent, {
|
||||
upstreamBlockId: prev.upstreamBlockId,
|
||||
downstreamBlockId: prev.downstreamBlockId,
|
||||
state: prev.state,
|
||||
}],
|
||||
}));
|
||||
};
|
||||
|
||||
const onBackBtnClick = () => {
|
||||
setCurrentContainerState((prev) => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (prev.parent.length < 1) {
|
||||
return prev;
|
||||
}
|
||||
const prevParent = prev.parent[prev.parent.length - 1];
|
||||
return {
|
||||
upstreamBlockId: prevParent!.upstreamBlockId,
|
||||
downstreamBlockId: prevParent!.downstreamBlockId,
|
||||
state: prevParent!.state,
|
||||
parent: prev.parent.slice(0, -1),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CompareContainersWidgetInner
|
||||
upstreamBlockId={currentContainerState.upstreamBlockId}
|
||||
downstreamBlockId={currentContainerState.downstreamBlockId}
|
||||
parent={currentContainerState.parent}
|
||||
state={currentContainerState.state}
|
||||
onRowClick={onRowClick}
|
||||
onBackBtnClick={onBackBtnClick}
|
||||
localUpdateAlertCount={localUpdateAlertCount}
|
||||
localUpdateAlertBlockName={localUpdateAlertBlockName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
96
src/container-comparison/ContainerRow.test.tsx
Normal file
96
src/container-comparison/ContainerRow.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
fireEvent, initializeMocks, render, screen,
|
||||
} from '../testUtils';
|
||||
import ContainerRow from './ContainerRow';
|
||||
import messages from './messages';
|
||||
|
||||
describe('<ContainerRow />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
test('renders with default props', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" />);
|
||||
expect(await screen.findByText('Test title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with modified state', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="modified" />);
|
||||
expect(await screen.findByText(
|
||||
messages.modifiedDiffBeforeMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with removed state', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="removed" />);
|
||||
expect(await screen.findByText(
|
||||
messages.removedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onClick when clicked', async () => {
|
||||
const onClick = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerRow
|
||||
title="Test title"
|
||||
containerType="subsection"
|
||||
side="Before"
|
||||
state="modified"
|
||||
onClick={onClick}
|
||||
/>);
|
||||
const titleDiv = await screen.findByText('Test title');
|
||||
const card = titleDiv.closest('.clickable');
|
||||
expect(card).not.toBe(null);
|
||||
await user.click(card!);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('calls onClick when pressed enter or space', async () => {
|
||||
const onClick = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerRow
|
||||
title="Test title"
|
||||
containerType="subsection"
|
||||
side="Before"
|
||||
state="modified"
|
||||
onClick={onClick}
|
||||
/>);
|
||||
const titleDiv = await screen.findByText('Test title');
|
||||
const card = titleDiv.closest('.clickable');
|
||||
expect(card).not.toBe(null);
|
||||
fireEvent.select(card!);
|
||||
await user.keyboard('{enter}');
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders with originalName', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="locallyRenamed" originalName="Modified name" />);
|
||||
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with local content update', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyContentUpdated" />);
|
||||
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with rename and local content update', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyRenamedAndContentUpdated" originalName="Modified name" />);
|
||||
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with moved state', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="moved" />);
|
||||
expect(await screen.findByText(
|
||||
messages.movedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with added state', async () => {
|
||||
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="added" />);
|
||||
expect(await screen.findByText(
|
||||
messages.addedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
132
src/container-comparison/ContainerRow.tsx
Normal file
132
src/container-comparison/ContainerRow.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ActionRow, Card, Icon, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Cached, ChevronRight, Delete, Done, Plus,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import messages from './messages';
|
||||
import { ContainerState } from './types';
|
||||
import { isRowClickable } from './utils';
|
||||
|
||||
export interface ContainerRowProps {
|
||||
title: string;
|
||||
containerType: ContainerType | keyof typeof COMPONENT_TYPES | string;
|
||||
state?: ContainerState;
|
||||
side: 'Before' | 'After';
|
||||
originalName?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface StateContext {
|
||||
className: string;
|
||||
icon: React.ComponentType;
|
||||
message?: MessageDescriptor;
|
||||
message2?: MessageDescriptor;
|
||||
}
|
||||
|
||||
const ContainerRow = ({
|
||||
title, containerType, state, side, originalName, onClick,
|
||||
}: ContainerRowProps) => {
|
||||
const isClickable = isRowClickable(state, containerType as ContainerType);
|
||||
const stateContext: StateContext = useMemo(() => {
|
||||
let message: MessageDescriptor | undefined;
|
||||
let message2: MessageDescriptor | undefined;
|
||||
switch (state) {
|
||||
case 'added':
|
||||
message = side === 'Before' ? messages.addedDiffBeforeMessage : messages.addedDiffAfterMessage;
|
||||
return { className: 'text-white bg-success-500', icon: Plus, message };
|
||||
case 'modified':
|
||||
message = side === 'Before' ? messages.modifiedDiffBeforeMessage : messages.modifiedDiffAfterMessage;
|
||||
return { className: 'text-white bg-warning-900', icon: Cached, message };
|
||||
case 'removed':
|
||||
message = side === 'Before' ? messages.removedDiffBeforeMessage : messages.removedDiffAfterMessage;
|
||||
return { className: 'text-white bg-danger-600', icon: Delete, message };
|
||||
case 'locallyRenamed':
|
||||
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
|
||||
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
|
||||
case 'locallyContentUpdated':
|
||||
message = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
|
||||
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
|
||||
case 'locallyRenamedAndContentUpdated':
|
||||
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
|
||||
message2 = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
|
||||
return {
|
||||
className: 'bg-light-300 text-light-300 ', icon: Done, message, message2,
|
||||
};
|
||||
case 'moved':
|
||||
message = side === 'Before' ? messages.movedDiffBeforeMessage : messages.movedDiffAfterMessage;
|
||||
return { className: 'bg-light-300 text-light-300', icon: Done, message };
|
||||
default:
|
||||
return { className: 'bg-light-300 text-light-300', icon: Done, message };
|
||||
}
|
||||
}, [state, side]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
isClickable={isClickable}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
className="mb-2 rounded shadow-sm border border-light-100"
|
||||
>
|
||||
<Stack direction="horizontal" gap={0}>
|
||||
<div
|
||||
className={`px-1 align-self-stretch align-content-center rounded-left ${stateContext.className}`}
|
||||
>
|
||||
<Icon size="sm" src={stateContext.icon} />
|
||||
</div>
|
||||
<ActionRow className="p-2">
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon
|
||||
src={getItemIcon(containerType)}
|
||||
screenReaderText={containerType}
|
||||
title={title}
|
||||
/>
|
||||
<span className="small font-weight-bold">{title}</span>
|
||||
</Stack>
|
||||
{stateContext.message ? (
|
||||
<div className="d-flex flex-column">
|
||||
<span className="micro">
|
||||
<FormattedMessage
|
||||
{...stateContext.message}
|
||||
values={{
|
||||
blockType: containerType,
|
||||
name: originalName,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{stateContext.message2 && (
|
||||
<span className="micro">
|
||||
<FormattedMessage
|
||||
{...stateContext.message2}
|
||||
values={{
|
||||
blockType: containerType,
|
||||
name: originalName,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="micro"> </span>
|
||||
)}
|
||||
</Stack>
|
||||
<ActionRow.Spacer />
|
||||
{isClickable && <Icon size="md" src={ChevronRight} />}
|
||||
</ActionRow>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerRow;
|
||||
116
src/container-comparison/data/api.mock.ts
Normal file
116
src/container-comparison/data/api.mock.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* istanbul ignore file */
|
||||
import { CourseContainerChildrenData, type UpstreamReadyToSyncChildrenInfo } from '@src/course-unit/data/types';
|
||||
import * as unitApi from '@src/course-unit/data/api';
|
||||
|
||||
/**
|
||||
* Mock for `getLibraryContainerChildren()`
|
||||
*
|
||||
* This mock returns a fixed response for the given container ID.
|
||||
*/
|
||||
export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> {
|
||||
let numChildren: number = 3;
|
||||
let blockType: string;
|
||||
let displayName: string;
|
||||
let upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[] = [];
|
||||
switch (containerId) {
|
||||
case mockGetCourseContainerChildren.unitId:
|
||||
blockType = 'text';
|
||||
displayName = 'unit block 00';
|
||||
break;
|
||||
case mockGetCourseContainerChildren.sectionId:
|
||||
blockType = 'subsection';
|
||||
displayName = 'Test Title';
|
||||
break;
|
||||
case mockGetCourseContainerChildren.subsectionId:
|
||||
blockType = 'unit';
|
||||
displayName = 'subsection block 00';
|
||||
break;
|
||||
case mockGetCourseContainerChildren.sectionShowsAlertSingleText:
|
||||
blockType = 'subsection';
|
||||
displayName = 'Test Title';
|
||||
upstreamReadyToSyncChildrenInfo = [{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
|
||||
name: 'Html block 11',
|
||||
blockType: 'html',
|
||||
downstreamCustomized: ['display_name'],
|
||||
upstream: 'upstream-id',
|
||||
}];
|
||||
break;
|
||||
case mockGetCourseContainerChildren.sectionShowsAlertMultipleText:
|
||||
blockType = 'subsection';
|
||||
displayName = 'Test Title';
|
||||
upstreamReadyToSyncChildrenInfo = [
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
|
||||
name: 'Html block 11',
|
||||
blockType: 'html',
|
||||
downstreamCustomized: ['display_name'],
|
||||
upstream: 'upstream-id',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@2',
|
||||
name: 'Html block 22',
|
||||
blockType: 'html',
|
||||
downstreamCustomized: ['display_name'],
|
||||
upstream: 'upstream-id',
|
||||
},
|
||||
];
|
||||
break;
|
||||
case mockGetCourseContainerChildren.unitIdLoading:
|
||||
case mockGetCourseContainerChildren.sectionIdLoading:
|
||||
case mockGetCourseContainerChildren.subsectionIdLoading:
|
||||
return new Promise(() => { });
|
||||
default:
|
||||
blockType = 'section';
|
||||
displayName = 'section block 00';
|
||||
numChildren = 0;
|
||||
break;
|
||||
}
|
||||
const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => (
|
||||
{
|
||||
...child,
|
||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
||||
id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`,
|
||||
name: `${blockType} block ${idx}${idx}`,
|
||||
blockType,
|
||||
upstreamLink: {
|
||||
upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`,
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
}
|
||||
));
|
||||
return Promise.resolve({
|
||||
canPasteComponent: true,
|
||||
isPublished: false,
|
||||
children,
|
||||
displayName,
|
||||
upstreamReadyToSyncChildrenInfo,
|
||||
});
|
||||
}
|
||||
mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0';
|
||||
mockGetCourseContainerChildren.subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0';
|
||||
mockGetCourseContainerChildren.sectionId = 'block-v1:UNIX+UX1+2025_T3+type@section+block@0';
|
||||
mockGetCourseContainerChildren.sectionShowsAlertSingleText = 'block-v1:UNIX+UX1+2025_T3+type@section2+block@0';
|
||||
mockGetCourseContainerChildren.sectionShowsAlertMultipleText = 'block-v1:UNIX+UX1+2025_T3+type@section3+block@0';
|
||||
mockGetCourseContainerChildren.unitIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@loading';
|
||||
mockGetCourseContainerChildren.subsectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@loading';
|
||||
mockGetCourseContainerChildren.sectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@section+block@loading';
|
||||
mockGetCourseContainerChildren.childTemplate = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1',
|
||||
name: 'Unit 1 remote edit - local edit',
|
||||
blockType: 'unit',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
};
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockGetCourseContainerChildren.applyMock = () => {
|
||||
jest.spyOn(unitApi, 'getCourseContainerChildren').mockImplementation(mockGetCourseContainerChildren);
|
||||
};
|
||||
30
src/container-comparison/data/apiHooks.ts
Normal file
30
src/container-comparison/data/apiHooks.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCourseContainerChildren } from '@src/course-unit/data/api';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
|
||||
export const containerComparisonQueryKeys = {
|
||||
all: ['containerComparison'],
|
||||
/**
|
||||
* Base key for a course
|
||||
*/
|
||||
course: (courseKey: string) => [...containerComparisonQueryKeys.all, courseKey],
|
||||
/**
|
||||
* Key for a single container
|
||||
*/
|
||||
container: (getUpstreamInfo: boolean, usageKey?: string) => {
|
||||
if (usageKey === undefined) {
|
||||
return [undefined, undefined, getUpstreamInfo.toString()];
|
||||
}
|
||||
const courseKey = getCourseKey(usageKey);
|
||||
return [...containerComparisonQueryKeys.course(courseKey), usageKey, getUpstreamInfo.toString()];
|
||||
},
|
||||
};
|
||||
|
||||
export const useCourseContainerChildren = (usageKey?: string, getUpstreamInfo?: boolean) => (
|
||||
useQuery({
|
||||
enabled: !!usageKey,
|
||||
queryFn: () => getCourseContainerChildren(usageKey!, getUpstreamInfo),
|
||||
// If we first get data with a valid `usageKey` and then the `usageKey` changes to undefined, an error occurs.
|
||||
queryKey: containerComparisonQueryKeys.container(getUpstreamInfo || false, usageKey),
|
||||
})
|
||||
);
|
||||
10
src/container-comparison/index.scss
Normal file
10
src/container-comparison/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.compare-changes-widget {
|
||||
.compare-card {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.big-icon {
|
||||
height: 68px;
|
||||
width: 68px;
|
||||
}
|
||||
}
|
||||
101
src/container-comparison/messages.ts
Normal file
101
src/container-comparison/messages.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: {
|
||||
id: 'course-authoring.container-comparison.diff.error.message',
|
||||
defaultMessage: 'Unexpected error: Failed to fetch container data',
|
||||
description: 'Generic error message',
|
||||
},
|
||||
removedDiffBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.removed-message',
|
||||
defaultMessage: 'This {blockType} will be removed in the new version',
|
||||
description: 'Description for removed component in before section of diff preview',
|
||||
},
|
||||
removedDiffAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.removed-message',
|
||||
defaultMessage: 'This {blockType} was removed',
|
||||
description: 'Description for removed component in after section of diff preview',
|
||||
},
|
||||
modifiedDiffBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.modified-message',
|
||||
defaultMessage: 'This {blockType} will be modified',
|
||||
description: 'Description for modified component in before section of diff preview',
|
||||
},
|
||||
modifiedDiffAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.modified-message',
|
||||
defaultMessage: 'This {blockType} was modified',
|
||||
description: 'Description for modified component in after section of diff preview',
|
||||
},
|
||||
addedDiffBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.added-message',
|
||||
defaultMessage: 'This {blockType} will be added in the new version',
|
||||
description: 'Description for added component in before section of diff preview',
|
||||
},
|
||||
addedDiffAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.added-message',
|
||||
defaultMessage: 'This {blockType} was added',
|
||||
description: 'Description for added component in after section of diff preview',
|
||||
},
|
||||
renamedDiffBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.locally-updated-message',
|
||||
defaultMessage: 'Library Name: {name}',
|
||||
description: 'Description for locally updated component in before section of diff preview',
|
||||
},
|
||||
renamedUpdatedDiffAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.locally-updated-message',
|
||||
defaultMessage: 'Library name remains overwritten',
|
||||
description: 'Description for locally updated component in after section of diff preview',
|
||||
},
|
||||
locallyContentUpdatedBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.locally-content-updated-message',
|
||||
defaultMessage: 'This {blockType} was edited locally',
|
||||
description: 'Description for locally content updated component in before section of diff preview',
|
||||
},
|
||||
locallyContentUpdatedAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.locally-content-updated-message',
|
||||
defaultMessage: 'Local edit will remain',
|
||||
description: 'Description for locally content updated component in after section of diff preview',
|
||||
},
|
||||
movedDiffBeforeMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.before.moved-message',
|
||||
defaultMessage: 'This {blockType} will be moved in the new version',
|
||||
description: 'Description for moved component in before section of diff preview',
|
||||
},
|
||||
movedDiffAfterMessage: {
|
||||
id: 'course-authoring.container-comparison.diff.after.moved-message',
|
||||
defaultMessage: 'This {blockType} was moved',
|
||||
description: 'Description for moved component in after section of diff preview',
|
||||
},
|
||||
breadcrumbAriaLabel: {
|
||||
id: 'course-authoring.container-comparison.diff.breadcrumb.ariaLabel',
|
||||
defaultMessage: 'Title breadcrumb',
|
||||
description: 'Aria label text for breadcrumb in diff preview',
|
||||
},
|
||||
diffBeforeTitle: {
|
||||
id: 'course-authoring.container-comparison.diff.before.title',
|
||||
defaultMessage: 'Before',
|
||||
description: 'Before section title text',
|
||||
},
|
||||
diffAfterTitle: {
|
||||
id: 'course-authoring.container-comparison.diff.after.title',
|
||||
defaultMessage: 'After',
|
||||
description: 'After section title text',
|
||||
},
|
||||
localChangeInTextAlert: {
|
||||
id: 'course-authoring.container-comparison.text-with-local-change.alert',
|
||||
defaultMessage: 'The only change is to {count, plural, one {text block <b>{blockName}</b> which has been edited} other {<b>{count} text blocks</b> which have been edited}} in this course. Accepting will not remove local edits.',
|
||||
description: 'Alert to show if the only change is on text components with local overrides.',
|
||||
},
|
||||
newContainer: {
|
||||
id: 'course-authoring.container-comparison.new-container.text',
|
||||
defaultMessage: 'This {containerType} is new',
|
||||
description: 'Text to show in the comparison when a container is new.',
|
||||
},
|
||||
deletedContainer: {
|
||||
id: 'course-authoring.container-comparison.deleted-container.text',
|
||||
defaultMessage: 'This {containerType} has been removed',
|
||||
description: 'Text to show in the comparison when a container is removed.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
31
src/container-comparison/types.ts
Normal file
31
src/container-comparison/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { UpstreamInfo } from '@src/data/types';
|
||||
|
||||
export type ContainerState = 'removed' | 'added' | 'modified' | 'childrenModified' | 'locallyContentUpdated' | 'locallyRenamed' | 'locallyRenamedAndContentUpdated' | 'moved';
|
||||
|
||||
export type WithState<T> = T & { state?: ContainerState, originalName?: string };
|
||||
export type WithIndex<T> = T & { index: number };
|
||||
|
||||
export type CourseContainerChildBase = {
|
||||
name: string;
|
||||
id: string;
|
||||
upstreamLink: UpstreamInfo;
|
||||
blockType: string;
|
||||
};
|
||||
|
||||
export type ContainerChildBase = {
|
||||
displayName: string;
|
||||
id: string;
|
||||
containerType?: string;
|
||||
blockType?: string;
|
||||
} & ({
|
||||
containerType: string;
|
||||
} | {
|
||||
blockType: string;
|
||||
});
|
||||
|
||||
export type ContainerChild = {
|
||||
name: string;
|
||||
id?: string;
|
||||
downstreamId?: string;
|
||||
blockType: string;
|
||||
};
|
||||
359
src/container-comparison/utils.test.ts
Normal file
359
src/container-comparison/utils.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { ContainerChildBase, CourseContainerChildBase } from './types';
|
||||
import { diffPreviewContainerChildren } from './utils';
|
||||
|
||||
export const getMockCourseContainerData = (
|
||||
type: 'added|deleted' | 'moved|deleted' | 'all' | 'locallyEdited',
|
||||
): [CourseContainerChildBase[], ContainerChildBase[]] => {
|
||||
switch (type) {
|
||||
case 'moved|deleted':
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
||||
name: 'Unit 1 remote edit - local edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
versionSynced: 11,
|
||||
versionAvailable: 11,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['display_name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
||||
name: 'New unit remote edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
||||
versionSynced: 7,
|
||||
versionAvailable: 7,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
||||
name: 'Unit with tags',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
versionSynced: 2,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
||||
name: 'One more unit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 1,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
displayName: 'Unit with tags',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
displayName: 'Unit 1 remote edit 2',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
displayName: 'One more unit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
],
|
||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
||||
case 'added|deleted':
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
||||
name: 'Unit 1 remote edit - local edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
versionSynced: 11,
|
||||
versionAvailable: 11,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['display_name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
||||
name: 'New unit remote edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
||||
versionSynced: 7,
|
||||
versionAvailable: 7,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
||||
name: 'Unit with tags',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
versionSynced: 2,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
||||
name: 'One more unit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 1,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
displayName: 'Unit 1 remote edit 2',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
displayName: 'Unit with tags',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:added-unit-1',
|
||||
displayName: 'Added unit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
displayName: 'One more unit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
],
|
||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
||||
case 'all':
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
||||
name: 'Unit 1 remote edit - local edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
versionSynced: 11,
|
||||
versionAvailable: 11,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['display_name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
||||
name: 'New unit remote edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
||||
versionSynced: 7,
|
||||
versionAvailable: 7,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
||||
name: 'Unit with tags',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
versionSynced: 2,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
|
||||
name: 'One more unit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 1,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
displayName: 'Unit with tags',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:added-unit-1',
|
||||
displayName: 'Added unit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
|
||||
displayName: 'One more unit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
displayName: 'Unit 1 remote edit 2',
|
||||
containerType: 'unit',
|
||||
},
|
||||
],
|
||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
||||
case 'locallyEdited':
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
|
||||
name: 'Unit 1 remote edit - local edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
versionSynced: 11,
|
||||
versionAvailable: 11,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['display_name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
|
||||
name: 'New unit remote edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
||||
versionSynced: 7,
|
||||
versionAvailable: 7,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['data'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
|
||||
name: 'Unit with tags - local edit',
|
||||
blockType: 'vertical',
|
||||
upstreamLink: {
|
||||
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
versionSynced: 2,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
downstreamCustomized: ['display_name', 'data'],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
|
||||
displayName: 'Unit 1 remote edit - remote edit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
|
||||
displayName: 'New unit remote edit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
|
||||
displayName: 'Unit with tags - remote edit',
|
||||
containerType: 'unit',
|
||||
},
|
||||
],
|
||||
] as [CourseContainerChildBase[], ContainerChildBase[]];
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
|
||||
describe('diffPreviewContainerChildren', () => {
|
||||
it('should handle moved and deleted', () => {
|
||||
const [a, b] = getMockCourseContainerData('moved|deleted');
|
||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
||||
expect(result[0].length).toEqual(result[1].length);
|
||||
// renamed takes precendence over moved
|
||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
||||
expect(result[1][2].state).toEqual('locallyRenamed');
|
||||
expect(result[0][1].state).toEqual('removed');
|
||||
expect(result[1][1].state).toEqual('removed');
|
||||
expect(result[1][2].name).toEqual(a[0].name);
|
||||
});
|
||||
|
||||
it('should handle add and delete', () => {
|
||||
const [a, b] = getMockCourseContainerData('added|deleted');
|
||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
||||
expect(result[0].length).toEqual(result[1].length);
|
||||
// No change, state=undefined
|
||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
||||
expect(result[0][0].originalName).toEqual(b[0].displayName);
|
||||
expect(result[1][0].state).toEqual('locallyRenamed');
|
||||
|
||||
// Deleted entry
|
||||
expect(result[0][1].state).toEqual('removed');
|
||||
expect(result[1][1].state).toEqual('removed');
|
||||
expect(result[1][0].name).toEqual(a[0].name);
|
||||
expect(result[0][3].name).toEqual(result[1][3].name);
|
||||
expect(result[0][3].state).toEqual('added');
|
||||
expect(result[1][3].state).toEqual('added');
|
||||
});
|
||||
|
||||
it('should handle add, delete and moved', () => {
|
||||
const [a, b] = getMockCourseContainerData('all');
|
||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
||||
expect(result[0].length).toEqual(result[1].length);
|
||||
// renamed takes precendence over moved
|
||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
||||
expect(result[1][4].state).toEqual('locallyRenamed');
|
||||
expect(result[1][4].id).toEqual(result[0][0].id);
|
||||
|
||||
// Deleted entry
|
||||
expect(result[0][1].state).toEqual('removed');
|
||||
expect(result[1][1].state).toEqual('removed');
|
||||
expect(result[1][1].name).toEqual(result[0][1].name);
|
||||
|
||||
// added entry
|
||||
expect(result[0][2].state).toEqual('added');
|
||||
expect(result[1][2].state).toEqual('added');
|
||||
expect(result[1][2].id).toEqual(result[0][2].id);
|
||||
});
|
||||
|
||||
it('should handle locally edited content', () => {
|
||||
const [a, b] = getMockCourseContainerData('locallyEdited');
|
||||
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
|
||||
expect(result[0].length).toEqual(result[1].length);
|
||||
// renamed
|
||||
expect(result[0][0].state).toEqual('locallyRenamed');
|
||||
expect(result[1][0].state).toEqual('locallyRenamed');
|
||||
expect(result[1][0].id).toEqual(result[0][0].id);
|
||||
// content updated
|
||||
expect(result[0][1].state).toEqual('locallyContentUpdated');
|
||||
expect(result[1][1].state).toEqual('locallyContentUpdated');
|
||||
expect(result[1][1].id).toEqual(result[0][1].id);
|
||||
// renamed and content updated
|
||||
expect(result[0][2].state).toEqual('locallyRenamedAndContentUpdated');
|
||||
expect(result[1][2].state).toEqual('locallyRenamedAndContentUpdated');
|
||||
expect(result[1][2].id).toEqual(result[0][2].id);
|
||||
});
|
||||
});
|
||||
143
src/container-comparison/utils.ts
Normal file
143
src/container-comparison/utils.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { UpstreamInfo } from '@src/data/types';
|
||||
import { ContainerType, normalizeContainerType } from '@src/generic/key-utils';
|
||||
import {
|
||||
ContainerChild,
|
||||
ContainerChildBase,
|
||||
ContainerState,
|
||||
CourseContainerChildBase,
|
||||
WithIndex,
|
||||
WithState,
|
||||
} from './types';
|
||||
|
||||
export function checkIsReadyToSync(link: UpstreamInfo): boolean {
|
||||
return (link.versionSynced < (link.versionAvailable || 0))
|
||||
|| (link.versionSynced < (link.versionDeclined || 0))
|
||||
|| ((link.readyToSyncChildren?.length || 0) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two arrays of container children (`a` and `b`) to determine the differences between them.
|
||||
* It generates two lists indicating which elements have been added, modified, moved, or removed.
|
||||
*/
|
||||
export function diffPreviewContainerChildren<A extends CourseContainerChildBase, B extends ContainerChildBase>(
|
||||
a: A[],
|
||||
b: B[],
|
||||
idKey: string = 'id',
|
||||
): [WithState<ContainerChild>[], WithState<ContainerChild>[]] {
|
||||
const mapA = new Map<any, WithIndex<A>>();
|
||||
const mapB = new Map<any, WithIndex<ContainerChild>>();
|
||||
for (let index = 0; index < a.length; index++) {
|
||||
const element = a[index];
|
||||
mapA.set(element.upstreamLink?.upstreamRef, { ...element, index });
|
||||
}
|
||||
const updatedA: WithState<ContainerChild>[] = Array(a.length);
|
||||
const addedA: Array<WithIndex<ContainerChild>> = [];
|
||||
const updatedB: WithState<ContainerChild>[] = [];
|
||||
for (let index = 0; index < b.length; index++) {
|
||||
const newVersion = b[index];
|
||||
const oldVersion = mapA.get(newVersion.id);
|
||||
|
||||
if (!oldVersion) {
|
||||
// This is a newly added component
|
||||
addedA.push({
|
||||
id: newVersion.id,
|
||||
name: newVersion.displayName,
|
||||
blockType: (newVersion.containerType || newVersion.blockType)!,
|
||||
index,
|
||||
});
|
||||
updatedB.push({
|
||||
name: newVersion.displayName,
|
||||
blockType: (newVersion.blockType || newVersion.containerType)!,
|
||||
id: newVersion.id,
|
||||
state: 'added',
|
||||
});
|
||||
} else {
|
||||
// It was present in previous version
|
||||
let state: ContainerState | undefined;
|
||||
const displayName = oldVersion.upstreamLink.downstreamCustomized.includes('display_name') ? oldVersion.name : newVersion.displayName;
|
||||
let originalName: string | undefined;
|
||||
// FIXME: This logic doesn't work when the content is updated locally and the upstream display name is updated.
|
||||
// `isRenamed` becomes true.
|
||||
// We probably need to differentiate between `contentModified` and `rename` in the backend or
|
||||
// send `downstream_customized` field to the frontend and use it here.
|
||||
const isRenamed = displayName !== newVersion.displayName && displayName === oldVersion.name;
|
||||
const isContentModified = oldVersion.upstreamLink.downstreamCustomized.includes('data');
|
||||
if (index !== oldVersion.index) {
|
||||
// has moved from its position
|
||||
state = 'moved';
|
||||
}
|
||||
if ((oldVersion.upstreamLink.downstreamCustomized.length || 0) > 0) {
|
||||
if (isRenamed) {
|
||||
state = 'locallyRenamed';
|
||||
originalName = newVersion.displayName;
|
||||
}
|
||||
if (isContentModified) {
|
||||
state = 'locallyContentUpdated';
|
||||
}
|
||||
if (isRenamed && isContentModified) {
|
||||
state = 'locallyRenamedAndContentUpdated';
|
||||
}
|
||||
} else if (checkIsReadyToSync(oldVersion.upstreamLink)) {
|
||||
// has a new version ready to sync
|
||||
state = 'modified';
|
||||
}
|
||||
// Insert in its original index
|
||||
updatedA.splice(oldVersion.index, 1, {
|
||||
name: oldVersion.name,
|
||||
blockType: normalizeContainerType(oldVersion.blockType),
|
||||
id: oldVersion.upstreamLink.upstreamRef,
|
||||
downstreamId: oldVersion.id,
|
||||
state,
|
||||
originalName,
|
||||
});
|
||||
updatedB.push({
|
||||
name: displayName,
|
||||
blockType: (newVersion.blockType || newVersion.containerType)!,
|
||||
id: newVersion.id,
|
||||
downstreamId: oldVersion.id,
|
||||
state,
|
||||
});
|
||||
// Delete it from mapA as it is processed.
|
||||
mapA.delete(newVersion.id);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are remaining items in mapA, it means they were deleted in newVersion;
|
||||
mapA.forEach((oldVersion) => {
|
||||
updatedA.splice(oldVersion.index, 1, {
|
||||
name: oldVersion.name,
|
||||
blockType: normalizeContainerType(oldVersion.blockType),
|
||||
id: oldVersion.upstreamLink.upstreamRef,
|
||||
downstreamId: oldVersion.id,
|
||||
state: 'removed',
|
||||
});
|
||||
updatedB.splice(oldVersion.index, 0, {
|
||||
id: oldVersion.upstreamLink.upstreamRef,
|
||||
name: oldVersion.name,
|
||||
blockType: normalizeContainerType(oldVersion.blockType),
|
||||
downstreamId: oldVersion.id,
|
||||
state: 'removed',
|
||||
});
|
||||
});
|
||||
|
||||
// Create a map for id with index of newly updatedB array
|
||||
for (let index = 0; index < updatedB.length; index++) {
|
||||
const element = updatedB[index];
|
||||
mapB.set(element[idKey], { ...element, index });
|
||||
}
|
||||
|
||||
// Use new mapB for getting new index for added elements
|
||||
addedA.forEach((addedRow) => {
|
||||
updatedA.splice(mapB.get(addedRow.id)?.index!, 0, { ...addedRow, state: 'added' });
|
||||
});
|
||||
|
||||
return [updatedA, updatedB];
|
||||
}
|
||||
|
||||
export function isRowClickable(state?: ContainerState, blockType?: ContainerType) {
|
||||
return state && blockType && ['modified', 'added', 'removed'].includes(state) && [
|
||||
ContainerType.Section,
|
||||
ContainerType.Subsection,
|
||||
ContainerType.Unit,
|
||||
].includes(blockType);
|
||||
}
|
||||
@@ -435,8 +435,8 @@ const ContentTagsCollapsible = ({
|
||||
onKeyDown={handleSelectOnKeyDown}
|
||||
ref={/** @type {React.RefObject} */(selectRef)}
|
||||
isMulti
|
||||
isLoading={updateTags.isLoading}
|
||||
isDisabled={updateTags.isLoading}
|
||||
isLoading={updateTags.isPending}
|
||||
isDisabled={updateTags.isPending}
|
||||
name="tags-select"
|
||||
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
|
||||
isSearchable
|
||||
|
||||
@@ -37,3 +37,9 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
|
||||
// https://github.com/openedx/frontend-app-authoring/issues/1898
|
||||
#toast-root[data-focus-on-hidden] {
|
||||
pointer-events: initial !important;
|
||||
}
|
||||
|
||||
@@ -719,14 +719,16 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
|
||||
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
|
||||
'contentLibrary',
|
||||
'lib:org:lib',
|
||||
'content',
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
]);
|
||||
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, {
|
||||
queryKey: [
|
||||
'contentLibrary',
|
||||
'lib:org:lib',
|
||||
'content',
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { useMemo } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router';
|
||||
import { TagData, TagListData } from '@src/taxonomy/data/types';
|
||||
import {
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
@@ -17,18 +17,16 @@ import {
|
||||
} from './api';
|
||||
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
|
||||
import { UpdateTagsData } from './types';
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {string|null} parentTag The tag whose children we're loading, if any
|
||||
* @param {string} searchTerm The term passed in to perform search on tags
|
||||
* @param {number} numPages How many pages of tags to load at this level
|
||||
* @param taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param parentTag The tag whose children we're loading, if any
|
||||
* @param searchTerm The term passed in to perform search on tags
|
||||
* @param numPages How many pages of tags to load at this level
|
||||
*/
|
||||
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
|
||||
export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null = null, numPages = 1, searchTerm = '') => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryFn = async ({ queryKey }) => {
|
||||
@@ -36,8 +34,7 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
|
||||
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
|
||||
};
|
||||
|
||||
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
|
||||
const queries = [];
|
||||
const queries: { queryKey: any[]; queryFn: typeof queryFn; staleTime: number }[] = [];
|
||||
for (let page = 1; page <= numPages; page++) {
|
||||
queries.push(
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
|
||||
@@ -54,8 +51,7 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
|
||||
const preLoadedData = new Map();
|
||||
|
||||
const newTags = dataPages.map(result => {
|
||||
/** @type {TagData[]} */
|
||||
const simplifiedTagsList = [];
|
||||
const simplifiedTagsList: TagData[] = [];
|
||||
|
||||
result.data?.results?.forEach((tag) => {
|
||||
if (tag.parentValue === parentTag) {
|
||||
@@ -73,8 +69,7 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
|
||||
// Store the pre-loaded descendants into the query cache:
|
||||
preLoadedData.forEach((tags, parentValue) => {
|
||||
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
|
||||
/** @type {TagListData} */
|
||||
const cachedData = {
|
||||
const cachedData: TagListData = {
|
||||
next: '',
|
||||
previous: '',
|
||||
count: tags.length,
|
||||
@@ -101,9 +96,9 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags applied to the content object
|
||||
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
* @param contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
*/
|
||||
export const useContentTaxonomyTagsData = (contentId) => (
|
||||
export const useContentTaxonomyTagsData = (contentId: string) => (
|
||||
useQuery({
|
||||
queryKey: ['contentTaxonomyTags', contentId],
|
||||
queryFn: () => getContentTaxonomyTagsData(contentId),
|
||||
@@ -112,37 +107,30 @@ export const useContentTaxonomyTagsData = (contentId) => (
|
||||
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param {string} contentId The id of the content object
|
||||
* @param {boolean} enabled Flag to enable/disable the query
|
||||
* @param contentId The id of the content object
|
||||
* @param enabled Flag to enable/disable the query
|
||||
*/
|
||||
export const useContentData = (contentId, enabled) => (
|
||||
export const useContentData = (contentId: string, enabled: boolean) => (
|
||||
useQuery({
|
||||
queryKey: ['contentData', contentId],
|
||||
queryFn: enabled ? () => getContentData(contentId) : undefined,
|
||||
queryFn: () => getContentData(contentId),
|
||||
enabled,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to update the tags applied to the content object
|
||||
* @param {string} contentId The id of the content object to update tags for
|
||||
* @param contentId The id of the content object to update tags for
|
||||
*/
|
||||
export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
export const useContentTaxonomyTagsUpdater = (contentId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const unitIframe = window.frames['xblock-iframe'];
|
||||
const { containerId } = useParams();
|
||||
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* any,
|
||||
* any,
|
||||
* {
|
||||
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
|
||||
mutationFn: ({ tagsData }: { tagsData: Promise<UpdateTagsData[]> }) => (
|
||||
updateContentTaxonomyTags(contentId, tagsData)
|
||||
),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
@@ -157,13 +145,13 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
// Obtain library id from contentId
|
||||
const libraryId = getLibraryId(contentId);
|
||||
// Invalidate component metadata to update tags count
|
||||
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(contentId) });
|
||||
// Invalidate content search to update tags count
|
||||
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'], predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
// If the tags for an item were edited from a container page (Unit, Subsection, Section),
|
||||
// invalidate children query to fetch count again.
|
||||
if (containerId) {
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1,5 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ModeComment } from '@openedx/paragon/icons';
|
||||
@@ -127,4 +127,4 @@ ChecklistItemComment.propTypes = {
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ChecklistItemComment);
|
||||
export default ChecklistItemComment;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
|
||||
dispatch(fetchBestPracticeChecklistSuccess({ data }));
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ mockUseLibBlockMetadata.applyMock();
|
||||
|
||||
const searchParamsGetMock = jest.fn();
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let mockShowToast: (message: string, action?: ToastActionData) => void;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
jest.mock('../studio-home/hooks', () => ({
|
||||
@@ -114,7 +114,7 @@ describe('<CourseLibraries />', () => {
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissBtn);
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
waitFor(() => expect(alert).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(alert).not.toBeInTheDocument());
|
||||
// review updates button
|
||||
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
|
||||
await user.click(reviewActionBtn);
|
||||
@@ -327,4 +327,19 @@ describe('<CourseLibraries ReviewTab />', () => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
|
||||
it('should show sync modal with local changes', async () => {
|
||||
const itemIndex = 3;
|
||||
const user = userEvent.setup();
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
|
||||
expect(previewBtns.length).toEqual(7);
|
||||
await user.click(previewBtns[itemIndex]);
|
||||
|
||||
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,14 +32,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
|
||||
onReview,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
|
||||
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
|
||||
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
|
||||
const alertKey = `outOfSyncCountAlert-${courseId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
if (outOfSyncCount === 0) {
|
||||
@@ -50,7 +50,7 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
|
||||
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
|
||||
|
||||
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
|
||||
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
|
||||
}, [outOfSyncCount, lastPublishedDate, isPending, data]);
|
||||
|
||||
const dismissAlert = () => {
|
||||
setShowAlert(false);
|
||||
|
||||
@@ -144,7 +144,7 @@ const ItemReviewList = ({
|
||||
|
||||
const {
|
||||
hits,
|
||||
isLoading: isIndexDataLoading,
|
||||
isPending: isIndexDataPending,
|
||||
hasError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -173,6 +173,8 @@ const ItemReviewList = ({
|
||||
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
|
||||
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
|
||||
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
|
||||
blockType: info.blockType,
|
||||
isLocallyModified: outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
|
||||
});
|
||||
}, [outOfSyncItemsByKey]);
|
||||
|
||||
@@ -213,13 +215,16 @@ const ItemReviewList = ({
|
||||
|
||||
const updateBlock = useCallback(async (info: ContentHit) => {
|
||||
try {
|
||||
await acceptChangesMutation.mutateAsync(info.usageKey);
|
||||
await acceptChangesMutation.mutateAsync({
|
||||
blockId: info.usageKey,
|
||||
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
|
||||
});
|
||||
reloadLinks(info.usageKey);
|
||||
showToast(intl.formatMessage(
|
||||
messages.updateSingleBlockSuccess,
|
||||
{ name: info.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
|
||||
}
|
||||
}, []);
|
||||
@@ -230,20 +235,22 @@ const ItemReviewList = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
|
||||
await ignoreChangesMutation.mutateAsync({
|
||||
blockId: blockData.downstreamBlockId,
|
||||
});
|
||||
reloadLinks(blockData.downstreamBlockId);
|
||||
showToast(intl.formatMessage(
|
||||
messages.ignoreSingleBlockSuccess,
|
||||
{ name: blockData.displayName },
|
||||
));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
|
||||
} finally {
|
||||
closeConfirmModal();
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
if (isIndexDataLoading) {
|
||||
if (isIndexDataPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -314,7 +321,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data: outOfSyncItems,
|
||||
isLoading: isSyncItemsLoading,
|
||||
isPending: isSyncItemsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useEntityLinks({
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -26,6 +27,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -41,6 +43,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 16,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": true,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -56,6 +59,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -71,6 +75,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -86,6 +91,7 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface BasePublishableEntityLink {
|
||||
created: string;
|
||||
updated: string;
|
||||
readyToSync: boolean;
|
||||
downstreamIsModified: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {
|
||||
|
||||
@@ -32,7 +32,7 @@ const messages = defineMessages({
|
||||
description: 'Tab title for review tab',
|
||||
},
|
||||
reviewTabDescriptionEmpty: {
|
||||
id: 'course-authoring.course-libraries.tab.home.description-no-links',
|
||||
id: 'course-authoring.course-libraries.tab.review.description-no-links',
|
||||
defaultMessage: 'All components are up to date',
|
||||
description: 'Description text for home tab',
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ jest.mock('react-redux', () => ({
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
|
||||
expect(useBtn).toBeInTheDocument();
|
||||
userEvent.click(newBtn);
|
||||
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
userEvent.click(useBtn);
|
||||
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
await userEvent.click(newBtn);
|
||||
await waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
await userEvent.click(useBtn);
|
||||
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const onExpandMock = jest.fn();
|
||||
const onClickMenuButtonMock = jest.fn();
|
||||
@@ -232,16 +233,6 @@ describe('<CardHeader />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('check editing is enabled when isDisabledEditField is false', async () => {
|
||||
renderComponent({ ...cardHeaderProps });
|
||||
|
||||
@@ -254,8 +245,8 @@ describe('<CardHeader />', () => {
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check editing is disabled when isDisabledEditField is true', async () => {
|
||||
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
|
||||
it('check editing is disabled when saving is in progress', async () => {
|
||||
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
|
||||
|
||||
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
|
||||
import TagCount from '@src/generic/tag-count';
|
||||
import { useEscapeClick } from '@src/hooks';
|
||||
import { XBlockActions } from '@src/data/types';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
@@ -41,7 +42,6 @@ interface CardHeaderProps {
|
||||
isFormOpen: boolean;
|
||||
onEditSubmit: (titleValue: string) => void;
|
||||
closeForm: () => void;
|
||||
isDisabledEditField: boolean;
|
||||
onClickDelete: () => void;
|
||||
onClickUnlink: () => void;
|
||||
onClickDuplicate: () => void;
|
||||
@@ -69,6 +69,7 @@ interface CardHeaderProps {
|
||||
extraActionsComponent?: ReactNode,
|
||||
onClickSync?: () => void;
|
||||
readyToSync?: boolean;
|
||||
savingStatus?: RequestStatusType;
|
||||
}
|
||||
|
||||
const CardHeader = ({
|
||||
@@ -83,7 +84,6 @@ const CardHeader = ({
|
||||
isFormOpen,
|
||||
onEditSubmit,
|
||||
closeForm,
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickUnlink,
|
||||
onClickDuplicate,
|
||||
@@ -103,6 +103,7 @@ const CardHeader = ({
|
||||
extraActionsComponent,
|
||||
onClickSync,
|
||||
readyToSync,
|
||||
savingStatus,
|
||||
}: CardHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -119,6 +120,7 @@ const CardHeader = ({
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
const { data: contentTagCount } = useContentTagsCount(cardId);
|
||||
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
|
||||
|
||||
useEffect(() => {
|
||||
const locatorId = searchParams.get('show');
|
||||
@@ -172,7 +174,7 @@ const CardHeader = ({
|
||||
onEditSubmit(titleValue);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabledEditField}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
@@ -186,7 +188,7 @@ const CardHeader = ({
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
// @ts-ignore
|
||||
disabled={isDisabledEditField}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -238,7 +240,7 @@ const CardHeader = ({
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
disabled={isDisabledEditField}
|
||||
disabled={isSaving}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
@@ -246,7 +248,7 @@ const CardHeader = ({
|
||||
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
disabled={isDisabledEditField}
|
||||
disabled={isSaving}
|
||||
onClick={openManageTagsDrawer}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline } from './types';
|
||||
import { CourseOutline, CourseDetails } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = (
|
||||
courseId: string,
|
||||
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
@@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
* @returns {Promise<CourseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course details.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<CourseDetails>}
|
||||
*/
|
||||
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseDetailsApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param courseId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { getCourseItem } from './api';
|
||||
import { getCourseDetails, getCourseItem } from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
@@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = {
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
|
||||
|
||||
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ export const useCreateCourseBlock = (
|
||||
) => useMutation({
|
||||
mutationFn: createCourseXblock,
|
||||
onSettled: async (data) => {
|
||||
callback?.(data.locator, data.parent_locator);
|
||||
callback?.(data?.locator, data.parent_locator);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
|
||||
enabled: enabled && itemId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
export const useCourseDetails = (courseId?: string) => (
|
||||
useQuery({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
|
||||
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
* @param {string} courseId - ID of the course
|
||||
* @returns {Object} - Object containing fetch course outline index query success or failure status
|
||||
*/
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): object {
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
@@ -148,7 +148,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -165,7 +165,7 @@ export function enableCourseHighlightsEmailsQuery(courseId: string) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -182,7 +182,7 @@ export function setVideoSharingOptionQuery(courseId: string, option: string) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export function publishCourseItemQuery(itemId: string, sectionId: string) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -300,7 +300,7 @@ export function configureCourseItemQuery(sectionId: string, configureFn: () => P
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -390,7 +390,7 @@ export function editCourseItemQuery(itemId: string, sectionId: string, displayNa
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -412,7 +412,7 @@ function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
|
||||
dispatch(deleteItemFn());
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -469,7 +469,7 @@ function duplicateCourseItemQuery(
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -541,7 +541,7 @@ function addNewCourseItemQuery(
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -612,7 +612,7 @@ export function addUnitFromLibrary(body: {
|
||||
callback(result.locator);
|
||||
}
|
||||
});
|
||||
} catch (error) /* istanbul ignore next */ {
|
||||
} catch /* istanbul ignore next */ {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -643,7 +643,7 @@ function setBlockOrderListQuery(
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
@@ -728,7 +728,7 @@ export function pasteClipboardContent(parentLocator: string, sectionId: string)
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
@@ -743,7 +743,7 @@ export function dismissNotificationQuery(url: string) {
|
||||
await dismissNotification(url).then(async () => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,6 +24,15 @@ export interface CourseOutline {
|
||||
rerunNotificationId: null;
|
||||
}
|
||||
|
||||
// TODO: This interface has only basic data, all the rest needs to be added.
|
||||
export interface CourseDetails {
|
||||
courseId: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
org: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import moment from 'moment';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
|
||||
@@ -64,9 +65,11 @@ import {
|
||||
} from './data/thunk';
|
||||
import { useCreateCourseBlock } from './data/apiHooks';
|
||||
import { getCourseItem } from './data/api';
|
||||
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
|
||||
@@ -156,7 +159,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
data.shouldScroll = true;
|
||||
// Page should scroll to newly added subsection.
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
@@ -171,7 +174,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
@@ -245,6 +248,8 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const handleEditSubmit = (itemId, sectionId, displayName) => {
|
||||
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
|
||||
// Invalidate container diff queries to update sync diff preview
|
||||
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
};
|
||||
|
||||
const handleDeleteItemSubmit = () => {
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Campaign as CampaignIcon,
|
||||
InfoOutline as InfoOutlineIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import {
|
||||
Alert, Button, Hyperlink, Truncate,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Campaign as CampaignIcon,
|
||||
Error as ErrorIcon,
|
||||
InfoOutline as InfoOutlineIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { uniqBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import AlertProctoringError from '../../generic/AlertProctoringError';
|
||||
import messages from './messages';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { getPasteFileNotices } from '../data/selectors';
|
||||
import { dismissError, removePasteFileNotices } from '../data/slice';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
import messages from './messages';
|
||||
|
||||
const PageAlerts = ({
|
||||
courseId,
|
||||
@@ -437,6 +438,7 @@ const PageAlerts = ({
|
||||
{conflictingFilesPasteAlert()}
|
||||
{newFilesPasteAlert()}
|
||||
{renderOutOfSyncAlert()}
|
||||
<CourseOutlinePageAlertsSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
}));
|
||||
|
||||
const unit = {
|
||||
id: 'unit-1',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
};
|
||||
|
||||
const subsection = {
|
||||
id: '123',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
displayName: 'Subsection Name',
|
||||
category: 'sequential',
|
||||
published: true,
|
||||
@@ -43,7 +43,7 @@ const subsection = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const section = {
|
||||
id: '123',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
displayName: 'Section Name',
|
||||
category: 'chapter',
|
||||
published: true,
|
||||
@@ -71,7 +71,10 @@ const section = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:section:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
@@ -88,7 +91,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
@@ -187,7 +189,9 @@ describe('<SectionCard />', () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
|
||||
// url encode subsection.id
|
||||
const subsectionIdUrl = encodeURIComponent(subsection.id);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
|
||||
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
@@ -199,7 +203,9 @@ describe('<SectionCard />', () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
|
||||
// url encode subsection.id
|
||||
const unitIdUrl = encodeURIComponent(unit.id);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
|
||||
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
@@ -231,7 +237,6 @@ describe('<SectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -251,7 +256,6 @@ describe('<SectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -11,7 +11,7 @@ import classNames from 'classnames';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
@@ -39,7 +39,7 @@ interface SectionCardProps {
|
||||
onOpenPublishModal: () => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string,
|
||||
savingStatus?: RequestStatusType,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
@@ -144,7 +144,9 @@ const SectionCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'section',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
@@ -301,7 +303,7 @@ const SectionCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -52,7 +52,7 @@ const unit = {
|
||||
};
|
||||
|
||||
const subsection: XBlock = {
|
||||
id: '123',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
displayName: 'Subsection Name',
|
||||
category: 'sequential',
|
||||
published: true,
|
||||
@@ -75,12 +75,15 @@ const subsection: XBlock = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:subsection:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const section: XBlock = {
|
||||
id: '123',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -115,7 +118,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
|
||||
isCustomRelativeDatesActive={false}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
@@ -322,7 +324,7 @@ describe('<SubsectionCard />', () => {
|
||||
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
|
||||
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
parentLocator: '123',
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
category: 'vertical',
|
||||
libraryContentKey: containerKey,
|
||||
});
|
||||
@@ -339,7 +341,6 @@ describe('<SubsectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -359,7 +360,6 @@ describe('<SubsectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isEmpty } from 'lodash';
|
||||
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
@@ -40,7 +40,7 @@ interface SubsectionCardProps {
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
onOpenPublishModal: () => void,
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string,
|
||||
savingStatus?: RequestStatusType,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
@@ -126,7 +126,9 @@ const SubsectionCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'subsection',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
@@ -303,7 +305,7 @@ const SubsectionCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
}));
|
||||
|
||||
const section = {
|
||||
id: '1',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -34,7 +34,7 @@ const section = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const subsection = {
|
||||
id: '12',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
displayName: 'Subsection Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -48,7 +48,7 @@ const subsection = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const unit = {
|
||||
id: '123',
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
displayName: 'unit Name',
|
||||
category: 'vertical',
|
||||
published: true,
|
||||
@@ -65,7 +65,10 @@ const unit = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:unit:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
@@ -81,7 +84,6 @@ const renderComponent = (props?: object) => render(
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
@@ -108,7 +110,10 @@ describe('<UnitCard />', () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
|
||||
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
|
||||
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute(
|
||||
'href',
|
||||
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides header based on isHeaderVisible flag', async () => {
|
||||
@@ -199,7 +204,6 @@ describe('<UnitCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -219,7 +223,6 @@ describe('<UnitCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -13,8 +13,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { isUnitReadOnly } from '@src/course-unit/data/utils';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import TitleLink from '@src/course-outline/card-header/TitleLink';
|
||||
@@ -33,7 +32,7 @@ interface UnitCardProps {
|
||||
onOpenPublishModal: () => void;
|
||||
onOpenConfigureModal: () => void;
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string;
|
||||
savingStatus?: RequestStatusType;
|
||||
onOpenDeleteModal: () => void;
|
||||
onOpenUnlinkModal: () => void;
|
||||
onDuplicateSubmit: () => void;
|
||||
@@ -104,12 +103,12 @@ const UnitCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'unit',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
const readOnly = isUnitReadOnly(unit);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...unitActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
@@ -247,7 +246,7 @@ const UnitCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function changeRoleTeamUserQuery(courseId, email, role) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch ({ message }) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function deleteCourseTeamQuery(courseId, email) {
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
isUnitVerticalType,
|
||||
isUnitLibraryType,
|
||||
isSplitTestType,
|
||||
isProblemBankType,
|
||||
staticFileNotices,
|
||||
currentlyVisibleToStudents,
|
||||
unitXBlockActions,
|
||||
@@ -219,6 +220,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
parentLocator={blockId}
|
||||
isSplitTestType={isSplitTestType}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isProblemBankType={isProblemBankType}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
addComponentTemplateData={addComponentTemplateData}
|
||||
/>
|
||||
|
||||
@@ -2218,7 +2218,7 @@ describe('<CourseUnit />', () => {
|
||||
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
|
||||
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
|
||||
|
||||
waitFor(() => {
|
||||
await waitFor(() => {
|
||||
const unitHeaderTitle = screen.getByTestId('unit-header-title');
|
||||
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
|
||||
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||
@@ -2291,19 +2291,17 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
|
||||
render(<RootWrapper />);
|
||||
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
|
||||
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
|
||||
|
||||
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
|
||||
.map((child) => (child.block_id === targetBlockId
|
||||
? { ...child, block_type: 'html' }
|
||||
: child));
|
||||
// Convert the second child from drag and drop to HTML:
|
||||
const targetChild = updatedCourseVerticalChildrenMock.children[1];
|
||||
targetChild.block_type = 'html';
|
||||
targetChild.name = 'Test HTML Block';
|
||||
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({
|
||||
parent_locator: blockId,
|
||||
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
|
||||
duplicate_source_locator: targetChild.block_id,
|
||||
}))
|
||||
.replyOnce(200, { locator: '1234567890' });
|
||||
|
||||
@@ -2311,21 +2309,20 @@ describe('<CourseUnit />', () => {
|
||||
.onGet(getCourseVerticalChildrenApiUrl(blockId))
|
||||
.reply(200, updatedCourseVerticalChildrenMock);
|
||||
|
||||
render(<RootWrapper />);
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
|
||||
expect(iframe).toBeInTheDocument();
|
||||
simulatePostMessageEvent(messageTypes.currentXBlockId, {
|
||||
id: targetBlockId,
|
||||
});
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
|
||||
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
|
||||
expect(mockedUsedNavigate)
|
||||
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
|
||||
// After duplicating, the editor modal will open:
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
simulatePostMessageEvent(messageTypes.duplicateXBlock, { usageId: targetChild.block_id });
|
||||
simulatePostMessageEvent(messageTypes.newXBlockEditor, { blockType: 'html', usageId: targetChild.block_id });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2353,14 +2350,14 @@ describe('<CourseUnit />', () => {
|
||||
|
||||
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
|
||||
|
||||
// Disable the "Edit" button
|
||||
// Edit button should be enabled even for library imported units
|
||||
const unitHeaderTitle = screen.getByTestId('unit-header-title');
|
||||
const editButton = within(unitHeaderTitle).getByRole(
|
||||
'button',
|
||||
{ name: 'Edit' },
|
||||
);
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeDisabled();
|
||||
expect(editButton).toBeEnabled();
|
||||
|
||||
// The "Publish" button should still be enabled
|
||||
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
|
||||
@@ -2371,14 +2368,6 @@ describe('<CourseUnit />', () => {
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
expect(publishButton).toBeEnabled();
|
||||
|
||||
// Disable the "Manage Tags" button
|
||||
const manageTagsButton = screen.getByRole(
|
||||
'button',
|
||||
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
|
||||
);
|
||||
expect(manageTagsButton).toBeInTheDocument();
|
||||
expect(manageTagsButton).toBeDisabled();
|
||||
|
||||
// Does not render the "Add Components" section
|
||||
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { fetchCourseSectionVerticalData } from '../data/thunk';
|
||||
import { getCourseSectionVerticalApiUrl } from '../data/api';
|
||||
import { courseSectionVerticalMock } from '../__mocks__';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import AddComponent from './AddComponent';
|
||||
import AddComponent, { AddComponentProps } from './AddComponent';
|
||||
import messages from './messages';
|
||||
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
|
||||
import { messageTypes } from '../constants';
|
||||
@@ -56,13 +56,11 @@ jest.mock('../../generic/hooks/context/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
const renderComponent = (props?: AddComponentProps) => render(
|
||||
<IframeProvider>
|
||||
<AddComponent
|
||||
blockId={blockId}
|
||||
isUnitVerticalType
|
||||
parentLocator={blockId}
|
||||
addComponentTemplateData={{}}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
|
||||
{...props}
|
||||
/>
|
||||
@@ -94,7 +92,7 @@ describe('<AddComponent />', () => {
|
||||
),
|
||||
});
|
||||
expect(btn).toBeInTheDocument();
|
||||
if (component.beta) {
|
||||
if (componentTemplates[component].beta) {
|
||||
expect(within(btn).queryByText('Beta')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
@@ -7,28 +6,64 @@ import {
|
||||
ActionRow, Button, StandardModal, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import ComponentModalView from './add-component-modals/ComponentModalView';
|
||||
import AddComponentButton from './add-component-btn';
|
||||
import messages from './messages';
|
||||
import { ComponentPicker } from '../../library-authoring/component-picker';
|
||||
import { ContentType } from '../../library-authoring/routes';
|
||||
import { messageTypes } from '../constants';
|
||||
import { useIframe } from '../../generic/hooks/context/hooks';
|
||||
import { useEventListener } from '../../generic/hooks';
|
||||
import VideoSelectorPage from '../../editors/VideoSelectorPage';
|
||||
import EditorPage from '../../editors/EditorPage';
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { ComponentPicker } from '@src/library-authoring/component-picker';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
|
||||
import EditorPage from '@src/editors/EditorPage';
|
||||
import { SelectedComponent } from '@src/library-authoring';
|
||||
import { fetchCourseSectionVerticalData } from '../data/thunk';
|
||||
import { messageTypes } from '../constants';
|
||||
import messages from './messages';
|
||||
import AddComponentButton from './add-component-btn';
|
||||
import ComponentModalView from './add-component-modals/ComponentModalView';
|
||||
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
|
||||
|
||||
type ComponentTemplateData = {
|
||||
displayName: string,
|
||||
category?: string,
|
||||
type: string,
|
||||
beta?: boolean,
|
||||
templates: Array<{
|
||||
boilerplateName?: string,
|
||||
category?: string,
|
||||
displayName: string,
|
||||
supportLevel?: string | boolean,
|
||||
}>,
|
||||
supportLegend: {
|
||||
allowUnsupportedXblocks?: boolean,
|
||||
documentationLabel?: string,
|
||||
showLegend?: boolean,
|
||||
},
|
||||
};
|
||||
|
||||
export interface AddComponentProps {
|
||||
isSplitTestType?: boolean,
|
||||
isUnitVerticalType?: boolean,
|
||||
parentLocator: string,
|
||||
handleCreateNewCourseXBlock: (
|
||||
args: object,
|
||||
callback?: (args: { courseKey: string, locator: string }) => void
|
||||
) => void,
|
||||
isProblemBankType?: boolean,
|
||||
addComponentTemplateData?: {
|
||||
blockId: string,
|
||||
parentLocator?: string,
|
||||
model: ComponentTemplateData,
|
||||
},
|
||||
}
|
||||
|
||||
const AddComponent = ({
|
||||
parentLocator,
|
||||
isSplitTestType,
|
||||
isUnitVerticalType,
|
||||
isProblemBankType,
|
||||
addComponentTemplateData,
|
||||
handleCreateNewCourseXBlock,
|
||||
}) => {
|
||||
}: AddComponentProps) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -36,16 +71,16 @@ const AddComponent = ({
|
||||
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
|
||||
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
|
||||
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
|
||||
const blockId = addComponentTemplateData.parentLocator || parentLocator;
|
||||
const blockId = addComponentTemplateData?.parentLocator || parentLocator;
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
|
||||
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
|
||||
|
||||
const [blockType, setBlockType] = useState(null);
|
||||
const [courseId, setCourseId] = useState(null);
|
||||
const [newBlockId, setNewBlockId] = useState(null);
|
||||
const [blockType, setBlockType] = useState<string | null>(null);
|
||||
const [courseId, setCourseId] = useState<string | null>(null);
|
||||
const [newBlockId, setNewBlockId] = useState<string | null>(null);
|
||||
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
|
||||
const [selectedComponents, setSelectedComponents] = useState([]);
|
||||
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
|
||||
const [usageId, setUsageId] = useState(null);
|
||||
const { sendMessageToIframe } = useIframe();
|
||||
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
|
||||
@@ -84,7 +119,7 @@ const AddComponent = ({
|
||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, blockId, sequenceId]);
|
||||
|
||||
const handleLibraryV2Selection = useCallback((selection) => {
|
||||
const handleLibraryV2Selection = useCallback((selection: SelectedComponent) => {
|
||||
handleCreateNewCourseXBlock({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: selection.blockType,
|
||||
@@ -94,7 +129,7 @@ const AddComponent = ({
|
||||
closeAddLibraryContentModal();
|
||||
}, [usageId]);
|
||||
|
||||
const handleCreateNewXBlock = (type, moduleName) => {
|
||||
const handleCreateNewXBlock = (type: string, moduleName?: string) => {
|
||||
switch (type) {
|
||||
case COMPONENT_TYPES.discussion:
|
||||
case COMPONENT_TYPES.dragAndDrop:
|
||||
@@ -156,16 +191,16 @@ const AddComponent = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (isUnitVerticalType || isSplitTestType) {
|
||||
if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
|
||||
<>
|
||||
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
|
||||
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
|
||||
{componentTemplates.map((component) => {
|
||||
{componentTemplates.map((component: ComponentTemplateData) => {
|
||||
const { type, displayName, beta } = component;
|
||||
let modalParams;
|
||||
let modalParams: { open: () => void, close: () => void, isOpen: boolean };
|
||||
|
||||
if (!component.templates.length) {
|
||||
return null;
|
||||
@@ -268,7 +303,7 @@ const AddComponent = ({
|
||||
/>
|
||||
</div>
|
||||
</StandardModal>
|
||||
{isXBlockEditorModalOpen && (
|
||||
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
|
||||
<div className="editor-page">
|
||||
<EditorPage
|
||||
courseId={courseId}
|
||||
@@ -288,32 +323,4 @@ const AddComponent = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
AddComponent.propTypes = {
|
||||
isSplitTestType: PropTypes.bool.isRequired,
|
||||
isUnitVerticalType: PropTypes.bool.isRequired,
|
||||
parentLocator: PropTypes.string.isRequired,
|
||||
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
|
||||
addComponentTemplateData: {
|
||||
blockId: PropTypes.string.isRequired,
|
||||
model: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
category: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
templates: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
boilerplateName: PropTypes.string,
|
||||
category: PropTypes.string,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
}),
|
||||
),
|
||||
supportLegend: PropTypes.shape({
|
||||
allowUnsupportedXblocks: PropTypes.bool,
|
||||
documentationLabel: PropTypes.string,
|
||||
showLegend: PropTypes.bool,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default AddComponent;
|
||||
@@ -41,6 +41,7 @@ export const getXBlockSupportMessages = (intl) => ({
|
||||
|
||||
export const messageTypes = {
|
||||
refreshXBlock: 'refreshXBlock',
|
||||
refreshIframe: 'refreshIframe',
|
||||
showMoveXBlockModal: 'showMoveXBlockModal',
|
||||
completeXBlockMoving: 'completeXBlockMoving',
|
||||
rollbackMovedXBlock: 'rollbackMovedXBlock',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { PUBLISH_TYPES } from '../constants';
|
||||
import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
|
||||
|
||||
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
|
||||
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
|
||||
export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
|
||||
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
|
||||
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
|
||||
|
||||
/**
|
||||
* Edit course unit display name.
|
||||
* @param {string} unitId
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editUnitDisplayName(unitId, displayName) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(unitId), {
|
||||
metadata: {
|
||||
display_name: displayName,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch vertical block data from the container_handler endpoint.
|
||||
* @param {string} unitId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getVerticalData(unitId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseSectionVerticalApiUrl(unitId));
|
||||
|
||||
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
|
||||
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
|
||||
|
||||
return courseSectionVerticalData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new course XBlock.
|
||||
* @param {Object} options - The options for creating the XBlock.
|
||||
* @param {string} options.type - The type of the XBlock.
|
||||
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
|
||||
* @param {string} options.parentLocator - The parent locator.
|
||||
* @param {string} [options.displayName] - The display name.
|
||||
* @param {string} [options.boilerplate] - The boilerplate.
|
||||
* @param {string} [options.stagedContent] - The staged content.
|
||||
* @param {string} [options.libraryContentKey] - component key from library if being imported.
|
||||
*/
|
||||
export async function createCourseXblock({
|
||||
type,
|
||||
category,
|
||||
parentLocator,
|
||||
displayName,
|
||||
boilerplate,
|
||||
stagedContent,
|
||||
libraryContentKey,
|
||||
}) {
|
||||
const body = {
|
||||
type,
|
||||
boilerplate,
|
||||
category: category || type,
|
||||
parent_locator: parentLocator,
|
||||
display_name: displayName,
|
||||
staged_content: stagedContent,
|
||||
library_content_key: libraryContentKey,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postXBlockBaseApiUrl(), body);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
|
||||
* and toggling visibility to students.
|
||||
* @param {string} unitId - The ID of the course unit.
|
||||
* @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges).
|
||||
* @param {boolean} isVisible - The visibility status for students.
|
||||
* @param {boolean} groupAccess - Access group key set.
|
||||
* @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled.
|
||||
* @returns {Promise<any>} A promise that resolves with the response data.
|
||||
*/
|
||||
export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) {
|
||||
const body = {
|
||||
publish: groupAccess ? null : type,
|
||||
...(type === PUBLISH_TYPES.republish ? {
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisible ? true : null,
|
||||
group_access: groupAccess || null,
|
||||
discussion_enabled: isDiscussionEnabled,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(unitId), body);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object containing course section vertical children data.
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseVerticalChildren(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseVerticalChildrenApiUrl(itemId));
|
||||
const camelCaseData = camelCaseObject(data);
|
||||
|
||||
return updateXBlockBlockIdToId(camelCaseData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a unit item.
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteUnitItem(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getXBlockBaseApiUrl(itemId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a unit item.
|
||||
* @param {string} itemId
|
||||
* @param {string} XBlockId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function duplicateUnitItem(itemId, XBlockId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postXBlockBaseApiUrl(), {
|
||||
parent_locator: itemId,
|
||||
duplicate_source_locator: XBlockId,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
* @property {string} id - The unique identifier of the course.
|
||||
* @property {string} displayName - The display name of the course.
|
||||
* @property {string} category - The category of the course (e.g., "course").
|
||||
* @property {boolean} hasChildren - Whether the course has child items.
|
||||
* @property {boolean} unitLevelDiscussions - Indicates if unit-level discussions are available.
|
||||
* @property {Object} childInfo - Information about the child elements of the course.
|
||||
* @property {string} childInfo.category - The category of the child (e.g., "chapter").
|
||||
* @property {string} childInfo.display_name - The display name of the child element.
|
||||
* @property {Array<Object>} childInfo.children - List of children within the child_info (could be empty).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get an object containing course outline data.
|
||||
* @param {string} courseId - The identifier of the course.
|
||||
* @returns {Promise<courseOutline>} - The course outline data.
|
||||
*/
|
||||
export async function getCourseOutlineInfo(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseOutlineInfoUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} moveInfo
|
||||
* @property {string} moveSourceLocator - The locator of the source block being moved.
|
||||
* @property {string} parentLocator - The locator of the parent block where the source is being moved to.
|
||||
* @property {number} sourceIndex - The index position of the source block.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move a unit item to new unit.
|
||||
* @param {string} sourceLocator - The ID of the item to be moved.
|
||||
* @param {string} targetParentLocator - The ID of the XBlock associated with the item.
|
||||
* @returns {Promise<moveInfo>} - The move information.
|
||||
*/
|
||||
export async function patchUnitItem(sourceLocator, targetParentLocator) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(postXBlockBaseApiUrl(), {
|
||||
parent_locator: targetParentLocator,
|
||||
move_source_locator: sourceLocator,
|
||||
});
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the changes from upstream library block in course
|
||||
* @param {string} blockId - The ID of the item to be updated from library.
|
||||
*/
|
||||
export async function acceptLibraryBlockChanges(blockId) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(libraryBlockChangesUrl(blockId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore the changes from upstream library block in course
|
||||
* @param {string} blockId - The ID of the item to be updated from library.
|
||||
*/
|
||||
export async function ignoreLibraryBlockChanges(blockId) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(libraryBlockChangesUrl(blockId));
|
||||
}
|
||||
188
src/course-unit/data/api.ts
Normal file
188
src/course-unit/data/api.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { PUBLISH_TYPES } from '../constants';
|
||||
import { CourseContainerChildrenData, CourseOutlineData, MoveInfoData } from './types';
|
||||
import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
|
||||
|
||||
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getXBlockBaseApiUrl = (itemId: string) => `${getStudioBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseSectionVerticalApiUrl = (itemId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
|
||||
export const getCourseVerticalChildrenApiUrl = (itemId: string, getUpstreamInfo: boolean = false) => `${getStudioBaseUrl()}/api/contentstore/v1/container/${itemId}/children?get_upstream_info=${getUpstreamInfo}`;
|
||||
export const getCourseOutlineInfoUrl = (courseId: string) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
|
||||
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
|
||||
export const libraryBlockChangesUrl = (blockId: string) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
|
||||
|
||||
/**
|
||||
* Edit course unit display name.
|
||||
*/
|
||||
export async function editUnitDisplayName(unitId: string, displayName: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(unitId), {
|
||||
metadata: {
|
||||
display_name: displayName,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch vertical block data from the container_handler endpoint.
|
||||
*/
|
||||
export async function getVerticalData(unitId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseSectionVerticalApiUrl(unitId));
|
||||
|
||||
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
|
||||
courseSectionVerticalData.xblockInfo.readOnly = isUnitImportedFromLib(courseSectionVerticalData.xblockInfo);
|
||||
|
||||
return courseSectionVerticalData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new course XBlock.
|
||||
*/
|
||||
export async function createCourseXblock({
|
||||
type,
|
||||
category,
|
||||
parentLocator,
|
||||
displayName,
|
||||
boilerplate,
|
||||
stagedContent,
|
||||
libraryContentKey,
|
||||
}: {
|
||||
type: string,
|
||||
category?: string, // The category of the XBlock. Defaults to the type if not provided.
|
||||
parentLocator: string,
|
||||
displayName?: string,
|
||||
boilerplate?: string,
|
||||
stagedContent?: string,
|
||||
libraryContentKey?: string, // component key from library if being imported.
|
||||
}) {
|
||||
const body = {
|
||||
type,
|
||||
boilerplate,
|
||||
category: category || type,
|
||||
parent_locator: parentLocator,
|
||||
display_name: displayName,
|
||||
staged_content: stagedContent,
|
||||
library_content_key: libraryContentKey,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postXBlockBaseApiUrl(), body);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
|
||||
* and toggling visibility to students.
|
||||
*/
|
||||
export async function handleCourseUnitVisibilityAndData(
|
||||
unitId: string,
|
||||
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
|
||||
isVisible: boolean, // The visibility status for students.
|
||||
groupAccess: boolean,
|
||||
isDiscussionEnabled: boolean,
|
||||
): Promise<object> {
|
||||
const body = {
|
||||
publish: groupAccess ? null : type,
|
||||
...(type === PUBLISH_TYPES.republish ? {
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisible ? true : null,
|
||||
group_access: groupAccess || null,
|
||||
discussion_enabled: isDiscussionEnabled,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(unitId), body);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object containing course vertical children data.
|
||||
*/
|
||||
export async function getCourseContainerChildren(
|
||||
itemId: string,
|
||||
getUpstreamInfo: boolean = false,
|
||||
): Promise<CourseContainerChildrenData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseVerticalChildrenApiUrl(itemId, getUpstreamInfo));
|
||||
const camelCaseData = camelCaseObject(data);
|
||||
|
||||
return updateXBlockBlockIdToId(camelCaseData) as CourseContainerChildrenData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a unit item.
|
||||
*/
|
||||
export async function deleteUnitItem(itemId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getXBlockBaseApiUrl(itemId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a unit item.
|
||||
*/
|
||||
export async function duplicateUnitItem(itemId: string, XBlockId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postXBlockBaseApiUrl(), {
|
||||
parent_locator: itemId,
|
||||
duplicate_source_locator: XBlockId,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object containing course outline data.
|
||||
*/
|
||||
export async function getCourseOutlineInfo(courseId: string): Promise<CourseOutlineData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseOutlineInfoUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a unit item to new unit.
|
||||
*/
|
||||
export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise<MoveInfoData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(postXBlockBaseApiUrl(), {
|
||||
parent_locator: targetParentLocator,
|
||||
move_source_locator: sourceLocator,
|
||||
});
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the changes from upstream library block in course
|
||||
*/
|
||||
export async function acceptLibraryBlockChanges({
|
||||
blockId,
|
||||
overrideCustomizations = false,
|
||||
}: {
|
||||
blockId: string,
|
||||
overrideCustomizations?: boolean,
|
||||
}) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(libraryBlockChangesUrl(blockId), { override_customizations: overrideCustomizations });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore the changes from upstream library block in course
|
||||
*/
|
||||
export async function ignoreLibraryBlockChanges({ blockId } : { blockId: string }) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(libraryBlockChangesUrl(blockId));
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
import { handleResponseErrors } from '../../generic/saving-error-alert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { updateModel, updateModels } from '../../generic/model-store';
|
||||
} from '@src/generic/processing-notification/data/slice';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import { updateModel, updateModels } from '@src/generic/model-store';
|
||||
import { messageTypes } from '../constants';
|
||||
import {
|
||||
editUnitDisplayName,
|
||||
getVerticalData,
|
||||
createCourseXblock,
|
||||
getCourseVerticalChildren,
|
||||
getCourseContainerChildren,
|
||||
handleCourseUnitVisibilityAndData,
|
||||
deleteUnitItem,
|
||||
duplicateUnitItem,
|
||||
@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
return false;
|
||||
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
|
||||
}
|
||||
const courseSectionVerticalData = await getVerticalData(blockId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
@@ -163,7 +163,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
}
|
||||
}
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
if (callback) {
|
||||
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
|
||||
}
|
||||
|
||||
try {
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
|
||||
if (isSplitTestType) {
|
||||
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
|
||||
const childrenDataArray = await Promise.all(
|
||||
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
|
||||
blockIds.map(blockId => getCourseContainerChildren(blockId)),
|
||||
);
|
||||
const allChildren = childrenDataArray.reduce(
|
||||
(acc, data) => acc.concat(data.children || []),
|
||||
@@ -204,7 +204,7 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
|
||||
}
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -239,7 +239,7 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
|
||||
callback(courseKey, locator);
|
||||
const courseSectionVerticalData = await getVerticalData(itemId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
|
||||
55
src/course-unit/data/types.ts
Normal file
55
src/course-unit/data/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { UpstreamInfo, XBlock } from '@src/data/types';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
|
||||
export interface MoveInfoData {
|
||||
/**
|
||||
* The locator of the source block being moved.
|
||||
*/
|
||||
moveSourceLocator: string;
|
||||
/**
|
||||
* The locator of the parent block where the source is being moved to.
|
||||
*/
|
||||
parentLocator: string;
|
||||
/**
|
||||
* The index position of the source block.
|
||||
*/
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
export interface CourseOutlineData {
|
||||
id: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
hasChildren: boolean;
|
||||
unitLevelDiscussions: boolean;
|
||||
childInfo: {
|
||||
category: string;
|
||||
displayName: string;
|
||||
children: XBlock[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContainerChildData {
|
||||
blockId: string;
|
||||
blockType: ContainerType | keyof typeof COMPONENT_TYPES;
|
||||
id: string;
|
||||
name: string;
|
||||
upstreamLink: UpstreamInfo;
|
||||
}
|
||||
|
||||
export interface UpstreamReadyToSyncChildrenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
upstream: string;
|
||||
blockType: string;
|
||||
downstreamCustomized: string[];
|
||||
}
|
||||
|
||||
export interface CourseContainerChildrenData {
|
||||
canPasteComponent: boolean;
|
||||
children: ContainerChildData[];
|
||||
isPublished: boolean;
|
||||
displayName: string;
|
||||
upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[];
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export const updateXBlockBlockIdToId = (data: object): object => {
|
||||
* @param unit - uses the 'upstreamInfo' object if found.
|
||||
* @returns True if readOnly, False if editable.
|
||||
*/
|
||||
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
|
||||
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
|
||||
!!upstreamInfo
|
||||
&& !!upstreamInfo.upstreamRef
|
||||
&& upstreamInfo.upstreamRef.startsWith('lct:')
|
||||
|
||||
@@ -34,8 +34,6 @@ const HeaderTitle = ({
|
||||
COURSE_BLOCK_NAMES.component.id,
|
||||
].includes(currentItemData.category);
|
||||
|
||||
const readOnly = !!currentItemData.readOnly;
|
||||
|
||||
const onConfigureSubmit = (...arg) => {
|
||||
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
|
||||
};
|
||||
@@ -82,7 +80,6 @@ const HeaderTitle = ({
|
||||
className="ml-1 flex-shrink-0"
|
||||
iconAs={EditIcon}
|
||||
onClick={handleTitleEdit}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.altButtonSettings)}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
|
||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('Units sourced from upstream show a disabled edit button', async () => {
|
||||
it('Units sourced from upstream show a enabled edit button', async () => {
|
||||
// Override mock unit with one sourced from an upstream library
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -95,7 +95,7 @@ describe('<HeaderTitle />', () => {
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
|
||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
|
||||
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
|
||||
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
|
||||
const isProblemBankType = [
|
||||
COURSE_BLOCK_NAMES.legacyLibraryContent.id,
|
||||
COURSE_BLOCK_NAMES.itembank.id,
|
||||
].includes(unitCategory);
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleViewLive: () => {
|
||||
@@ -254,6 +258,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
isUnitVerticalType,
|
||||
isUnitLibraryType,
|
||||
isSplitTestType,
|
||||
isProblemBankType,
|
||||
sharedClipboardData,
|
||||
showPasteXBlock,
|
||||
showPasteUnit,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
.lib-preview-xblock-changes-modal {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
.preview-title {
|
||||
span {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
|
||||
import {
|
||||
act,
|
||||
render as baseRender,
|
||||
screen,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
} from '@src/testUtils';
|
||||
import { ToastActionData } from '@src/generic/toast-context';
|
||||
|
||||
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
|
||||
import { messageTypes } from '../constants';
|
||||
import { libraryBlockChangesUrl } from '../data/api';
|
||||
import { ToastActionData } from '../../generic/toast-context';
|
||||
|
||||
const usageKey = 'some-id';
|
||||
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
|
||||
const defaultEventData: LibraryChangesMessageData = {
|
||||
displayName: 'Test block',
|
||||
downstreamBlockId: usageKey,
|
||||
upstreamBlockId: 'lct:org:lib1:unit:1',
|
||||
upstreamBlockVersionSynced: 1,
|
||||
isContainer: false,
|
||||
isLocallyModified: false,
|
||||
blockType: 'html',
|
||||
};
|
||||
|
||||
const mockSendMessageToIframe = jest.fn();
|
||||
jest.mock('../../generic/hooks/context/hooks', () => ({
|
||||
jest.mock('@src/generic/hooks/context/hooks', () => ({
|
||||
useIframe: () => ({
|
||||
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
|
||||
setIframeRef: () => {},
|
||||
@@ -45,7 +48,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
|
||||
};
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
let mockShowToast: (message: string, action?: ToastActionData) => void;
|
||||
|
||||
describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
beforeEach(() => {
|
||||
@@ -60,7 +63,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -132,4 +134,59 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal of text with local changes', async () => {
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('update changes works', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
|
||||
await user.click(acceptBtn);
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
|
||||
messageTypes.completeXBlockEditing,
|
||||
{ locator: usageKey },
|
||||
);
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keep changes work', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
|
||||
await user.click(ignoreBtn);
|
||||
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
|
||||
await user.click(ignoreConfirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
|
||||
messageTypes.completeXBlockEditing,
|
||||
{ locator: usageKey },
|
||||
);
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,111 @@
|
||||
import { useCallback, useContext, useState } from 'react';
|
||||
import {
|
||||
ActionRow, Button, ModalDialog, useToggle,
|
||||
useCallback, useContext, useMemo, useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ActionRow, Button, Icon, ModalDialog, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useEventListener } from '../../generic/hooks';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
|
||||
|
||||
import { messageTypes } from '../constants';
|
||||
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
|
||||
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import { useIframe } from '../../generic/hooks/context/hooks';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import messages from './messages';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import LoadingButton from '../../generic/loading-button';
|
||||
import Loading from '../../generic/Loading';
|
||||
|
||||
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined;
|
||||
|
||||
const ConfirmationModal = ({
|
||||
modalType,
|
||||
onClose,
|
||||
updateAndRefresh,
|
||||
}: {
|
||||
modalType: ConfirmationModalType,
|
||||
onClose: () => void,
|
||||
updateAndRefresh: (accept: boolean, overrideCustomizations: boolean) => void,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
btnLabel,
|
||||
btnVariant,
|
||||
accept,
|
||||
overrideCustomizations,
|
||||
} = useMemo(() => {
|
||||
let resultTitle: string | undefined;
|
||||
let resultDescription: string | undefined;
|
||||
let resutlBtnLabel: string | undefined;
|
||||
let resultAccept: boolean = false;
|
||||
let resultOverrideCustomizations: boolean = false;
|
||||
let resultBtnVariant: 'danger' | 'primary' = 'danger';
|
||||
|
||||
switch (modalType) {
|
||||
case 'ignore':
|
||||
resultTitle = intl.formatMessage(messages.confirmationTitle);
|
||||
resultDescription = intl.formatMessage(messages.confirmationDescription);
|
||||
resutlBtnLabel = intl.formatMessage(messages.confirmationConfirmBtn);
|
||||
break;
|
||||
case 'update':
|
||||
resultTitle = intl.formatMessage(messages.updateToPublishedLibraryContentTitle);
|
||||
resultDescription = intl.formatMessage(messages.updateToPublishedLibraryContentBody);
|
||||
resutlBtnLabel = intl.formatMessage(messages.updateToPublishedLibraryContentConfirm);
|
||||
resultAccept = true;
|
||||
resultOverrideCustomizations = true;
|
||||
break;
|
||||
case 'keep':
|
||||
resultTitle = intl.formatMessage(messages.keepCourseContentTitle);
|
||||
resultDescription = intl.formatMessage(messages.keepCourseContentBody);
|
||||
resutlBtnLabel = intl.formatMessage(messages.keepCourseContentButton);
|
||||
resultBtnVariant = 'primary';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
title: resultTitle,
|
||||
description: resultDescription,
|
||||
btnLabel: resutlBtnLabel,
|
||||
accept: resultAccept,
|
||||
btnVariant: resultBtnVariant,
|
||||
overrideCustomizations: resultOverrideCustomizations,
|
||||
};
|
||||
}, [modalType]);
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={modalType !== undefined}
|
||||
close={onClose}
|
||||
variant="warning"
|
||||
title={title}
|
||||
description={description}
|
||||
onDeleteSubmit={() => updateAndRefresh(accept, overrideCustomizations)}
|
||||
btnLabel={btnLabel}
|
||||
buttonVariant={btnVariant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface LibraryChangesMessageData {
|
||||
displayName: string,
|
||||
downstreamBlockId: string,
|
||||
upstreamBlockId: string,
|
||||
upstreamBlockVersionSynced: number,
|
||||
isLocallyModified?: boolean,
|
||||
isContainer: boolean,
|
||||
blockType?: string | null,
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
|
||||
export interface PreviewLibraryXBlockChangesProps {
|
||||
@@ -45,27 +128,41 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const intl = useIntl();
|
||||
|
||||
// ignore changes confirmation modal toggle.
|
||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
||||
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
|
||||
|
||||
const acceptChangesMutation = useAcceptLibraryBlockChanges();
|
||||
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
|
||||
|
||||
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
|
||||
|
||||
const getBody = useCallback(() => {
|
||||
if (!blockData) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (blockData.isContainer) {
|
||||
return (
|
||||
<CompareContainersWidget
|
||||
upstreamBlockId={blockData.upstreamBlockId}
|
||||
downstreamBlockId={blockData.downstreamBlockId}
|
||||
isReadyToSyncIndividually={blockData.isReadyToSyncIndividually}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CompareChangesWidget
|
||||
usageKey={blockData.upstreamBlockId}
|
||||
oldUsageKey={blockData.downstreamBlockId}
|
||||
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
|
||||
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
|
||||
newVersion="published"
|
||||
isContainer={blockData.isContainer}
|
||||
hasLocalChanges={isTextWithLocalChanges}
|
||||
showNewTitle={isTextWithLocalChanges}
|
||||
/>
|
||||
);
|
||||
}, [blockData]);
|
||||
}, [blockData, isTextWithLocalChanges]);
|
||||
|
||||
const updateAndRefresh = useCallback(async (accept: boolean) => {
|
||||
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!blockData) {
|
||||
return;
|
||||
@@ -75,30 +172,58 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
|
||||
|
||||
try {
|
||||
await mutation.mutateAsync(blockData.downstreamBlockId);
|
||||
await mutation.mutateAsync({
|
||||
blockId: blockData.downstreamBlockId,
|
||||
overrideCustomizations,
|
||||
});
|
||||
postChange(accept);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
showToast(intl.formatMessage(failureMsg));
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
const itemIcon = getItemIcon(blockData.blockType || '');
|
||||
|
||||
// Build title
|
||||
const defaultTitle = intl.formatMessage(
|
||||
blockData.isContainer
|
||||
? messages.defaultContainerTitle
|
||||
: messages.defaultComponentTitle,
|
||||
{
|
||||
itemIcon: <Icon size="lg" src={itemIcon} />,
|
||||
},
|
||||
);
|
||||
const title = blockData.displayName
|
||||
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
|
||||
? intl.formatMessage(messages.title, {
|
||||
blockTitle: blockData?.displayName,
|
||||
blockIcon: <Icon size="lg" src={itemIcon} />,
|
||||
})
|
||||
: defaultTitle;
|
||||
|
||||
// Build aria label
|
||||
const defaultAriaLabel = intl.formatMessage(
|
||||
blockData.isContainer
|
||||
? messages.defaultContainerTitle
|
||||
: messages.defaultComponentTitle,
|
||||
{
|
||||
itemIcon: '',
|
||||
},
|
||||
);
|
||||
const ariaLabel = blockData.displayName
|
||||
? intl.formatMessage(messages.title, {
|
||||
blockTitle: blockData?.displayName,
|
||||
blockIcon: '',
|
||||
})
|
||||
: defaultAriaLabel;
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
size="xl"
|
||||
title={title}
|
||||
title={ariaLabel}
|
||||
className="lib-preview-xblock-changes-modal"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
@@ -106,43 +231,57 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{title}
|
||||
<div className="d-flex preview-title">
|
||||
{title}
|
||||
</div>
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<AlertMessage
|
||||
show
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
|
||||
/>
|
||||
{isTextWithLocalChanges && (
|
||||
<AlertMessage
|
||||
show
|
||||
variant="info"
|
||||
icon={Info}
|
||||
title={intl.formatMessage(messages.localEditsAlert)}
|
||||
/>
|
||||
)}
|
||||
{getBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<LoadingButton
|
||||
onClick={() => updateAndRefresh(true)}
|
||||
label={intl.formatMessage(messages.acceptChangesBtn)}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={openConfirmModal}
|
||||
>
|
||||
<FormattedMessage {...messages.ignoreChangesBtn} />
|
||||
</Button>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
<FormattedMessage {...messages.cancelBtn} />
|
||||
</ModalDialog.CloseButton>
|
||||
{isTextWithLocalChanges ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setConfirmationModalType('update')}
|
||||
>
|
||||
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} />
|
||||
</Button>
|
||||
) : (
|
||||
<LoadingButton
|
||||
onClick={() => updateAndRefresh(true, false)}
|
||||
label={intl.formatMessage(messages.acceptChangesBtn)}
|
||||
/>
|
||||
)}
|
||||
{isTextWithLocalChanges ? (
|
||||
<Button
|
||||
onClick={() => setConfirmationModalType('keep')}
|
||||
>
|
||||
<FormattedMessage {...messages.keepCourseContentButton} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setConfirmationModalType('ignore')}
|
||||
>
|
||||
<FormattedMessage {...messages.ignoreChangesBtn} />
|
||||
</Button>
|
||||
)}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
<DeleteModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
close={closeConfirmModal}
|
||||
variant="warning"
|
||||
title={intl.formatMessage(messages.confirmationTitle)}
|
||||
description={intl.formatMessage(messages.confirmationDescription)}
|
||||
onDeleteSubmit={() => updateAndRefresh(false)}
|
||||
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
|
||||
<ConfirmationModal
|
||||
modalType={confirmationModalType}
|
||||
onClose={() => setConfirmationModalType(undefined)}
|
||||
updateAndRefresh={updateAndRefresh}
|
||||
/>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-title',
|
||||
defaultMessage: 'Preview changes: {blockTitle}',
|
||||
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}',
|
||||
description: 'Preview changes modal title text',
|
||||
},
|
||||
defaultContainerTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
|
||||
defaultMessage: 'Preview changes: Container',
|
||||
defaultMessage: 'Preview changes: {itemIcon} Container',
|
||||
description: 'Preview changes modal default title text for containers',
|
||||
},
|
||||
defaultComponentTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
|
||||
defaultMessage: 'Preview changes: Component',
|
||||
defaultMessage: 'Preview changes: {itemIcon} Component',
|
||||
description: 'Preview changes modal default title text for components',
|
||||
},
|
||||
acceptChangesBtn: {
|
||||
@@ -36,11 +36,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to ignore changes',
|
||||
description: 'Toast message to display when ignore changes call fails',
|
||||
},
|
||||
cancelBtn: {
|
||||
id: 'authoring.course-unit.preview-changes.cancel-btn',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Preview changes modal cancel button text.',
|
||||
},
|
||||
confirmationTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
|
||||
defaultMessage: 'Ignore these changes?',
|
||||
@@ -56,10 +51,45 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Ignore',
|
||||
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
|
||||
},
|
||||
olderVersionPreviewAlert: {
|
||||
id: 'course-authoring.review-tab.preview.old-version-alert',
|
||||
defaultMessage: 'The old version preview is the previous library version',
|
||||
description: 'Alert message stating that older version in preview is of library block',
|
||||
localEditsAlert: {
|
||||
id: 'course-authoring.review-tab.preview.loal-edits-alert',
|
||||
defaultMessage: 'This library content has local edits.',
|
||||
description: 'Alert message stating that the content has local edits',
|
||||
},
|
||||
updateToPublishedLibraryContentButton: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.button.text',
|
||||
defaultMessage: 'Update to published library content',
|
||||
description: 'Label of the button to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentTitle: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.title',
|
||||
defaultMessage: 'Update to published library content?',
|
||||
description: 'Title of the modal to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentBody: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.body',
|
||||
defaultMessage: 'Updating this block will discard local changes. Any edits made within this course will be discarded, and cannot be recovered',
|
||||
description: 'Body of the modal to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentConfirm: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.confirm',
|
||||
defaultMessage: 'Discard local edits and update',
|
||||
description: 'Label of the button in the modal to update a content to the published library content',
|
||||
},
|
||||
keepCourseContentButton: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.button.text',
|
||||
defaultMessage: 'Keep course content',
|
||||
description: 'Label of the button to keep the content of a course component',
|
||||
},
|
||||
keepCourseContentTitle: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.modal.title',
|
||||
defaultMessage: 'Keep course content?',
|
||||
description: 'Title of the modal to keep the content of a course component',
|
||||
},
|
||||
keepCourseContentBody: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.modal.body',
|
||||
defaultMessage: 'This will keep the locally edited course content. If the component is published again in its library, you can choose to update to published library content',
|
||||
description: 'Body of the modal to keep the content of a course component',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type UseMessageHandlersTypes = {
|
||||
handleOpenManageTagsModal: (id: string) => void;
|
||||
handleShowProcessingNotification: (variant: string) => void;
|
||||
handleHideProcessingNotification: () => void;
|
||||
handleRefreshIframe: () => void;
|
||||
};
|
||||
|
||||
export type MessageHandlersTypes = Record<string, (payload: any) => void>;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const useMessageHandlers = ({
|
||||
handleShowProcessingNotification,
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
}: UseMessageHandlersTypes): MessageHandlersTypes => {
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
@@ -50,6 +51,7 @@ export const useMessageHandlers = ({
|
||||
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
|
||||
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
|
||||
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
|
||||
[messageTypes.refreshIframe]: handleRefreshIframe,
|
||||
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
|
||||
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
|
||||
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),
|
||||
|
||||
@@ -46,6 +46,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Useful to reload iframe
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
@@ -182,6 +184,12 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
dispatch(hideProcessingNotification());
|
||||
};
|
||||
|
||||
const handleRefreshIframe = () => {
|
||||
// Updating iframeKey forces the iframe to re-render.
|
||||
/* istanbul ignore next */
|
||||
setIframeKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const messageHandlers = useMessageHandlers({
|
||||
courseId,
|
||||
dispatch,
|
||||
@@ -199,6 +207,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
handleShowProcessingNotification,
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
});
|
||||
|
||||
useIframeMessages(messageHandlers);
|
||||
@@ -268,6 +277,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.xblockIframeTitle)}
|
||||
name="xblock-iframe"
|
||||
|
||||
@@ -58,7 +58,7 @@ export function createCourseUpdateQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { creatingUpdate: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -80,7 +80,7 @@ export function editCourseUpdateQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -102,7 +102,7 @@ export function deleteCourseUpdateQuery(courseId, updateId) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { deletingUpdates: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
@@ -150,7 +150,7 @@ export function editCourseHandoutsQuery(courseId, data) {
|
||||
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
|
||||
error: { savingHandouts: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatuses({
|
||||
status: { createCourseUpdateQuery: RequestStatus.FAILED },
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('CustomPages', () => {
|
||||
it('should update page order on drag', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const buttons = await screen.queryAllByRole('button');
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const draggableButton = buttons[9];
|
||||
expect(draggableButton).toBeVisible();
|
||||
await act(async () => {
|
||||
|
||||
@@ -132,7 +132,7 @@ export function updateCustomPageVisibility({ blockId, metadata }) {
|
||||
},
|
||||
}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export const updateSingleCustomPage = ({
|
||||
}));
|
||||
setCurrentPage(null);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function getCourseDetail(courseId: string, username: string) {
|
||||
*/
|
||||
export const waffleFlagDefaults = {
|
||||
enableCourseOptimizer: false,
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
useNewHomePage: true,
|
||||
useNewCustomPages: true,
|
||||
useNewScheduleDetailsPage: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getWaffleFlags, waffleFlagDefaults } from './api';
|
||||
export const useWaffleFlags = (courseId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isPending: isLoading, isError } = useQuery({
|
||||
queryKey: ['waffleFlags', courseId],
|
||||
queryFn: () => getWaffleFlags(courseId),
|
||||
// Waffle flags change rarely, so never bother refetching them:
|
||||
|
||||
@@ -48,11 +48,23 @@ export interface XBlockPrereqs {
|
||||
blockDisplayName: string;
|
||||
}
|
||||
|
||||
export interface UpstreamChildrenInfo {
|
||||
name: string;
|
||||
upstream: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UpstreamInfo {
|
||||
readyToSync: boolean,
|
||||
upstreamRef: string,
|
||||
versionSynced: number,
|
||||
versionAvailable: number | null,
|
||||
versionDeclined: number | null,
|
||||
errorMessage: string | null,
|
||||
downstreamCustomized: string[],
|
||||
hasTopLevelParent?: boolean,
|
||||
readyToSyncChildren?: UpstreamChildrenInfo[],
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
|
||||
export interface XBlock {
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-unused-vars */
|
||||
/* eslint-disable import/extensions */
|
||||
/* eslint-disable import/no-unresolved */
|
||||
/**
|
||||
* This is an example component for an xblock Editor
|
||||
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
|
||||
* To use run npm run-script addXblock <your>
|
||||
*/
|
||||
|
||||
/* istanbul ignore file */
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form,
|
||||
Spinner,
|
||||
Collapsible,
|
||||
Icon,
|
||||
IconButton,
|
||||
Dropdown,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
DeleteOutline,
|
||||
Add,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
InsertPhoto,
|
||||
MoreHoriz,
|
||||
Check,
|
||||
} from '@openedx/paragon/icons';
|
||||
import {
|
||||
actions,
|
||||
selectors,
|
||||
} from '../../data/redux';
|
||||
import {
|
||||
RequestKeys,
|
||||
} from '../../data/constants/requests';
|
||||
import './index.scss';
|
||||
import EditorContainer from '../EditorContainer';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
||||
// should be re-thought and cleaned up to avoid this pattern.
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import * as module from '.';
|
||||
import { actions, selectors } from '../../data/redux';
|
||||
import { RequestKeys } from '../../data/constants/requests';
|
||||
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
|
||||
import Button from '../../sharedComponents/Button';
|
||||
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
|
||||
import messages from './messages';
|
||||
|
||||
export const hooks = {
|
||||
getContent: () => ({
|
||||
@@ -30,77 +40,498 @@ export const hooks = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const thumbEditor = ({
|
||||
export const GameEditor = ({
|
||||
onClose,
|
||||
// redux
|
||||
blockValue,
|
||||
lmsEndpointUrl,
|
||||
blockFailed,
|
||||
blockFinished,
|
||||
initializeEditor,
|
||||
exampleValue,
|
||||
// inject
|
||||
intl,
|
||||
}) => (
|
||||
<EditorContainer
|
||||
getContent={module.hooks.getContent}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div>
|
||||
{exampleValue}
|
||||
|
||||
// settings
|
||||
settings,
|
||||
shuffleTrue,
|
||||
shuffleFalse,
|
||||
timerTrue,
|
||||
timerFalse,
|
||||
type,
|
||||
updateType,
|
||||
|
||||
// list
|
||||
list,
|
||||
updateTerm,
|
||||
updateTermImage,
|
||||
updateDefinition,
|
||||
updateDefinitionImage,
|
||||
toggleOpen,
|
||||
setList,
|
||||
addCard,
|
||||
removeCard,
|
||||
|
||||
isDirty,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
// State for list
|
||||
const [state, setState] = React.useState(list);
|
||||
React.useEffect(() => { setState(list); }, [list]);
|
||||
|
||||
// Non-reducer functions go here
|
||||
const getDescriptionHeader = () => {
|
||||
// Function to determine what the header will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Flashcard terms';
|
||||
case 'matching':
|
||||
return 'Matching terms';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
// Function to determine what the description will say based on type
|
||||
switch (type) {
|
||||
case 'flashcards':
|
||||
return 'Enter your terms and definitions below. Learners will review each card by viewing the term, then flipping to reveal the definition.';
|
||||
case 'matching':
|
||||
return 'Enter your terms and definitions below. Learners must match each term with the correct definition.';
|
||||
default:
|
||||
return 'Undefined';
|
||||
}
|
||||
};
|
||||
|
||||
const saveTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateTermImage({ index, termImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTermImage = (index) => {
|
||||
const id = `term_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateTermImage({ index, termImage: '' });
|
||||
};
|
||||
|
||||
const saveDefinitionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
const file = document.getElementById(id).files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateDefinitionImage({ index, definitionImage: event.target.result });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDefintionImage = (index) => {
|
||||
const id = `definition_image_upload|${index}`;
|
||||
document.getElementById(id).value = '';
|
||||
updateDefinitionImage({ index, definitionImage: '' });
|
||||
};
|
||||
|
||||
const moveCardUp = (index) => {
|
||||
if (index === 0) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index], temp[index - 1]] = [temp[index - 1], temp[index]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const moveCardDown = (index) => {
|
||||
if (index === state.length - 1) { return; }
|
||||
const temp = state.slice();
|
||||
[temp[index + 1], temp[index]] = [temp[index], temp[index + 1]];
|
||||
setState(temp);
|
||||
};
|
||||
|
||||
const loading = (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
screenreadertext={intl.formatMessage(messages.loadingSpinner)}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished
|
||||
? (
|
||||
<div className="text-center p-6">
|
||||
<Spinner
|
||||
animation="border"
|
||||
className="m-3"
|
||||
// Use a messages.js file for intl messages.
|
||||
screenreadertext={intl.formatMessage('Loading Spinner')}
|
||||
/>
|
||||
);
|
||||
|
||||
const termImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.term_image} alt="TERM_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeTermImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const termImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`term_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const definitionImageDiv = (card, index) => (
|
||||
<div className="card-image-area d-flex align-items-center align-self-stretch">
|
||||
<img className="card-image" src={card.definition_image} alt="DEF_IMG" />
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt="DEL_IMG"
|
||||
variant="primary"
|
||||
onClick={() => removeDefintionImage(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const definitionImageUploadButton = (card, index) => (
|
||||
<IconButton
|
||||
src={InsertPhoto}
|
||||
iconAs={Icon}
|
||||
alt="IMG"
|
||||
variant="primary"
|
||||
onClick={() => document.getElementById(`definition_image_upload|${index}`).click()}
|
||||
/>
|
||||
);
|
||||
|
||||
const timerSettingsOption = (
|
||||
<SettingsOption
|
||||
className="sidebar-timer d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Timer"
|
||||
summary={settings.timer ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Measure the time it takes learners to match all terms and definitions. Used to calculate a learner's score.</div>
|
||||
<Button
|
||||
onClick={() => timerFalse()}
|
||||
variant={!settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => timerTrue()}
|
||||
variant={settings.timer ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
);
|
||||
|
||||
const page = (
|
||||
<div className="page-body d-flex align-items-start">
|
||||
<div className="terms d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description d-flex flex-column align-items-start align-self-stretch">
|
||||
<div className="description-header">
|
||||
{getDescriptionHeader()}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p>
|
||||
Your Editor Goes here.
|
||||
You can get at the xblock data with the blockValue field.
|
||||
here is what is in your xblock: {JSON.stringify(blockValue)}
|
||||
</p>
|
||||
)}
|
||||
<div className="description-body align-self-stretch">
|
||||
{getDescription()}
|
||||
</div>
|
||||
</div>
|
||||
<DraggableList
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
itemList={state}
|
||||
setState={setState}
|
||||
updateOrder={() => (newList) => setList(newList)}
|
||||
>
|
||||
{
|
||||
state.map((card, index) => (
|
||||
<SortableItem
|
||||
id={card.id}
|
||||
key={card.id}
|
||||
buttonClassName="draggable-button"
|
||||
componentStyle={{
|
||||
background: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '24px',
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0px 1px 5px #ADADAD',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
flexFlow: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Collapsible.Advanced
|
||||
className="card"
|
||||
defaultOpen
|
||||
onOpen={() => toggleOpen({ index, isOpen: true })}
|
||||
onClose={() => toggleOpen({ index, isOpen: false })}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id={`term_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveTermImage(index)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id={`definition_image_upload|${index}`}
|
||||
hidden
|
||||
onChange={() => saveDefinitionImage(index)}
|
||||
/>
|
||||
<Collapsible.Trigger className="card-heading">
|
||||
<div className="drag-spacer" />
|
||||
<div className="card-heading d-flex align-items-center align-self-stretch">
|
||||
<div className="card-number">{index + 1}</div>
|
||||
{!card.editorOpen ? (
|
||||
<div className="preview-block position-relative w-100">
|
||||
<span className="align-middle">
|
||||
<span className="preview-term">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.term_image !== ''
|
||||
? <img className="img-preview" src={card.term_image} alt="TERM_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.term !== '' ? card.term : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
<span className="preview-definition">
|
||||
{type === 'flashcards' ? (
|
||||
<span className="d-inline-block align-middle pr-2">
|
||||
{card.definition_image !== ''
|
||||
? <img className="img-preview" src={card.definition_image} alt="DEF_IMG_PRV" />
|
||||
: <Icon className="img-preview" src={InsertPhoto} />}
|
||||
</span>
|
||||
)
|
||||
: ''}
|
||||
{card.definition !== '' ? card.definition : <span className="text-gray">No text</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: <div className="card-spacer d-flex align-self-stretch" />}
|
||||
<Dropdown onToggle={(isOpen, e) => e.stopPropagation()}>
|
||||
<Dropdown.Toggle
|
||||
className="card-dropdown"
|
||||
as={IconButton}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => moveCardUp(index)}>Move up</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => moveCardDown(index)}>Move down</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={() => removeCard({ index })}>Delete</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandMore}
|
||||
iconAs={Icon}
|
||||
alt="EXPAND"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div>
|
||||
<IconButton
|
||||
src={ExpandLess}
|
||||
iconAs={Icon}
|
||||
alt="COLLAPSE"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<div className="card-body p-0">
|
||||
<Collapsible.Body>
|
||||
<div className="card-body-divider">
|
||||
<div className="card-divider" />
|
||||
</div>
|
||||
<div className="card-term d-flex flex-column align-items-start align-self-stretch">
|
||||
Term
|
||||
{(type !== 'matching' && card.term_image !== '') && termImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`term|${index}`}
|
||||
placeholder="Enter your term"
|
||||
value={card.term}
|
||||
onChange={(e) => updateTerm({ index, term: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && termImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-divider" />
|
||||
<div className="card-definition d-flex flex-column align-items-start align-self-stretch">
|
||||
Definition
|
||||
{(type !== 'matching' && card.definition_image !== '') && definitionImageDiv(card, index)}
|
||||
<div className="card-input-line d-flex align-items-start align-self-stretch">
|
||||
<Form.Control
|
||||
className="d-flex flex-column align-items-start align-self-stretch"
|
||||
id={`definition|${index}`}
|
||||
placeholder="Enter your definition"
|
||||
value={card.definition}
|
||||
onChange={(e) => updateDefinition({ index, definition: e.target.value })}
|
||||
/>
|
||||
{type !== 'matching' && definitionImageUploadButton(card, index)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Body>
|
||||
</div>
|
||||
</Collapsible.Advanced>
|
||||
</SortableItem>
|
||||
))
|
||||
}
|
||||
</DraggableList>
|
||||
<Button
|
||||
className="add-button"
|
||||
onClick={() => addCard()}
|
||||
>
|
||||
<IconButton
|
||||
src={Add}
|
||||
iconAs={Icon}
|
||||
alt="ADD"
|
||||
variant="primary"
|
||||
/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sidebar d-flex flex-column align-items-start flex-shrink-0">
|
||||
<SettingsOption
|
||||
className="sidebar-type d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Type"
|
||||
summary={type.substr(0, 1).toUpperCase() + type.substr(1)}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<Button
|
||||
onClick={() => updateType('flashcards')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Flashcards</span>
|
||||
<span hidden={type !== 'flashcards'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
<div className="card-divider" />
|
||||
<Button
|
||||
onClick={() => updateType('matching')}
|
||||
className="type-button"
|
||||
>
|
||||
<span className="small text-primary-500">Matching</span>
|
||||
<span hidden={type !== 'matching'}><Icon src={Check} className="text-success" /></span>
|
||||
</Button>
|
||||
</SettingsOption>
|
||||
<SettingsOption
|
||||
className="sidebar-shuffle d-flex flex-column align-items-start align-self-stretch"
|
||||
title="Shuffle"
|
||||
summary={settings.shuffle ? 'On' : 'Off'}
|
||||
isCardCollapsibleOpen="true"
|
||||
>
|
||||
<>
|
||||
<div className="settings-description">Shuffle the order of terms shown to learners when reviewing.</div>
|
||||
<Button
|
||||
onClick={() => shuffleFalse()}
|
||||
variant={!settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => shuffleTrue()}
|
||||
variant={settings.shuffle ? 'primary' : 'outline-primary'}
|
||||
className="toggle-button rounded-0"
|
||||
>
|
||||
On
|
||||
</Button>
|
||||
</>
|
||||
</SettingsOption>
|
||||
{type === 'matching' && timerSettingsOption}
|
||||
</div>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
thumbEditor.defaultProps = {
|
||||
blockValue: null,
|
||||
lmsEndpointUrl: null,
|
||||
);
|
||||
|
||||
// Page content goes here
|
||||
return (
|
||||
<EditorContainer
|
||||
getContent={hooks.getContent}
|
||||
onClose={onClose}
|
||||
isDirty={() => isDirty}
|
||||
>
|
||||
<div className="editor-body h-75 overflow-auto">
|
||||
{!blockFinished ? loading : page}
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
thumbEditor.propTypes = {
|
||||
|
||||
GameEditor.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
// redux
|
||||
blockValue: PropTypes.shape({
|
||||
data: PropTypes.shape({ data: PropTypes.string }),
|
||||
}),
|
||||
lmsEndpointUrl: PropTypes.string,
|
||||
blockFailed: PropTypes.bool.isRequired,
|
||||
blockFinished: PropTypes.bool.isRequired,
|
||||
initializeEditor: PropTypes.func.isRequired,
|
||||
// inject
|
||||
intl: intlShape.isRequired,
|
||||
list: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
updateTerm: PropTypes.func.isRequired,
|
||||
updateTermImage: PropTypes.func.isRequired,
|
||||
updateDefinition: PropTypes.func.isRequired,
|
||||
updateDefinitionImage: PropTypes.func.isRequired,
|
||||
toggleOpen: PropTypes.func.isRequired,
|
||||
setList: PropTypes.func.isRequired,
|
||||
addCard: PropTypes.func.isRequired,
|
||||
removeCard: PropTypes.func.isRequired,
|
||||
settings: PropTypes.shape({
|
||||
shuffle: PropTypes.bool.isRequired,
|
||||
timer: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
shuffleTrue: PropTypes.func.isRequired,
|
||||
shuffleFalse: PropTypes.func.isRequired,
|
||||
timerTrue: PropTypes.func.isRequired,
|
||||
timerFalse: PropTypes.func.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
updateType: PropTypes.func.isRequired,
|
||||
|
||||
isDirty: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
blockValue: selectors.app.blockValue(state),
|
||||
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
|
||||
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
|
||||
// TODO fill with redux state here if needed
|
||||
exampleValue: selectors.game.exampleValue(state),
|
||||
settings: selectors.game.settings(state),
|
||||
type: selectors.game.type(state),
|
||||
list: selectors.game.list(state),
|
||||
isDirty: selectors.game.isDirty(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
initializeEditor: actions.app.initializeEditor,
|
||||
// TODO fill with dispatches here if needed
|
||||
|
||||
// shuffle
|
||||
shuffleTrue: actions.game.shuffleTrue,
|
||||
shuffleFalse: actions.game.shuffleFalse,
|
||||
|
||||
// timer
|
||||
timerTrue: actions.game.timerTrue,
|
||||
timerFalse: actions.game.timerFalse,
|
||||
|
||||
// type
|
||||
updateType: actions.game.updateType,
|
||||
|
||||
// list
|
||||
updateTerm: actions.game.updateTerm,
|
||||
updateTermImage: actions.game.updateTermImage,
|
||||
updateDefinition: actions.game.updateDefinition,
|
||||
updateDefinitionImage: actions.game.updateDefinitionImage,
|
||||
toggleOpen: actions.game.toggleOpen,
|
||||
setList: actions.game.setList,
|
||||
addCard: actions.game.addCard,
|
||||
removeCard: actions.game.removeCard,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(GameEditor);
|
||||
|
||||
275
src/editors/containers/GameEditor/index.scss
Normal file
275
src/editors/containers/GameEditor/index.scss
Normal file
@@ -0,0 +1,275 @@
|
||||
/* Basic styles to support GameEditor layout and classes used in JSX */
|
||||
.editor-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
padding: 8px 0 0 24px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 0;
|
||||
gap: 16px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.terms > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
padding: 8px 24px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.description-header {
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.draggable-button {
|
||||
cursor: grab;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: #EEF1F5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
max-height: 32px;
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
.card-image-area {
|
||||
display: flex;
|
||||
padding: 0 24px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: var(--light-400, #EAE6E5);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-right: 8px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.preview-term {
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.preview-block {
|
||||
margin-right: 8px;
|
||||
bottom: 35%;
|
||||
}
|
||||
|
||||
.preview-definition {
|
||||
display: inline-block;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.description-body {
|
||||
align-self: stretch;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
border: var(--extras-white, #FFFFFF);
|
||||
background: var(--extras-white, #FFFFFF);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-spacer {
|
||||
flex: 1 0 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.card-delete-button, .card-image-button, .image-delete-button {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 44px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-body-divider {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.card-term, .card-definition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
gap: 16px;
|
||||
color: var(--primary-500, #00262B);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.card-input-line {
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1 0 0;
|
||||
border: 1px solid var(--gray-500, #707070);
|
||||
background: #FFFFFF;
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
color: var(--gray-500, #707070);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.sidebar-type, .sidebar-shuffle, .sidebar-timer {
|
||||
gap: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--light-700, #D7D3D1);
|
||||
background: #FFFFFF;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drag-spacer {
|
||||
width: 20px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.check {
|
||||
fill: green;
|
||||
}
|
||||
|
||||
.card-dropdown {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
padding-bottom: 16px;
|
||||
color: #51565C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
11
src/editors/containers/GameEditor/messages.ts
Normal file
11
src/editors/containers/GameEditor/messages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingSpinner: {
|
||||
id: 'GameEditor.loadingSpinner',
|
||||
defaultMessage: 'Loading Spinner',
|
||||
description: 'Loading message for spinner screenreader text.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -18,6 +18,7 @@ import { FeedbackBox } from './components/Feedback';
|
||||
import * as hooks from './hooks';
|
||||
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
|
||||
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
|
||||
import { answerRangeFormatRegex } from '../../../data/OLXParser';
|
||||
|
||||
const AnswerOption = ({
|
||||
answer,
|
||||
@@ -48,6 +49,11 @@ const AnswerOption = ({
|
||||
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
|
||||
: undefined;
|
||||
|
||||
const validateAnswerRange = (value) => {
|
||||
const cleanedValue = value.replace(/^\s+|\s+$/g, '');
|
||||
return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue);
|
||||
};
|
||||
|
||||
const getInputArea = () => {
|
||||
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
|
||||
return (
|
||||
@@ -77,8 +83,9 @@ const AnswerOption = ({
|
||||
);
|
||||
}
|
||||
// Return Answer Range View
|
||||
const isValidValue = validateAnswerRange(answer.title);
|
||||
return (
|
||||
<div>
|
||||
<Form.Group isInvalid={!isValidValue}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
@@ -88,10 +95,15 @@ const AnswerOption = ({
|
||||
onChange={setAnswerTitle}
|
||||
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
|
||||
/>
|
||||
{!isValidValue && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
<FormattedMessage {...messages.answerRangeErrorText} />
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<div className="pgn__form-switch-helper-text">
|
||||
<FormattedMessage {...messages.answerRangeHelperText} />
|
||||
</div>
|
||||
</div>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user