Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
3c27558994 chore(deps): bump axios and @edx/frontend-platform
Bumps [axios](https://github.com/axios/axios) to 0.30.3 and updates ancestor dependencies [axios](https://github.com/axios/axios) and [@edx/frontend-platform](https://github.com/openedx/frontend-platform). These dependencies need to be updated together.


Updates `axios` from 0.30.2 to 0.30.3
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.30.2...v0.30.3)

Updates `axios` from 1.13.4 to 1.13.5
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.30.2...v0.30.3)

Updates `@edx/frontend-platform` from 8.5.4 to 8.5.5
- [Release notes](https://github.com/openedx/frontend-platform/releases)
- [Commits](https://github.com/openedx/frontend-platform/compare/v8.5.4...v8.5.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 0.30.3
  dependency-type: indirect
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: indirect
- dependency-name: "@edx/frontend-platform"
  dependency-version: 8.5.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-18 23:20:01 +00:00
186 changed files with 3649 additions and 4292 deletions

View File

@@ -17,17 +17,29 @@ jobs:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: code-coverage-report
path: coverage/*.*
# We are trying out oxlint for a while. Please report if you ever see lint issues that eslint catches but oxlint
# misses. We expect the opposite (oxlint should catch more issues).
lint-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- run: npm install
- run: npm run oxlint
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v6
- name: Download code coverage results
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: code-coverage-report
path: coverage

View File

@@ -51,9 +51,7 @@ validate-no-uncommitted-package-lock-changes:
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
# We are trying out oxlint. Now that it's been working well for a while with both oxlint and eslint, we have disabled
# eslint, and after a few weeks we'll evaluate whether any problems are slipping through if only oxlint is used.
npm run oxlint
npm run lint -- --max-warnings 0
npm run types
npm run test:ci
npm run build

120
package-lock.json generated
View File

@@ -24,10 +24,9 @@
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/frontend-platform": "^8.5.5",
"@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
@@ -95,7 +94,7 @@
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.16.0",
"oxlint-tsgolint": "^0.11.2",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
@@ -2305,9 +2304,9 @@
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -2336,9 +2335,9 @@
}
},
"node_modules/@codemirror/view": {
"version": "6.39.16",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz",
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==",
"version": "6.39.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz",
"integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
@@ -5160,10 +5159,6 @@
"resolved": "plugins/course-apps/calculator",
"link": true
},
"node_modules/@openedx-plugins/course-app-dates": {
"resolved": "plugins/course-apps/dates",
"link": true
},
"node_modules/@openedx-plugins/course-app-edxnotes": {
"resolved": "plugins/course-apps/edxnotes",
"link": true
@@ -5472,9 +5467,9 @@
}
},
"node_modules/@openedx/paragon/node_modules/axios": {
"version": "0.30.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.30.2.tgz",
"integrity": "sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==",
"version": "0.30.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.30.3.tgz",
"integrity": "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.4",
@@ -5574,9 +5569,9 @@
}
},
"node_modules/@oxlint-tsgolint/darwin-arm64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz",
"integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.11.2.tgz",
"integrity": "sha512-LXQ47SH4MjzgI8xXMMB22N9G6yXojL8YNemPgvwtMw37by5H2rOBXsdViX2r0ubV75ak1/7GlxVAFEKQ9lc+Dw==",
"cpu": [
"arm64"
],
@@ -5588,9 +5583,9 @@
]
},
"node_modules/@oxlint-tsgolint/darwin-x64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz",
"integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.11.2.tgz",
"integrity": "sha512-am1cy2mhq56DhG5gdErCfAnHYr2JiJIxRtRyXfAkAVekteaAwRwK1OytjO7s455oGNUVKPD3M8bkEJ3L/FWk8A==",
"cpu": [
"x64"
],
@@ -5602,9 +5597,9 @@
]
},
"node_modules/@oxlint-tsgolint/linux-arm64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz",
"integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.11.2.tgz",
"integrity": "sha512-KNMXweLVdUevvi7XvDiiJbQSBKZQmRyBAwS2G8R32AxUusdDccmt0yB++0nH5WN+U5tLLEa0BlkaVTVHhxThAw==",
"cpu": [
"arm64"
],
@@ -5616,9 +5611,9 @@
]
},
"node_modules/@oxlint-tsgolint/linux-x64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz",
"integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.11.2.tgz",
"integrity": "sha512-bkKayG26rLua4RVhtZOk8GbplBTTD9k+NI8EA+qwP7TSC3ndtIlj/LHNo17+DPa4IYrhd+2vLsUxTvGh7TeTgg==",
"cpu": [
"x64"
],
@@ -5630,9 +5625,9 @@
]
},
"node_modules/@oxlint-tsgolint/win32-arm64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.16.0.tgz",
"integrity": "sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.11.2.tgz",
"integrity": "sha512-0imJQy2VhFeOms961lgAEbmlr3LdepBb2ClWYeu0HPc8Mi05x/bT4BReFY7L4gdctajYIrKDS2Dzp2zEqeHn1g==",
"cpu": [
"arm64"
],
@@ -5644,9 +5639,9 @@
]
},
"node_modules/@oxlint-tsgolint/win32-x64": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz",
"integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.11.2.tgz",
"integrity": "sha512-kAYRB8WP+t6TRzO/4DALoggtw8NjE6mPk8VzEOK3EJRtE3Pdo1fdVVCE2xaPctQEf7JZ+1D55ZNLnTR7lT8Bxg==",
"cpu": [
"x64"
],
@@ -7057,9 +7052,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"license": "MIT"
},
"node_modules/@types/mime": {
@@ -8978,15 +8973,12 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/batch": {
@@ -9361,9 +9353,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
"funding": [
{
"type": "opencollective",
@@ -17891,21 +17883,21 @@
}
},
"node_modules/oxlint-tsgolint": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz",
"integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==",
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.11.2.tgz",
"integrity": "sha512-CgtoZ4vAQCWYaJwQRPIFp6aId+db/s1cgIPJky7Sx8hA/nEO/ZSfvL4bee1GmldU84GcVC8nNiF6FJEdj2xDEw==",
"dev": true,
"license": "MIT",
"bin": {
"tsgolint": "bin/tsgolint.js"
},
"optionalDependencies": {
"@oxlint-tsgolint/darwin-arm64": "0.16.0",
"@oxlint-tsgolint/darwin-x64": "0.16.0",
"@oxlint-tsgolint/linux-arm64": "0.16.0",
"@oxlint-tsgolint/linux-x64": "0.16.0",
"@oxlint-tsgolint/win32-arm64": "0.16.0",
"@oxlint-tsgolint/win32-x64": "0.16.0"
"@oxlint-tsgolint/darwin-arm64": "0.11.2",
"@oxlint-tsgolint/darwin-x64": "0.11.2",
"@oxlint-tsgolint/linux-arm64": "0.11.2",
"@oxlint-tsgolint/linux-x64": "0.11.2",
"@oxlint-tsgolint/win32-arm64": "0.11.2",
"@oxlint-tsgolint/win32-x64": "0.11.2"
}
},
"node_modules/p-limit": {
@@ -24721,22 +24713,6 @@
}
}
},
"plugins/course-apps/dates": {
"name": "@openedx-plugins/course-app-dates",
"version": "0.1.0",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
},
"plugins/course-apps/edxnotes": {
"name": "@openedx-plugins/course-app-edxnotes",
"version": "0.1.0",

View File

@@ -48,10 +48,9 @@
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/frontend-platform": "^8.5.5",
"@edx/openedx-atlas": "^0.7.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-dates": "file:plugins/course-apps/dates",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
@@ -119,7 +118,7 @@
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.16.0",
"oxlint-tsgolint": "^0.11.2",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
type DatesSettingsProps = {
onClose: () => void;
};
const DatesSettings: React.FC<DatesSettingsProps> = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="dates"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableAppHelp)}
enableAppLabel={intl.formatMessage(messages.enableAppLabel)}
learnMoreText={intl.formatMessage(messages.learnMore)}
onClose={onClose}
validationSchema={{}}
initialValues={{}}
onSettingsSave={async () => true}
/>
);
};
export default DatesSettings;

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.dates.heading',
defaultMessage: 'Configure dates',
description: 'Heading for the Dates settings modal shown in Pages & Resources.',
},
enableAppLabel: {
id: 'course-authoring.pages-resources.dates.enable-app.label',
defaultMessage: 'Dates',
description: 'Label for the toggle that enables the Dates experience.',
},
enableAppHelp: {
id: 'course-authoring.pages-resources.dates.enable-app.help',
defaultMessage: 'Show the Dates tab in course navigation, where learners can view important course dates.',
description: 'Helper text explaining what enabling the Dates experience does.',
},
learnMore: {
id: 'course-authoring.pages-resources.dates.learn-more',
defaultMessage: 'Learn more about dates',
description: 'Link text that leads to documentation about the Dates experience.',
},
});
export default messages;

View File

@@ -1,17 +0,0 @@
{
"name": "@openedx-plugins/course-app-dates",
"version": "0.1.0",
"description": "Dates configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"optional": true
}
}
}

View File

@@ -4,17 +4,21 @@ import {
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { useSelector } from 'react-redux';
import { getCourseItem } from '@src/course-outline/data/api';
import { useDispatch, useSelector } from 'react-redux';
import {
addSection, addSubsection, addUnit, updateSavingStatus,
} from '@src/course-outline/data/slice';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { useToggleWithValue } from '@src/hooks';
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
import { CourseDetailsData } from './data/api';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
import { RequestStatus, RequestStatusType } from './data/constants';
type ModalState = {
value?: XBlock | UnitXBlock;
value: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};
@@ -26,8 +30,10 @@ export type CourseAuthoringContextData = {
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
isUnlinkModalOpen: boolean;
@@ -60,6 +66,7 @@ export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
@@ -107,11 +114,47 @@ export const CourseAuthoringProvider = ({
window.location.assign(url);
}
};
const addSectionToCourse = /* istanbul ignore next */ async (locator: string) => {
try {
const data = await getCourseItem(locator);
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
const addSubsectionToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
try {
const data = await getCourseItem(locator);
// Page should scroll to newly added subsection.
data.shouldScroll = true;
dispatch(addSubsection({ parentLocator, data }));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
const addUnitToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
try {
const data = await getCourseItem(locator);
// Page should scroll to newly added subsection.
data.shouldScroll = true;
dispatch(addUnit({ parentLocator, data }));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
const handleAddSection = useCreateCourseBlock(addSectionToCourse);
const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse);
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);
const handleAddAndOpenUnit = useCreateCourseBlock(openUnitPage);
const handleAddUnit = useCreateCourseBlock(addUnitToCourse);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
@@ -119,7 +162,9 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
@@ -139,7 +184,9 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,

View File

@@ -31,8 +31,6 @@ import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
import { CourseImportProvider } from './import-page/CourseImportContext';
import { CourseExportProvider } from './export-page/CourseExportContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -143,23 +141,11 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="import"
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
element={<PageWrap><CourseImportPage /></PageWrap>}
/>
<Route
path="export"
element={(
<PageWrap>
<CourseExportProvider>
<CourseExportPage />
</CourseExportProvider>
</PageWrap>
)}
element={<PageWrap><CourseExportPage /></PageWrap>}
/>
<Route
path="optimizer"

View File

@@ -1,73 +1,58 @@
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { COURSE_PERMISSIONS } from '@src/authz/constants';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import SubHeader from '@src/generic/sub-header/SubHeader';
import AlertMessage from '@src/generic/alert-message';
import getPageHeadTitle from '@src/generic/utils';
import Placeholder from '@src/editors/Placeholder';
import Placeholder from '../editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import AlertMessage from '../generic/alert-message';
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import SettingCard from './setting-card/SettingCard';
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
import getPageHeadTitle from '../generic/utils';
const AdvancedSettings = () => {
const intl = useIntl();
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false);
const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const { courseId, courseDetails } = useCourseAuthoringContext();
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canManageAdvancedSettings: {
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
},
}, isAuthzEnabled);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const {
data: advancedSettingsData = {},
isPending: isPendingSettingsStatus,
failureReason: settingsStatusError,
} = useCourseAdvancedSettings(courseId);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const {
data: proctoringExamErrors = {},
} = useProctoringExamErrors(courseId);
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
const {
isPending: isQueryPending,
isSuccess: isQuerySuccess,
error: queryError,
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
@@ -75,34 +60,30 @@ const AdvancedSettings = () => {
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (isQuerySuccess) {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (queryError && !hasInternetConnectionError) {
// @ts-ignore
setErrorFields(queryError?.response?.data ?? []);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [isQuerySuccess, queryError]);
}, [savingStatus]);
if (isLoading) {
return (
<div className="row justify-content-center m-6">
<LoadingSpinner />
</div>
);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (settingsStatusError?.response?.status === 403) {
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
<Placeholder />
@@ -124,42 +105,31 @@ const AdvancedSettings = () => {
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setShowSuccessAlert(false);
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
setIsQueryPending(true);
} else {
showSaveSettingsPrompt(false);
showErrorModal(!errorModal);
}
};
/* istanbul ignore next */
const handleInternetConnectionFailed = () => {
setInternetConnectionError(true);
showSaveSettingsPrompt(false);
setShowSuccessAlert(false);
};
const handleQueryProcessing = () => {
setShowSuccessAlert(false);
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
};
const handleManuallyChangeClick = (setToState) => {
showErrorModal(setToState);
showSaveSettingsPrompt(true);
};
// Show permission denied alert when authz is enabled and user doesn't have permission
const authzIsEnabledAndNoPermission = isAuthzEnabled
&& !isLoadingUserPermissions
&& !userPermissions?.canManageAdvancedSettings;
if (authzIsEnabledAndNoPermission) {
return <PermissionDeniedAlert />;
}
return (
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name ?? '', intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="advanced-settings px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
@@ -169,11 +139,7 @@ const AdvancedSettings = () => {
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
>
{/* Empty children to satisfy the type checker */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<></>
</AlertProctoringError>
/>
)}
<TransitionReplace>
{showSuccessAlert ? (
@@ -226,8 +192,8 @@ const AdvancedSettings = () => {
defaultMessage="{visibility} deprecated settings"
values={{
visibility:
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
}}
/>
</Button>
@@ -269,8 +235,9 @@ const AdvancedSettings = () => {
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={Boolean(queryError)}
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
@@ -281,18 +248,18 @@ const AdvancedSettings = () => {
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
role="dialog"
actions={[
!isQueryPending ? (
!isQueryPending && (
<Button variant="tertiary" onClick={handleResetSettingsValues}>
{intl.formatMessage(messages.buttonCancelText)}
</Button>
) : /* istanbul ignore next */ null,
),
<StatefulButton
key="statefulBtn"
onClick={handleUpdateAdvancedSettingsData}
state={isQueryPending ? RequestStatus.PENDING : 'default'}
{...updateSettingsButtonState}
/>,
].filter((action): action is JSX.Element => action !== null)}
].filter(Boolean)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}

View File

@@ -0,0 +1,147 @@
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import {
render as baseRender,
fireEvent,
initializeMocks,
waitFor,
} from '../testUtils';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<AdvancedSettings />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
it('should render without errors', async () => {
const { getByText } = render();
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(advancedSettingsElement).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render();
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea.value).toBe('[1, 2, 3]');
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render();
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render();
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render();
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

@@ -1,190 +0,0 @@
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import {
render as baseRender,
fireEvent,
initializeMocks,
screen,
} from '@src/testUtils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => { }}
/>
)));
jest.mock('@src/authz/data/apiHooks', () => ({
useUserPermissions: jest.fn(),
}));
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<AdvancedSettings />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
});
it('should render placeholder when settings fetch returns 403', async () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(403);
render();
expect(await screen.findByText(/Under Construction/i)).toBeInTheDocument();
});
it('should render without errors', async () => {
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should render setting element', async () => {
render();
expect(await screen.findByText(/Advanced Module List/i)).toBeInTheDocument();
expect(screen.queryByText('Certificate web/html view enabled')).toBeNull();
});
it('should change to onСhange', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea).toHaveValue('[1, 2, 3]');
});
it('should display a warning alert', async () => {
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(screen.getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
it('should display a tooltip on clicking on the icon', async () => {
const user = userEvent.setup();
render();
const button = await screen.findByLabelText(/Show help text/i);
await user.click(button);
expect(screen.getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
it('should change deprecated button text', async () => {
const user = userEvent.setup();
render();
const showDeprecatedItemsBtn = await screen.findByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
await user.click(showDeprecatedItemsBtn);
expect(screen.getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
expect(screen.getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
await user.click(screen.getByText(messages.buttonCancelText.defaultMessage));
expect(textarea).toHaveValue('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
fireEvent.blur(textarea);
expect(textarea).toHaveValue('[3, 2, 1,');
await user.click(screen.getByText('Save changes'));
await user.click(await screen.findByText('Change manually'));
expect(textarea).toHaveValue('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea).toHaveValue('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
await user.click(screen.getByText('Save changes'));
expect(screen.getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should show error modal on save failure', async () => {
const user = userEvent.setup();
render();
const textarea = await screen.findByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(500);
await user.click(screen.getByText('Save changes'));
expect(await screen.findByText('Validation error while saving')).toBeInTheDocument();
});
it('should render without errors when authz.enable_course_authoring flag is enabled and the user is authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: true },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
})).toBeInTheDocument();
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
it('should show permission alert when authz.enable_course_authoring flag is enabled and the user is not authorized', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useUserPermissions).mockReturnValue({
isLoading: false,
data: { canManageAdvancedSettings: false },
} as unknown as ReturnType<typeof useUserPermissions>);
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,11 @@
/* eslint-disable import/prefer-default-export */
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '@src/utils';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
@@ -12,8 +13,10 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
/**
* Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseAdvancedSettings(courseId: string): Promise<Record<string, any>> {
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
const keepValues = {};
@@ -33,11 +36,11 @@ export async function getCourseAdvancedSettings(courseId: string): Promise<Recor
/**
* Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/
export async function updateCourseAdvancedSettings(
courseId: string,
settings: Record<string, any>,
): Promise<Record<string, any>> {
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
const keepValues = {};
@@ -57,8 +60,10 @@ export async function updateCourseAdvancedSettings(
/**
* Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getProctoringExamErrors(courseId: string): Promise<Record<string, any>> {
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
const keepValues = {};
Object.keys(data).forEach((key) => {
@@ -72,6 +77,5 @@ export async function getProctoringExamErrors(courseId: string): Promise<Record<
value: keepValues[key]?.value,
};
});
return formattedData;
}

View File

@@ -1,56 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import {
getCourseAdvancedSettings,
getProctoringExamErrors,
updateCourseAdvancedSettings,
} from './api';
export const advancedSettingsQueryKeys = {
all: ['advancedSettings'],
/** Base key for advanced settings specific to a courseId */
courseAdvancedSettings: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId],
/** Key for proctoring exam errors specific to a courseId */
proctoringExamErrors: (courseId: string) => [...advancedSettingsQueryKeys.all, courseId, 'proctoringErrors'],
};
const sortSettingsByDisplayName = (settings: Record<string, any>): Record<string, any> => (
Object.fromEntries(Object.entries(settings).sort(
([, v1], [, v2]) => v1.displayName.localeCompare(v2.displayName),
))
);
/**
* Fetches the advanced settings for a course, sorted alphabetically by display name.
*/
export const useCourseAdvancedSettings = (courseId: string) => (
useQuery<Record<string, any>, AxiosError>({
queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId),
queryFn: () => getCourseAdvancedSettings(courseId),
select: sortSettingsByDisplayName,
})
);
/**
* Fetches the proctoring exam errors for a course.
*/
export const useProctoringExamErrors = (courseId: string) => (
useQuery({
queryKey: advancedSettingsQueryKeys.proctoringExamErrors(courseId),
queryFn: () => getProctoringExamErrors(courseId),
})
);
/**
* Returns a mutation to update the advanced settings for a course.
*/
export const useUpdateCourseAdvancedSettings = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<Record<string, any>, AxiosError, Record<string, any>>({
mutationFn: (settings: Record<string, any>) => updateCourseAdvancedSettings(courseId, settings),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: advancedSettingsQueryKeys.courseAdvancedSettings(courseId) });
},
});
};

View File

@@ -0,0 +1,5 @@
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'advancedSettings',
initialState: {
loadingStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
courseAppSettings: {},
proctoringErrors: {},
sendRequestErrors: {},
},
reducers: {
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
updateCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
getDataSendErrors: (state, { payload }) => {
Object.assign(state.sendRequestErrors, payload);
},
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
Object.assign(state.proctoringErrors, payload);
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
getDataSendErrors,
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
fetchProctoringExamErrorsSuccess,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,85 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
import {
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
updateLoadingStatus,
updateSavingStatus,
fetchProctoringExamErrorsSuccess,
getDataSendErrors,
} from './slice';
export function fetchCourseAppSettings(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getCourseAdvancedSettings(courseId);
const sortedDisplayName = [];
Object.values(settingValues).forEach(value => {
const { displayName } = value;
sortedDisplayName.push(displayName);
});
const sortedSettingValues = {};
sortedDisplayName.sort((a, b) => a.localeCompare(b)).forEach((displayName => {
Object.entries(settingValues).forEach(([key, value]) => {
if (value.displayName === displayName) {
sortedSettingValues[key] = value;
}
});
}));
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
export function updateCourseAppSetting(courseId, settings) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
dispatch(updateCourseAppsSettingsSuccess(settingValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
let errorData;
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch {
errorData = {};
}
dispatch(getDataSendErrors(errorData));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchProctoringExamErrors(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -14,7 +14,3 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};
export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
};

View File

@@ -403,19 +403,10 @@ mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mo
/**
* Mock for `getContentData()`
*/
export async function mockContentData(contentId: string): Promise<any> {
switch (contentId) {
case mockContentData.textXBlock:
return mockContentData.textXBlockData;
default:
return mockContentData.data;
}
export async function mockContentData(): Promise<any> {
return mockContentData.data;
}
mockContentData.data = {
displayName: 'Unit 1',
};
mockContentData.textXBlock = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
mockContentData.textXBlockData = {
displayName: 'Text XBlock 1',
};
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);

View File

@@ -1,12 +1,16 @@
import {
render,
waitFor,
screen,
initializeMocks,
} from '@src/testUtils';
import '@testing-library/jest-dom';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import {
courseId,
generateCourseLaunchData,
@@ -16,6 +20,7 @@ import messages from './messages';
import CourseChecklist from './index';
let axiosMock;
let store;
const renderComponent = () => {
render(
@@ -28,18 +33,22 @@ const renderComponent = () => {
const mockStore = async (status) => {
axiosMock.onGet(getCourseLaunchApiUrl(courseId)).reply(status, generateCourseLaunchData());
axiosMock.onGet(getCourseBestPracticesApiUrl(courseId)).reply(status, generateCourseBestPracticesData());
await executeThunk(fetchCourseLaunchQuery(courseId), store.dispatch);
await executeThunk(fetchCourseBestPracticesQuery(courseId), store.dispatch);
};
describe('CourseChecklistPage', () => {
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
describe('renders', () => {
describe('if enable_quality prop is true', () => {
it('two checklist components ', async () => {
await mockStore(200);
renderComponent();
await mockStore(200);
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
@@ -47,9 +56,9 @@ describe('CourseChecklistPage', () => {
});
describe('an aria-live region with', () => {
it('an aria-live region', async () => {
it('an aria-live region', () => {
renderComponent();
const ariaLiveRegion = await screen.findByRole('status');
const ariaLiveRegion = screen.getByRole('status');
expect(ariaLiveRegion).toBeDefined();
@@ -57,17 +66,29 @@ describe('CourseChecklistPage', () => {
});
it('correct content when the launch checklist has loaded', async () => {
await mockStore(404);
renderComponent();
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
await mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
});
it('correct content when the best practices checklist is loading', async () => {
await mockStore(404);
renderComponent();
expect(
await screen.findByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
).toBeInTheDocument();
await mockStore(404);
await waitFor(() => {
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
expect(
screen.getByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
).toBeInTheDocument();
});
});
});
});
@@ -90,15 +111,27 @@ describe('CourseChecklistPage', () => {
describe('an aria-live region with', () => {
it('correct content when the launch checklist has loaded', async () => {
await mockStore(404);
renderComponent();
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
await mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
});
it('correct content when the best practices checklist is loading', async () => {
await mockStore(404);
renderComponent();
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
await mockStore(404);
await waitFor(() => {
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
});
});
});
});
@@ -111,7 +144,11 @@ describe('CourseChecklistPage', () => {
renderComponent();
expect(await screen.findByRole('alert')).toBeInTheDocument();
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,33 +1,42 @@
import { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import { Container, Stack } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { DeprecatedReduxState } from '@src/store';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import AriaLiveRegion from './AriaLiveRegion';
import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import { useCourseBestPractices, useCourseLaunch } from './data/apiHooks';
const CourseChecklist = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { courseId, courseDetails } = useCourseAuthoringContext();
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
const {
data: bestPracticeData,
isPending: isPendingBestPacticeData,
} = useCourseBestPractices({ courseId });
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
}, [courseId]);
const {
data: launchData,
isPending: isPendingLaunchData,
failureReason: launchError,
} = useCourseLaunch({ courseId });
loadingStatus,
launchData,
bestPracticeData,
} = useSelector((state: DeprecatedReduxState) => state.courseChecklist);
const isLoadingDenied = launchError?.response?.status === 403;
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
if (isLoadingDenied) {
return (
@@ -55,8 +64,8 @@ const CourseChecklist = () => {
/>
<AriaLiveRegion
{...{
isCourseLaunchChecklistLoading: isPendingLaunchData,
isCourseBestPracticeChecklistLoading: isPendingBestPacticeData,
isCourseLaunchChecklistLoading,
isCourseBestPracticeChecklistLoading,
enableQuality,
}}
/>
@@ -66,7 +75,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isPendingLaunchData}
isLoading={isCourseLaunchChecklistLoading}
/>
{enableQuality && (
<ChecklistSection
@@ -74,7 +83,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isPendingBestPacticeData}
isLoading={isCourseBestPracticeChecklistLoading}
/>
)}
</Stack>

View File

@@ -0,0 +1,64 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
export const getCourseLaunchApiUrl = ({
courseId,
gradedOnly,
validateOras,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
/**
* Get course best practices.
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
*/
export async function getCourseBestPractices({
courseId,
excludeGraded,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
return camelCaseObject(data);
}
/** @typedef {object} courseLaunchData
* @property {boolean} isSelfPaced
* @property {object} dates
* @property {object} assignments
* @property {object} grades
* @property {number} grades.sum_of_weights
* @property {object} certificates
* @property {object} updates
* @property {object} proctoring
*/
/**
* Get course launch.
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
* @returns {Promise<courseLaunchData>}
*/
export async function getCourseLaunch({
courseId,
gradedOnly,
validateOras,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}

View File

@@ -1,52 +0,0 @@
import { initializeMocks } from '@src/testUtils';
import {
CourseBestPracticesRequest,
CourseLaunchRequest,
getCourseBestPractices,
getCourseBestPracticesApiUrl,
getCourseLaunch,
getCourseLaunchApiUrl,
} from './api';
let axiosMock;
describe('course checklist data API', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
describe('getCourseBestPractices', () => {
it('should fetch course best practices', async () => {
const params: CourseBestPracticesRequest = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
excludeGraded: true,
all: true,
};
const url = getCourseBestPracticesApiUrl(params);
axiosMock.onGet(url).reply(200, { is_self_paced: false });
const result = await getCourseBestPractices(params);
expect(axiosMock.history.get[0].url).toEqual(url);
expect(result).toEqual({ isSelfPaced: false });
});
});
describe('getCourseLaunch', () => {
it('should fetch course launch validation', async () => {
const params: CourseLaunchRequest = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
gradedOnly: true,
validateOras: true,
all: true,
};
const url = getCourseLaunchApiUrl(params);
axiosMock.onGet(url).reply(200, { is_self_paced: false });
const result = await getCourseLaunch(params);
expect(axiosMock.history.get[0].url).toEqual(url);
expect(result).toEqual({ isSelfPaced: false });
});
});
});

View File

@@ -1,98 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export interface CourseBestPracticesRequest {
courseId: string;
excludeGraded?: boolean;
all?: boolean;
}
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded = true,
all = true,
}: CourseBestPracticesRequest) => (
`${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`
);
export interface CourseLaunchRequest {
courseId: string;
gradedOnly?: boolean;
validateOras?: boolean;
all?: boolean;
}
export const getCourseLaunchApiUrl = ({
courseId,
gradedOnly = true,
validateOras = true,
all = true,
}: CourseLaunchRequest) => (
`${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`
);
export interface CourseBestPractices {
isSelfPaced: boolean;
sections: Record<string, any>;
subsection: Record<string, any>;
units: Record<string, any>;
videos: Record<string, any>;
}
/**
* Get course best practices.
*/
export async function getCourseBestPractices({
courseId,
excludeGraded,
all,
}: CourseBestPracticesRequest): Promise<CourseBestPractices> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
return camelCaseObject(data);
}
export interface CourseLaunchData {
isSelfPaced: boolean;
dates: {
hasEndDate: boolean;
hasStartDate: boolean;
};
assignments: Record<string, any>;
grades: {
hasGradingPolicy: boolean;
sumOfWeights: number;
};
certificates: {
hasCertificate: boolean;
isActivated: boolean;
isEnabled: boolean;
};
updates: {
hasUpdate: boolean;
};
proctoring: {
hasProctoringEscalationEmail: boolean;
needsProctoringEscalationEmail: boolean;
};
}
/**
* Get course launch.
*/
export async function getCourseLaunch({
courseId,
gradedOnly,
validateOras,
all,
}: CourseLaunchRequest): Promise<CourseLaunchData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}

View File

@@ -1,52 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { AxiosError } from 'axios';
import { useQuery } from '@tanstack/react-query';
import {
CourseBestPracticesRequest,
CourseLaunchData,
CourseLaunchRequest,
getCourseBestPractices,
getCourseLaunch,
} from './api';
export const courseChecklistQueryKeys = {
all: ['courseChecklist'],
courseBestPractices: (params: CourseBestPracticesRequest) => [
...courseChecklistQueryKeys.all,
'bestPractices',
params,
],
courseLaunch: (params: CourseLaunchRequest) => [
...courseChecklistQueryKeys.all,
'launch',
params,
],
};
/**
* Hook to fetch course best practices.
*
* It is necessary to update on each mount, because it is not known
* for sure whether the checklist has been updated or not.
*/
export const useCourseBestPractices = (params: CourseBestPracticesRequest) => (
useQuery({
queryKey: courseChecklistQueryKeys.courseBestPractices(params),
queryFn: () => getCourseBestPractices(params),
refetchOnMount: 'always',
})
);
/**
* Hook to fetch course launch validation.
*
* It is necessary to update on each mount, because it is not known
* for sure whether the checklist has been updated or not.
*/
export const useCourseLaunch = (params: CourseLaunchRequest) => (
useQuery<CourseLaunchData, AxiosError>({
queryKey: courseChecklistQueryKeys.courseLaunch(params),
queryFn: () => getCourseLaunch(params),
refetchOnMount: 'always',
})
);

View File

@@ -0,0 +1,41 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseChecklist',
initialState: {
loadingStatus: {
launchChecklistStatus: RequestStatus.IN_PROGRESS,
bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS,
},
launchData: {},
bestPracticeData: {},
},
reducers: {
fetchLaunchChecklistSuccess: (state, { payload }) => {
state.launchData = payload.data;
},
updateLaunchChecklistStatus: (state, { payload }) => {
state.loadingStatus.launchChecklistStatus = payload.status;
},
fetchBestPracticeChecklistSuccess: (state, { payload }) => {
state.bestPracticeData = payload.data;
},
updateBestPracticeChecklisttStatus: (state, { payload }) => {
state.loadingStatus.bestPracticeChecklistStatus = payload.status;
},
},
});
export const {
fetchLaunchChecklistSuccess,
updateLaunchChecklistStatus,
fetchBestPracticeChecklistSuccess,
updateBestPracticeChecklisttStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,50 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseBestPractices,
getCourseLaunch,
} from './api';
import {
fetchLaunchChecklistSuccess,
updateLaunchChecklistStatus,
fetchBestPracticeChecklistSuccess,
updateBestPracticeChecklisttStatus,
} from './slice';
export function fetchCourseLaunchQuery({
courseId,
gradedOnly = true,
validateOras = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseLaunch({
courseId, gradedOnly, validateOras, all,
});
dispatch(fetchLaunchChecklistSuccess({ data }));
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
}
}
};
}
export function fetchCourseBestPracticesQuery({
courseId,
excludeGraded = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
dispatch(fetchBestPracticeChecklistSuccess({ data }));
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
} catch {
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -36,9 +36,10 @@ import {
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
import {
courseOutlineIndexMock as originalCourseOutlineIndexMock,
courseOutlineIndexMock,
courseOutlineIndexWithoutSections,
courseBestPracticesMock,
courseLaunchMock,
@@ -70,7 +71,6 @@ const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
const getContainerType = jest.fn().mockReturnValue('unit');
const clearSelection = jest.fn();
let selectedContainerId: string | undefined;
let courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
window.HTMLElement.prototype.scrollIntoView = jest.fn();
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
@@ -96,6 +96,13 @@ jest.mock('@src/help-urls/hooks', () => ({
}),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));
jest.mock('./data/api', () => ({
...jest.requireActual('./data/api'),
getTagsCount: () => jest.fn().mockResolvedValue({}),
@@ -156,8 +163,6 @@ describe('<CourseOutline />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
selectedContainerId = undefined;
// restore index mock
courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
jest.mocked(useLocation).mockReturnValue({
pathname: mockPathname,
@@ -295,7 +300,7 @@ describe('<CourseOutline />', () => {
expect(alertElements.find(
(el) => el.classList.contains('alert-content'),
)).toHaveTextContent(
'Unable to save changes. Please try again.',
pageAlertMessages.alertFailedGeneric.defaultMessage,
);
});
@@ -399,7 +404,6 @@ describe('<CourseOutline />', () => {
});
it('adds new subsection correctly', async () => {
const user = userEvent.setup();
const { findAllByTestId } = renderComponent();
const [section] = await findAllByTestId('section-card');
let subsections = await within(section).findAllByTestId('subsection-card');
@@ -424,14 +428,10 @@ describe('<CourseOutline />', () => {
axiosMock
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
.reply(200, courseSubsectionMock);
const firstSectionData = courseOutlineIndexMock.courseStructure.childInfo.children[0];
// @ts-ignore
firstSectionData.childInfo.children.push(courseSubsectionMock);
axiosMock
.onGet(getXBlockApiUrl(firstSectionData.id))
.reply(200, firstSectionData);
const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' });
await user.click(newSubsectionButton);
await act(async () => {
fireEvent.click(newSubsectionButton);
});
subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(3);
@@ -540,7 +540,6 @@ describe('<CourseOutline />', () => {
});
it('adds a section from library correctly', async () => {
const user = userEvent.setup();
getContainerKey.mockReturnValue('lct:org:lib:section:1');
getContainerKey.mockReturnValue('section');
renderComponent();
@@ -553,14 +552,11 @@ describe('<CourseOutline />', () => {
locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd',
courseKey: 'course-v1:UNIX+UX1+2025_T3',
});
axiosMock
.onGet(getXBlockApiUrl('block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd'))
.reply(200, courseSectionMock);
const addSectionFromLibraryButton = await screen.findByRole('button', {
name: /use section from library/i,
});
await user.click(addSectionFromLibraryButton);
fireEvent.click(addSectionFromLibraryButton);
// click dummy button to execute onComponentSelected prop.
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
@@ -709,54 +705,26 @@ describe('<CourseOutline />', () => {
});
it('check edit title works for section, subsection and unit', async () => {
const user = userEvent.setup();
renderComponent();
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const { findAllByTestId } = renderComponent();
const checkEditTitle = async (element, item, newName, elementName) => {
axiosMock.reset();
axiosMock
.onPost(getCourseItemApiUrl(item.id))
.reply(200, { dummy: 'value' });
if (item.id === section.id) {
// return normal section data the first time to keep original name first
axiosMock
.onGet(getXBlockApiUrl(section.id))
// @ts-ignore
.replyOnce(section);
}
// mock section, subsection and unit name and check within the elements.
// this is done to avoid adding conditions to this mock.
axiosMock
.onGet(getXBlockApiUrl(section.id))
.onGet(getXBlockApiUrl(item.id))
.reply(200, {
...section,
...item,
display_name: newName,
childInfo: {
children: [
{
...section.childInfo.children[0],
display_name: newName,
childInfo: {
children: [
{
...section.childInfo.children[0].childInfo.children[0],
display_name: newName,
},
],
},
},
],
},
});
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
fireEvent.click(editButton);
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
fireEvent.change(editField, { target: { value: newName } });
await user.keyboard('{enter}');
await act(async () => fireEvent.blur(editField));
expect(
axiosMock.history.post[axiosMock.history.post.length - 1].data,
).toBe(JSON.stringify({
@@ -769,7 +737,8 @@ describe('<CourseOutline />', () => {
};
// check section
const [sectionElement] = await screen.findAllByTestId('section-card');
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
await checkEditTitle(sectionElement, section, 'New section name', 'section');
// check subsection
@@ -1658,14 +1627,15 @@ describe('<CourseOutline />', () => {
});
it('check update highlights when update highlights query is successfully', async () => {
const user = userEvent.setup();
renderComponent();
const { getByRole } = renderComponent();
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const highlights = [
'New Highlight 1',
'New Highlight 2',
'New Highlight 3',
'New Highlight 4',
'New Highlight 5',
];
axiosMock
@@ -1683,21 +1653,12 @@ describe('<CourseOutline />', () => {
...section,
highlights,
});
const highlightBtn = await screen.findAllByRole('button', { name: '0 Section highlights' });
await user.click(highlightBtn[0]);
const dialog = await screen.findByRole('dialog');
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 1' }), {
target: { value: 'New Highlight 1' },
});
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 2' }), {
target: { value: 'New Highlight 2' },
});
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 3' }), {
target: { value: 'New Highlight 3' },
});
await user.click(await within(dialog).findByRole('button', { name: 'Save' }));
expect(await screen.findByRole('button', { name: '3 Section highlights' })).toBeInTheDocument();
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
await waitFor(() => {
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
});
});
it('check whether section move up and down options work correctly', async () => {
@@ -2309,7 +2270,6 @@ describe('<CourseOutline />', () => {
});
it('check whether unit copy & paste option works correctly', async () => {
const user = userEvent.setup();
renderComponent();
// get first section -> first subsection -> first unit element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
@@ -2368,6 +2328,13 @@ describe('<CourseOutline />', () => {
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
expect(lastUnitElement).toHaveTextContent(unit.displayName);
// check pasteFileNotices in store
expect(store.getState().courseOutline.pasteFileNotices).toEqual({
newFiles: ['some.css'],
conflictingFiles: ['con.css'],
errorFiles: ['error.css'],
});
let alerts = await screen.findAllByRole('alert');
// Exclude processing notification toast
alerts = alerts.filter((el) => !el.classList.contains('toast-container'));
@@ -2376,18 +2343,18 @@ describe('<CourseOutline />', () => {
// check alerts for errorFiles
let dismissBtn = await within(alerts[0]).findByText('Dismiss');
await user.click(dismissBtn);
fireEvent.click(dismissBtn);
// check alerts for conflictingFiles
dismissBtn = await within(alerts[1]).findByText('Dismiss');
await user.click(dismissBtn);
fireEvent.click(dismissBtn);
// check alerts for newFiles
dismissBtn = await within(alerts[2]).findByText('Dismiss');
await user.click(dismissBtn);
fireEvent.click(dismissBtn);
// check that all alerts are gone
expect((screen.queryAllByRole('alert')).length).toEqual(0);
// check pasteFileNotices in store
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
});
it('should show toats on export tags', async () => {

View File

@@ -71,8 +71,10 @@ const CourseOutline = () => {
const {
courseId,
courseUsageKey,
handleAddBlock,
handleAddSubsection,
handleAddUnit,
handleAddAndOpenUnit,
handleAddSection,
isUnlinkModalOpen,
closeUnlinkModal,
currentSelection,
@@ -95,7 +97,6 @@ const CourseOutline = () => {
isDisabledReindexButton,
isHighlightsModalOpen,
isConfigureModalOpen,
isConfigureOpPending,
isDeleteModalOpen,
closeHighlightsModal,
handleConfigureModalClose,
@@ -108,17 +109,14 @@ const CourseOutline = () => {
handleEnableHighlightsSubmit,
handleInternetConnectionFailed,
handleOpenHighlightsModal,
isSectionHighlightsUpdatePending,
handleHighlightsFormSubmit,
handleConfigureItemSubmit,
handleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
isDuplicatingItem,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
isPasting,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextLearnmoreUrl,
@@ -131,6 +129,7 @@ const CourseOutline = () => {
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
errors,
resetScrollState,
handleUnlinkItemSubmit,
} = useCourseOutline({ courseId });
@@ -387,6 +386,7 @@ const CourseOutline = () => {
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onOrderChange={updateSectionOrderByIndex}
resetScrollState={resetScrollState}
>
<SortableContext
id={section.id}
@@ -413,6 +413,7 @@ const CourseOutline = () => {
onOpenConfigureModal={openConfigureModal}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
resetScrollState={resetScrollState}
>
<SortableContext
id={subsection.id}
@@ -522,12 +523,10 @@ const CourseOutline = () => {
// Show processing toast if any mutation is running
isShow={
isShowProcessingNotification
|| handleAddBlock.isPending
|| handleAddUnit.isPending
|| handleAddAndOpenUnit.isPending
|| isConfigureOpPending
|| isSectionHighlightsUpdatePending
|| isDuplicatingItem
|| isPasting
|| handleAddSubsection.isPending
|| handleAddSection.isPending
}
// HACK: Use saving as default title till we have a need for better messages
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}

View File

@@ -14,8 +14,10 @@ jest.mock('@src/studio-home/data/selectors', () => ({
}),
}));
const handleAddSection = { mutateAsync: jest.fn() };
const handleAddSubsection = { mutateAsync: jest.fn() };
const handleAddAndOpenUnit = { mutateAsync: jest.fn() };
const handleAddBlock = { mutateAsync: jest.fn() };
const handleAddUnit = { mutateAsync: jest.fn() };
const courseUsageKey = 'some/usage/key';
const setCurrentSelection = jest.fn();
jest.mock('@src/CourseAuthoringContext', () => ({
@@ -23,8 +25,10 @@ jest.mock('@src/CourseAuthoringContext', () => ({
courseId: 5,
courseUsageKey,
getUnitUrl: (id: string) => `/some/${id}`,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
handleAddBlock,
handleAddUnit,
setCurrentSelection,
}),
}));
@@ -77,11 +81,9 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
it('calls appropriate new handlers', async () => {
const parentLocator = `parent-of-${containerType}`;
const grandParentLocator = `grandparent-of-${containerType}`;
render(<OutlineAddChildButtons
childType={containerType}
parentLocator={parentLocator}
grandParentLocator={grandParentLocator}
/>, { extraWrapper: OutlineSidebarProvider });
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
@@ -89,18 +91,17 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
await userEvent.click(newBtn);
switch (containerType) {
case ContainerType.Section:
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: 'Section',
}));
break;
case ContainerType.Subsection:
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Sequential,
parentLocator,
displayName: 'Subsection',
sectionId: parentLocator,
}));
break;
case ContainerType.Unit:
@@ -108,7 +109,6 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
type: ContainerType.Vertical,
parentLocator,
displayName: 'Unit',
sectionId: grandParentLocator,
}));
break;
default:

View File

@@ -28,7 +28,9 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
const intl = useIntl();
const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
const {
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
@@ -56,7 +58,10 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
>
<Col className="py-3">
<Stack direction="horizontal" gap={3}>
{(handleAddAndOpenUnit.isPending || handleAddBlock.isPending) && (
{(handleAddSection.isPending
|| handleAddSubsection.isPending
|| handleAddAndOpenUnit.isPending
|| handleAddUnit.isPending) && (
<LoadingSpinner />
)}
<h3 className="mb-0">{getTitle()}</h3>
@@ -81,11 +86,11 @@ interface BaseProps {
btnClasses?: string;
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
parentLocator: string;
grandParentLocator?: string;
}
interface NewChildButtonsProps extends BaseProps {
handleUseFromLibraryClick?: () => void;
grandParentLocator?: string;
}
const NewOutlineAddChildButtons = ({
@@ -108,7 +113,8 @@ const NewOutlineAddChildButtons = ({
const intl = useIntl();
const {
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const { startCurrentFlow } = useOutlineSidebarContext();
@@ -126,7 +132,7 @@ const NewOutlineAddChildButtons = ({
newButton: messages.newSectionButton,
importButton: messages.useSectionFromLibraryButton,
};
onNewCreateContent = () => handleAddBlock.mutateAsync({
onNewCreateContent = () => handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -138,11 +144,10 @@ const NewOutlineAddChildButtons = ({
newButton: messages.newSubsectionButton,
importButton: messages.useSubsectionFromLibraryButton,
};
onNewCreateContent = () => handleAddBlock.mutateAsync({
onNewCreateContent = () => handleAddSubsection.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId: parentLocator,
});
flowType = ContainerType.Subsection;
break;
@@ -155,7 +160,6 @@ const NewOutlineAddChildButtons = ({
type: ContainerType.Vertical,
parentLocator,
displayName: COURSE_BLOCK_NAMES.vertical.name,
sectionId: grandParentLocator,
});
flowType = ContainerType.Unit;
break;
@@ -222,7 +226,6 @@ const LegacyOutlineAddChildButtons = ({
btnClasses = 'mt-4 border-gray-500 rounded-0',
btnSize,
parentLocator,
grandParentLocator,
onClickCard,
}: BaseProps) => {
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
@@ -234,7 +237,8 @@ const LegacyOutlineAddChildButtons = ({
const intl = useIntl();
const {
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const [
@@ -259,12 +263,12 @@ const LegacyOutlineAddChildButtons = ({
importButton: messages.useSectionFromLibraryButton,
modalTitle: messages.sectionPickerModalTitle,
};
onNewCreateContent = () => handleAddBlock.mutateAsync({
onNewCreateContent = () => handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
@@ -279,18 +283,16 @@ const LegacyOutlineAddChildButtons = ({
importButton: messages.useSubsectionFromLibraryButton,
modalTitle: messages.subsectionPickerModalTitle,
};
onNewCreateContent = () => handleAddBlock.mutateAsync({
onNewCreateContent = () => handleAddSubsection.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId: parentLocator,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator,
libraryContentKey: selected.usageKey,
sectionId: parentLocator,
});
visibleTabs = [ContentType.subsections];
query = ['block_type = "subsection"'];
@@ -305,14 +307,12 @@ const LegacyOutlineAddChildButtons = ({
type: ContainerType.Vertical,
parentLocator,
displayName: COURSE_BLOCK_NAMES.vertical.name,
sectionId: grandParentLocator,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddAndOpenUnit.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator,
libraryContentKey: selected.usageKey,
sectionId: grandParentLocator,
});
visibleTabs = [ContentType.units];
query = ['block_type = "unit"'];

View File

@@ -48,7 +48,6 @@ interface CardHeaderProps {
onClickMoveDown: () => void;
onClickCopy?: () => void;
onClickCard?: (e: React.MouseEvent) => void;
onClickManageTags?: () => void;
titleComponent: ReactNode;
namePrefix: string;
proctoringExamConfigurationLink?: string,
@@ -87,7 +86,6 @@ const CardHeader = ({
onClickMoveDown,
onClickCopy,
onClickCard,
onClickManageTags,
titleComponent,
namePrefix,
actions,
@@ -115,7 +113,6 @@ const CardHeader = ({
if (showNewSidebar && showAlignSidebar) {
setCurrentPageKey('align');
onClickMenuButton();
onClickManageTags?.();
} else {
openLegacyTagsDrawer();
}

View File

@@ -1,15 +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,
CourseItemUpdateResult,
ConfigureSectionData,
ConfigureSubsectionData,
ConfigureUnitData,
StaticFileNotices,
} from './types';
import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -220,15 +212,23 @@ export async function publishCourseItem(itemId: string): Promise<CourseItemUpdat
/**
* Configure course section
* @param {string} sectionId
* @param {boolean} isVisibleToStaffOnly
* @param {string} startDatetime
* @returns {Promise<Object>}
*/
export async function configureCourseSection(variables: ConfigureSectionData): Promise<object> {
export async function configureCourseSection(
sectionId: string,
isVisibleToStaffOnly: boolean,
startDatetime: string,
): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(variables.sectionId), {
.post(getCourseItemApiUrl(sectionId), {
publish: 'republish',
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
start: variables.startDatetime,
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
start: startDatetime,
},
});
@@ -236,30 +236,66 @@ export async function configureCourseSection(variables: ConfigureSectionData): P
}
/**
* Configure course subsection
* Configure course section
* @param {string} itemId
* @param {string} isVisibleToStaffOnly
* @param {string} releaseDate
* @param {string} graderType
* @param {string} dueDate
* @param {boolean} isProctoredExam,
* @param {boolean} isOnboardingExam,
* @param {boolean} isPracticeExam,
* @param {string} examReviewRules,
* @param {boolean} isTimeLimited
* @param {number} defaultTimeLimitMin
* @param {string} hideAfterDue
* @param {string} showCorrectness
* @param {boolean} isPrereq,
* @param {string} prereqUsageKey,
* @param {number} prereqMinScore,
* @param {number} prereqMinCompletion,
* @returns {Promise<Object>}
*/
export async function configureCourseSubsection(variables: ConfigureSubsectionData): Promise<object> {
export async function configureCourseSubsection(
itemId: string,
isVisibleToStaffOnly: string,
releaseDate: string,
graderType: string,
dueDate: string,
isTimeLimited: boolean,
isProctoredExam: boolean,
isOnboardingExam: boolean,
isPracticeExam: boolean,
examReviewRules: string,
defaultTimeLimitMin: number,
hideAfterDue: string,
showCorrectness: string,
isPrereq: boolean,
prereqUsageKey: string,
prereqMinScore: number,
prereqMinCompletion: number,
): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(variables.itemId), {
.post(getCourseItemApiUrl(itemId), {
publish: 'republish',
graderType: variables.graderType,
isPrereq: variables.isPrereq,
prereqUsageKey: variables.prereqUsageKey,
prereqMinScore: variables.prereqMinScore,
prereqMinCompletion: variables.prereqMinCompletion,
graderType,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
due: variables.dueDate,
hide_after_due: variables.hideAfterDue,
show_correctness: variables.showCorrectness,
is_practice_exam: variables.isPracticeExam,
is_time_limited: variables.isTimeLimited,
is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam,
exam_review_rules: variables.examReviewRules,
default_time_limit_minutes: variables.defaultTimeLimitMin,
is_onboarding_exam: variables.isOnboardingExam,
start: variables.releaseDate,
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
due: dueDate,
hide_after_due: hideAfterDue,
show_correctness: showCorrectness,
is_practice_exam: isPracticeExam,
is_time_limited: isTimeLimited,
is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam,
exam_review_rules: examReviewRules,
default_time_limit_minutes: defaultTimeLimitMin,
is_onboarding_exam: isOnboardingExam,
start: releaseDate,
},
});
return data;
@@ -267,16 +303,26 @@ export async function configureCourseSubsection(variables: ConfigureSubsectionDa
/**
* Configure course unit
* @param {string} unitId
* @param {boolean} isVisibleToStaffOnly
* @param {object} groupAccess
* @param {boolean} discussionEnabled
* @returns {Promise<Object>}
*/
export async function configureCourseUnit(variables: ConfigureUnitData): Promise<object> {
export async function configureCourseUnit(
unitId: string,
isVisibleToStaffOnly: boolean,
groupAccess: object,
discussionEnabled: boolean,
): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(variables.unitId), {
.post(getCourseItemApiUrl(unitId), {
publish: 'republish',
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
group_access: variables.groupAccess,
discussion_enabled: variables.discussionEnabled,
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
group_access: groupAccess,
discussion_enabled: discussionEnabled,
},
});
@@ -315,10 +361,7 @@ export async function deleteCourseItem(itemId: string): Promise<object> {
/**
* Duplicate course section
*/
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<{
courseKey: string;
locator: string;
}> {
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
duplicate_source_locator: itemId,
@@ -424,12 +467,7 @@ export async function setVideoSharingOption(
* @param {string} parentLocator
* @returns {Promise<Object>}
*/
export async function pasteBlock(parentLocator: string): Promise<{
locator: string;
courseKey: string;
staticFileNotices: StaticFileNotices;
upstreamRef: string;
}> {
export async function pasteBlock(parentLocator: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,

View File

@@ -1,21 +1,11 @@
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
import { addSection, duplicateSection, updateSectionList } from '@src/course-outline/data/slice';
import {
ConfigureSectionData,
ConfigureSubsectionData,
ConfigureUnitData,
StaticFileNotices,
} from '@src/course-outline/data/types';
import { createGlobalState } from '@src/data/apiHooks';
import type { XBlockBase, XblockChildInfo } from '@src/data/types';
import { getBlockType, getCourseKey } from '@src/generic/key-utils';
import type { XBlock } from '@src/data/types';
import { getCourseKey } from '@src/generic/key-utils';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { ParentIds } from '@src/generic/types';
import {
QueryClient,
skipToken, useMutation, useQuery, useQueryClient,
} from '@tanstack/react-query';
import { useDispatch } from 'react-redux';
import {
createCourseXblock,
type CreateCourseXBlockType,
@@ -24,12 +14,6 @@ import {
getCourseDetails,
getCourseItem,
publishCourseItem,
configureCourseSection,
configureCourseSubsection,
configureCourseUnit,
updateCourseSectionHighlights,
duplicateCourseItem,
pasteBlock,
} from './api';
export const courseOutlineQueryKeys = {
@@ -42,14 +26,6 @@ export const courseOutlineQueryKeys = {
...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined),
itemId,
],
scrollToCourseItemId: (courseId?: string) => [
...courseOutlineQueryKeys.course(courseId),
'scroll',
],
pasteFileNotices: (courseId?: string) => [
...courseOutlineQueryKeys.course(courseId),
'pasteFileNotices',
],
courseDetails: (courseId?: string) => [
...courseOutlineQueryKeys.course(courseId),
'details',
@@ -65,30 +41,22 @@ export const courseOutlineQueryKeys = {
],
};
type ScrollState = {
id?: string;
type ParentIds = {
/** This id will be used to invalidate data of parent subsection */
subsectionId?: string;
/** This id will be used to invalidate data of parent section */
sectionId?: string;
};
export const useScrollState = createGlobalState<ScrollState>(courseOutlineQueryKeys.scrollToCourseItemId, {
id: undefined,
});
/**
* Invalidate parent Subsection and Section data.
*
* This function ensures that cached data for parent subsection and section is invalidated
* when child items are created, updated, or deleted.
*
* Priority:
* 1. If sectionId exists, invalidate section data which also updates all children block data
* 2. Else If subsectionId exists, invalidate subsection data
*/
const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => {
if (variables.subsectionId) {
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
}
if (variables.sectionId) {
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) });
} else if (variables.subsectionId) {
// istanbul ignore next
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
}
};
@@ -98,74 +66,31 @@ type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds;
* Hook to create an XBLOCK in a course .
* The `locator` is the ID of the parent block where this new XBLOCK should be created.
* Can also be used to import block from library by passing `libraryContentKey` in request body
*
* @param callback - Optional function called after successful creation to handle additional logic
* @returns Mutation object for creating course blocks
*/
export const useCreateCourseBlock = (
courseKey: string,
callback?: ((locator: string, parentLocator: string) => Promise<void>),
) => {
const queryClient = useQueryClient();
const { setData } = useScrollState(courseKey);
const dispatch = useDispatch();
return useMutation({
mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables),
onSuccess: async (data: { locator: string; }, variables) => {
onSettled: async (data: { locator: string; }, _err, variables) => {
await callback?.(data.locator, variables.parentLocator);
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) });
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
});
await invalidateParentQueries(queryClient, variables);
// scroll to newly added block
setData({ id: data.locator });
// if newly created block is chapter or section, fetch and add it to store
// all other types are handled by invalidateParentQueries and useCourseItemData
if (getBlockType(data.locator) === 'chapter') {
const newBlock = await getCourseItem(data.locator);
dispatch(addSection(newBlock));
}
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initialData?: T, enabled: boolean = true) => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useQuery<T>({
export const useCourseItemData = <T = XBlock>(itemId?: string, initialData?: T, enabled: boolean = true) => (
useQuery({
initialData,
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
queryFn: enabled && itemId ? async () => {
const data = await getCourseItem<T>(itemId!);
// If the container has children blocks, update children react-query cache
// data without hitting the API as each xblock call returns its children information as well.
if ('childInfo' in data) {
// This could mean that data is of a section or subsection
(data.childInfo as XblockChildInfo).children.forEach(async (child) => {
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(child.id) });
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(child.id), child);
if ('childInfo' in child) {
// This means that the data is of section and so its children subsections also
// have children i.e. units
(child.childInfo as XblockChildInfo).children.forEach(async (grandChild) => {
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(grandChild.id) });
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(grandChild.id), grandChild);
});
}
});
}
// We update redux store section list to update children list in outline.
// Even though each block has its own hook to fetch data, new child blocks or deleted blocks
// won't be detected as the child blocks are rendered in the outline from the top level
// sectionList from redux store.
if (['chapter', 'section'].includes(data.category)) {
const payload = { [data.id]: data };
dispatch(updateSectionList(payload));
}
return data;
} : skipToken,
});
};
queryFn: enabled && itemId ? () => getCourseItem<T>(itemId!) : skipToken,
})
);
export const useCourseDetails = (courseId?: string, enabled: boolean = true) => (
useQuery({
@@ -174,15 +99,6 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) =>
})
);
/**
* Hook to update the display name of a course block.
*
* This mutation updates the display name of a course item and invalidates relevant cache queries
* to ensure the UI reflects the changes.
*
* @param courseId - The ID of the course containing the item
* @returns Mutation object for updating course block names
*/
export const useUpdateCourseBlockName = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation({
@@ -191,9 +107,10 @@ export const useUpdateCourseBlockName = (courseId: string) => {
displayName: string;
} & ParentIds) => editItemDisplayName({ itemId: variables.itemId, displayName: variables.displayName }),
onSuccess: async (_data, variables) => {
await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
await invalidateParentQueries(queryClient, variables);
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
},
});
};
@@ -205,8 +122,9 @@ export const usePublishCourseItem = () => {
itemId: string;
} & ParentIds) => publishCourseItem(variables.itemId),
onSettled: (_data, _err, variables) => {
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
@@ -223,103 +141,3 @@ export const useDeleteCourseItem = () => {
},
});
};
export const useConfigureSection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: ConfigureSectionData & ParentIds) => configureCourseSection(variables),
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
});
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
export const useConfigureSubsection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables),
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
export const useConfigureUnit = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: ConfigureUnitData & ParentIds) => configureCourseUnit(variables),
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.unitId)) });
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
export const useUpdateCourseSectionHighlights = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: {
sectionId: string;
highlights: string[];
} & ParentIds) => updateCourseSectionHighlights(variables.sectionId, variables.highlights),
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
});
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
},
});
};
export const useDuplicateItem = (courseKey: string) => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { setData } = useScrollState(courseKey);
return useMutation({
mutationFn: (variables: {
itemId: string;
parentId: string;
} & ParentIds) => duplicateCourseItem(variables.itemId, variables.parentId),
onSuccess: async (data, variables) => {
await invalidateParentQueries(queryClient, variables);
// add duplicated section to store, subsection and unit are handled by invalidateParentQueries
if (getBlockType(variables.itemId) === 'chapter') {
const duplicatedItem = await getCourseItem(data.locator);
dispatch(duplicateSection({ id: variables.itemId, duplicatedItem }));
}
// scroll to newly added block
setData({ id: data.locator });
},
});
};
export const usePasteFileNotices = createGlobalState<StaticFileNotices>(
courseOutlineQueryKeys.pasteFileNotices,
{
newFiles: [],
conflictingFiles: [],
errorFiles: [],
},
);
export const usePasteItem = (courseId?: string) => {
const queryClient = useQueryClient();
const { setData: setScrollState } = useScrollState(courseId);
const { setData } = usePasteFileNotices(courseId);
return useMutation({
mutationFn: (variables: {
parentLocator: string;
} & ParentIds) => pasteBlock(variables.parentLocator),
onSuccess: async (data, variables) => {
await invalidateParentQueries(queryClient, variables);
// set pasteFileNotices
setData(data.staticFileNotices);
// scroll to pasted block
setScrollState({ id: data.locator });
},
});
};

View File

@@ -7,5 +7,6 @@ export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
export const getTimedExamsFlag = (state) => state.courseOutline.enableTimedExams;
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
export const getErrors = (state) => state.courseOutline.errors;
export const getCreatedOn = (state) => state.courseOutline.createdOn;

View File

@@ -47,8 +47,9 @@ const initialState = {
},
enableProctoredExams: false,
enableTimedExams: false,
pasteFileNotices: {},
createdOn: null,
} satisfies CourseOutlineState;
} satisfies CourseOutlineState as unknown as CourseOutlineState;
const slice = createSlice({
name: 'courseOutline',
@@ -132,6 +133,27 @@ const slice = createSlice({
payload,
];
},
resetScrollField: (state) => {
state.sectionsList = state.sectionsList.map((section) => {
section.shouldScroll = false;
section.childInfo.children.map((subsection) => {
subsection.shouldScroll = false;
return subsection;
});
return section;
});
},
addSubsection: (state: CourseOutlineState, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
if (section.id === payload.parentLocator) {
section.childInfo.children = [
...section.childInfo.children.filter(child => child.id !== payload.data.id), // Filter to avoid duplicates
payload.data,
];
}
return section;
});
},
deleteSection: (state: CourseOutlineState, { payload }) => {
state.sectionsList = state.sectionsList.filter(
({ id }) => id !== payload.itemId,
@@ -148,6 +170,25 @@ const slice = createSlice({
return section;
});
},
// FIXME: This is a temporary measure to add unit using redux even while we are
// actively trying to get rid of it.
// To remove this and other add functions, we need to migrate course outline data
// to a react-query and perform optimistic updates to add/remove content.
addUnit: /* istanbul ignore next */ (state: CourseOutlineState, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
section.childInfo.children = section.childInfo.children.map((subsection) => {
if (subsection.id !== payload.parentLocator) {
return subsection;
}
subsection.childInfo.children = [
...subsection.childInfo.children.filter(({ id }) => id !== payload.data.id),
payload.data,
];
return subsection;
});
return section;
});
},
deleteUnit: (state: CourseOutlineState, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
if (section.id !== payload.sectionId) {
@@ -173,11 +214,20 @@ const slice = createSlice({
return [...result, currentValue];
}, []);
},
setPasteFileNotices: (state: CourseOutlineState, { payload }) => {
state.pasteFileNotices = payload;
},
removePasteFileNotices: (state: CourseOutlineState, { payload }) => {
const pasteFileNotices = { ...state.pasteFileNotices };
payload.forEach((key: string | number) => delete pasteFileNotices[key]);
state.pasteFileNotices = pasteFileNotices;
},
},
});
export const {
addSection,
addSubsection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,
@@ -192,9 +242,13 @@ export const {
deleteSection,
deleteSubsection,
deleteUnit,
addUnit,
duplicateSection,
reorderSectionList,
setPasteFileNotices,
removePasteFileNotices,
dismissError,
resetScrollField,
} = slice.actions;
export const {

View File

@@ -11,15 +11,21 @@ import {
} from '../utils/getChecklistForStatusBar';
import { getErrorDetails } from '../utils/getErrorDetails';
import {
duplicateCourseItem,
enableCourseHighlightsEmails,
getCourseBestPractices,
getCourseLaunch,
getCourseOutlineIndex,
getCourseItem,
configureCourseSection,
configureCourseSubsection,
configureCourseUnit,
restartIndexingOnCourse,
updateCourseSectionHighlights,
setSectionOrderList,
setVideoSharingOption,
setCourseItemOrderList,
pasteBlock,
dismissNotification, createDiscussionsTopics,
} from './api';
import {
@@ -33,7 +39,9 @@ import {
updateSavingStatus,
updateSectionList,
updateFetchSectionLoadingStatus,
duplicateSection,
reorderSectionList,
setPasteFileNotices,
updateCourseLaunchQueryStatus,
} from './slice';
@@ -193,13 +201,32 @@ export function fetchCourseReindexQuery(reindexLink: string) {
/**
* Fetches course sections and optionally scrolls to a specific subsection/unit.
*/
export function fetchCourseSectionQuery(sectionIds: string[]) {
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
subsectionId: string,
unitId?: string,
}) {
return async (dispatch) => {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const sections = {};
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
results.forEach(section => {
if (scrollToId) {
const targetSubsection = section?.childInfo?.children?.find(
subsection => subsection.id === scrollToId.subsectionId,
);
if (targetSubsection) {
if (scrollToId.unitId) {
const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId);
if (targetUnit) {
targetUnit.shouldScroll = true;
}
} else {
targetSubsection.shouldScroll = true;
}
}
}
sections[section.id] = section;
});
dispatch(updateSectionList(sections));
@@ -213,6 +240,186 @@ export function fetchCourseSectionQuery(sectionIds: string[]) {
};
}
export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery([sectionId]));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await configureFn().then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery([sectionId]));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime),
));
};
}
export function configureCourseSubsectionQuery(
itemId: string,
sectionId: string,
isVisibleToStaffOnly: string,
releaseDate: string,
graderType: string,
dueDate: string,
isTimeLimited: boolean,
isProctoredExam: boolean,
isOnboardingExam: boolean,
isPracticeExam: boolean,
examReviewRules: string,
defaultTimeLimitMin: number,
hideAfterDue: string,
showCorrectness: string,
isPrereq: boolean,
prereqUsageKey: string,
prereqMinScore: number,
prereqMinCompletion: number,
) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseSubsection(
itemId,
isVisibleToStaffOnly,
releaseDate,
graderType,
dueDate,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
defaultTimeLimitMin,
hideAfterDue,
showCorrectness,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
),
));
};
}
export function configureCourseUnitQuery(
itemId: string,
sectionId: string,
isVisibleToStaffOnly: boolean,
groupAccess: object,
discussionEnabled: boolean,
) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
));
};
}
/**
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
* @param {string} itemId
* @param {string} parentLocator
* @param {(locator) => Promise<any>} duplicateFn
*/
function duplicateCourseItemQuery(
itemId: string,
parentLocator: string,
duplicateFn: (locator: string) => Promise<any>,
) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
try {
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
if (result) {
await duplicateFn(result.locator);
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function duplicateSectionQuery(sectionId: string, courseBlockId: string) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
sectionId,
courseBlockId,
async (locator) => {
const duplicatedItem = await getCourseItem(locator);
// Page should scroll to newly duplicated item.
duplicatedItem.shouldScroll = true;
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
},
));
};
}
export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
subsectionId,
sectionId,
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
subsectionId: itemId, // To scroll to the newly duplicated subsection
})),
));
};
}
export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
unitId,
subsectionId,
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
subsectionId,
unitId: itemId, // To scroll to the newly duplicated unit
})),
));
};
}
function setBlockOrderListQuery(
parentId: string,
blockIds: string[],
@@ -308,6 +515,27 @@ export function setUnitOrderListQuery(
};
}
export function pasteClipboardContent(parentLocator: string, sectionId: string) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
try {
await pasteBlock(parentLocator).then(async (result: any) => {
if (result) {
dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
dispatch(setPasteFileNotices(result?.staticFileNotices));
}
});
} catch {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function dismissNotificationQuery(url: string) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));

View File

@@ -74,6 +74,7 @@ export interface CourseOutlineState {
actions: XBlockActions;
enableProctoredExams: boolean;
enableTimedExams: boolean;
pasteFileNotices: object;
createdOn: null | Date;
}
@@ -89,42 +90,3 @@ export interface CourseItemUpdateResult {
displayName?: string;
}
}
export interface ConfigureSectionData {
sectionId: string,
isVisibleToStaffOnly: boolean,
startDatetime: string,
}
export interface ConfigureSubsectionData {
itemId: string,
isVisibleToStaffOnly: boolean,
releaseDate: string,
graderType: string,
dueDate: string,
isTimeLimited: boolean,
isProctoredExam: boolean,
isOnboardingExam: boolean,
isPracticeExam: boolean,
examReviewRules: string,
defaultTimeLimitMin: number,
hideAfterDue: string,
showCorrectness: string,
isPrereq: boolean,
prereqUsageKey: string,
prereqMinScore: number,
prereqMinCompletion: number,
}
export interface ConfigureUnitData {
unitId: string,
isVisibleToStaffOnly: boolean,
groupAccess: object,
discussionEnabled: boolean,
}
export type StaticFileNotices = {
conflictingFiles: string[],
errorFiles: string[],
newFiles: string[],
};

View File

@@ -12,21 +12,13 @@ import { ContainerType, getBlockType } from '@src/generic/key-utils';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
import { useQueryClient } from '@tanstack/react-query';
import {
courseOutlineQueryKeys,
useConfigureSection,
useConfigureSubsection,
useConfigureUnit,
useDeleteCourseItem,
useDuplicateItem,
usePasteItem,
useUpdateCourseSectionHighlights,
} from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useDeleteCourseItem } from '@src/course-outline/data/apiHooks';
import { COURSE_BLOCK_NAMES } from './constants';
import {
deleteSection,
deleteSubsection,
deleteUnit,
resetScrollField,
updateSavingStatus,
} from './data/slice';
import {
@@ -41,15 +33,23 @@ import {
getCreatedOn,
} from './data/selectors';
import {
duplicateSectionQuery,
duplicateSubsectionQuery,
duplicateUnitQuery,
enableCourseHighlightsEmailsQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
fetchCourseReindexQuery,
updateCourseSectionHighlightsQuery,
configureCourseSectionQuery,
configureCourseSubsectionQuery,
configureCourseUnitQuery,
setSectionOrderListQuery,
setVideoSharingOptionQuery,
setSubsectionOrderListQuery,
setUnitOrderListQuery,
pasteClipboardContent,
dismissNotificationQuery,
syncDiscussionsTopics,
} from './data/thunk';
@@ -57,7 +57,7 @@ import {
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const {
handleAddBlock,
handleAddSection,
setCurrentSelection,
currentSelection,
currentUnlinkModalData,
@@ -99,19 +99,18 @@ const useCourseOutline = ({ courseId }) => {
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
const { mutate: pasteClipboardContent, isPending: isPasting } = usePasteItem(courseId);
const handlePasteClipboardClick = (parentLocator, subsectionId, sectionId) => {
pasteClipboardContent({
parentLocator,
subsectionId,
sectionId,
});
const handlePasteClipboardClick = (parentLocator, sectionId) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
const resetScrollState = () => {
dispatch(resetScrollField());
};
const headerNavigationsActions = {
handleNewSection: () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleAddBlock.mutateAsync({
handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseStructure?.id,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -148,16 +147,9 @@ const useCourseOutline = ({ courseId }) => {
openHighlightsModal();
};
const {
mutate: updateCourseSectionHighlights,
isPending: isSectionHighlightsUpdatePending,
} = useUpdateCourseSectionHighlights();
const handleHighlightsFormSubmit = (highlights) => {
const dataToSend = Object.values(highlights).filter(Boolean);
updateCourseSectionHighlights({
sectionId: currentSelection?.currentId,
highlights: dataToSend,
});
dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend));
closeHighlightsModal();
};
@@ -177,52 +169,39 @@ const useCourseOutline = ({ courseId }) => {
return;
}
await unlinkDownstream({
downstreamBlockId: currentUnlinkModalData.value.id,
sectionId: currentUnlinkModalData.sectionId,
subsectionId: currentUnlinkModalData.subsectionId,
}, {
await unlinkDownstream(currentUnlinkModalData.value.id, {
onSuccess: () => {
closeUnlinkModal();
// istanbul ignore next
// refresh child block data
currentUnlinkModalData.value.childInfo?.children.forEach((block) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) });
block.childInfo?.children.forEach(({ id: blockId }) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
});
});
// refresh parent blocks data
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.sectionId),
});
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.subsectionId),
});
},
});
}, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]);
const {
mutate: configureCourseSection,
isPending: isSectionConfigurePending,
} = useConfigureSection();
const {
mutate: configureCourseSubsection,
isPending: isSubsectionConfigurePending,
} = useConfigureSubsection();
const {
mutate: configureCourseUnit,
isPending: isUnitConfigurePending,
} = useConfigureUnit();
const isConfigureOpPending = isSectionConfigurePending || isSubsectionConfigurePending || isUnitConfigurePending;
const handleConfigureItemSubmit = (variables) => {
const handleConfigureItemSubmit = (...arg) => {
const category = getBlockType(currentSelection.currentId);
switch (category) {
case COURSE_BLOCK_NAMES.chapter.id:
configureCourseSection({
sectionId: currentSelection?.sectionId,
...variables,
});
dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg));
break;
case COURSE_BLOCK_NAMES.sequential.id:
configureCourseSubsection({
itemId: currentSelection?.currentId,
sectionId: currentSelection?.sectionId,
...variables,
});
dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
break;
case COURSE_BLOCK_NAMES.vertical.id:
configureCourseUnit({
unitId: currentSelection?.currentId,
sectionId: currentSelection?.sectionId,
...variables,
});
dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
break;
default:
// istanbul ignore next
@@ -296,35 +275,20 @@ const useCourseOutline = ({ courseId }) => {
deleteSubsection,
]);
const {
mutate: duplicateItem,
isPending: isDuplicatingItem,
} = useDuplicateItem(courseId);
const handleDuplicateSectionSubmit = () => {
duplicateItem({
itemId: currentSelection?.currentId,
parentId: courseStructure.id,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id));
};
const handleDuplicateSubsectionSubmit = () => {
duplicateItem({
itemId: currentSelection?.currentId,
parentId: currentSelection?.sectionId,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId));
};
const handleDuplicateUnitSubmit = () => {
duplicateItem({
itemId: currentSelection?.currentId,
parentId: currentSelection?.subsectionId,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
dispatch(duplicateUnitQuery(
currentSelection?.currentId,
currentSelection?.subsectionId,
currentSelection?.sectionId,
));
};
const handleVideoSharingOptionChange = (value) => {
@@ -407,14 +371,12 @@ const useCourseOutline = ({ courseId }) => {
isConfigureModalOpen,
openConfigureModal,
handleConfigureModalClose,
isConfigureOpPending,
headerNavigationsActions,
handleEnableHighlightsSubmit,
handleHighlightsFormSubmit,
handleConfigureItemSubmit,
statusBarData,
isEnableHighlightsModalOpen,
isSectionHighlightsUpdatePending,
openEnableHighlightsModal,
closeEnableHighlightsModal,
isInternetConnectionAlertFailed: isSavingStatusFailed,
@@ -429,11 +391,9 @@ const useCourseOutline = ({ courseId }) => {
handleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
isDuplicatingItem,
handleDuplicateUnitSubmit,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
isPasting,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextLearnmoreUrl,
@@ -447,6 +407,7 @@ const useCourseOutline = ({ courseId }) => {
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
errors,
resetScrollState,
handleUnlinkItemSubmit,
};
};

View File

@@ -21,7 +21,7 @@ import type { ContainerType } from '@src/generic/key-utils';
import { XBlock } from '@src/data/types';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { snakeCaseKeys } from '@src/editors/utils';
import { getXBlockApiUrl, getXBlockBaseApiUrl } from '@src/course-outline/data/api';
import { getXBlockBaseApiUrl } from '@src/course-outline/data/api';
import MockAdapter from 'axios-mock-adapter/types';
import { AddSidebar } from './AddSidebar';
@@ -199,13 +199,10 @@ describe('AddSidebar', () => {
const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123';
axiosMock.onPost(getXBlockBaseApiUrl())
.reply(200, { locator: sectionId });
axiosMock.onGet(getXBlockApiUrl(sectionId))
.reply(200, {});
renderComponent();
const subsection = await screen.findByRole('button', { name: 'Subsection' });
await user.click(subsection);
await waitFor(() => expect(axiosMock.history.post.length).toBeGreaterThan(1));
// should add a section first
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
type: 'chapter',
@@ -253,8 +250,6 @@ describe('AddSidebar', () => {
.reply(200, { locator: subsectionId });
axiosMock.onPost(getXBlockBaseApiUrl(), unitBody)
.reply(200, { locator: unitId });
axiosMock.onGet(getXBlockApiUrl(sectionId))
.reply(200, {});
renderComponent();
const unit = await screen.findByRole('button', { name: 'Unit' });

View File

@@ -50,7 +50,8 @@ type AddContentButtonProps = {
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
const {
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const {
@@ -64,7 +65,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
let subsectionParentId = lastEditableSubsection?.data?.id;
const addSection = (onSuccess?: (data: { locator: string; }) => void) => {
handleAddBlock.mutate({
handleAddSection.mutate({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -81,11 +82,10 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
};
const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => {
handleAddBlock.mutate({
handleAddSubsection.mutate({
type: ContainerType.Sequential,
parentLocator: sectionId,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId,
}, {
onSuccess: (data: { locator: string; }) => {
// istanbul ignore next
@@ -146,7 +146,8 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
}, [
blockType,
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
currentFlow,
sectionParentId,
@@ -154,7 +155,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
lastEditableSubsection,
]);
const disabled = handleAddBlock.isPending || handleAddAndOpenUnit.isPending;
const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending;
return (
<BlockCardButton
@@ -212,7 +213,9 @@ const AddNewContent = () => {
const ShowLibraryContent = () => {
const {
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
const {
isCurrentFlowOn,
@@ -230,7 +233,7 @@ const ShowLibraryContent = () => {
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
switch (blockType) {
case 'section':
await handleAddBlock.mutateAsync({
await handleAddSection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
@@ -240,12 +243,11 @@ const ShowLibraryContent = () => {
case 'subsection':
sectionParentId = currentFlow?.parentLocator || sectionParentId;
if (sectionParentId) {
await handleAddBlock.mutateAsync({
await handleAddSubsection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator: sectionParentId,
libraryContentKey: usageKey,
sectionId: sectionParentId,
});
}
break;
@@ -255,7 +257,7 @@ const ShowLibraryContent = () => {
);
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
if (subsectionParentId) {
await handleAddBlock.mutateAsync({
await handleAddUnit.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator: subsectionParentId,
@@ -271,7 +273,9 @@ const ShowLibraryContent = () => {
stopCurrentFlow();
}, [
courseUsageKey,
handleAddBlock,
handleAddSection,
handleAddSubsection,
handleAddUnit,
lastEditableSection,
lastEditableSubsection,
currentFlow,

View File

@@ -37,16 +37,21 @@ const itemData = {
},
};
const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({
selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id },
openContainerInfoSidebar: jest.fn(),
});
const mockUseCourseAuthoringContext = jest.fn().mockReturnValue({
openUnlinkModal: jest.fn(),
courseId: 'course1',
});
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
useOutlineSidebarContext: () => mockUseOutlineSidebarContext(),
}));
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => mockUseCourseAuthoringContext(),
}));
const mockPostChange = jest.fn();
const mockOpenContainerInfoSidebar = jest.fn();
const mockOpenSyncModal = jest.fn();
jest.mock('@src/hooks', () => ({
useToggleWithValue: () => [false, {}, mockOpenSyncModal, jest.fn()],
@@ -65,14 +70,7 @@ describe('LibraryReferenceCard', () => {
});
it('renders the LibraryReferenceCard normally', async () => {
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(/Library Reference/)).toBeInTheDocument();
});
@@ -88,14 +86,7 @@ describe('LibraryReferenceCard', () => {
axiosMock
.onGet(getXBlockApiUrl(itemData.id))
.reply(200, data);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.`,
)).toBeInTheDocument();
@@ -118,14 +109,7 @@ describe('LibraryReferenceCard', () => {
readyToSync: true,
},
});
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`${itemData.displayName} has available updates`,
)).toBeInTheDocument();
@@ -144,14 +128,7 @@ describe('LibraryReferenceCard', () => {
downstreamCustomized: ['displayName'],
},
});
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`${itemData.displayName} has been modified in this course.`,
)).toBeInTheDocument();
@@ -169,16 +146,9 @@ describe('LibraryReferenceCard', () => {
errorMessage: 'some error',
},
});
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section which has a broken link. To receive library updates to this component, unlink the broken link.`,
`${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.`,
)).toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Unlink section' }));
@@ -210,14 +180,7 @@ describe('LibraryReferenceCard', () => {
axiosMock
.onGet(getXBlockApiUrl(sectionData.id))
.reply(200, parentData);
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section which has updates available.`,
)).toBeInTheDocument();
@@ -237,20 +200,13 @@ describe('LibraryReferenceCard', () => {
topLevelParentKey: sectionData.upstreamInfo.downstreamKey,
},
});
render(
<LibraryReferenceCard
itemId={itemData.id}
sectionId={sectionData.id}
postChange={mockPostChange}
goToParent={mockOpenContainerInfoSidebar}
/>,
);
render(<LibraryReferenceCard itemId={itemData.id} />);
expect(await screen.findByText(
`${itemData.displayName} was reused as part of a section.`,
)).toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'View section' }));
expect(mockOpenContainerInfoSidebar).toHaveBeenCalledWith(
expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith(
sectionData.id,
undefined,
sectionData.id,

View File

@@ -3,43 +3,38 @@ import {
Button, Card, Icon, Stack,
} from '@openedx/paragon';
import { Cached, LinkOff, Newsstand } from '@openedx/paragon/icons';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import type { XBlock, XBlockBase } from '@src/data/types';
import { XBlock } from '@src/data/types';
import { ContainerType, getBlockType, normalizeContainerType } from '@src/generic/key-utils';
import { useToggleWithValue } from '@src/hooks';
import { useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import messages from './messages';
interface SubProps {
blockData: XBlockBase;
blockData: XBlock;
displayName: string;
openSyncModal: (val: XBlockBase) => void;
sectionId?: string;
openSyncModal: (val: XBlock) => void;
}
interface HasTopParentSubProps extends SubProps {
goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void;
}
const HasTopParentTextAndButton = ({
blockData,
displayName,
openSyncModal,
goToParent,
sectionId,
}: HasTopParentSubProps) => {
const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
const { upstreamInfo } = blockData;
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { openUnlinkModal } = useCourseAuthoringContext();
const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey);
const handleUnlinkClick = () => {
// istanbul ignore if
if (!sectionId || !parentData) {
if (!selectedContainerState?.sectionId || !parentData) {
return;
}
openUnlinkModal({ value: parentData, sectionId });
openUnlinkModal({ value: parentData, sectionId: selectedContainerState.sectionId });
};
const handleSyncClick = () => {
@@ -57,17 +52,17 @@ const HasTopParentTextAndButton = ({
}
const category = getBlockType(upstreamInfo.topLevelParentKey) as ContainerType;
if ([ContainerType.Chapter, ContainerType.Section].includes(category)) {
return goToParent(
return openContainerInfoSidebar(
upstreamInfo.topLevelParentKey,
undefined,
upstreamInfo.topLevelParentKey,
);
}
// Only possible option is sequential or subsection
return goToParent(
return openContainerInfoSidebar(
upstreamInfo.topLevelParentKey,
upstreamInfo.topLevelParentKey,
sectionId,
selectedContainerState?.sectionId,
);
};
@@ -124,13 +119,9 @@ const HasTopParentTextAndButton = ({
);
};
const TopLevelTextAndButton = ({
blockData,
displayName,
openSyncModal,
sectionId,
}: SubProps) => {
const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
const { upstreamInfo } = blockData;
const { selectedContainerState } = useOutlineSidebarContext();
const { openUnlinkModal } = useCourseAuthoringContext();
const messageValues = {
name: displayName,
@@ -138,10 +129,10 @@ const TopLevelTextAndButton = ({
const handleUnlinkClick = () => {
// istanbul ignore if
if (!sectionId) {
if (!selectedContainerState?.sectionId) {
return;
}
openUnlinkModal({ value: blockData, sectionId });
openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId });
};
const handleSyncClick = () => {
@@ -166,13 +157,13 @@ const TopLevelTextAndButton = ({
if (upstreamInfo?.readyToSync) {
return (
<Stack direction="vertical" gap={2}>
<FormattedMessage {...messages.topParentReadyToSyncText} values={messageValues} />
<FormattedMessage {...messages.topParentReaadyToSyncText} values={messageValues} />
<Button
variant="outline-primary"
iconBefore={Cached}
onClick={handleSyncClick}
>
<FormattedMessage {...messages.topParentReadyToSyncBtn} values={messageValues} />
<FormattedMessage {...messages.topParentReaadyToSyncBtn} values={messageValues} />
</Button>
</Stack>
);
@@ -189,23 +180,15 @@ const TopLevelTextAndButton = ({
interface Props {
itemId?: string;
sectionId?: string;
postChange: (accept: boolean) => void,
goToParent: (containerId: string, subsectionId?: string, sectionId?: string) => void;
}
/**
* Libray reference card to show info and actions about
* upstream link of an item.
*/
export const LibraryReferenceCard = ({
itemId,
sectionId,
postChange,
goToParent,
}: Props) => {
export const LibraryReferenceCard = ({ itemId }: Props) => {
const { data: itemData, isPending } = useCourseItemData(itemId);
const { selectedContainerState } = useOutlineSidebarContext();
const { courseId } = useCourseAuthoringContext();
const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue<XBlock>();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const blockSyncData = useMemo(() => {
if (!syncModalData?.upstreamInfo?.readyToSync) {
@@ -222,6 +205,19 @@ export const LibraryReferenceCard = ({
};
}, [syncModalData]);
// istanbul ignore next
const handleOnPostChangeSync = useCallback(() => {
if (selectedContainerState?.sectionId) {
dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId]));
}
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.course(courseId),
});
}
}, [dispatch, selectedContainerState, queryClient, courseId]);
if (!itemData?.upstreamInfo?.upstreamRef) {
return null;
}
@@ -239,14 +235,11 @@ export const LibraryReferenceCard = ({
blockData={itemData}
displayName={itemData.displayName}
openSyncModal={openSyncModal}
sectionId={sectionId}
/>
<HasTopParentTextAndButton
blockData={itemData}
displayName={itemData.displayName}
openSyncModal={openSyncModal}
sectionId={sectionId}
goToParent={goToParent}
/>
</Stack>
</Card.Section>
@@ -256,7 +249,7 @@ export const LibraryReferenceCard = ({
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={postChange}
postChange={handleOnPostChangeSync}
/>
)}
</div>

View File

@@ -9,11 +9,12 @@ import { useOutlineSidebarContext } from './OutlineSidebarContext';
export const OutlineAlignSidebar = () => {
const {
courseId,
currentSelection,
setCurrentSelection,
} = useCourseAuthoringContext();
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
const sidebarContentId = selectedContainerState?.currentId || courseId;
const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId;
const { data: contentData } = useContentData(sidebarContentId);

View File

@@ -36,7 +36,6 @@ interface OutlineSidebarContextData {
open: () => void;
toggle: () => void;
selectedContainerState?: SelectionState;
setSelectedContainerState: (selectedContainerState?: SelectionState) => void;
openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void;
clearSelection: () => void;
/** Stores last section that allows adding subsections inside it. */
@@ -144,7 +143,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
setCurrentFlow(flow);
}, [setCurrentFlow, setCurrentPageKey]);
const { data: currentItemData } = useCourseItemData<XBlock>(selectedContainerState?.currentId);
const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId);
const sectionsList = useSelector(getSectionsList);
/** Stores last section that allows adding subsections inside it. */
@@ -189,7 +188,6 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerState,
setSelectedContainerState,
openContainerInfoSidebar,
clearSelection,
lastEditableSection,
@@ -207,7 +205,6 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
open,
toggle,
selectedContainerState,
setSelectedContainerState,
openContainerInfoSidebar,
clearSelection,
lastEditableSection,

View File

@@ -2,18 +2,12 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard';
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
import { normalizeContainerType } from '@src/generic/key-utils';
import { SidebarContent, SidebarSection } from '@src/generic/sidebar';
import { useGetBlockTypes } from '@src/search-manager';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
import messages from '../messages';
interface Props {
@@ -22,46 +16,17 @@ interface Props {
export const InfoSection = ({ itemId }: Props) => {
const intl = useIntl();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const { data: itemData } = useCourseItemData(itemId);
const { data: componentData } = useGetBlockTypes(
[`breadcrumbs.usage_key = "${itemId}"`],
);
const category = normalizeContainerType(itemData?.category || '');
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { courseId } = useCourseAuthoringContext();
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
/**
* Called after a library component sync operation completes (e.g. accepting or ignoring
* an upstream update). Refreshes all stale data that may have been affected:
* - Re-fetches the parent section's outline data so counts/status stay current.
* - Invalidates the library links query so the sync-status badges update.
* - Invalidates the full course outline query so the top-level view reflects the change.
*/
// istanbul ignore next
const handleOnPostChangeSync = useCallback(() => {
// invalidating section data will update all children blocks as well.
if (selectedContainerState?.sectionId) {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(selectedContainerState?.sectionId),
});
}
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [dispatch, selectedContainerState, queryClient, courseId]);
return (
<>
<LibraryReferenceCard
itemId={itemId}
sectionId={selectedContainerState?.sectionId}
postChange={handleOnPostChangeSync}
goToParent={openContainerInfoSidebar}
/>
<LibraryReferenceCard itemId={itemId} />
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages[`${category}ContentSummaryText`])}

View File

@@ -170,6 +170,66 @@ const messages = defineMessages({
defaultMessage: 'Settings',
description: 'Settings tab title in container sidebar',
},
libraryReferenceCardText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.text',
defaultMessage: 'Library Reference',
description: 'Library reference card text in sidebar',
},
hasTopParentText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text',
defaultMessage: '{name} was reused as part of a {parentType}.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block',
},
hasTopParentBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn',
defaultMessage: 'View {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block',
},
hasTopParentReadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text',
defaultMessage: '{name} was reused as part of a {parentType} which has updates available.',
description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block',
},
hasTopParentReadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block',
},
hasTopParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text',
defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.',
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.',
},
hasTopParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn',
defaultMessage: 'Unlink {parentType}',
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.',
},
topParentBrokenLinkText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text',
defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.',
description: 'Text displayed in sidebar library reference card when a block has a broken link.',
},
topParentBrokenLinkBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn',
defaultMessage: 'Unlink from library',
description: 'Text displayed in sidebar library reference card button when a block has a broken link.',
},
topParentModifiedText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text',
defaultMessage: '{name} has been modified in this course.',
description: 'Text displayed in sidebar library reference card when it is modified in course.',
},
topParentReaadyToSyncText: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text',
defaultMessage: '{name} has available updates',
description: 'Text displayed in sidebar library reference card when it is has updates available.',
},
topParentReaadyToSyncBtn: {
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn',
defaultMessage: 'Review Updates',
description: 'Text displayed in sidebar library reference card button when it is has updates available.',
},
cannotAddAlertMsg: {
id: 'course-authoring.course-outline.sidebar.library.reference.add-sidebar.alert.text',
defaultMessage: '{name} is a library {category}. Content cannot be added to Library referenced {category}s.',

View File

@@ -11,10 +11,9 @@ import {
} from '@openedx/paragon/icons';
import { uniqBy } from 'lodash';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import { usePasteFileNotices } from '@src/course-outline/data/apiHooks';
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
@@ -24,7 +23,8 @@ import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import AlertMessage from '../../generic/alert-message';
import AlertProctoringError from '../../generic/AlertProctoringError';
import { API_ERROR_TYPES } from '../constants';
import { dismissError } from '../data/slice';
import { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import messages from './messages';
const PageAlerts = ({
@@ -48,7 +48,7 @@ const PageAlerts = ({
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
localStorage.getItem(discussionAlertDismissKey) === null,
);
const { data: pasteFileNotices, setData: setPasteFileNotices } = usePasteFileNotices(courseId);
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
const navigate = useNavigate();
@@ -247,16 +247,16 @@ const PageAlerts = ({
const newFilesPasteAlert = () => {
const onDismiss = () => {
setPasteFileNotices({ ...pasteFileNotices, newFiles: [] });
dispatch(removePasteFileNotices(['newFiles']));
};
if (pasteFileNotices?.newFiles?.length) {
if (newFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: pasteFileNotices.newFiles.length })}
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: newFiles.length })}
description={intl.formatMessage(
messages.newFileAlertDesc,
{ newFilesLen: pasteFileNotices.newFiles.length, newFilesStr: pasteFileNotices.newFiles.join(', ') },
{ newFilesLen: newFiles.length, newFilesStr: newFiles.join(', ') },
)}
dismissible
show
@@ -279,16 +279,16 @@ const PageAlerts = ({
const errorFilesPasteAlert = () => {
const onDismiss = () => {
setPasteFileNotices({ ...pasteFileNotices, errorFiles: [] });
dispatch(removePasteFileNotices(['errorFiles']));
};
if (pasteFileNotices?.errorFiles?.length) {
if (errorFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(messages.errorFileAlertTitle)}
description={intl.formatMessage(
messages.errorFileAlertDesc,
{ errorFilesLen: pasteFileNotices.errorFiles.length, errorFilesStr: pasteFileNotices.errorFiles.join(', ') },
{ errorFilesLen: errorFiles.length, errorFilesStr: errorFiles.join(', ') },
)}
dismissible
show
@@ -303,22 +303,19 @@ const PageAlerts = ({
const conflictingFilesPasteAlert = () => {
const onDismiss = () => {
setPasteFileNotices({ ...pasteFileNotices, conflictingFiles: [] });
dispatch(removePasteFileNotices(['conflictingFiles']));
};
if (pasteFileNotices?.conflictingFiles?.length) {
if (conflictingFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(
messages.conflictingFileAlertTitle,
{ conflictingFilesLen: pasteFileNotices.conflictingFiles.length },
{ conflictingFilesLen: conflictingFiles.length },
)}
description={intl.formatMessage(
messages.conflictingFileAlertDesc,
{
conflictingFilesLen: pasteFileNotices.conflictingFiles.length,
conflictingFilesStr: pasteFileNotices.conflictingFiles.join(', '),
},
{ conflictingFilesLen: conflictingFiles.length, conflictingFilesStr: conflictingFiles.join(', ') },
)}
dismissible
show

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
act,
render,
@@ -21,10 +22,9 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
let mockNotices = {};
jest.mock('@src/course-outline/data/apiHooks', () => ({
...jest.requireActual('@src/course-outline/data/apiHooks'),
usePasteFileNotices: () => ({ data: mockNotices }),
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('../../course-libraries/data/apiHooks', () => ({
@@ -72,7 +72,7 @@ describe('<PageAlerts />', () => {
},
});
store = initializeStore();
mockNotices = {};
useSelector.mockReturnValue({});
});
it('renders null when no alerts are present', async () => {
@@ -174,11 +174,11 @@ describe('<PageAlerts />', () => {
});
it('renders new & error files alert', async () => {
mockNotices = {
useSelector.mockReturnValue({
newFiles: ['periodic-table.css'],
conflictingFiles: [],
errorFiles: ['error.css'],
};
});
renderComponent();
expect(screen.queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument();
expect(screen.queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument();
@@ -189,11 +189,11 @@ describe('<PageAlerts />', () => {
});
it('renders conflicting files alert', async () => {
mockNotices = {
useSelector.mockReturnValue({
newFiles: [],
conflictingFiles: ['some.css', 'some.js'],
errorFiles: [],
};
});
renderComponent();
expect(screen.queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument();
expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(

View File

@@ -1,15 +1,16 @@
/* eslint-disable import/named */
import React from 'react';
import React, { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
ActionRow,
} from '@openedx/paragon';
import { usePublishCourseItem } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks';
import type { UnitXBlock, XBlock } from '@src/data/types';
import LoadingButton from '@src/generic/loading-button';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useQueryClient } from '@tanstack/react-query';
import messages from './messages';
import { COURSE_BLOCK_NAMES } from '../constants';
@@ -25,6 +26,22 @@ const PublishModal = () => {
: undefined;
const children: Array<XBlock | UnitXBlock> | undefined = childInfo?.children;
const publishMutation = usePublishCourseItem();
const queryClient = useQueryClient();
const childrenIds = useMemo(() => children?.reduce((
result: string[],
current: XBlock | UnitXBlock,
): string[] => {
let temp = [...result];
if ('childInfo' in current) {
const grandChildren = current.childInfo.children.filter((child) => child.hasChanges);
temp = [...temp, ...grandChildren.map((child) => child.id)];
}
if (current.hasChanges) {
temp.push(current.id);
}
return temp;
}, []), [children]);
const onPublishSubmit = async () => {
if (id) {
@@ -35,6 +52,10 @@ const PublishModal = () => {
}, {
onSettled: () => {
closePublishModal();
// Update query client to refresh the data of all children blocks
childrenIds?.forEach((blockId) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
});
},
});
}

View File

@@ -26,6 +26,8 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
handleAddSubsectionFromLibrary: jest.fn(),
handleNewSubsectionSubmit: jest.fn(),
setCurrentSelection,
}),
}));
@@ -97,6 +99,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
isSectionsExpanded
isSelfPaced={false}
isCustomRelativeDatesActive={false}
resetScrollState={jest.fn()}
{...props}
>
<span>children</span>
@@ -318,7 +321,6 @@ describe('<SectionCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -344,7 +346,6 @@ describe('<SectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -368,9 +369,5 @@ describe('<SectionCard />', () => {
currentId: section.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: section.id,
sectionId: section.id,
});
});
});

View File

@@ -1,6 +1,7 @@
import {
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import {
Bubble, Button, useToggle,
} from '@openedx/paragon';
@@ -13,6 +14,7 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
import TitleButton from '@src/course-outline/card-header/TitleButton';
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
import { ContainerType } from '@src/generic/key-utils';
@@ -22,9 +24,8 @@ import type { XBlock } from '@src/data/types';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import moment from 'moment';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import messages from './messages';
interface SectionCardProps {
@@ -40,6 +41,7 @@ interface SectionCardProps {
index: number,
canMoveItem: (oldIndex: number, newIndex: number) => boolean,
onOrderChange: (oldIndex: number, newIndex: number) => void,
resetScrollState: () => void,
}
const SectionCard = ({
@@ -55,10 +57,12 @@ const SectionCard = ({
onDuplicateSubmit,
isSectionsExpanded,
onOrderChange,
resetScrollState,
}: SectionCardProps) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const {
@@ -67,7 +71,6 @@ const SectionCard = ({
const queryClient = useQueryClient();
// Set initialData state from course outline and subsequently depend on its own state
const { data: section = initialData } = useCourseItemData(initialData.id, initialData);
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
const isScrolledToElement = locatorId === section?.id;
// Expand the section if a search result should be shown/scrolled to
@@ -108,10 +111,6 @@ const SectionCard = ({
useEffect(() => {
// istanbul ignore if
if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) {
queryClient.cancelQueries({
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
// eslint-disable-next-line no-console
}).catch((error) => console.error('Error cancelling query:', error));
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
}
}, [initialData, section]);
@@ -153,13 +152,13 @@ const SectionCard = ({
}, [activeId, overId]);
useEffect(() => {
if (currentRef.current && (scrollState?.id === section.id || isScrolledToElement)) {
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop, true);
resetScrollState().catch((error) => handleResponseErrors(error));
resetScrollState();
}
}, [isScrolledToElement, scrollState, resetScrollState]);
}, [isScrolledToElement]);
useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the section is expanded
@@ -168,13 +167,11 @@ const SectionCard = ({
}, [locatorId, setIsExpanded]);
const handleOnPostChangeSync = useCallback(() => {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
dispatch(fetchCourseSectionQuery([section.id]));
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [section, courseId, queryClient]);
}, [dispatch, section, courseId, queryClient]);
// re-create actions object for customizations
const actions = { ...sectionActions };
@@ -202,13 +199,6 @@ const SectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: section.id,
sectionId: section.id,
});
};
const handleOpenHighlightsModal = () => {
onOpenHighlightsModal(section);
};
@@ -294,7 +284,6 @@ const SectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -31,7 +31,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
handleAddAndOpenUnit: handleOnAddUnitFromLibrary,
handleAddBlock: {},
handleAddSubsection: {},
handleAddSection: {},
setCurrentSelection,
}),
}));
@@ -126,6 +127,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}
onPasteClick={jest.fn()}
resetScrollState={jest.fn()}
isSectionsExpanded={false}
{...props}
>
@@ -342,7 +344,6 @@ describe('<SubsectionCard />', () => {
type: COMPONENT_TYPES.libraryV2,
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
category: 'vertical',
sectionId: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
libraryContentKey: containerKey,
});
});
@@ -415,7 +416,6 @@ describe('<SubsectionCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -441,7 +441,6 @@ describe('<SubsectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -466,10 +465,5 @@ describe('<SubsectionCard />', () => {
subsectionId: subsection.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
});
});

View File

@@ -1,6 +1,7 @@
import {
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
@@ -14,6 +15,7 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
import { useClipboard, PasteComponent } from '@src/generic/clipboard';
import TitleButton from '@src/course-outline/card-header/TitleButton';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
import { ContainerType } from '@src/generic/key-utils';
@@ -24,9 +26,8 @@ import type { XBlock } from '@src/data/types';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import moment from 'moment';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import messages from './messages';
interface SubsectionCardProps {
@@ -42,11 +43,8 @@ interface SubsectionCardProps {
getPossibleMoves: (index: number, step: number) => void,
onOrderChange: (section: XBlock, moveDetails: any) => void,
onOpenConfigureModal: () => void,
onPasteClick: (
parentLocator: string,
subsectionId: string,
sectionId: string
) => void,
onPasteClick: (parentLocator: string, sectionId: string) => void,
resetScrollState: () => void,
}
const SubsectionCard = ({
@@ -63,11 +61,13 @@ const SubsectionCard = ({
onOrderChange,
onOpenConfigureModal,
onPasteClick,
resetScrollState,
}: SubsectionCardProps) => {
const currentRef = useRef(null);
const intl = useIntl();
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
@@ -80,7 +80,6 @@ const SubsectionCard = ({
// Set initialData state from course outline and subsequently depend on its own state
const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData);
const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData);
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
const isScrolledToElement = locatorId === subsection.id;
const {
@@ -147,10 +146,6 @@ const SubsectionCard = ({
useEffect(() => {
// istanbul ignore if
if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) {
queryClient.cancelQueries({
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
// eslint-disable-next-line no-console
}).catch((error) => console.error('Error cancelling query:', error));
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
}
}, [initialData, subsection]);
@@ -167,22 +162,12 @@ const SubsectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleOnPostChangeSync = useCallback(() => {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
dispatch(fetchCourseSectionQuery([section.id]));
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [section, queryClient, courseId]);
}, [dispatch, section, queryClient, courseId]);
const handleSubsectionMoveUp = () => {
onOrderChange(section, moveUpDetails);
@@ -192,7 +177,7 @@ const SubsectionCard = ({
onOrderChange(section, moveDownDetails);
};
const handlePasteButtonClick = () => onPasteClick(id, id, section.id);
const handlePasteButtonClick = () => onPasteClick(id, section.id);
const titleComponent = (
<TitleButton
@@ -227,13 +212,13 @@ const SubsectionCard = ({
useEffect(() => {
// if this items has been newly added, scroll to it.
if (currentRef.current && (scrollState?.id === subsection.id || isScrolledToElement)) {
if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) {
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop, true);
resetScrollState().catch((error) => handleResponseErrors(error));
resetScrollState();
}
}, [isScrolledToElement, scrollState, resetScrollState]);
}, [isScrolledToElement]);
useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the subsection is expanded
@@ -305,7 +290,6 @@ const SubsectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -307,7 +307,6 @@ describe('<UnitCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -333,7 +332,6 @@ describe('<UnitCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -358,10 +356,5 @@ describe('<UnitCard />', () => {
subsectionId: subsection.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: unit.id,
subsectionId: subsection.id,
sectionId: section.id,
});
});
});

View File

@@ -5,12 +5,14 @@ import {
useRef,
} from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
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';
@@ -22,9 +24,8 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import type { UnitXBlock, XBlock } from '@src/data/types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
import moment from 'moment';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
interface UnitCardProps {
@@ -60,8 +61,9 @@ const UnitCard = ({
discussionsSettings,
}: UnitCardProps) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
const namePrefix = 'unit';
@@ -77,7 +79,6 @@ const UnitCard = ({
initialSubsectionData,
);
const { data: unit = initialData } = useCourseItemData<UnitXBlock>(initialData.id, initialData);
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
const isScrolledToElement = locatorId === unit.id;
const {
@@ -139,14 +140,6 @@ const UnitCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: unit.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleUnitMoveUp = () => {
onOrderChange(section, moveUpDetails);
};
@@ -161,13 +154,11 @@ const UnitCard = ({
};
const handleOnPostChangeSync = useCallback(() => {
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
dispatch(fetchCourseSectionQuery([section.id]));
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [section, queryClient, courseId]);
}, [dispatch, section, queryClient, courseId]);
const onClickCard = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
@@ -203,23 +194,18 @@ const UnitCard = ({
useEffect(() => {
// istanbul ignore if
if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) {
queryClient.cancelQueries({
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
// eslint-disable-next-line no-console
}).catch((error) => console.error('Error cancelling query:', error));
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
}
}, [initialData, unit]);
useEffect(() => {
// if this items has been newly added, scroll to it.
if (currentRef.current && (scrollState?.id === unit.id || isScrolledToElement)) {
if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) {
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop, true);
resetScrollState().catch((error) => handleResponseErrors(error));
}
}, [isScrolledToElement, scrollState, resetScrollState]);
}, [isScrolledToElement]);
if (!isHeaderVisible) {
return null;
@@ -283,7 +269,6 @@ const UnitCard = ({
onClickSync={openSyncModal}
onClickCard={onClickCard}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -5,14 +5,11 @@ import {
Layout,
} from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import SubHeader from '@src/generic/sub-header/SubHeader';
import { USER_ROLES } from '@src/constants';
import getPageHeadTitle from '@src/generic/utils';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';
import { USER_ROLES } from '../constants';
import messages from './messages';
import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar';
import AddUserForm from './add-user-form/AddUserForm';
@@ -20,9 +17,12 @@ import AddTeamMember from './add-team-member/AddTeamMember';
import CourseTeamMember from './course-team-member/CourseTeamMember';
import InfoModal from './info-modal/InfoModal';
import { useCourseTeam } from './hooks';
import getPageHeadTitle from '../generic/utils';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const CourseTeam = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const {
@@ -43,6 +43,7 @@ const CourseTeam = () => {
isShowAddTeamMember,
isShowInitialSidebar,
isShowUserFilledSidebar,
isInternetConnectionAlertFailed,
openForm,
hideForm,
closeInfoModal,
@@ -50,7 +51,8 @@ const CourseTeam = () => {
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
} = useCourseTeam();
handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId });
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
@@ -84,7 +86,7 @@ const CourseTeam = () => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions ? (
headerActions={isAllowActions && (
<Button
variant="primary"
iconBefore={IconAdd}
@@ -94,7 +96,7 @@ const CourseTeam = () => {
>
{intl.formatMessage(messages.addNewMemberButton)}
</Button>
) : undefined}
)}
/>
<section className="course-team-section">
<div className="members-container">
@@ -137,7 +139,7 @@ const CourseTeam = () => {
isOpen={isInfoModalOpen}
close={closeInfoModal}
currentEmail={currentEmail}
errorMessage={errorMessage ?? ''}
errorMessage={errorMessage}
courseName={courseName}
modalType={modalType}
onDeleteSubmit={handleDeleteUserSubmit}
@@ -159,9 +161,9 @@ const CourseTeam = () => {
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={errorMessage !== undefined}
isFailed={isInternetConnectionAlertFailed}
isQueryPending={isQueryPending}
onInternetConnectionFailed={/* istanbul ignore next */ () => {}}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
</div>
</>

View File

@@ -0,0 +1,219 @@
// @ts-check
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import { USER_ROLES } from '../constants';
import { executeThunk } from '../utils';
import { RequestStatus } from '../data/constants';
import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk';
import {
fireEvent,
initializeMocks,
render as baseRender,
waitFor,
} from '../testUtils';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<CourseTeam />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<CourseTeam />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
it('render CourseTeam component with 3 team members correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(3);
});
});
it('render CourseTeam component with 1 team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const {
getByText, getByRole, getByTestId, getAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(getAllByTestId('course-team-member')).toHaveLength(1);
});
});
it('render CourseTeam component without team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(0);
});
});
it('render CourseTeam component with initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const { getByTestId, queryByTestId } = render();
await waitFor(() => {
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
});
});
it('render CourseTeam component without initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getByTestId, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: 'Add a new team member' });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
const { queryByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
});
});
it('should delete user', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { queryByText } = render();
axiosMock
.onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
.reply(200);
await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch);
expect(queryByText('staff@example.com')).not.toBeInTheDocument();
});
it('should change role user', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getAllByText } = render();
axiosMock
.onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
.reply(200, { role: USER_ROLES.admin });
await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch);
expect(getAllByText('Admin')).toHaveLength(1);
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(403);
const { getByRole } = render();
await waitFor(() => {
expect(getByRole('alert')).toBeInTheDocument();
const { loadingCourseTeamStatus } = store.getState().courseTeam;
expect(loadingCourseTeamStatus).toEqual(RequestStatus.DENIED);
});
});
it('sets loading status to FAILED upon receiving a 404 response from the API', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(404);
render();
await waitFor(() => {
const { loadingCourseTeamStatus } = store.getState().courseTeam;
expect(loadingCourseTeamStatus).toEqual(RequestStatus.FAILED);
});
});
});

View File

@@ -1,242 +0,0 @@
import userEvent from '@testing-library/user-event';
import {
screen,
initializeMocks,
render as baseRender,
waitFor,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '@src/constants';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import addUserFormMessages from './add-user-form/messages';
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<CourseTeam />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<CourseTeam />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
});
it('render CourseTeam component with 3 team members correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(3);
});
it('render CourseTeam component with 1 team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.getAllByTestId('course-team-member')).toHaveLength(1);
});
it('render CourseTeam component without team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(0);
});
it('render CourseTeam component with initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
render();
expect(await screen.findByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(screen.queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
});
it('render CourseTeam component without initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
render();
expect(await screen.findByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
});
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage });
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
});
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = await screen.findByRole('button', { name: 'Add a new team member' });
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
render();
await screen.findByText(messages.headingTitle.defaultMessage);
expect(screen.queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByTestId('add-team-member')).not.toBeInTheDocument();
});
it('should delete user', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const deleteUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
axiosMock
.onDelete(deleteUrl)
.reply(200);
render();
const deleteButton = (await screen.findAllByRole('button', { name: /delete user/i }))[0];
expect(deleteButton).toBeInTheDocument();
await user.click(deleteButton);
expect(await screen.findByText('Delete course team member'));
const confirmDelete = screen.getByRole('button', { name: /delete/i });
expect(confirmDelete).toBeInTheDocument();
await user.click(confirmDelete);
await waitFor(() => {
expect(axiosMock.history.delete.length).toBe(1);
});
expect(axiosMock.history.delete[0].url).toEqual(deleteUrl);
});
it('should change role user', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const updateUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
axiosMock
.onPut(updateUrl)
.reply(200, { role: USER_ROLES.admin });
render();
const updateButton = (await screen.findAllByRole('button', { name: /add admin access/i }))[0];
expect(updateButton).toBeInTheDocument();
await user.click(updateButton);
await waitFor(() => {
expect(axiosMock.history.put.length).toBe(1);
});
expect(axiosMock.history.put[0].url).toEqual(updateUrl);
});
it('should show warning modal when submitting an already existing user email', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
await user.type(screen.getByRole('textbox'), 'staff@example.com');
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
expect(await screen.findByText('Already a course team member')).toBeInTheDocument();
});
it('should hide the form after successfully adding a new user', async () => {
const user = userEvent.setup();
const newEmail = 'newuser@example.com';
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
axiosMock
.onPost(updateCourseTeamUserApiUrl(courseId, newEmail))
.reply(200);
render();
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
await user.type(screen.getByRole('textbox'), newEmail);
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
await waitFor(() => {
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
});
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(403);
render();
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,128 @@
import React from 'react';
import {
render,
fireEvent,
act,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { EXAMPLE_USER_EMAIL } from '../constants';
import initializeStore from '../../store';
import { USER_ROLES } from '../../constants';
import { updateCourseTeamUserApiUrl } from '../data/api';
import { createCourseTeamQuery } from '../data/thunk';
import { executeThunk } from '../../utils';
import AddUserForm from './AddUserForm';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onSubmitMock = jest.fn();
const onCancelMock = jest.fn();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<AddUserForm
onSubmit={onSubmitMock}
onCancel={onCancelMock}
/>
</IntlProvider>
</AppProvider>
);
describe('<AddUserForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render AddUserForm component correctly', () => {
const { getByText, getByPlaceholderText } = render(<RootWrapper />);
expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
});
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
const { getByPlaceholderText, getByRole } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage });
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
await act(async () => {
fireEvent.click(addUserButton);
});
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(
{ email: EXAMPLE_USER_EMAIL },
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
axiosMock
.onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff })
.reply(200, { role: USER_ROLES.staff });
await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch);
});
it('calls onCancel when the "Cancel" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const cancelButton = getByText(messages.cancelButton.defaultMessage);
fireEvent.click(cancelButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it('"Add User" button is disabled when the email input field is empty', () => {
const { getByText } = render(<RootWrapper />);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
expect(addUserButton).toBeDisabled();
});
it('"Add User" button is not disabled when the email input field is not empty', () => {
const { getByPlaceholderText, getByText } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
expect(addUserButton).not.toBeDisabled();
});
});

View File

@@ -1,96 +0,0 @@
import userEvent from '@testing-library/user-event';
import {
render,
screen,
fireEvent,
waitFor,
initializeMocks,
} from '@src/testUtils';
import { EXAMPLE_USER_EMAIL } from '../constants';
import AddUserForm from './AddUserForm';
import messages from './messages';
const mockPathname = '/foo-bar';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onSubmitMock = jest.fn();
const onCancelMock = jest.fn();
const renderComponent = () => render(
<AddUserForm
onSubmit={onSubmitMock}
onCancel={onCancelMock}
/>,
);
describe('<AddUserForm />', () => {
beforeEach(() => {
initializeMocks();
});
it('render AddUserForm component correctly', () => {
renderComponent();
expect(screen.getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
expect(screen.getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
});
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
const user = userEvent.setup();
renderComponent();
const emailInput = screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
const addUserButton = screen.getByRole('button', { name: messages.addUserButton.defaultMessage });
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
await user.click(addUserButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
});
expect(onSubmitMock).toHaveBeenCalledWith(
{ email: EXAMPLE_USER_EMAIL },
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
it('calls onCancel when the "Cancel" button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const cancelButton = screen.getByText(messages.cancelButton.defaultMessage);
await user.click(cancelButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it('"Add User" button is disabled when the email input field is empty', () => {
renderComponent();
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
expect(addUserButton).toBeDisabled();
});
it('"Add User" button is not disabled when the email input field is not empty', () => {
renderComponent();
const emailInput = screen.getByPlaceholderText(
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
);
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
expect(addUserButton).not.toBeDisabled();
});
});

View File

@@ -4,8 +4,6 @@ export const MODAL_TYPES = {
warning: 'warning',
} as const;
export type ModalType = typeof MODAL_TYPES[keyof typeof MODAL_TYPES];
export const BADGE_STATES = {
admin: 'primary-700',
staff: 'gray-500',

View File

@@ -0,0 +1,54 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { USER_ROLES } from '../../constants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
/**
* Get course team.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseTeam(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseTeamApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function createTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
}
/**
* Change role course team user.
* @param {string} courseId
* @param {string} email
* @param {string} role
* @returns {Promise<Object>}
*/
export async function changeRoleTeamUser(courseId, email, role) {
await getAuthenticatedHttpClient()
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
}
/**
* Delete course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function deleteTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.delete(updateCourseTeamUserApiUrl(courseId, email));
}

View File

@@ -1,54 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { USER_ROLES } from '@src/constants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseTeamApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
export const updateCourseTeamUserApiUrl = (courseId: string, email: string) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
export interface CourseTeamUser {
id: number;
email: string;
role: string;
username: string;
}
export interface CourseTeam {
users: CourseTeamUser[];
allowActions: boolean;
showTransferOwnershipHint: boolean;
}
/**
* Get course team.
*/
export async function getCourseTeam(courseId: string): Promise<CourseTeam> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseTeamApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course team user.
*/
export async function createTeamUser(courseId: string, email: string) {
await getAuthenticatedHttpClient()
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
}
/**
* Change role course team user.
*/
export async function changeRoleTeamUser(courseId: string, email: string, role: string) {
await getAuthenticatedHttpClient()
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
}
/**
* Delete course team user.
*/
export async function deleteTeamUser(courseId: string, email: string) {
await getAuthenticatedHttpClient()
.delete(updateCourseTeamUserApiUrl(courseId, email));
}

View File

@@ -1,64 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import * as api from './api';
export const courseTeamQueryKeys = {
all: ['courseTeam'],
/** Base key for course team data specific to a courseId */
courseTeam: (courseId: string) => [...courseTeamQueryKeys.all, courseId],
};
/**
* Hook to fetch the course team for the given courseId
*/
export const useCourseTeamData = (courseId: string) => (
useQuery<api.CourseTeam, AxiosError>({
queryKey: courseTeamQueryKeys.courseTeam(courseId),
queryFn: () => api.getCourseTeam(courseId),
})
);
/**
* Hook to create a new course team user
*/
export const useCreateTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, string>({
mutationFn: (email: string) => api.createTeamUser(courseId, email),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};
export type ChangeRoleRequest = {
email: string;
role: string;
};
/**
* Hook to change the role of a course team user
*/
export const useChangeRoleTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, ChangeRoleRequest>({
mutationFn: ({ email, role }: ChangeRoleRequest) => api.changeRoleTeamUser(courseId, email, role),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};
/**
* Hook to delete a course team user
*/
export const useDeleteTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, string>({
mutationFn: (email: string) => api.deleteTeamUser(courseId, email),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};

View File

@@ -0,0 +1,6 @@
export const getCourseTeamUsers = (state) => state.courseTeam.users;
export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus;
export const getErrorMessage = (state) => state.courseTeam.errorMessage;
export const getIsAllowActions = (state) => state.courseTeam.allowActions;
export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint;
export const getSavingStatus = (state) => state.courseTeam.savingStatus;

View File

@@ -0,0 +1,46 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseTeam',
initialState: {
loadingCourseTeamStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
users: [],
showTransferOwnershipHint: false,
allowActions: false,
errorMessage: '',
},
reducers: {
fetchCourseTeamSuccess: (state, { payload }) => {
state.users = payload.users;
state.showTransferOwnershipHint = payload.showTransferOwnershipHint;
state.allowActions = payload.allowActions;
},
updateLoadingCourseTeamStatus: (state, { payload }) => {
state.loadingCourseTeamStatus = payload.status;
},
deleteCourseTeamUser: (state, { payload }) => {
state.users = state.users.filter((user) => user.email !== payload);
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
setErrorMessage: (state, { payload }) => {
state.errorMessage = payload;
},
},
});
export const {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,91 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseTeam,
deleteTeamUser,
createTeamUser,
changeRoleTeamUser,
} from './api';
import {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} from './slice';
export function fetchCourseTeamQuery(courseId) {
return async (dispatch) => {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED }));
}
return false;
}
};
}
export function createCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await createTeamUser(courseId, email);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
const message = error?.response?.data?.error || '';
dispatch(setErrorMessage(message));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function changeRoleTeamUserQuery(courseId, email, role) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await changeRoleTeamUser(courseId, email, role);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function deleteCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await deleteTeamUser(courseId, email);
dispatch(deleteCourseTeamUser(email));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

139
src/course-team/hooks.jsx Normal file
View File

@@ -0,0 +1,139 @@
import { useDispatch, useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useEffect, useState } from 'react';
import { useToggle } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '../constants';
import { RequestStatus } from '../data/constants';
import {
changeRoleTeamUserQuery,
createCourseTeamQuery,
deleteCourseTeamQuery,
fetchCourseTeamQuery,
} from './data/thunk';
import {
getCourseTeamLoadingStatus,
getCourseTeamUsers,
getErrorMessage,
getIsAllowActions,
getIsOwnershipHint, getSavingStatus,
} from './data/selectors';
import { setErrorMessage } from './data/slice';
import { MODAL_TYPES } from './constants';
const useCourseTeam = ({ courseId }) => {
const dispatch = useDispatch();
const { email: currentUserEmail } = getAuthenticatedUser();
const { courseDetails } = useCourseAuthoringContext();
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
const [isFormVisible, openForm, hideForm] = useToggle(false);
const [currentEmail, setCurrentEmail] = useState('');
const [isQueryPending, setIsQueryPending] = useState(false);
const courseTeamUsers = useSelector(getCourseTeamUsers);
const errorMessage = useSelector(getErrorMessage);
const savingStatus = useSelector(getSavingStatus);
const isAllowActions = useSelector(getIsAllowActions);
const isOwnershipHint = useSelector(getIsOwnershipHint);
const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus);
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
const handleOpenInfoModal = (type, email) => {
setCurrentEmail(email);
setModalType(type);
openInfoModal();
};
const handleCloseInfoModal = () => {
dispatch(setErrorMessage(''));
closeInfoModal();
};
const handleAddUserSubmit = (data) => {
setIsQueryPending(true);
const { email } = data;
const isUserContains = courseTeamUsers.some((user) => user.email === email);
if (isUserContains) {
handleOpenInfoModal(MODAL_TYPES.warning, email);
return;
}
dispatch(createCourseTeamQuery(courseId, email)).then((result) => {
if (result) {
hideForm();
dispatch(setErrorMessage(''));
return;
}
handleOpenInfoModal(MODAL_TYPES.error, email);
});
};
const handleDeleteUserSubmit = () => {
setIsQueryPending(true);
dispatch(deleteCourseTeamQuery(courseId, currentEmail));
handleCloseInfoModal();
};
const handleChangeRoleUserSubmit = (email, role) => {
setIsQueryPending(true);
dispatch(changeRoleTeamUserQuery(courseId, email, role));
};
const handleInternetConnectionFailed = () => {
setIsQueryPending(false);
};
const handleOpenDeleteModal = (email) => {
handleOpenInfoModal(MODAL_TYPES.delete, email);
};
useEffect(() => {
dispatch(fetchCourseTeamQuery(courseId));
}, [courseId]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [savingStatus]);
return {
modalType,
errorMessage,
courseName: courseDetails?.name || '',
currentEmail,
courseTeamUsers,
currentUserEmail,
isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS,
isLoadingDenied: loadingCourseTeamStatus === RequestStatus.DENIED,
isSingleAdmin,
isFormVisible,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isQueryPending,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenInfoModal,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
handleInternetConnectionFailed,
};
};
export { useCourseTeam };

View File

@@ -1,118 +0,0 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useState } from 'react';
import { useToggle } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '../constants';
import messages from './messages';
import { MODAL_TYPES, type ModalType } from './constants';
import {
useChangeRoleTeamUser,
useCourseTeamData,
useCreateTeamUser,
useDeleteTeamUser,
} from './data/apiHooks';
const useCourseTeam = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const { email: currentUserEmail } = getAuthenticatedUser();
const { courseDetails } = useCourseAuthoringContext();
const {
data,
isPending: isLoadingCourseTeamStatus,
failureReason: courseTeamQueryError,
} = useCourseTeamData(courseId);
const {
users: courseTeamUsers = [],
allowActions: isAllowActions = false,
showTransferOwnershipHint: isOwnershipHint = false,
} = data ?? {};
const addUserMutation = useCreateTeamUser(courseId);
const editUserRoleMutation = useChangeRoleTeamUser(courseId);
const deleteUserMutation = useDeleteTeamUser(courseId);
const [modalType, setModalType] = useState<ModalType>(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
const [isFormVisible, openForm, hideForm] = useToggle(false);
const [currentEmail, setCurrentEmail] = useState('');
const courseTeamStatusIsDenied = courseTeamQueryError?.response?.status === 403;
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
const handleOpenInfoModal = (type: ModalType, email: string) => {
setCurrentEmail(email);
setModalType(type);
openInfoModal();
};
const handleAddUserSubmit = (body: { email: string }) => {
const { email } = body;
const isUserContains = courseTeamUsers.some((user) => user.email === email);
if (isUserContains) {
handleOpenInfoModal(MODAL_TYPES.warning, email);
return;
}
addUserMutation.mutateAsync(email).then(() => {
hideForm();
}).catch(() => {
handleOpenInfoModal(MODAL_TYPES.error, email);
});
};
const handleDeleteUserSubmit = () => {
deleteUserMutation.mutate(currentEmail);
closeInfoModal();
};
const handleChangeRoleUserSubmit = (email: string, role: string) => {
editUserRoleMutation.mutate({ email, role });
};
const handleOpenDeleteModal = (email: string) => {
handleOpenInfoModal(MODAL_TYPES.delete, email);
};
const getErrorMessage = () => {
const errorObject = addUserMutation.error ?? editUserRoleMutation.error ?? deleteUserMutation.error;
// @ts-ignore
return errorObject?.response?.data?.error ?? intl.formatMessage(messages.unknownError);
};
return {
modalType,
courseName: courseDetails?.name ?? '',
currentEmail,
courseTeamUsers,
currentUserEmail,
errorMessage: getErrorMessage(),
isLoading: isLoadingCourseTeamStatus,
isLoadingDenied: courseTeamStatusIsDenied,
isSingleAdmin,
isFormVisible,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isQueryPending: addUserMutation.isPending || deleteUserMutation.isPending || editUserRoleMutation.isPending,
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
isShowUserFilledSidebar: Boolean(courseTeamUsers?.length) || isFormVisible,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenInfoModal,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
};
};
export { useCourseTeam };

View File

@@ -13,11 +13,6 @@ const messages = defineMessages({
id: 'course-authoring.course-team.button.new-team-member',
defaultMessage: 'New team member',
},
unknownError: {
id: 'course-authoring.course-team.error.unknown',
defaultMessage: 'An unexpected error occurred. Please try again.',
description: 'Fallback error message shown when the API returns an error in an unexpected format.',
},
});
export default messages;

View File

@@ -25,7 +25,6 @@ import { getClipboardUrl } from '@src/generic/data/api';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockContentData } from '@src/content-tags-drawer/data/api.mocks';
import {
mockContentLibrary,
mockGetContentLibraryV2List,
@@ -90,7 +89,6 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentData.applyMock();
const {
block_id: id,
@@ -2941,10 +2939,9 @@ describe('<CourseUnit />', () => {
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: mockContentData.textXBlock });
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
await screen.findByText('Align');
await screen.findByText(mockContentData.textXBlockData.displayName);
});
describe('Add sidebar', () => {
@@ -3247,21 +3244,4 @@ describe('<CourseUnit />', () => {
expect(sidebarToggle).toBeInTheDocument();
expect(within(sidebarToggle).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
});
it('opens the component info sidebar on postMessage event', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.xblockSelected, {
contentId: mockContentData.textXBlock,
});
await screen.findByText(mockContentData.textXBlockData.displayName);
});
});

View File

@@ -14,7 +14,7 @@ const expectedCourseItemDataWithUnit = {
childInfo: {
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
id: 'unitId',
},
],
},
@@ -64,12 +64,7 @@ describe('SubsectionUnitRedirect', () => {
// Confirm redirection by checking the final URL
const mockNavigate = screen.getByTestId('mock-navigate');
expect(mockNavigate).toBeInTheDocument();
expect(mockNavigate).toHaveAttribute(
'data-to',
`/course/${courseId}/container/${encodeURIComponent(
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
)}`,
);
expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}/container/unitId`);
});
});

View File

@@ -2,15 +2,14 @@ import { LoadingSpinner } from '@src/generic/Loading';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useParams, Navigate } from 'react-router-dom';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { XBlock } from '@src/data/types';
import { useCourseItemData } from '../course-outline/data/apiHooks';
const SubsectionUnitRedirect = () => {
const { courseId } = useCourseAuthoringContext();
let { subsectionId } = useParams();
// if the call is made via the click on breadcrumbs the re won't be courseId available
// in such cases the page should redirect to the 1st unit of he subsection
const { data: courseItemData, isLoading } = useCourseItemData<XBlock>(subsectionId);
const { data: courseItemData, isLoading } = useCourseItemData(subsectionId);
let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id;
if (isLoading) {

View File

@@ -79,7 +79,4 @@ export const messageTypes = {
copyXBlockLegacy: 'copyXBlockLegacy',
hideProcessingNotification: 'hideProcessingNotification',
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
xblockSelected: 'xblockSelected',
clearSelection: 'clearSelection',
selectXblock: 'selectXBlock',
};

View File

@@ -91,7 +91,7 @@ describe('<HeaderNavigations />', () => {
expect(infoButton).toBeInTheDocument();
await user.click(infoButton);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info', null);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info');
});
it('click Add button should open add sidebar', async () => {
@@ -107,6 +107,6 @@ describe('<HeaderNavigations />', () => {
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add', null);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add');
});
});

View File

@@ -52,7 +52,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
<Button
variant="outline-primary"
iconBefore={InfoOutline}
onClick={() => setCurrentPageKey('info', null)}
onClick={() => setCurrentPageKey('info')}
>
{intl.formatMessage(messages.infoButton)}
</Button>
@@ -60,7 +60,7 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat
<Button
variant="outline-primary"
iconBefore={Add}
onClick={() => setCurrentPageKey('add', null)}
onClick={() => setCurrentPageKey('add')}
>
{intl.formatMessage(messages.addButton)}
</Button>

View File

@@ -11,7 +11,6 @@ import {
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
import { COURSE_BLOCK_NAMES } from '@src/constants';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ConfigureUnitData } from '@src/course-outline/data/types';
import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
import messages from './messages';
@@ -22,7 +21,13 @@ type HeaderTitleProps = {
isTitleEditFormOpen: boolean;
handleTitleEdit: () => void;
handleTitleEditSubmit: (title: string) => void;
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
handleConfigureSubmit: (
id: string,
isVisible: boolean,
groupAccess: boolean,
isDiscussionEnabled: boolean,
closeModalFn: (value: boolean) => void
) => void;
};
/**
@@ -51,12 +56,14 @@ const HeaderTitle = ({
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
handleConfigureSubmit({
...variables,
unitId: currentItemData.id,
closeModalFn: closeConfigureModal,
});
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(
currentItemData.id,
arg[0],
arg[1],
arg[2],
closeConfigureModal,
);
};
useEffect(() => {

View File

@@ -14,7 +14,6 @@ import { useEventListener } from '@src/generic/hooks';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '@src/constants';
import { ConfigureUnitData } from '@src/course-outline/data/types';
import { messageTypes, PUBLISH_TYPES } from './constants';
import {
createNewCourseXBlock,
@@ -74,8 +73,7 @@ export const useCourseUnit = ({
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
const { canPasteComponent } = courseVerticalChildren;
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
const sequenceId = courseUnit.ancestorInfo?.ancestors[0]?.id;
const sectionId = courseUnit.ancestorInfo?.ancestors[1]?.id;
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLegacyLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
@@ -100,17 +98,19 @@ export const useCourseUnit = ({
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
};
const handleConfigureSubmit = (variables: ConfigureUnitData & { closeModalFn?: () => void }) => {
const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => {
dispatch(editCourseUnitVisibilityAndData(
variables.unitId,
id,
PUBLISH_TYPES.republish,
variables.isVisibleToStaffOnly,
variables.groupAccess,
variables.discussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: variables.unitId }),
isVisible,
groupAccess,
isDiscussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
blockId,
));
variables.closeModalFn?.();
if (typeof closeModalFn === 'function') {
closeModalFn();
}
};
const handleTitleEditSubmit = (displayName) => {
@@ -139,26 +139,18 @@ export const useCourseUnit = ({
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
const unitXBlockActions = {
handleDelete: async (XBlockId: string) => {
// oxlint-disable-next-line typescript-eslint(await-thenable)
await dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
handleDelete: (XBlockId) => {
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
},
handleDuplicate: (XBlockId: string) => {
handleDuplicate: (XBlockId) => {
dispatch(duplicateUnitItemQuery(
blockId,
XBlockId,
(courseKey: string, locator: string) => sendMessageToIframe(
messageTypes.completeXBlockDuplicating,
{ courseKey, locator },
),
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
));
},
handleUnlink: async (XBlockId: string) => {
await unlinkDownstream({
downstreamBlockId: XBlockId,
subsectionId: sequenceId,
sectionId,
});
handleUnlink: async (XBlockId) => {
await unlinkDownstream(XBlockId);
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
},
};

View File

@@ -1,5 +1,4 @@
import { render, screen, initializeMocks } from '@src/testUtils';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { UnitSidebarProvider } from './UnitSidebarContext';
@@ -17,11 +16,9 @@ jest.mock('react-router-dom', () => ({
}));
const renderComponent = () => render(
<IframeProvider>
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>
</IframeProvider>,
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>,
);
describe('OutlineAlignSidebar', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
import { AlignSidebar } from '@src/generic/sidebar/AlignSidebar';
import { useCallback } from 'react';
import { useUnitSidebarContext } from './UnitSidebarContext';
/**
@@ -9,19 +9,18 @@ import { useUnitSidebarContext } from './UnitSidebarContext';
*/
export const UnitAlignSidebar = () => {
const { blockId } = useParams();
const { selectedComponentId, setCurrentPageKey } = useUnitSidebarContext();
const { currentComponentId, setCurrentPageKey } = useUnitSidebarContext();
const sidebarContentId = selectedComponentId || blockId;
const sidebarContentId = currentComponentId || blockId;
const {
data: contentData,
} = useContentData(sidebarContentId);
// istanbul ignore next
const handleBack = useCallback(() => {
// Set the align sidebar without current component to back
// to unit align sidebar.
setCurrentPageKey('align', null);
setCurrentPageKey('align');
}, [setCurrentPageKey]);
return (
@@ -31,7 +30,7 @@ export const UnitAlignSidebar = () => {
? contentData.displayName : ''
}
contentId={sidebarContentId || ''}
onBackBtnClick={selectedComponentId ? handleBack : undefined}
onBackBtnClick={currentComponentId ? handleBack : undefined}
/>
);
};

View File

@@ -4,19 +4,19 @@ import {
import { SidebarPage } from '@src/generic/sidebar';
import { useToggle } from '@openedx/paragon';
import { useStateWithUrlSearchParam } from '@src/hooks';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { messageTypes } from '../constants';
export type UnitSidebarPageKeys = 'info' | 'add' | 'align';
export type UnitSidebarPages = Record<UnitSidebarPageKeys, SidebarPage>;
interface UnitSidebarContextData {
currentPageKey: UnitSidebarPageKeys;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string | null) => void;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string) => void;
currentTabKey?: string;
setCurrentTabKey: (tabKey: string | undefined) => void;
selectedComponentId?: string;
setSelectedComponentId: (componentId?: string) => void;
// The Id of the component used in the current sidebar page
// The component is not necessarily selected to open a selected sidebar.
// Example: Align sidebar
currentComponentId?: string;
isOpen: boolean;
open: () => void;
toggle: () => void;
@@ -32,7 +32,6 @@ export const UnitSidebarProvider = ({
children?: React.ReactNode,
readOnly: boolean,
}) => {
const { sendMessageToIframe } = useIframe();
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<UnitSidebarPageKeys>(
'info',
'sidebar',
@@ -40,23 +39,16 @@ export const UnitSidebarProvider = ({
(value: UnitSidebarPageKeys) => value,
);
const [currentTabKey, setCurrentTabKey] = useState<string>();
const [selectedComponentId, setSelectedComponentId] = useState<string>();
const [currentComponentId, setCurrentComponentId] = useState<string>();
const [isOpen, open,, toggle] = useToggle(true);
const setCurrentPageKey = useCallback(/* istanbul ignore next */ (
pageKey: UnitSidebarPageKeys,
componentId?: string | null,
componentId?: string,
) => {
// Reset tab
setCurrentTabKey(undefined);
setCurrentPageKeyState(pageKey);
if (componentId !== undefined) {
setSelectedComponentId(componentId === null ? undefined : componentId);
}
if (componentId === null) {
// Deselect the component
sendMessageToIframe(messageTypes.clearSelection, null);
}
setCurrentComponentId(componentId);
open();
}, [open]);
@@ -66,8 +58,7 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
selectedComponentId,
setSelectedComponentId,
currentComponentId,
isOpen,
open,
toggle,
@@ -78,8 +69,7 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
selectedComponentId,
setSelectedComponentId,
currentComponentId,
isOpen,
open,
toggle,

View File

@@ -71,11 +71,6 @@ const messages = defineMessages({
defaultMessage: 'Advanced Blocks',
description: 'Title for the add advanced blocks page in the unit sidebar',
},
sidebarDisabledAddTooltip: {
id: 'course-authoring.course-unit.sidebar.add.disabled.tooltip',
defaultMessage: 'Cannot add content to components',
description: 'Tooltip for the Add sidebar when is disabled.',
},
});
export default messages;

View File

@@ -2,10 +2,10 @@ import { getConfig } from '@edx/frontend-platform';
import { Info, Tag, Plus } from '@openedx/paragon/icons';
import { SidebarPage } from '@src/generic/sidebar';
import messages from './messages';
import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { AddSidebar } from './AddSidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import { InfoSidebar } from './unit-info/InfoSidebar';
export type UnitSidebarPages = {
info: SidebarPage;
@@ -21,11 +21,10 @@ export type UnitSidebarPages = {
*/
export const useUnitSidebarPages = (): UnitSidebarPages => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
const { readOnly, selectedComponentId } = useUnitSidebarContext();
const hasComponentSelected = selectedComponentId !== undefined;
const { readOnly } = useUnitSidebarContext();
return {
info: {
component: InfoSidebar,
component: UnitInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
@@ -34,8 +33,6 @@ export const useUnitSidebarPages = (): UnitSidebarPages => {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
disabled: hasComponentSelected,
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
},
}),
...(showAlignSidebar && {

View File

@@ -1,83 +0,0 @@
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate } from 'react-router-dom';
import { Tag } from '@openedx/paragon/icons';
import { useQueryClient } from '@tanstack/react-query';
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
import { ContentTagsSnippet } from '@src/content-tags-drawer';
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
import type { XBlockData } from '@src/content-tags-drawer/data/types';
import { getItemIcon } from '@src/generic/block-type-utils';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { messageTypes } from '@src/course-unit/constants';
import { LibraryReferenceCard } from '@src/generic/library-reference-card/LibraryReferenceCard';
import { getCourseUnitData } from '@src/course-unit/data/selectors';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks';
import { useUnitSidebarContext } from '../UnitSidebarContext';
import messages from './messages';
/**
* Sidebar info for components
*/
export const ComponentInfoSidebar = () => {
const intl = useIntl();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { sendMessageToIframe } = useIframe();
const unitData = useSelector(getCourseUnitData);
const { courseId } = useCourseAuthoringContext();
const sectionId = unitData?.ancestorInfo?.ancestors?.find(
(ancestor) => ancestor.category === 'chapter',
)?.id;
const {
selectedComponentId,
setCurrentPageKey,
} = useUnitSidebarContext();
const { data: contentData } = useContentData(selectedComponentId) as { data: XBlockData | undefined };
// istanbul ignore next
const handleBack = () => {
setCurrentPageKey('info', null);
};
const handleGoToParent = (containerId: string) => {
navigate(`/course/${courseId}?show=${encodeURIComponent(containerId)}`);
};
// istanbul ignore next
const handlePostChange = () => {
sendMessageToIframe(messageTypes.refreshXBlock, null);
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(sectionId),
});
};
return (
<>
<SidebarTitle
title={contentData?.displayName || ''}
icon={getItemIcon(contentData?.category || '')}
onBackBtnClick={handleBack}
/>
<LibraryReferenceCard
itemId={selectedComponentId}
sectionId={sectionId}
goToParent={handleGoToParent}
postChange={handlePostChange}
/>
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionTaxonomies)}
icon={Tag}
>
<ContentTagsSnippet contentId={selectedComponentId || ''} />
</SidebarSection>
</SidebarContent>
</>
);
};

View File

@@ -1,19 +0,0 @@
import { useUnitSidebarContext } from '../UnitSidebarContext';
import { ComponentInfoSidebar } from './ComponentInfoSidebar';
import { UnitInfoSidebar } from './UnitInfoSidebar';
/**
* Main component to render the Info Sidebar in the unit page
*
* Depending of the selected component, this can render
* the unit infor sidebar or the component info sidebar
*/
export const InfoSidebar = () => {
const { selectedComponentId } = useUnitSidebarContext();
if (selectedComponentId) {
return <ComponentInfoSidebar />;
}
return <UnitInfoSidebar />;
};

View File

@@ -189,7 +189,7 @@ const UnitInfoSettings = () => {
};
/**
* Component that renders the tabs of the info sidebar for units.
* Main component that renders the tabs of the info sidebar.
*/
export const UnitInfoSidebar = () => {
const intl = useIntl();

View File

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

View File

@@ -1,12 +1,11 @@
import { useMemo } from 'react';
import { debounce } from 'lodash';
import { useClipboard } from '@src/generic/clipboard';
import { messageTypes } from '@src/course-unit/constants';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { updateSavingStatus } from '@src/course-unit/data/slice';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { useClipboard } from '../../../generic/clipboard';
import { handleResponseErrors } from '../../../generic/saving-error-alert';
import { NOTIFICATION_MESSAGES } from '../../../constants';
import { updateSavingStatus } from '../../data/slice';
import { messageTypes } from '../../constants';
import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
/**
@@ -33,7 +32,6 @@ export const useMessageHandlers = ({
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
handleXBlockSelected,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -47,7 +45,7 @@ export const useMessageHandlers = ({
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({
courseXBlockDropdownHeight,
}) => setIframeOffset(courseXBlockDropdownHeight),
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
[messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id),
[messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal,
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
@@ -65,7 +63,6 @@ export const useMessageHandlers = ({
payload.type,
payload.locator,
),
[messageTypes.xblockSelected]: ({ contentId }) => handleXBlockSelected(contentId),
}), [
courseId,
handleDeleteXBlock,
@@ -74,6 +71,5 @@ export const useMessageHandlers = ({
handleManageXBlockAccess,
handleScrollToXBlock,
copyToClipboard,
handleXBlockSelected,
]);
};

View File

@@ -24,9 +24,6 @@ import { UnlinkModal } from '@src/generic/unlink-modal';
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
import EditorPage from '@src/editors/EditorPage';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { ConfigureUnitData } from '@src/course-outline/data/types';
import { AccessManagedXBlockDataTypes } from '@src/data/types';
import { messageTypes } from '../constants';
import {
fetchCourseSectionVerticalData,
@@ -39,6 +36,7 @@ import {
import messages from './messages';
import {
XBlockContainerIframeProps,
AccessManagedXBlockDataTypes,
} from './types';
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
import { useUnitSidebarContext } from '../unit-sidebar/UnitSidebarContext';
@@ -55,25 +53,19 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const {
setCurrentPageKey,
setSelectedComponentId,
} = useUnitSidebarContext();
const { setCurrentPageKey } = useUnitSidebarContext();
// Useful to reload iframe
const [iframeKey, setIframeKey] = useState(0);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const { isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal } = useCourseAuthoringContext();
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState<string>('');
const { useVideoGalleryFlow } = useWaffleFlags(courseId);
const [newBlockId, setNewBlockId] = useState<string>('');
const [
accessManagedXBlockData,
setAccessManagedXBlockData,
] = useState<AccessManagedXBlockDataTypes | undefined>(undefined);
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
const [iframeOffset, setIframeOffset] = useState(0);
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
const [unlinkXBlockId, setUnlinkXBlockId] = useState<string | null>(null);
@@ -123,7 +115,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleUnlinkXBlock = (usageId: string) => {
setUnlinkXBlockId(usageId);
openUnlinkModal({});
openUnlinkModal();
};
const handleManageXBlockAccess = (usageId: string) => {
@@ -135,10 +127,9 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}
};
const onDeleteSubmit = async () => {
const onDeleteSubmit = () => {
if (deleteXBlockId) {
await unitXBlockActions.handleDelete(deleteXBlockId);
setSelectedComponentId(undefined);
unitXBlockActions.handleDelete(deleteXBlockId);
closeDeleteModal();
}
};
@@ -150,14 +141,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
}
};
const onManageXBlockAccessSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
const onManageXBlockAccessSubmit = (...args: any[]) => {
if (configureXBlockId) {
handleConfigureSubmit({
unitId: configureXBlockId,
...variables,
closeModalFn: closeConfigureModal,
});
setAccessManagedXBlockData(undefined);
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
setAccessManagedXBlockData({});
}
};
@@ -193,7 +180,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleOpenManageTagsModal = (id: string) => {
if (isUnitPageNewDesignEnabled()) {
setCurrentPageKey('align', id);
sendMessageToIframe(messageTypes.selectXblock, { locator: id });
} else {
// Legacy manage tags modal
setConfigureXBlockId(id);
@@ -218,10 +204,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeKey((prev) => prev + 1);
};
const handleXBlockSelected = (id) => {
setCurrentPageKey('info', id);
};
const messageHandlers = useMessageHandlers({
courseId,
dispatch,
@@ -240,7 +222,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
handleXBlockSelected,
});
useIframeMessages(readonly ? {} : messageHandlers);
@@ -296,17 +277,19 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
/>
</div>
)}
<ConfigureModal
isXBlockComponent
isOpen={isConfigureModalOpen}
onClose={() => {
closeConfigureModal();
setAccessManagedXBlockData(undefined);
}}
onConfigureSubmit={onManageXBlockAccessSubmit}
currentItemData={accessManagedXBlockData}
isSelfPaced={false}
/>
{Object.keys(accessManagedXBlockData).length ? (
<ConfigureModal
isXBlockComponent
isOpen={isConfigureModalOpen}
onClose={() => {
closeConfigureModal();
setAccessManagedXBlockData({});
}}
onConfigureSubmit={onManageXBlockAccessSubmit}
currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes}
isSelfPaced={false}
/>
) : null}
<iframe
key={iframeKey}
ref={iframeRef}

View File

@@ -1,5 +1,4 @@
import { ConfigureUnitData } from '@src/course-outline/data/types';
import { UserPartitionTypes } from '@src/data/types';
import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types';
export interface XBlockActionsTypes {
canCopy: boolean;
@@ -32,11 +31,46 @@ export interface XBlockContainerIframeProps {
blockId: string;
isUnitVerticalType: boolean,
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => Promise<void> | void;
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;
handleUnlink: (XBlockId: string | null) => void;
};
courseVerticalChildren: Array<XBlockTypes>;
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
readonly?: boolean;
}
export type AccessManagedXBlockDataTypes = {
id: string;
displayName?: string;
start?: string;
visibilityState?: string | boolean;
blockType: string;
due?: string;
isTimeLimited?: boolean;
defaultTimeLimitMinutes?: number;
hideAfterDue?: boolean;
showCorrectness?: string | boolean;
courseGraders?: string[];
category?: string;
format?: string;
userPartitionInfo?: UserPartitionInfoTypes;
ancestorHasStaffLock?: boolean;
isPrereq?: boolean;
prereqs?: XBlockPrereqs[];
prereq?: string;
prereqMinScore?: number;
prereqMinCompletion?: number;
releasedToStudents?: boolean;
wasExamEverLinkedWithExternal?: boolean;
isProctoredExam?: boolean;
isOnboardingExam?: boolean;
isPracticeExam?: boolean;
examReviewRules?: string;
supportsOnboarding?: boolean;
showReviewRules?: boolean;
onlineProctoringRules?: string;
discussionEnabled: boolean;
};
export type FormattedAccessManagedXBlockDataTypes = Omit<AccessManagedXBlockDataTypes, 'discussionEnabled'>;

View File

@@ -1,20 +1,25 @@
import { getConfig } from '@edx/frontend-platform';
import { AccessManagedXBlockDataTypes } from '@src/data/types';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { XBlockTypes } from './types';
import { FormattedAccessManagedXBlockDataTypes, XBlockTypes } from './types';
/**
* Formats the XBlock data into a standardized structure for access management.
*
* @param {XBlockTypes} xblock - The XBlock object containing the original data.
* @param {string} usageId - The unique identifier for the XBlock.
*
* @returns {FormattedAccessManagedXBlockDataTypes} - The formatted XBlock data, ready for access management operations.
*/
export const formatAccessManagedXBlockData = (
xblock: XBlockTypes,
usageId: string,
): AccessManagedXBlockDataTypes => ({
): FormattedAccessManagedXBlockDataTypes => ({
category: COURSE_BLOCK_NAMES.component.id,
displayName: xblock.name,
userPartitionInfo: xblock.userPartitionInfo,
showCorrectness: 'always',
blockType: xblock.blockType,
id: usageId,
});

View File

@@ -38,7 +38,7 @@ const messages = defineMessages({
},
customPagesExplanationBody: {
id: 'course-authoring.custom-pages.customPagesExplanation.body',
defaultMessage: `You can create and edit custom pages to provide students with additional course content. For example, you can create
defaultMessage: `You can create and edit custom pages to probide students with additional course content. For example, you can create
pages for the grading policy, course slide, and a course calendar.`,
},
studentViewExplanationHeader: {

View File

@@ -92,7 +92,6 @@ export const waffleFlagDefaults = {
useNewGroupConfigurationsPage: true,
useReactMarkdownEditor: true,
useVideoGalleryFlow: false,
enableAuthzCourseAuthoring: false,
} as const;
export type WaffleFlagName = keyof typeof waffleFlagDefaults;

View File

@@ -130,38 +130,3 @@ export const useCourseDetails = (courseId: string) => {
status,
};
};
/**
* Create a global state function for a query.
*/
export function createGlobalState<T>(
queryKeyFn: (queryKeyArgs?: any) => unknown[],
initialData: T | null = null,
) {
return (queryKeyArgs?: any) => {
const queryClient = useQueryClient();
const queryKey = queryKeyFn(queryKeyArgs);
const { data } = useQuery({
queryKey,
queryFn: () => Promise.resolve(initialData),
refetchInterval: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchIntervalInBackground: false,
});
function setData(x: Partial<T>) {
queryClient.setQueryData(queryKey, x);
}
async function resetData() {
await queryClient.invalidateQueries({
queryKey,
});
}
return { data, setData, resetData };
};
}

View File

@@ -70,7 +70,7 @@ export interface UpstreamInfo {
isReadyToSyncIndividually?: boolean,
}
export interface XBlockBase {
export interface XBlock {
id: string;
locator: string;
usageKey: string;
@@ -92,7 +92,7 @@ export interface XBlockBase {
due?: string;
relativeWeeksDue?: number;
format?: string;
courseGraders?: string[];
courseGraders: string[];
hasChanges: boolean;
actions: XBlockActions;
explanatoryMessage?: string;
@@ -102,11 +102,13 @@ export interface XBlockBase {
highlightsEnabled: boolean;
highlightsPreviewOnly: boolean;
highlightsDocUrl: string;
childInfo: XblockChildInfo;
ancestorHasStaffLock: boolean;
staffOnlyMessage: boolean;
hasPartitionGroupComponents: boolean;
userPartitionInfo?: UserPartitionInfoTypes;
enableCopyPasteUnits: boolean;
shouldScroll: boolean;
isHeaderVisible: boolean;
proctoringExamConfigurationLink?: string;
isTimeLimited?: boolean;
@@ -123,18 +125,9 @@ export interface XBlockBase {
prereqMinCompletion?: number;
discussionEnabled?: boolean;
upstreamInfo?: UpstreamInfo;
wasExamEverLinkedWithExternal?: boolean;
supportsOnboarding?: boolean;
showReviewRules?: boolean;
onlineProctoringRules?: string;
groupAccess?: object;
}
export interface XBlock extends XBlockBase {
childInfo: XblockChildInfo;
}
export interface UnitXBlock extends XBlockBase {}
export type UnitXBlock = Omit<XBlock, 'childInfo'>;
interface OutlineError {
data?: string;
@@ -169,35 +162,3 @@ export type SelectionState = {
sectionId?: string;
subsectionId?: string;
};
export type AccessManagedXBlockDataTypes = {
id: string;
displayName?: string;
start?: string;
visibilityState?: string | boolean;
due?: string;
isTimeLimited?: boolean;
defaultTimeLimitMinutes?: number;
hideAfterDue?: boolean;
showCorrectness?: string | boolean;
courseGraders?: string[];
category?: string;
format?: string;
userPartitionInfo?: UserPartitionInfoTypes;
ancestorHasStaffLock?: boolean;
isPrereq?: boolean;
prereqs?: XBlockPrereqs[];
prereq?: string;
prereqMinScore?: number;
prereqMinCompletion?: number;
releasedToStudents?: boolean;
wasExamEverLinkedWithExternal?: boolean;
isProctoredExam?: boolean;
isOnboardingExam?: boolean;
isPracticeExam?: boolean;
examReviewRules?: string;
supportsOnboarding?: boolean;
showReviewRules?: boolean;
onlineProctoringRules?: string;
discussionEnabled?: boolean;
};

View File

@@ -1,16 +1,7 @@
import React, { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Icon,
IconButton,
ModalDialog,
ModalCloseButton,
Stack,
useToggle,
} from '@openedx/paragon';
import { Close, CloseFullscreen, OpenInFull } from '@openedx/paragon/icons';
import { useToggle } from '@openedx/paragon';
import { LibraryBlock } from '../library-authoring/LibraryBlock';
import { EditorModalWrapper } from './containers/EditorContainer';
@@ -20,8 +11,6 @@ import messages from './messages';
import CancelConfirmModal from './containers/EditorContainer/components/CancelConfirmModal';
import { IframeProvider } from '../generic/hooks/context/iFrameContext';
import editorModalWrapperMessages from './containers/EditorContainer/messages';
interface AdvancedEditorProps {
usageKey: string,
onClose: (() => void) | null,
@@ -31,7 +20,6 @@ const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
const intl = useIntl();
const { showToast } = React.useContext(ToastContext);
const [isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal] = useToggle(false);
const [isFullscreen, , , toggleFullscreen] = useToggle(false);
useEffect(() => {
const handleIframeMessage = (event) => {
@@ -61,28 +49,7 @@ const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
return (
<>
<EditorModalWrapper onClose={openCancelConfirmModal} fullscreen={isFullscreen}>
<ModalDialog.Header>
<ActionRow>
<ModalDialog.Title>
{intl.formatMessage(editorModalWrapperMessages.modalTitle)}
</ModalDialog.Title>
<ActionRow.Spacer />
<Stack direction="horizontal" reversed gap={1}>
<ModalCloseButton
as={IconButton}
src={Close}
iconAs={Icon}
/>
<IconButton
src={isFullscreen ? CloseFullscreen : OpenInFull}
iconAs={Icon}
alt={intl.formatMessage(messages.advancedEditorFullscreenButtonAlt)}
onClick={toggleFullscreen}
/>
</Stack>
</ActionRow>
</ModalDialog.Header>
<EditorModalWrapper onClose={openCancelConfirmModal}>
<IframeProvider>
<LibraryBlock
usageKey={usageKey}

View File

@@ -8,57 +8,39 @@ import {
IconButton,
ModalDialog,
Spinner,
Stack,
Toast,
useToggle,
} from '@openedx/paragon';
import { Close, CloseFullscreen, OpenInFull } from '@openedx/paragon/icons';
import { Close } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { parseErrorMsg } from '@src/library-authoring/add-content/AddContent';
import libraryMessages from '@src/library-authoring/add-content/messages';
import usePromptIfDirty from '@src/generic/promptIfDirty/usePromptIfDirty';
import { EditorComponent } from '../../EditorComponent';
import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks';
import messages from './messages';
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContent';
import libraryMessages from '../../../library-authoring/add-content/messages';
import './index.scss';
import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty';
import CancelConfirmModal from './components/CancelConfirmModal';
interface WrapperProps {
children: React.ReactNode;
}
export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void, fullscreen?: boolean }> = (
{
children,
onClose,
fullscreen = false,
},
) => {
export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void }> = ({ children, onClose }) => {
const intl = useIntl();
const title = intl.formatMessage(messages.modalTitle);
return (
<ModalDialog
isOpen
onClose={onClose}
title={title}
size={fullscreen ? 'fullscreen' : 'xl'}
isOverflowVisible={false}
hasCloseButton={false}
>
{children}
</ModalDialog>
<ModalDialog isOpen size="xl" isOverflowVisible={false} onClose={onClose} title={title}>{children}</ModalDialog>
);
};
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => <ModalDialog.Body className="pb-0">{children}</ModalDialog.Body>;
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => <ModalDialog.Body className="pb-0">{ children }</ModalDialog.Body>;
// eslint-disable-next-line react/jsx-no-useless-fragment
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => <>{children}</>;
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => <>{ children }</>;
interface Props extends EditorComponent {
children: React.ReactNode;
@@ -81,7 +63,6 @@ const EditorContainer: React.FC<Props> = ({
const [saved, setSaved] = React.useState(false);
const isInitialized = hooks.isInitialized();
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
const [isFullscreen, , , toggleFullscreen] = useToggle(false);
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
const { createFailed, createFailedError } = hooks.createFailed();
const disableSave = !isInitialized;
@@ -116,9 +97,8 @@ const EditorContainer: React.FC<Props> = ({
handleCancel();
}
};
return (
<EditorModalWrapper onClose={confirmCancelIfDirty} fullscreen={isFullscreen}>
<EditorModalWrapper onClose={confirmCancelIfDirty}>
{createFailed && (
<Toast show onClose={clearCreateFailed}>
{parseErrorMsg(
@@ -147,27 +127,15 @@ const EditorContainer: React.FC<Props> = ({
/>
<ModalDialog.Header className="shadow-sm zindex-10">
<div className="d-flex flex-row justify-content-between">
<ActionRow>
<h2 className="h3 col pl-0">
<TitleHeader isInitialized={isInitialized} />
</h2>
<ActionRow.Spacer />
<Stack direction="horizontal" reversed gap={1}>
<IconButton
src={Close}
iconAs={Icon}
onClick={confirmCancelIfDirty}
alt={intl.formatMessage(messages.exitButtonAlt)}
autoFocus
/>
<IconButton
src={isFullscreen ? CloseFullscreen : OpenInFull}
iconAs={Icon}
alt={intl.formatMessage(messages.toggleFullscreenButtonLabel)}
onClick={toggleFullscreen}
/>
</Stack>
</ActionRow>
<h2 className="h3 col pl-0">
<TitleHeader isInitialized={isInitialized} />
</h2>
<IconButton
src={Close}
iconAs={Icon}
onClick={confirmCancelIfDirty}
alt={intl.formatMessage(messages.exitButtonAlt)}
/>
</div>
</ModalDialog.Header>
<EditorModalBody>

View File

@@ -1,6 +1,7 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
cancelConfirmTitle: {
id: 'authoring.editorContainer.cancelConfirm.title',
defaultMessage: 'Exit the editor?',
@@ -56,11 +57,6 @@ const messages = defineMessages({
defaultMessage: 'Save',
description: 'Label for Save button',
},
toggleFullscreenButtonLabel: {
id: 'authoring.editorHeader.toggleFullscreen.label',
defaultMessage: 'Toggle Fullscreen',
description: 'Label for toggle fullscreen button',
},
});
export default messages;

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