Compare commits

..

1 Commits

Author SHA1 Message Date
XnpioChV
49c28de286 feat: Base page for Migrate Legacy Libraries 2025-09-02 14:19:37 -05:00
456 changed files with 6467 additions and 27669 deletions

2
.env
View File

@@ -36,7 +36,6 @@ 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
@@ -49,4 +48,3 @@ 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'

View File

@@ -37,7 +37,6 @@ 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=''
@@ -52,4 +51,3 @@ 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'

View File

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

View File

@@ -14,15 +14,6 @@ module.exports = createConfig(
'no-restricted-exports': 'off',
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
'no-restricted-syntax': 'off',
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@edx/frontend-platform/i18n'],
importNames: ['injectIntl'],
message: "Use 'useIntl' hook instead of injectIntl.",
},
],
}],
},
settings: {
// Import URLs should be resolved using aliases

View File

@@ -30,9 +30,9 @@ We're trying to move away from some deprecated patterns in this codebase. Please
check if your PR meets these recommendations before asking for a review:
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
- [ ] Avoid `propTypes` and `defaultProps` in any new or modified code.
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
- [ ] Avoid using `../` in import paths. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`

View File

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

View File

@@ -12,12 +12,12 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
@@ -27,11 +27,9 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Download code coverage results
uses: actions/download-artifact@v6
uses: actions/download-artifact@v5
with:
pattern: code-coverage-report
path: coverage
merge-multiple: true
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
with:

2
.nvmrc
View File

@@ -1 +1 @@
24
20

View File

@@ -11,5 +11,4 @@ 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"

9604
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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": "^8.1.0",
"@edx/frontend-component-header": "^6.2.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.6.2",
"@openedx/frontend-build": "^14.5.0",
"@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": "5.90.7",
"@tinymce/tinymce-react": "^6.0.0",
"@tanstack/react-query": "4.40.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"fast-xml-parser": "^4.0.10",
"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": "^8.0.0",
"universal-cookie": "^4.0.4",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"

View File

@@ -125,13 +125,10 @@ describe('ORASettings', () => {
});
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await waitFor(() => {
waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge');

View File

@@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
@@ -20,6 +20,7 @@ const defaultProps = {
courseId,
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let store;
const intlWrapper = children => (
@@ -101,7 +102,7 @@ describe('ProctoredExamSettings', () => {
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
@@ -151,7 +152,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
@@ -224,7 +225,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
@@ -408,7 +409,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
@@ -417,7 +418,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -427,7 +428,7 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -436,7 +437,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -445,7 +446,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -456,7 +457,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -469,7 +470,7 @@ describe('ProctoredExamSettings', () => {
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -482,7 +483,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -496,7 +497,7 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -515,7 +516,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
@@ -528,14 +529,14 @@ describe('ProctoredExamSettings', () => {
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
@@ -543,7 +544,7 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<ProctoredExamSettings {...defaultProps} />));
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
@@ -553,7 +554,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -565,7 +566,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -577,7 +578,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -596,7 +597,7 @@ describe('ProctoredExamSettings', () => {
});
it('Disable button while submitting', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -606,7 +607,7 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
@@ -637,7 +638,7 @@ describe('ProctoredExamSettings', () => {
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
@@ -664,7 +665,7 @@ describe('ProctoredExamSettings', () => {
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -705,7 +706,7 @@ describe('ProctoredExamSettings', () => {
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// update exam service config
@@ -749,7 +750,7 @@ describe('ProctoredExamSettings', () => {
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// does not update exam service config
@@ -779,7 +780,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -797,7 +798,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -815,7 +816,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -834,7 +835,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -867,7 +868,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<ProctoredExamSettings {...defaultProps} />)));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });

View File

@@ -1,6 +1,11 @@
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.',

View File

@@ -19,7 +19,7 @@ export function updateXpertSettings(courseId, state) {
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
@@ -33,7 +33,7 @@ export function fetchXpertPluginConfigurable(courseId) {
try {
const { response } = await getXpertPluginConfigurable(courseId);
enabled = response?.enabled;
} catch {
} catch (e) {
enabled = undefined;
}
@@ -55,7 +55,7 @@ export function fetchXpertSettings(courseId) {
try {
const { response } = await getXpertSettings(courseId);
enabled = response?.enabled;
} catch {
} catch (e) {
enabled = undefined;
}
@@ -86,7 +86,7 @@ export function removeXpertSettings(courseId) {
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
@@ -105,7 +105,7 @@ export function resetXpertSettings(courseId, state) {
}
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false;
} catch {
} catch (error) {
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false;
}

View File

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

View File

@@ -1,4 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';
export { default as clipboardSection } from './clipboardSection';

View File

@@ -58,7 +58,7 @@ export function updateCourseAppSetting(courseId, settings) {
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch {
} catch (err) {
errorData = {};
}
@@ -77,7 +77,7 @@ export function fetchProctoringExamErrors(courseId) {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}

View File

@@ -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<HTMLButtonElement | null>(null);
const [target, setTarget] = useState(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.oneOfType([
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,

View File

@@ -1,3 +1,4 @@
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';
@@ -21,6 +22,7 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
@@ -84,10 +86,10 @@ describe('<SettingCard />', () => {
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await user.tab(); // blur off of the input.
await waitFor(() => {
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -17,7 +17,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
try {
JSON.parse(settingValue);
} catch {
} catch (e) {
let targetSettingValue = settingValue;
const firstNonWhite = settingValue.substring(0, 1);
const isValid = !['{', '[', "'"].includes(firstNonWhite);
@@ -30,7 +30,7 @@ export default function validateAdvancedSettingsData(settingObj, setErrorFields,
...prevEditedSettings,
[settingName]: targetSettingValue,
}));
} catch { /* empty */ }
} catch (quotedE) { /* empty */ }
}
pushDataToErrorArray(settingName);

View File

@@ -65,7 +65,7 @@ describe('CertificateDetails', () => {
await user.type(input, newInputValue);
await waitFor(() => {
waitFor(() => {
expect(input.value).toBe(newInputValue);
});
});

View File

@@ -96,7 +96,7 @@ describe('CertificateSignatories', () => {
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
});
it.skip('calls remove for the correct signatory when delete icon is clicked', async () => {
it('calls remove for the correct signatory when delete icon is clicked', async () => {
const user = userEvent.setup();
const { getAllByRole } = renderComponent(defaultProps);
@@ -105,9 +105,7 @@ describe('CertificateSignatories', () => {
await user.click(deleteIcons[0]);
// FIXME: this isn't called because the whole 'useEditSignatory' hook
// which calls it is mocked out.
await waitFor(() => {
waitFor(() => {
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, 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,7 +30,6 @@ const initialState = {
};
const defaultProps = {
index: 0,
...signatoriesMock[0],
showDeleteButton: true,
isEdit: true,
@@ -63,36 +62,31 @@ describe('Signatory Component', () => {
it('handles input change', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
renderSignatory({ ...defaultProps, handleChange });
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe';
expect(handleChange).not.toHaveBeenCalled();
expect(input.value).not.toBe(newInputValue);
await user.type(input, newInputValue);
await user.type(input, newInputValue, { name: 'signatories[0].name' });
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);
waitFor(() => {
expect(handleChange).toHaveBeenCalledWith(expect.anything());
expect(input.value).toBe(newInputValue);
});
});
it('opens image upload modal on button click', async () => {
const user = userEvent.setup();
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
const { getByRole, queryByRole } = renderSignatory(defaultProps);
const replaceButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
expect(queryByRole('presentation')).not.toBeInTheDocument();
await user.click(replaceButton);
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
expect(getByRole('presentation')).toBeInTheDocument();
});
it('shows confirm modal on delete icon click', async () => {

View File

@@ -62,7 +62,7 @@ describe('HeaderButtons Component', () => {
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
await user.click(dropdownButton);
const verifiedMode = getByRole('button', { name: certificatesDataMock.courseModes[1] });
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
await user.click(verifiedMode);
await waitFor(() => {

View File

@@ -7,7 +7,6 @@ export const STATEFUL_BUTTON_STATES = {
default: 'default',
pending: 'pending',
error: 'error',
disable: 'disable',
};
export const USER_ROLES = {
@@ -62,8 +61,6 @@ 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';
@@ -110,9 +107,3 @@ export const iframeMessageTypes = {
xblockEvent: 'xblock-event',
xblockScroll: 'xblock-scroll',
};
export const BROKEN = 'broken';
export const LOCKED = 'locked';
export const MANUAL = 'manual';

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,132 +0,0 @@
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">&nbsp;</span>
)}
</Stack>
<ActionRow.Spacer />
{isClickable && <Icon size="md" src={ChevronRight} />}
</ActionRow>
</Stack>
</Card>
);
};
export default ContainerRow;

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
.compare-changes-widget {
.compare-card {
min-height: 350px;
}
.big-icon {
height: 68px;
width: 68px;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -435,8 +435,8 @@ const ContentTagsCollapsible = ({
onKeyDown={handleSelectOnKeyDown}
ref={/** @type {React.RefObject} */(selectRef)}
isMulti
isLoading={updateTags.isPending}
isDisabled={updateTags.isPending}
isLoading={updateTags.isLoading}
isDisabled={updateTags.isLoading}
name="tags-select"
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
isSearchable

View File

@@ -37,9 +37,3 @@
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;
}

View File

@@ -719,16 +719,14 @@ describe('<ContentTagsDrawer />', () => {
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, {
queryKey: [
'contentLibrary',
'lib:org:lib',
'content',
'container',
containerId,
'children',
],
});
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
'contentLibrary',
'lib:org:lib',
'content',
'container',
containerId,
'children',
]);
});
});

View File

@@ -1,3 +1,4 @@
// @ts-check
import { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
@@ -7,7 +8,6 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import { TagData, TagListData } from '@src/taxonomy/data/types';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
@@ -17,16 +17,18 @@ import {
} from './api';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
import { UpdateTagsData } from './types';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
/**
* Builds the query to get the taxonomy tags
* @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
* @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
*/
export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null = null, numPages = 1, searchTerm = '') => {
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
const queryClient = useQueryClient();
const queryFn = async ({ queryKey }) => {
@@ -34,7 +36,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
};
const queries: { queryKey: any[]; queryFn: typeof queryFn; staleTime: number }[] = [];
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
const queries = [];
for (let page = 1; page <= numPages; page++) {
queries.push(
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
@@ -51,7 +54,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
const preLoadedData = new Map();
const newTags = dataPages.map(result => {
const simplifiedTagsList: TagData[] = [];
/** @type {TagData[]} */
const simplifiedTagsList = [];
result.data?.results?.forEach((tag) => {
if (tag.parentValue === parentTag) {
@@ -69,7 +73,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
// Store the pre-loaded descendants into the query cache:
preLoadedData.forEach((tags, parentValue) => {
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
const cachedData: TagListData = {
/** @type {TagListData} */
const cachedData = {
next: '',
previous: '',
count: tags.length,
@@ -96,9 +101,9 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
/**
* Builds the query to get the taxonomy tags applied to the content object
* @param contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
*/
export const useContentTaxonomyTagsData = (contentId: string) => (
export const useContentTaxonomyTagsData = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId),
@@ -107,30 +112,37 @@ export const useContentTaxonomyTagsData = (contentId: string) => (
/**
* Builds the query to get meta data about the content object
* @param contentId The id of the content object
* @param enabled Flag to enable/disable the query
* @param {string} contentId The id of the content object
* @param {boolean} enabled Flag to enable/disable the query
*/
export const useContentData = (contentId: string, enabled: boolean) => (
export const useContentData = (contentId, enabled) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
})
);
/**
* Builds the mutation to update the tags applied to the content object
* @param contentId The id of the content object to update tags for
* @param {string} contentId The id of the content object to update tags for
*/
export const useContentTaxonomyTagsUpdater = (contentId: string) => {
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
const { containerId } = useParams();
return useMutation({
mutationFn: ({ tagsData }: { tagsData: Promise<UpdateTagsData[]> }) => (
updateContentTaxonomyTags(contentId, tagsData)
),
/**
* @type {import("@tanstack/react-query").MutateFunction<
* any,
* any,
* {
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
* }
* >}
*/
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
@@ -145,13 +157,13 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(contentId) });
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count
queryClient.invalidateQueries({ queryKey: ['content_search'], predicate: (query) => libraryQueryPredicate(query, libraryId) });
queryClient.invalidateQueries(['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({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
}
}
},

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { ModeComment } from '@openedx/paragon/icons';
@@ -127,4 +127,4 @@ ChecklistItemComment.propTypes = {
]).isRequired,
};
export default ChecklistItemComment;
export default injectIntl(ChecklistItemComment);

View File

@@ -43,7 +43,7 @@ export function fetchCourseBestPracticesQuery({
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
dispatch(fetchBestPracticeChecklistSuccess({ data }));
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
} catch (error) {
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
}
};

View File

@@ -28,7 +28,7 @@ mockUseLibBlockMetadata.applyMock();
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
@@ -114,7 +114,7 @@ describe('<CourseLibraries />', () => {
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(alert).not.toBeInTheDocument());
waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn);
@@ -327,19 +327,4 @@ 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();
});
});

View File

@@ -32,14 +32,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
onReview,
}) => {
const intl = useIntl();
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
const { data, isLoading } = 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 (isPending) {
if (isLoading) {
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, isPending, data]);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);

View File

@@ -144,7 +144,7 @@ const ItemReviewList = ({
const {
hits,
isPending: isIndexDataPending,
isLoading: isIndexDataLoading,
hasError,
hasNextPage,
isFetchingNextPage,
@@ -173,8 +173,6 @@ 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]);
@@ -215,16 +213,13 @@ const ItemReviewList = ({
const updateBlock = useCallback(async (info: ContentHit) => {
try {
await acceptChangesMutation.mutateAsync({
blockId: info.usageKey,
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
});
await acceptChangesMutation.mutateAsync(info.usageKey);
reloadLinks(info.usageKey);
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
{ name: info.displayName },
));
} catch {
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
}
}, []);
@@ -235,22 +230,20 @@ const ItemReviewList = ({
return;
}
try {
await ignoreChangesMutation.mutateAsync({
blockId: blockData.downstreamBlockId,
});
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
reloadLinks(blockData.downstreamBlockId);
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
} catch {
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
} finally {
closeConfirmModal();
}
}, [blockData]);
if (isIndexDataPending) {
if (isIndexDataLoading) {
return <Loading />;
}
@@ -321,7 +314,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: outOfSyncItems,
isPending: isSyncItemsLoading,
isLoading: isSyncItemsLoading,
isError,
error,
} = useEntityLinks({

View File

@@ -11,7 +11,6 @@
"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"
},
@@ -27,7 +26,6 @@
"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"
},
@@ -43,7 +41,6 @@
"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"
},
@@ -59,7 +56,6 @@
"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"
},
@@ -75,7 +71,6 @@
"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"
},
@@ -91,7 +86,6 @@
"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"
},

View File

@@ -29,7 +29,6 @@ export interface BasePublishableEntityLink {
created: string;
updated: string;
readyToSync: boolean;
downstreamIsModified: boolean;
}
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {

View File

@@ -32,7 +32,7 @@ const messages = defineMessages({
description: 'Tab title for review tab',
},
reviewTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.review.description-no-links',
id: 'course-authoring.course-libraries.tab.home.description-no-links',
defaultMessage: 'All components are up to date',
description: 'Description text for home tab',
},

View File

@@ -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();
await userEvent.click(newBtn);
await waitFor(() => expect(newClickHandler).toHaveBeenCalled());
await userEvent.click(useBtn);
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
userEvent.click(newBtn);
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
userEvent.click(useBtn);
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
});
});
});

View File

@@ -7,7 +7,6 @@ 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();
@@ -233,6 +232,16 @@ 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 });
@@ -245,8 +254,8 @@ describe('<CardHeader />', () => {
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
});
it('check editing is disabled when saving is in progress', async () => {
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
it('check editing is disabled when isDisabledEditField is true', async () => {
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();

View File

@@ -24,7 +24,6 @@ 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';
@@ -42,6 +41,7 @@ interface CardHeaderProps {
isFormOpen: boolean;
onEditSubmit: (titleValue: string) => void;
closeForm: () => void;
isDisabledEditField: boolean;
onClickDelete: () => void;
onClickUnlink: () => void;
onClickDuplicate: () => void;
@@ -69,7 +69,6 @@ interface CardHeaderProps {
extraActionsComponent?: ReactNode,
onClickSync?: () => void;
readyToSync?: boolean;
savingStatus?: RequestStatusType;
}
const CardHeader = ({
@@ -84,6 +83,7 @@ const CardHeader = ({
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickUnlink,
onClickDuplicate,
@@ -103,7 +103,6 @@ const CardHeader = ({
extraActionsComponent,
onClickSync,
readyToSync,
savingStatus,
}: CardHeaderProps) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -120,7 +119,6 @@ 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');
@@ -174,7 +172,7 @@ const CardHeader = ({
onEditSubmit(titleValue);
}
}}
disabled={isSaving}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
@@ -188,7 +186,7 @@ const CardHeader = ({
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
disabled={isSaving}
disabled={isDisabledEditField}
/>
</>
)}
@@ -240,7 +238,7 @@ const CardHeader = ({
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isSaving}
disabled={isDisabledEditField}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
@@ -248,7 +246,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isSaving}
disabled={isDisabledEditField}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}

View File

@@ -1,7 +1,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { XBlock } from '@src/data/types';
import { CourseOutline, CourseDetails } from './types';
import { CourseOutline } from './types';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -9,8 +9,6 @@ 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,
@@ -48,7 +46,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()
@@ -57,18 +55,6 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
return camelCaseObject(data);
}
/**
* Get course details.
* @param {string} courseId
* @returns {Promise<CourseDetails>}
*/
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseDetailsApiUrl(courseId));
return camelCaseObject(data);
}
/**
*
* @param courseId

View File

@@ -1,6 +1,6 @@
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import { getCourseDetails, getCourseItem } from './api';
import { 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,10 +33,3 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
enabled: enabled && itemId !== undefined,
})
);
export const useCourseDetails = (courseId?: string) => (
useQuery({
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
})
);

View File

@@ -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): (dispatch: any) => Promise<void> {
export function fetchCourseOutlineIndexQuery(courseId: string): object {
return async (dispatch) => {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
@@ -148,7 +148,7 @@ export function fetchCourseBestPracticesQuery({
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
return true;
} catch {
} catch (error) {
return false;
}
};
@@ -165,7 +165,7 @@ export function enableCourseHighlightsEmailsQuery(courseId: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -182,7 +182,7 @@ export function setVideoSharingOptionQuery(courseId: string, option: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
dispatch(hideProcessingNotification());
}
@@ -260,7 +260,7 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -280,7 +280,7 @@ export function publishCourseItemQuery(itemId: string, sectionId: string) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -300,7 +300,7 @@ export function configureCourseItemQuery(sectionId: string, configureFn: () => P
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -390,7 +390,7 @@ export function editCourseItemQuery(itemId: string, sectionId: string, displayNa
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -412,7 +412,7 @@ function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
dispatch(deleteItemFn());
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -469,7 +469,7 @@ function duplicateCourseItemQuery(
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -541,7 +541,7 @@ function addNewCourseItemQuery(
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -612,7 +612,7 @@ export function addUnitFromLibrary(body: {
callback(result.locator);
}
});
} catch /* istanbul ignore next */ {
} catch (error) /* istanbul ignore next */ {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -643,7 +643,7 @@ function setBlockOrderListQuery(
dispatch(hideProcessingNotification());
}
});
} catch {
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
@@ -728,7 +728,7 @@ export function pasteClipboardContent(parentLocator: string, sectionId: string)
dispatch(setPasteFileNotices(result?.staticFileNotices));
}
});
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -743,7 +743,7 @@ export function dismissNotificationQuery(url: string) {
await dismissNotification(url).then(async () => {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
});
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};

View File

@@ -24,15 +24,6 @@ export interface CourseOutline {
rerunNotificationId: null;
}
// TODO: This interface has only basic data, all the rest needs to be added.
export interface CourseDetails {
courseId: string;
title: string;
subtitle?: string;
org: string;
description?: string;
}
export interface CourseOutlineState {
loadingStatus: {
outlineIndexLoadingStatus: string;

View File

@@ -3,7 +3,6 @@ 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';
@@ -65,11 +64,9 @@ 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);
@@ -159,7 +156,7 @@ const useCourseOutline = ({ courseId }) => {
data.shouldScroll = true;
// Page should scroll to newly added subsection.
dispatch(addSubsection({ parentLocator, data }));
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
@@ -174,7 +171,7 @@ const useCourseOutline = ({ courseId }) => {
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
@@ -248,8 +245,6 @@ 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 = () => {

View File

@@ -1,31 +1,30 @@
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 { API_ERROR_TYPES } from '../constants';
import messages from './messages';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import messages from './messages';
import { API_ERROR_TYPES } from '../constants';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
const PageAlerts = ({
courseId,
@@ -438,7 +437,6 @@ const PageAlerts = ({
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
{renderOutOfSyncAlert()}
<CourseOutlinePageAlertsSlot />
</>
);
};

View File

@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
id: 'unit-1',
};
const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '123',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -43,7 +43,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '123',
displayName: 'Section Name',
category: 'chapter',
published: true,
@@ -71,10 +71,7 @@ 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;
@@ -91,6 +88,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
@@ -189,9 +187,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
// url encode subsection.id
const subsectionIdUrl = encodeURIComponent(subsection.id);
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -203,9 +199,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
// url encode subsection.id
const unitIdUrl = encodeURIComponent(unit.id);
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -237,6 +231,7 @@ 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);
@@ -256,6 +251,7 @@ 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 });

View File

@@ -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, RequestStatusType } from '@src/data/constants';
import { RequestStatus } 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?: RequestStatusType,
savingStatus: string,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -144,9 +144,7 @@ const SectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'section',
};
}, [upstreamInfo]);
@@ -303,7 +301,7 @@ const SectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -52,7 +52,7 @@ const unit = {
};
const subsection: XBlock = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '123',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -75,15 +75,12 @@ 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: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '123',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -118,6 +115,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}
@@ -324,7 +322,7 @@ describe('<SubsectionCard />', () => {
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2,
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
parentLocator: '123',
category: 'vertical',
libraryContentKey: containerKey,
});
@@ -341,6 +339,7 @@ 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);
@@ -360,6 +359,7 @@ 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 });

View File

@@ -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, RequestStatusType } from '@src/data/constants';
import { RequestStatus } 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?: RequestStatusType,
savingStatus: string,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -126,9 +126,7 @@ const SubsectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'subsection',
};
}, [upstreamInfo]);
@@ -305,7 +303,7 @@ const SubsectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '1',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -34,7 +34,7 @@ const section = {
} satisfies Partial<XBlock> as XBlock;
const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '12',
displayName: 'Subsection Name',
published: true,
visibilityState: 'live',
@@ -48,7 +48,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
id: '123',
displayName: 'unit Name',
category: 'vertical',
published: true,
@@ -65,10 +65,7 @@ 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;
@@ -84,6 +81,7 @@ 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}`}
@@ -110,10 +108,7 @@ describe('<UnitCard />', () => {
const { findByTestId } = renderComponent();
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute(
'href',
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
);
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
});
it('hides header based on isHeaderVisible flag', async () => {
@@ -204,6 +199,7 @@ 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);
@@ -223,6 +219,7 @@ 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 });

View File

@@ -13,7 +13,8 @@ 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, RequestStatusType } from '@src/data/constants';
import { RequestStatus } from '@src/data/constants';
import { isUnitReadOnly } from '@src/course-unit/data/utils';
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';
@@ -32,7 +33,7 @@ interface UnitCardProps {
onOpenPublishModal: () => void;
onOpenConfigureModal: () => void;
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType;
savingStatus: string;
onOpenDeleteModal: () => void;
onOpenUnlinkModal: () => void;
onDuplicateSubmit: () => void;
@@ -103,12 +104,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.
@@ -246,7 +247,7 @@ const UnitCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -66,7 +66,7 @@ export function changeRoleTeamUserQuery(courseId, email, role) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
} catch ({ message }) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
@@ -83,7 +83,7 @@ export function deleteCourseTeamQuery(courseId, email) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}

View File

@@ -50,7 +50,6 @@ const CourseUnit = ({ courseId }) => {
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
isProblemBankType,
staticFileNotices,
currentlyVisibleToStudents,
unitXBlockActions,
@@ -220,7 +219,6 @@ const CourseUnit = ({ courseId }) => {
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>

View File

@@ -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';
await waitFor(() => {
waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
@@ -2291,17 +2291,19 @@ 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));
// 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';
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
.map((child) => (child.block_id === targetBlockId
? { ...child, block_type: 'html' }
: child));
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: targetChild.block_id,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
@@ -2309,20 +2311,21 @@ 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,
});
});
// 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();
waitFor(() => {
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
expect(mockedUsedNavigate)
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
@@ -2350,14 +2353,14 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
// Edit button should be enabled even for library imported units
// Disable the "Edit" button
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editButton = within(unitHeaderTitle).getByRole(
'button',
{ name: 'Edit' },
);
expect(editButton).toBeInTheDocument();
expect(editButton).toBeEnabled();
expect(editButton).toBeDisabled();
// The "Publish" button should still be enabled
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
@@ -2368,6 +2371,14 @@ 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();
});

View File

@@ -1,4 +1,5 @@
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';
@@ -6,64 +7,28 @@ import {
ActionRow, Button, StandardModal, useToggle,
} from '@openedx/paragon';
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,
},
}
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 { fetchCourseSectionVerticalData } from '../data/thunk';
const AddComponent = ({
parentLocator,
isSplitTestType,
isUnitVerticalType,
isProblemBankType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}: AddComponentProps) => {
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -71,16 +36,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<string | null>(null);
const [courseId, setCourseId] = useState<string | null>(null);
const [newBlockId, setNewBlockId] = useState<string | null>(null);
const [blockType, setBlockType] = useState(null);
const [courseId, setCourseId] = useState(null);
const [newBlockId, setNewBlockId] = useState(null);
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
@@ -119,7 +84,7 @@ const AddComponent = ({
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, blockId, sequenceId]);
const handleLibraryV2Selection = useCallback((selection: SelectedComponent) => {
const handleLibraryV2Selection = useCallback((selection) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
category: selection.blockType,
@@ -129,7 +94,7 @@ const AddComponent = ({
closeAddLibraryContentModal();
}, [usageId]);
const handleCreateNewXBlock = (type: string, moduleName?: string) => {
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
@@ -191,16 +156,16 @@ const AddComponent = ({
}
};
if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
if (isUnitVerticalType || isSplitTestType) {
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: ComponentTemplateData) => {
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams: { open: () => void, close: () => void, isOpen: boolean };
let modalParams;
if (!component.templates.length) {
return null;
@@ -303,7 +268,7 @@ const AddComponent = ({
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
@@ -323,4 +288,32 @@ 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;

View File

@@ -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, { AddComponentProps } from './AddComponent';
import AddComponent from './AddComponent';
import messages from './messages';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { messageTypes } from '../constants';
@@ -56,11 +56,13 @@ jest.mock('../../generic/hooks/context/hooks', () => ({
}),
}));
const renderComponent = (props?: AddComponentProps) => render(
const renderComponent = (props) => render(
<IframeProvider>
<AddComponent
blockId={blockId}
isUnitVerticalType
parentLocator={blockId}
addComponentTemplateData={{}}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
{...props}
/>
@@ -92,7 +94,7 @@ describe('<AddComponent />', () => {
),
});
expect(btn).toBeInTheDocument();
if (componentTemplates[component].beta) {
if (component.beta) {
expect(within(btn).queryByText('Beta')).toBeInTheDocument();
}
});

View File

@@ -41,7 +41,6 @@ export const getXBlockSupportMessages = (intl) => ({
export const messageTypes = {
refreshXBlock: 'refreshXBlock',
refreshIframe: 'refreshIframe',
showMoveXBlockModal: 'showMoveXBlockModal',
completeXBlockMoving: 'completeXBlockMoving',
rollbackMovedXBlock: 'rollbackMovedXBlock',

View File

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

218
src/course-unit/data/api.js Normal file
View File

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

View File

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

View File

@@ -3,17 +3,17 @@ import { camelCaseObject } from '@edx/frontend-platform';
import {
hideProcessingNotification,
showProcessingNotification,
} 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';
} 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';
import { messageTypes } from '../constants';
import {
editUnitDisplayName,
getVerticalData,
createCourseXblock,
getCourseContainerChildren,
getCourseVerticalChildren,
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
localStorage.removeItem('staticFileNotices');
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch {
} catch (error) {
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
dispatch(fetchSequenceFailure({ sequenceId }));
return false;
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
}
const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
const courseVerticalChildrenData = await getCourseVerticalChildren(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 getCourseContainerChildren(blockId);
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
if (callback) {
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
}
try {
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseContainerChildren(blockId)),
blockIds.map(blockId => getCourseVerticalChildren(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 {
} catch (error) {
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 getCourseContainerChildren(itemId);
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));

View File

@@ -1,55 +0,0 @@
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[];
}

View File

@@ -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 isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
!!upstreamInfo
&& !!upstreamInfo.upstreamRef
&& upstreamInfo.upstreamRef.startsWith('lct:')

View File

@@ -34,6 +34,8 @@ const HeaderTitle = ({
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const readOnly = !!currentItemData.readOnly;
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
};
@@ -80,6 +82,7 @@ const HeaderTitle = ({
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
disabled={readOnly}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}

View File

@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a enabled edit button', async () => {
it('Units sourced from upstream show a disabled 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 })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});

View File

@@ -72,10 +72,6 @@ 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: () => {
@@ -258,7 +254,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
isProblemBankType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,

View File

@@ -1,10 +1,4 @@
.lib-preview-xblock-changes-modal {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
.preview-title {
span {
margin: 0 10px;
}
}
}

View File

@@ -1,32 +1,29 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import {
act,
render as baseRender,
screen,
initializeMocks,
waitFor,
} from '@src/testUtils';
import { ToastActionData } from '@src/generic/toast-context';
} from '../../testUtils';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
const usageKey = 'some-id';
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('@src/generic/hooks/context/hooks', () => ({
jest.mock('../../generic/hooks/context/hooks', () => ({
useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {},
@@ -48,7 +45,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<IframePreviewLibraryXBlockChanges />', () => {
beforeEach(() => {
@@ -63,6 +60,7 @@ 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();
});
@@ -134,59 +132,4 @@ 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();
});
});

View File

@@ -1,111 +1,28 @@
import { useCallback, useContext, useState } from 'react';
import {
useCallback, useContext, useMemo, useState,
} from 'react';
import {
ActionRow, Button, Icon, ModalDialog, useToggle,
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
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 { useEventListener } from '../../generic/hooks';
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';
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}
/>
);
};
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
export interface LibraryChangesMessageData {
displayName: string,
downstreamBlockId: string,
upstreamBlockId: string,
upstreamBlockVersionSynced: number,
isLocallyModified?: boolean,
isContainer: boolean,
blockType?: string | null,
isReadyToSyncIndividually?: boolean,
}
export interface PreviewLibraryXBlockChangesProps {
@@ -128,41 +45,27 @@ export const PreviewLibraryXBlockChanges = ({
const { showToast } = useContext(ToastContext);
const intl = useIntl();
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
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"
hasLocalChanges={isTextWithLocalChanges}
showNewTitle={isTextWithLocalChanges}
isContainer={blockData.isContainer}
/>
);
}, [blockData, isTextWithLocalChanges]);
}, [blockData]);
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
const updateAndRefresh = useCallback(async (accept: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
@@ -172,58 +75,30 @@ export const PreviewLibraryXBlockChanges = ({
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
try {
await mutation.mutateAsync({
blockId: blockData.downstreamBlockId,
overrideCustomizations,
});
await mutation.mutateAsync(blockData.downstreamBlockId);
postChange(accept);
} catch {
} catch (e) {
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,
blockIcon: <Icon size="lg" src={itemIcon} />,
})
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
: 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={ariaLabel}
title={title}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -231,57 +106,43 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
<div className="d-flex preview-title">
{title}
</div>
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{isTextWithLocalChanges && (
<AlertMessage
show
variant="info"
icon={Info}
title={intl.formatMessage(messages.localEditsAlert)}
/>
)}
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
{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>
)}
<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>
</ActionRow>
</ModalDialog.Footer>
<ConfirmationModal
modalType={confirmationModalType}
onClose={() => setConfirmationModalType(undefined)}
updateAndRefresh={updateAndRefresh}
<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)}
/>
</ModalDialog>
);

View File

@@ -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: {blockIcon} {blockTitle}',
defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text',
},
defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: {itemIcon} Container',
defaultMessage: 'Preview changes: Container',
description: 'Preview changes modal default title text for containers',
},
defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
defaultMessage: 'Preview changes: {itemIcon} Component',
defaultMessage: 'Preview changes: Component',
description: 'Preview changes modal default title text for components',
},
acceptChangesBtn: {
@@ -36,6 +36,11 @@ 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?',
@@ -51,45 +56,10 @@ const messages = defineMessages({
defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
},
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',
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',
},
});

View File

@@ -15,7 +15,6 @@ export type UseMessageHandlersTypes = {
handleOpenManageTagsModal: (id: string) => void;
handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void;
handleRefreshIframe: () => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -31,7 +31,6 @@ export const useMessageHandlers = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -51,7 +50,6 @@ 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),

View File

@@ -46,8 +46,6 @@ 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);
@@ -184,12 +182,6 @@ 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,
@@ -207,7 +199,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
});
useIframeMessages(messageHandlers);
@@ -277,7 +268,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
/>
) : null}
<iframe
key={iframeKey}
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
name="xblock-iframe"

View File

@@ -58,7 +58,7 @@ export function createCourseUpdateQuery(courseId, data) {
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
error: { creatingUpdate: false },
}));
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({
status: { createCourseUpdateQuery: RequestStatus.FAILED },
@@ -80,7 +80,7 @@ export function editCourseUpdateQuery(courseId, data) {
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
error: { savingUpdates: false },
}));
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({
status: { createCourseUpdateQuery: RequestStatus.FAILED },
@@ -102,7 +102,7 @@ export function deleteCourseUpdateQuery(courseId, updateId) {
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
error: { deletingUpdates: false },
}));
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({
status: { createCourseUpdateQuery: RequestStatus.FAILED },
@@ -150,7 +150,7 @@ export function editCourseHandoutsQuery(courseId, data) {
status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL },
error: { savingHandouts: false },
}));
} catch {
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({
status: { createCourseUpdateQuery: RequestStatus.FAILED },

View File

@@ -100,7 +100,7 @@ describe('CustomPages', () => {
it('should update page order on drag', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const buttons = screen.queryAllByRole('button');
const buttons = await screen.queryAllByRole('button');
const draggableButton = buttons[9];
expect(draggableButton).toBeVisible();
await act(async () => {

View File

@@ -132,7 +132,7 @@ export function updateCustomPageVisibility({ blockId, metadata }) {
},
}));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -154,7 +154,7 @@ export const updateSingleCustomPage = ({
}));
setCurrentPage(null);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};

View File

@@ -32,7 +32,6 @@ export async function getCourseDetail(courseId: string, username: string) {
*/
export const waffleFlagDefaults = {
enableCourseOptimizer: false,
enableCourseOptimizerCheckPrevRunLinks: false,
useNewHomePage: true,
useNewCustomPages: true,
useNewScheduleDetailsPage: true,

View File

@@ -8,7 +8,7 @@ import { getWaffleFlags, waffleFlagDefaults } from './api';
export const useWaffleFlags = (courseId?: string) => {
const queryClient = useQueryClient();
const { data, isPending: isLoading, isError } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey: ['waffleFlags', courseId],
queryFn: () => getWaffleFlags(courseId),
// Waffle flags change rarely, so never bother refetching them:

View File

@@ -48,23 +48,11 @@ 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 {

View File

@@ -1,38 +1,28 @@
/* istanbul ignore file */
/* 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>
*/
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
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 { Spinner } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import EditorContainer from '../EditorContainer';
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
import Button from '../../sharedComponents/Button';
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
import messages from './messages';
// 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';
export const hooks = {
getContent: () => ({
@@ -40,498 +30,77 @@ export const hooks = {
}),
};
export const GameEditor = ({
export const thumbEditor = ({
onClose,
// redux
blockValue,
lmsEndpointUrl,
blockFailed,
blockFinished,
// 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)}
/>
initializeEditor,
exampleValue,
// inject
intl,
}) => (
<EditorContainer
getContent={module.hooks.getContent}
onClose={onClose}
>
<div>
{exampleValue}
</div>
);
const termImageDiv = (card, index) => (
<div className="card-image-area d-flex align-items-center align-self-stretch">
<img className="card-image" src={card.term_image} alt="TERM_IMG" />
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt="DEL_IMG"
variant="primary"
onClick={() => removeTermImage(index)}
/>
</div>
);
const termImageUploadButton = (card, index) => (
<IconButton
src={InsertPhoto}
iconAs={Icon}
alt="IMG"
variant="primary"
onClick={() => document.getElementById(`term_image_upload|${index}`).click()}
/>
);
const definitionImageDiv = (card, index) => (
<div className="card-image-area d-flex align-items-center align-self-stretch">
<img className="card-image" src={card.definition_image} alt="DEF_IMG" />
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt="DEL_IMG"
variant="primary"
onClick={() => removeDefintionImage(index)}
/>
</div>
);
const definitionImageUploadButton = (card, index) => (
<IconButton
src={InsertPhoto}
iconAs={Icon}
alt="IMG"
variant="primary"
onClick={() => document.getElementById(`definition_image_upload|${index}`).click()}
/>
);
const timerSettingsOption = (
<SettingsOption
className="sidebar-timer d-flex flex-column align-items-start align-self-stretch"
title="Timer"
summary={settings.timer ? 'On' : 'Off'}
isCardCollapsibleOpen="true"
>
<>
<div className="settings-description">Measure the time it takes learners to match all terms and definitions. Used to calculate a learner&apos;s score.</div>
<Button
onClick={() => timerFalse()}
variant={!settings.timer ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
Off
</Button>
<Button
onClick={() => timerTrue()}
variant={settings.timer ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
On
</Button>
</>
</SettingsOption>
);
const page = (
<div className="page-body d-flex align-items-start">
<div className="terms d-flex flex-column align-items-start align-self-stretch">
<div className="description d-flex flex-column align-items-start align-self-stretch">
<div className="description-header">
{getDescriptionHeader()}
<div className="editor-body h-75 overflow-auto">
{!blockFinished
? (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
// Use a messages.js file for intl messages.
screenreadertext={intl.formatMessage('Loading Spinner')}
/>
</div>
<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>
)
: (
<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>
);
// 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>
);
</EditorContainer>
);
thumbEditor.defaultProps = {
blockValue: null,
lmsEndpointUrl: null,
};
GameEditor.propTypes = {
thumbEditor.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,
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,
initializeEditor: PropTypes.func.isRequired,
// inject
intl: intlShape.isRequired,
};
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 }),
settings: selectors.game.settings(state),
type: selectors.game.type(state),
list: selectors.game.list(state),
isDirty: selectors.game.isDirty(state),
// TODO fill with redux state here if needed
exampleValue: selectors.game.exampleValue(state),
});
export const mapDispatchToProps = {
initializeEditor: actions.app.initializeEditor,
// 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,
// TODO fill with dispatches here if needed
};
export default connect(mapStateToProps, mapDispatchToProps)(GameEditor);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));

View File

@@ -1,275 +0,0 @@
/* Basic styles to support GameEditor layout and classes used in JSX */
.editor-body {
height: 100%;
}
.page-body {
gap: 24px;
display: flex;
padding: 8px 0 0 24px;
align-items: flex-start;
width: 100%;
background: var(--extras-white, #FFFFFF);
}
.terms {
display: flex;
flex-direction: column;
flex: 1 0 0;
gap: 16px;
align-self: stretch;
}
.terms > div {
width: 100%;
}
.sidebar {
width: 320px;
display: flex;
padding: 8px 24px 16px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
flex-shrink: 0;
}
.description-header {
color: var(--primary-500, #00262B);
font-size: 18px;
font-style: normal;
font-weight: 700;
line-height: 24px;
}
.draggable-button {
cursor: grab;
position: absolute;
left: 12px;
}
.card-number {
width: 32px;
height: 32px;
border-radius: 16px;
background: #EEF1F5;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: var(--primary-500, #00262B);
font-size: 18px;
font-style: normal;
font-weight: 700;
line-height: normal;
}
.img-preview {
width: 24px;
height: 24px;
object-fit: cover;
max-height: 32px;
max-width: 32px;
}
.card-image-area {
display: flex;
padding: 0 24px 8px;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
max-height: 200px;
border-radius: 8px;
}
.card-divider {
width: 100%;
display: flex;
height: 1px;
justify-content: center;
align-items: center;
align-self: stretch;
background: var(--light-400, #EAE6E5);
}
.add-button {
display: inline-flex;
align-items: center;
gap: 8px;
}
.type-button {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.toggle-button {
margin-right: 8px;
width: 50%;
}
.preview-term {
margin-right: 8px;
display: inline-block;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 8px;
padding-right: 8px;
position: absolute;
left: 0;
}
.preview-block {
margin-right: 8px;
bottom: 35%;
}
.preview-definition {
display: inline-block;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 8px;
padding-right: 8px;
position: absolute;
left: 50%;
}
.description {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.description-body {
align-self: stretch;
color: var(--primary-500, #00262B);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
.card {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
position: relative;
border-radius: 6px;
border: var(--extras-white, #FFFFFF);
background: var(--extras-white, #FFFFFF);
}
.card-heading {
display: flex;
align-items: center;
gap: 24px;
align-self: stretch;
width: 100%;
}
.card-spacer {
flex: 1 0 0;
align-self: stretch;
}
.card-delete-button, .card-image-button, .image-delete-button {
display: flex;
width: 32px;
height: 32px;
justify-content: center;
align-items: center;
gap: 10px;
flex-shrink: 0;
border-radius: 44px;
}
.card-body {
width: 100%;
position: relative;
}
.card-body-divider {
padding-top: 20px;
}
.card-term, .card-definition {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
gap: 16px;
color: var(--primary-500, #00262B);
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 28px;
padding: 24px;
}
.card-image {
max-height: 200px;
}
.card-input-line {
color: var(--gray-500, #707070);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
.card-field {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1 0 0;
border: 1px solid var(--gray-500, #707070);
background: #FFFFFF;
padding: 10px 16px;
gap: 10px;
align-self: stretch;
color: var(--gray-500, #707070);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
.sidebar-type, .sidebar-shuffle, .sidebar-timer {
gap: 16px;
border-radius: 4px;
border: 1px solid var(--light-700, #D7D3D1);
background: #FFFFFF;
justify-content: space-between;
}
.drag-spacer {
width: 20px;
height: 44px;
}
.check {
fill: green;
}
.card-dropdown {
z-index: 10;
}
.settings-description {
padding-bottom: 16px;
color: #51565C;
margin-bottom: 8px;
}

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
loadingSpinner: {
id: 'GameEditor.loadingSpinner',
defaultMessage: 'Loading Spinner',
description: 'Loading message for spinner screenreader text.',
},
});
export default messages;

View File

@@ -18,7 +18,6 @@ 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,
@@ -49,11 +48,6 @@ 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 (
@@ -83,9 +77,8 @@ const AnswerOption = ({
);
}
// Return Answer Range View
const isValidValue = validateAnswerRange(answer.title);
return (
<Form.Group isInvalid={!isValidValue}>
<div>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
@@ -95,15 +88,10 @@ 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>
</Form.Group>
</div>
);
};

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