Compare commits

...

31 Commits

Author SHA1 Message Date
renovate[bot]
fe07cd3cb7 fix(deps): update dependency react-error-boundary to v6 2026-03-16 00:34:01 +00:00
edX requirements bot
1efd559786 chore: update browserslist DB (#2943)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-16 00:30:22 +00:00
Navin Karkera
df79861685 fix: unit preview in course outline sidebar (#2940) 2026-03-13 13:42:39 -05:00
Rômulo Penido
24e1c73f6b feat: add UnitSidebarPagesContext (#2874) 2026-03-10 10:27:45 -05:00
renovate[bot]
449af65d01 chore(deps): update dependency oxlint-tsgolint to ^0.16.0 (#2930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 09:40:45 -07:00
edX requirements bot
fce65c0215 chore: update browserslist DB (#2929)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-09 00:26:52 +00:00
Muhammad Waleed
abcef4a502 fix: refresh search token after library (#2924)
creation for course creator access

  When a course creator creates a new library, the cached JWT token for
  Meilisearch needs to be refreshed to include the new library's access_id.
  Without this, newly added components won't appear in search results until
  the page is refreshed.

  This works in conjunction with the backend fix that creates SearchAccess
  records immediately on library creation.

  The fix invalidates the content_search query, triggering React Query to
  refetch the token with updated access permissions.
2026-03-05 14:17:27 +05:00
dependabot[bot]
5c1cdcf01c chore(deps): bump actions/upload-artifact from 6 to 7 (#2920)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 13:43:43 -08:00
dependabot[bot]
4dccc12883 chore(deps): bump actions/download-artifact from 7 to 8 (#2921) 2026-03-02 13:43:20 -08:00
renovate[bot]
f0e735b3a1 chore(deps): update codemirror (#2895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 13:41:55 -08:00
Chris Chávez
091d9a1c3e refactor: Migrate courseExport from redux store to React Query (#2908) 2026-03-02 17:12:02 +00:00
edX requirements bot
7157d17a4e chore: update browserslist DB (#2918)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-02 00:43:27 +00:00
Muhammad Anas
65096fde0e feat: add card in Pages and Resources to allow hiding the dates tab (#2834) 2026-02-27 15:37:41 -08:00
Braden MacDonald
af204c78a5 test: run only oxlint, not eslint (#2909) 2026-02-27 09:12:24 -08:00
Rômulo Penido
2e6209314f feat: add fullscreen button to XBlock editor (#2892) 2026-02-27 10:05:14 -05:00
Navin Karkera
060f7d4618 refactor(course-outline): replace thunks with react query, add typed APIs, and improve type usages (#2900)
* Replaces configure xblock and section highlights redux functions with react-query.
* Replaces section highlights thunks with react query
* Replaces duplicate block thunks
* Removes add subsection, unit redux functions
* Replaces scrollTo redux state to react-query based state.
* Replaces paste unit block redux functions
* Removes a lot of redux related functions as a result.
* Reduces API calls without compromising data integrity.
2026-02-27 09:23:46 -05:00
Chris Chávez
815b80a944 refactor: Migrate advancedSettings from the redux store to React Query (#2893) 2026-02-26 23:17:57 +00:00
Bryann Valderrama
e8cd7c2dcc feat: add team groups section in group configurations (#1728) 2026-02-26 14:02:31 -08:00
Chris Chávez
c4a09a2b43 refactor: Migrate courseImport from redux store to React query (#2902) 2026-02-26 14:34:13 -05:00
Areeb Sajjad
3599630cd7 fix: allow configure modal overflow to prevent datepicker clipping (#2901) 2026-02-25 17:19:18 -08:00
Chris Chávez
56726448fc Refactor: Move courseTeam from redux store to react query (#2888)
* refactor: Move `courseTeam` from redux store to react query

* fix: Broken types

* fix: Show user readable error

* fix: Broken coverage

* fix: Broken coverage

* refactor: Add default message error
2026-02-24 10:11:36 -05:00
renovate[bot]
b57386b9b6 chore(deps): update dependency oxlint-tsgolint to ^0.14.0 (#2896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:46:26 +00:00
renovate[bot]
e9bf4566de chore(deps): update dependency @types/lodash to v4.17.24 (#2897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 21:09:45 +00:00
edX requirements bot
1324b33789 chore: update browserslist DB (#2899)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-23 21:09:26 +00:00
Taylor Payne
b256036527 fix: correct typos in user-facing messages (#2894) 2026-02-21 11:43:13 -08:00
Navin Karkera
ee6006e0e3 refactor(course-outline): improve query cache handling and remove redux thunks (#2884)
- Centralizes and reuses query cache handling logic:
Introduces a `ParentIds` type (src/generic/types.ts) and standardizes its use across data/API hooks for updating or invalidating parent/child query caches.
- Ensures cache coherence using `cancelQueries` before updating query data:
Before calling `setQueryData` for any block, any inflight queries are cancelled to prevent race conditions and stale UI.
- Simplifies post-sync/invalidation flows:
Removes Redux thunk usages in favor of direct query invalidations using React Query APIs within course outline cards, sidebars, publish modal, and `unlinkmodal`.
- Refactors data types for clarity:
Splits XBlock into `XBlockBase` and derived interfaces so the presence of `childInfo` is explicit.
- Cleans up redundant code and props:
Removes unnecessary `memoization`, `useDispatch` imports, and duplicate logic in React components.
2026-02-20 13:10:28 -05:00
MuPp3t33r
8f8c6d8dd2 refactor: update relative path to absolute path alias (#2891) 2026-02-20 09:10:25 -08:00
Chris Chávez
7c1eb59f18 feat: Make selectable component cards & Component Info Sidebar [FC-0114] (#2880)
- Changes in the Unit sidebar context to enable selected components
- Implements the component info sidebar.
- Implements the container/component selection when opening the align sidebar
2026-02-19 17:02:26 -05:00
Rodrigo Mendez
5ccf39d130 feat: Add validation for Advanced Settings permissions using openedx-authz (#2869)
* feat: Add validation for Advanced Settings permissions using openedx-authz

* squash!: Increase test coverage

* squash!: Fix lint issues

* squash!: Validate advanced settings permission on HelpSidebar

* squash!: Increase test coverage

* squash!: Attend PR comments
2026-02-19 14:39:04 -06:00
Chris Chávez
42f26e7404 refactor: Move redux to react query in course checklist (#2870) 2026-02-19 13:10:18 -05:00
renovate[bot]
01ddf0d2ad chore(deps): update dependency @edx/frontend-platform to v8.5.5 (#2882)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 23:20:06 +00:00
192 changed files with 4610 additions and 3988 deletions

View File

@@ -17,29 +17,17 @@ jobs:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
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@v7
uses: actions/download-artifact@v8
with:
pattern: code-coverage-report
path: coverage

View File

@@ -51,7 +51,9 @@ validate-no-uncommitted-package-lock-changes:
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
# 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 types
npm run test:ci
npm run build

142
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"@edx/frontend-platform": "^8.4.0",
"@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",
@@ -94,7 +95,7 @@
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.2",
"oxlint-tsgolint": "^0.16.0",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
@@ -2304,9 +2305,9 @@
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -2335,9 +2336,9 @@
}
},
"node_modules/@codemirror/view": {
"version": "6.39.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz",
"integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==",
"version": "6.39.16",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz",
"integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
@@ -2664,15 +2665,15 @@
}
},
"node_modules/@edx/frontend-platform": {
"version": "8.5.4",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.4.tgz",
"integrity": "sha512-dGzTJ8slx81qAEhVK/sysrwChzrFSpQ9CMaBSYZUOHPS1D4Ww6lhtttDNvBtqcKrwRjzSSseZPuLiKG9ybHSGw==",
"version": "8.5.5",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz",
"integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==",
"license": "AGPL-3.0",
"dependencies": {
"@cospired/i18n-iso-languages": "4.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
"@formatjs/intl-relativetimeformat": "10.0.1",
"axios": "1.13.2",
"axios": "1.13.5",
"axios-cache-interceptor": "1.11.4",
"form-urlencoded": "4.1.4",
"glob": "7.2.3",
@@ -2710,17 +2711,6 @@
}
}
},
"node_modules/@edx/frontend-platform/node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/@edx/frontend-platform/node_modules/cookie": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
@@ -5170,6 +5160,10 @@
"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
@@ -5580,9 +5574,9 @@
}
},
"node_modules/@oxlint-tsgolint/darwin-arm64": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.11.2.tgz",
"integrity": "sha512-LXQ47SH4MjzgI8xXMMB22N9G6yXojL8YNemPgvwtMw37by5H2rOBXsdViX2r0ubV75ak1/7GlxVAFEKQ9lc+Dw==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz",
"integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==",
"cpu": [
"arm64"
],
@@ -5594,9 +5588,9 @@
]
},
"node_modules/@oxlint-tsgolint/darwin-x64": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.11.2.tgz",
"integrity": "sha512-am1cy2mhq56DhG5gdErCfAnHYr2JiJIxRtRyXfAkAVekteaAwRwK1OytjO7s455oGNUVKPD3M8bkEJ3L/FWk8A==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz",
"integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==",
"cpu": [
"x64"
],
@@ -5608,9 +5602,9 @@
]
},
"node_modules/@oxlint-tsgolint/linux-arm64": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.11.2.tgz",
"integrity": "sha512-KNMXweLVdUevvi7XvDiiJbQSBKZQmRyBAwS2G8R32AxUusdDccmt0yB++0nH5WN+U5tLLEa0BlkaVTVHhxThAw==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz",
"integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==",
"cpu": [
"arm64"
],
@@ -5622,9 +5616,9 @@
]
},
"node_modules/@oxlint-tsgolint/linux-x64": {
"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==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz",
"integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==",
"cpu": [
"x64"
],
@@ -5636,9 +5630,9 @@
]
},
"node_modules/@oxlint-tsgolint/win32-arm64": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.11.2.tgz",
"integrity": "sha512-0imJQy2VhFeOms961lgAEbmlr3LdepBb2ClWYeu0HPc8Mi05x/bT4BReFY7L4gdctajYIrKDS2Dzp2zEqeHn1g==",
"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==",
"cpu": [
"arm64"
],
@@ -5650,9 +5644,9 @@
]
},
"node_modules/@oxlint-tsgolint/win32-x64": {
"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==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz",
"integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==",
"cpu": [
"x64"
],
@@ -7063,9 +7057,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/mime": {
@@ -8601,14 +8595,13 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -8985,12 +8978,15 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"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==",
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/batch": {
@@ -9365,9 +9361,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
"version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
"funding": [
{
"type": "opencollective",
@@ -17895,21 +17891,21 @@
}
},
"node_modules/oxlint-tsgolint": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.11.2.tgz",
"integrity": "sha512-CgtoZ4vAQCWYaJwQRPIFp6aId+db/s1cgIPJky7Sx8hA/nEO/ZSfvL4bee1GmldU84GcVC8nNiF6FJEdj2xDEw==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz",
"integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==",
"dev": true,
"license": "MIT",
"bin": {
"tsgolint": "bin/tsgolint.js"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/p-limit": {
@@ -24725,6 +24721,22 @@
}
}
},
"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

@@ -51,6 +51,7 @@
"@edx/frontend-platform": "^8.4.0",
"@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",
@@ -83,7 +84,7 @@
"react": "^18.3.1",
"react-datepicker": "^8.10.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-error-boundary": "^6.0.0",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
@@ -118,7 +119,7 @@
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"oxlint-tsgolint": "^0.11.2",
"oxlint-tsgolint": "^0.16.0",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}

View File

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

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

@@ -0,0 +1,17 @@
{
"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,21 +4,17 @@ import {
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
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 { useSelector } from 'react-redux';
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 { RequestStatus, RequestStatusType } from './data/constants';
import { RequestStatusType } from './data/constants';
type ModalState = {
value: XBlock | UnitXBlock;
value?: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};
@@ -30,10 +26,8 @@ export type CourseAuthoringContextData = {
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
isUnlinkModalOpen: boolean;
@@ -66,7 +60,6 @@ export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
@@ -114,47 +107,11 @@ 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(openUnitPage);
const handleAddUnit = useCreateCourseBlock(addUnitToCourse);
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
@@ -162,9 +119,7 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
@@ -184,9 +139,7 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,

View File

@@ -31,6 +31,8 @@ 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:
@@ -141,11 +143,23 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="import"
element={<PageWrap><CourseImportPage /></PageWrap>}
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage /></PageWrap>}
element={(
<PageWrap>
<CourseExportProvider>
<CourseExportPage />
</CourseExportProvider>
</PageWrap>
)}
/>
<Route
path="optimizer"

View File

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

@@ -0,0 +1,190 @@
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,58 +1,73 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Helmet } from 'react-helmet';
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 Placeholder from '../editors/Placeholder';
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 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 getPageHeadTitle from '../generic/utils';
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
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));
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canManageAdvancedSettings: {
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
scope: courseId,
},
}, isAuthzEnabled);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const {
data: advancedSettingsData = {},
isPending: isPendingSettingsStatus,
failureReason: settingsStatusError,
} = useCourseAdvancedSettings(courseId);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const {
data: proctoringExamErrors = {},
} = useProctoringExamErrors(courseId);
const updateMutation = useUpdateCourseAdvancedSettings(courseId);
const {
isPending: isQueryPending,
isSuccess: isQuerySuccess,
error: queryError,
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
@@ -60,30 +75,34 @@ const AdvancedSettings = () => {
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
if (isQuerySuccess) {
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
} else if (queryError && !hasInternetConnectionError) {
// @ts-ignore
setErrorFields(queryError?.response?.data ?? []);
showErrorModal(true);
}
}, [savingStatus]);
}, [isQuerySuccess, queryError]);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
return (
<div className="row justify-content-center m-6">
<LoadingSpinner />
</div>
);
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
if (settingsStatusError?.response?.status === 403) {
return (
<div className="row justify-content-center m-6">
<Placeholder />
@@ -105,31 +124,42 @@ const AdvancedSettings = () => {
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setIsQueryPending(true);
setShowSuccessAlert(false);
updateMutation.mutate(parseArrayOrObjectValues(editedSettings));
} 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) && (
@@ -139,7 +169,11 @@ 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 ? (
@@ -192,8 +226,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>
@@ -235,9 +269,8 @@ const AdvancedSettings = () => {
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isFailed={Boolean(queryError)}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
@@ -248,18 +281,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(Boolean)}
].filter((action): action is JSX.Element => action !== null)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}

View File

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

View File

@@ -0,0 +1,56 @@
/* 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

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

@@ -1,48 +0,0 @@
/* 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

@@ -1,85 +0,0 @@
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,3 +14,7 @@ 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,10 +403,19 @@ mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mo
/**
* Mock for `getContentData()`
*/
export async function mockContentData(): Promise<any> {
return mockContentData.data;
export async function mockContentData(contentId: string): Promise<any> {
switch (contentId) {
case mockContentData.textXBlock:
return mockContentData.textXBlockData;
default:
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,16 +1,12 @@
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,
@@ -20,7 +16,6 @@ import messages from './messages';
import CourseChecklist from './index';
let axiosMock;
let store;
const renderComponent = () => {
render(
@@ -33,22 +28,18 @@ 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 () => {
renderComponent();
await mockStore(200);
renderComponent();
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
@@ -56,9 +47,9 @@ describe('CourseChecklistPage', () => {
});
describe('an aria-live region with', () => {
it('an aria-live region', () => {
it('an aria-live region', async () => {
renderComponent();
const ariaLiveRegion = screen.getByRole('status');
const ariaLiveRegion = await screen.findByRole('status');
expect(ariaLiveRegion).toBeDefined();
@@ -66,29 +57,17 @@ describe('CourseChecklistPage', () => {
});
it('correct content when the launch checklist has loaded', async () => {
renderComponent();
await mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
renderComponent();
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
it('correct content when the best practices checklist is loading', async () => {
renderComponent();
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();
});
renderComponent();
expect(
await screen.findByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
).toBeInTheDocument();
});
});
});
@@ -111,27 +90,15 @@ describe('CourseChecklistPage', () => {
describe('an aria-live region with', () => {
it('correct content when the launch checklist has loaded', async () => {
renderComponent();
await mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
renderComponent();
expect(await screen.findByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
it('correct content when the best practices checklist is loading', async () => {
renderComponent();
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();
});
renderComponent();
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
});
});
});
@@ -144,11 +111,7 @@ describe('CourseChecklistPage', () => {
renderComponent();
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});
});

View File

@@ -1,42 +1,33 @@
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';
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
}, [courseId]);
const {
data: bestPracticeData,
isPending: isPendingBestPacticeData,
} = useCourseBestPractices({ courseId });
const {
loadingStatus,
launchData,
bestPracticeData,
} = useSelector((state: DeprecatedReduxState) => state.courseChecklist);
data: launchData,
isPending: isPendingLaunchData,
failureReason: launchError,
} = useCourseLaunch({ courseId });
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
const isLoadingDenied = launchError?.response?.status === 403;
if (isLoadingDenied) {
return (
@@ -64,8 +55,8 @@ const CourseChecklist = () => {
/>
<AriaLiveRegion
{...{
isCourseLaunchChecklistLoading,
isCourseBestPracticeChecklistLoading,
isCourseLaunchChecklistLoading: isPendingLaunchData,
isCourseBestPracticeChecklistLoading: isPendingBestPacticeData,
enableQuality,
}}
/>
@@ -75,7 +66,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
isLoading={isPendingLaunchData}
/>
{enableQuality && (
<ChecklistSection
@@ -83,7 +74,7 @@ const CourseChecklist = () => {
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
isLoading={isPendingBestPacticeData}
/>
)}
</Stack>

View File

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

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

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

@@ -0,0 +1,52 @@
/* 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

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

@@ -1,50 +0,0 @@
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,10 +36,9 @@ import {
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
import {
courseOutlineIndexMock,
courseOutlineIndexMock as originalCourseOutlineIndexMock,
courseOutlineIndexWithoutSections,
courseBestPracticesMock,
courseLaunchMock,
@@ -71,6 +70,7 @@ 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,13 +96,6 @@ 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({}),
@@ -163,6 +156,8 @@ describe('<CourseOutline />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
selectedContainerId = undefined;
// restore index mock
courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
jest.mocked(useLocation).mockReturnValue({
pathname: mockPathname,
@@ -300,7 +295,7 @@ describe('<CourseOutline />', () => {
expect(alertElements.find(
(el) => el.classList.contains('alert-content'),
)).toHaveTextContent(
pageAlertMessages.alertFailedGeneric.defaultMessage,
'Unable to save changes. Please try again.',
);
});
@@ -404,6 +399,7 @@ 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');
@@ -428,10 +424,14 @@ 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 act(async () => {
fireEvent.click(newSubsectionButton);
});
await user.click(newSubsectionButton);
subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(3);
@@ -540,6 +540,7 @@ 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();
@@ -552,11 +553,14 @@ 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,
});
fireEvent.click(addSectionFromLibraryButton);
await user.click(addSectionFromLibraryButton);
// click dummy button to execute onComponentSelected prop.
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
@@ -705,26 +709,54 @@ describe('<CourseOutline />', () => {
});
it('check edit title works for section, subsection and unit', async () => {
const { findAllByTestId } = renderComponent();
const user = userEvent.setup();
renderComponent();
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
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(item.id))
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...item,
...section,
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 act(async () => fireEvent.blur(editField));
await user.keyboard('{enter}');
expect(
axiosMock.history.post[axiosMock.history.post.length - 1].data,
).toBe(JSON.stringify({
@@ -737,8 +769,7 @@ describe('<CourseOutline />', () => {
};
// check section
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [sectionElement] = await screen.findAllByTestId('section-card');
await checkEditTitle(sectionElement, section, 'New section name', 'section');
// check subsection
@@ -1627,15 +1658,14 @@ describe('<CourseOutline />', () => {
});
it('check update highlights when update highlights query is successfully', async () => {
const { getByRole } = renderComponent();
const user = userEvent.setup();
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
@@ -1653,12 +1683,21 @@ describe('<CourseOutline />', () => {
...section,
highlights,
});
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
await waitFor(() => {
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
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();
});
it('check whether section move up and down options work correctly', async () => {
@@ -2270,6 +2309,7 @@ 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;
@@ -2328,13 +2368,6 @@ 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'));
@@ -2343,18 +2376,18 @@ describe('<CourseOutline />', () => {
// check alerts for errorFiles
let dismissBtn = await within(alerts[0]).findByText('Dismiss');
fireEvent.click(dismissBtn);
await user.click(dismissBtn);
// check alerts for conflictingFiles
dismissBtn = await within(alerts[1]).findByText('Dismiss');
fireEvent.click(dismissBtn);
await user.click(dismissBtn);
// check alerts for newFiles
dismissBtn = await within(alerts[2]).findByText('Dismiss');
fireEvent.click(dismissBtn);
await user.click(dismissBtn);
// check pasteFileNotices in store
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
// check that all alerts are gone
expect((screen.queryAllByRole('alert')).length).toEqual(0);
});
it('should show toats on export tags', async () => {

View File

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

View File

@@ -14,10 +14,8 @@ jest.mock('@src/studio-home/data/selectors', () => ({
}),
}));
const handleAddSection = { mutateAsync: jest.fn() };
const handleAddSubsection = { mutateAsync: jest.fn() };
const handleAddAndOpenUnit = { mutateAsync: jest.fn() };
const handleAddUnit = { mutateAsync: jest.fn() };
const handleAddBlock = { mutateAsync: jest.fn() };
const courseUsageKey = 'some/usage/key';
const setCurrentSelection = jest.fn();
jest.mock('@src/CourseAuthoringContext', () => ({
@@ -25,10 +23,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({
courseId: 5,
courseUsageKey,
getUnitUrl: (id: string) => `/some/${id}`,
handleAddSection,
handleAddSubsection,
handleAddAndOpenUnit,
handleAddUnit,
handleAddBlock,
setCurrentSelection,
}),
}));
@@ -81,9 +77,11 @@ 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}` });
@@ -91,17 +89,18 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
await userEvent.click(newBtn);
switch (containerType) {
case ContainerType.Section:
await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: 'Section',
}));
break;
case ContainerType.Subsection:
await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Sequential,
parentLocator,
displayName: 'Subsection',
sectionId: parentLocator,
}));
break;
case ContainerType.Unit:
@@ -109,6 +108,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
type: ContainerType.Vertical,
parentLocator,
displayName: 'Unit',
sectionId: grandParentLocator,
}));
break;
default:

View File

@@ -28,9 +28,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
const intl = useIntl();
const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
const {
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddBlock,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
@@ -58,10 +56,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
>
<Col className="py-3">
<Stack direction="horizontal" gap={3}>
{(handleAddSection.isPending
|| handleAddSubsection.isPending
|| handleAddAndOpenUnit.isPending
|| handleAddUnit.isPending) && (
{(handleAddAndOpenUnit.isPending || handleAddBlock.isPending) && (
<LoadingSpinner />
)}
<h3 className="mb-0">{getTitle()}</h3>
@@ -86,11 +81,11 @@ interface BaseProps {
btnClasses?: string;
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
parentLocator: string;
grandParentLocator?: string;
}
interface NewChildButtonsProps extends BaseProps {
handleUseFromLibraryClick?: () => void;
grandParentLocator?: string;
}
const NewOutlineAddChildButtons = ({
@@ -113,8 +108,7 @@ const NewOutlineAddChildButtons = ({
const intl = useIntl();
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddBlock,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const { startCurrentFlow } = useOutlineSidebarContext();
@@ -132,7 +126,7 @@ const NewOutlineAddChildButtons = ({
newButton: messages.newSectionButton,
importButton: messages.useSectionFromLibraryButton,
};
onNewCreateContent = () => handleAddSection.mutateAsync({
onNewCreateContent = () => handleAddBlock.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -144,10 +138,11 @@ const NewOutlineAddChildButtons = ({
newButton: messages.newSubsectionButton,
importButton: messages.useSubsectionFromLibraryButton,
};
onNewCreateContent = () => handleAddSubsection.mutateAsync({
onNewCreateContent = () => handleAddBlock.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId: parentLocator,
});
flowType = ContainerType.Subsection;
break;
@@ -160,6 +155,7 @@ const NewOutlineAddChildButtons = ({
type: ContainerType.Vertical,
parentLocator,
displayName: COURSE_BLOCK_NAMES.vertical.name,
sectionId: grandParentLocator,
});
flowType = ContainerType.Unit;
break;
@@ -226,6 +222,7 @@ 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,
@@ -237,8 +234,7 @@ const LegacyOutlineAddChildButtons = ({
const intl = useIntl();
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddBlock,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const [
@@ -263,12 +259,12 @@ const LegacyOutlineAddChildButtons = ({
importButton: messages.useSectionFromLibraryButton,
modalTitle: messages.sectionPickerModalTitle,
};
onNewCreateContent = () => handleAddSection.mutateAsync({
onNewCreateContent = () => handleAddBlock.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
@@ -283,16 +279,18 @@ const LegacyOutlineAddChildButtons = ({
importButton: messages.useSubsectionFromLibraryButton,
modalTitle: messages.subsectionPickerModalTitle,
};
onNewCreateContent = () => handleAddSubsection.mutateAsync({
onNewCreateContent = () => handleAddBlock.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId: parentLocator,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator,
libraryContentKey: selected.usageKey,
sectionId: parentLocator,
});
visibleTabs = [ContentType.subsections];
query = ['block_type = "subsection"'];
@@ -307,12 +305,14 @@ 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,6 +48,7 @@ interface CardHeaderProps {
onClickMoveDown: () => void;
onClickCopy?: () => void;
onClickCard?: (e: React.MouseEvent) => void;
onClickManageTags?: () => void;
titleComponent: ReactNode;
namePrefix: string;
proctoringExamConfigurationLink?: string,
@@ -86,6 +87,7 @@ const CardHeader = ({
onClickMoveDown,
onClickCopy,
onClickCard,
onClickManageTags,
titleComponent,
namePrefix,
actions,
@@ -113,6 +115,7 @@ const CardHeader = ({
if (showNewSidebar && showAlignSidebar) {
setCurrentPageKey('align');
onClickMenuButton();
onClickManageTags?.();
} else {
openLegacyTagsDrawer();
}

View File

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

View File

@@ -1,11 +1,21 @@
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
import type { XBlock } from '@src/data/types';
import { getCourseKey } from '@src/generic/key-utils';
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 { 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,
@@ -14,6 +24,12 @@ import {
getCourseDetails,
getCourseItem,
publishCourseItem,
configureCourseSection,
configureCourseSubsection,
configureCourseUnit,
updateCourseSectionHighlights,
duplicateCourseItem,
pasteBlock,
} from './api';
export const courseOutlineQueryKeys = {
@@ -26,6 +42,14 @@ 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',
@@ -41,22 +65,30 @@ export const courseOutlineQueryKeys = {
],
};
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;
type ScrollState = {
id?: 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) });
}
};
@@ -66,31 +98,74 @@ 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),
onSettled: async (data: { locator: string; }, _err, variables) => {
onSuccess: async (data: { locator: string; }, variables) => {
await callback?.(data.locator, variables.parentLocator);
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) });
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
});
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
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));
}
},
});
};
export const useCourseItemData = <T = XBlock>(itemId?: string, initialData?: T, enabled: boolean = true) => (
useQuery({
export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initialData?: T, enabled: boolean = true) => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useQuery<T>({
initialData,
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
queryFn: enabled && itemId ? () => getCourseItem<T>(itemId!) : skipToken,
})
);
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,
});
};
export const useCourseDetails = (courseId?: string, enabled: boolean = true) => (
useQuery({
@@ -99,6 +174,15 @@ 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({
@@ -107,10 +191,9 @@ 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) });
},
});
};
@@ -122,9 +205,8 @@ export const usePublishCourseItem = () => {
itemId: string;
} & ParentIds) => publishCourseItem(variables.itemId),
onSettled: (_data, _err, variables) => {
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
},
});
};
@@ -141,3 +223,103 @@ 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,6 +7,5 @@ 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,9 +47,8 @@ const initialState = {
},
enableProctoredExams: false,
enableTimedExams: false,
pasteFileNotices: {},
createdOn: null,
} satisfies CourseOutlineState as unknown as CourseOutlineState;
} satisfies CourseOutlineState;
const slice = createSlice({
name: 'courseOutline',
@@ -133,27 +132,6 @@ 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,
@@ -170,25 +148,6 @@ 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) {
@@ -214,20 +173,11 @@ 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,
@@ -242,13 +192,9 @@ export const {
deleteSection,
deleteSubsection,
deleteUnit,
addUnit,
duplicateSection,
reorderSectionList,
setPasteFileNotices,
removePasteFileNotices,
dismissError,
resetScrollField,
} = slice.actions;
export const {

View File

@@ -11,21 +11,15 @@ 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 {
@@ -39,9 +33,7 @@ import {
updateSavingStatus,
updateSectionList,
updateFetchSectionLoadingStatus,
duplicateSection,
reorderSectionList,
setPasteFileNotices,
updateCourseLaunchQueryStatus,
} from './slice';
@@ -201,32 +193,13 @@ export function fetchCourseReindexQuery(reindexLink: string) {
/**
* Fetches course sections and optionally scrolls to a specific subsection/unit.
*/
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
subsectionId: string,
unitId?: string,
}) {
export function fetchCourseSectionQuery(sectionIds: 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));
@@ -240,186 +213,6 @@ export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
};
}
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[],
@@ -515,27 +308,6 @@ 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,7 +74,6 @@ export interface CourseOutlineState {
actions: XBlockActions;
enableProctoredExams: boolean;
enableTimedExams: boolean;
pasteFileNotices: object;
createdOn: null | Date;
}
@@ -90,3 +89,42 @@ 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,13 +12,21 @@ 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, useDeleteCourseItem } from '@src/course-outline/data/apiHooks';
import {
courseOutlineQueryKeys,
useConfigureSection,
useConfigureSubsection,
useConfigureUnit,
useDeleteCourseItem,
useDuplicateItem,
usePasteItem,
useUpdateCourseSectionHighlights,
} from '@src/course-outline/data/apiHooks';
import { COURSE_BLOCK_NAMES } from './constants';
import {
deleteSection,
deleteSubsection,
deleteUnit,
resetScrollField,
updateSavingStatus,
} from './data/slice';
import {
@@ -33,23 +41,15 @@ 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 {
handleAddSection,
handleAddBlock,
setCurrentSelection,
currentSelection,
currentUnlinkModalData,
@@ -99,18 +99,19 @@ const useCourseOutline = ({ courseId }) => {
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
const handlePasteClipboardClick = (parentLocator, sectionId) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
const resetScrollState = () => {
dispatch(resetScrollField());
const { mutate: pasteClipboardContent, isPending: isPasting } = usePasteItem(courseId);
const handlePasteClipboardClick = (parentLocator, subsectionId, sectionId) => {
pasteClipboardContent({
parentLocator,
subsectionId,
sectionId,
});
};
const headerNavigationsActions = {
handleNewSection: () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleAddSection.mutateAsync({
handleAddBlock.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseStructure?.id,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -147,9 +148,16 @@ const useCourseOutline = ({ courseId }) => {
openHighlightsModal();
};
const {
mutate: updateCourseSectionHighlights,
isPending: isSectionHighlightsUpdatePending,
} = useUpdateCourseSectionHighlights();
const handleHighlightsFormSubmit = (highlights) => {
const dataToSend = Object.values(highlights).filter(Boolean);
dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend));
updateCourseSectionHighlights({
sectionId: currentSelection?.currentId,
highlights: dataToSend,
});
closeHighlightsModal();
};
@@ -169,39 +177,52 @@ const useCourseOutline = ({ courseId }) => {
return;
}
await unlinkDownstream(currentUnlinkModalData.value.id, {
await unlinkDownstream({
downstreamBlockId: currentUnlinkModalData.value.id,
sectionId: currentUnlinkModalData.sectionId,
subsectionId: currentUnlinkModalData.subsectionId,
}, {
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 handleConfigureItemSubmit = (...arg) => {
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 category = getBlockType(currentSelection.currentId);
switch (category) {
case COURSE_BLOCK_NAMES.chapter.id:
dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg));
configureCourseSection({
sectionId: currentSelection?.sectionId,
...variables,
});
break;
case COURSE_BLOCK_NAMES.sequential.id:
dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
configureCourseSubsection({
itemId: currentSelection?.currentId,
sectionId: currentSelection?.sectionId,
...variables,
});
break;
case COURSE_BLOCK_NAMES.vertical.id:
dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
configureCourseUnit({
unitId: currentSelection?.currentId,
sectionId: currentSelection?.sectionId,
...variables,
});
break;
default:
// istanbul ignore next
@@ -275,20 +296,35 @@ const useCourseOutline = ({ courseId }) => {
deleteSubsection,
]);
const {
mutate: duplicateItem,
isPending: isDuplicatingItem,
} = useDuplicateItem(courseId);
const handleDuplicateSectionSubmit = () => {
dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id));
duplicateItem({
itemId: currentSelection?.currentId,
parentId: courseStructure.id,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
};
const handleDuplicateSubsectionSubmit = () => {
dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId));
duplicateItem({
itemId: currentSelection?.currentId,
parentId: currentSelection?.sectionId,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
};
const handleDuplicateUnitSubmit = () => {
dispatch(duplicateUnitQuery(
currentSelection?.currentId,
currentSelection?.subsectionId,
currentSelection?.sectionId,
));
duplicateItem({
itemId: currentSelection?.currentId,
parentId: currentSelection?.subsectionId,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
};
const handleVideoSharingOptionChange = (value) => {
@@ -371,12 +407,14 @@ const useCourseOutline = ({ courseId }) => {
isConfigureModalOpen,
openConfigureModal,
handleConfigureModalClose,
isConfigureOpPending,
headerNavigationsActions,
handleEnableHighlightsSubmit,
handleHighlightsFormSubmit,
handleConfigureItemSubmit,
statusBarData,
isEnableHighlightsModalOpen,
isSectionHighlightsUpdatePending,
openEnableHighlightsModal,
closeEnableHighlightsModal,
isInternetConnectionAlertFailed: isSavingStatusFailed,
@@ -391,9 +429,11 @@ const useCourseOutline = ({ courseId }) => {
handleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
isDuplicatingItem,
handleDuplicateUnitSubmit,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
isPasting,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextLearnmoreUrl,
@@ -407,7 +447,6 @@ 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 { getXBlockBaseApiUrl } from '@src/course-outline/data/api';
import { getXBlockApiUrl, getXBlockBaseApiUrl } from '@src/course-outline/data/api';
import MockAdapter from 'axios-mock-adapter/types';
import { AddSidebar } from './AddSidebar';
@@ -199,10 +199,13 @@ 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',
@@ -250,6 +253,8 @@ 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,8 +50,7 @@ type AddContentButtonProps = {
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddBlock,
handleAddAndOpenUnit,
} = useCourseAuthoringContext();
const {
@@ -65,7 +64,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
let subsectionParentId = lastEditableSubsection?.data?.id;
const addSection = (onSuccess?: (data: { locator: string; }) => void) => {
handleAddSection.mutate({
handleAddBlock.mutate({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
@@ -82,10 +81,11 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
};
const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => {
handleAddSubsection.mutate({
handleAddBlock.mutate({
type: ContainerType.Sequential,
parentLocator: sectionId,
displayName: COURSE_BLOCK_NAMES.sequential.name,
sectionId,
}, {
onSuccess: (data: { locator: string; }) => {
// istanbul ignore next
@@ -146,8 +146,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
}, [
blockType,
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddBlock,
handleAddAndOpenUnit,
currentFlow,
sectionParentId,
@@ -155,7 +154,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
lastEditableSubsection,
]);
const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending;
const disabled = handleAddBlock.isPending || handleAddAndOpenUnit.isPending;
return (
<BlockCardButton
@@ -213,9 +212,7 @@ const AddNewContent = () => {
const ShowLibraryContent = () => {
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddBlock,
} = useCourseAuthoringContext();
const {
isCurrentFlowOn,
@@ -233,7 +230,7 @@ const ShowLibraryContent = () => {
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
switch (blockType) {
case 'section':
await handleAddSection.mutateAsync({
await handleAddBlock.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
@@ -243,11 +240,12 @@ const ShowLibraryContent = () => {
case 'subsection':
sectionParentId = currentFlow?.parentLocator || sectionParentId;
if (sectionParentId) {
await handleAddSubsection.mutateAsync({
await handleAddBlock.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator: sectionParentId,
libraryContentKey: usageKey,
sectionId: sectionParentId,
});
}
break;
@@ -257,7 +255,7 @@ const ShowLibraryContent = () => {
);
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
if (subsectionParentId) {
await handleAddUnit.mutateAsync({
await handleAddBlock.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator: subsectionParentId,
@@ -273,9 +271,7 @@ const ShowLibraryContent = () => {
stopCurrentFlow();
}, [
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
handleAddBlock,
lastEditableSection,
lastEditableSubsection,
currentFlow,

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ export type OutlineSidebarPages = {
align?: SidebarPage;
};
export const getOutlineSidebarPages = () => ({
const getOutlineSidebarPages = () => ({
info: {
component: InfoSidebar,
icon: Info,
@@ -55,24 +55,24 @@ export const getOutlineSidebarPages = () => ({
* export function CourseOutlineSidebarWrapper(
* { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps },
* ) {
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
* const sidebarPages = useOutlineSidebarPagesContext();
*
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
* const sidebarPages = useOutlineSidebarPagesContext();
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* return (
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </OutlineSidebarPagesContext.Provider>
*}
* return (
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </OutlineSidebarPagesContext.Provider>
* );
* }
*/
export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined);
@@ -94,6 +94,7 @@ export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesPro
export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => {
const ctx = useContext(OutlineSidebarPagesContext);
// istanbul ignore if: this should never happen
if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); }
return ctx;
};

View File

@@ -2,12 +2,18 @@ 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 { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard';
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 { 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 {
@@ -16,17 +22,46 @@ 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} />
<LibraryReferenceCard
itemId={itemId}
sectionId={selectedContainerState?.sectionId}
postChange={handleOnPostChangeSync}
goToParent={openContainerInfoSidebar}
/>
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages[`${category}ContentSummaryText`])}

View File

@@ -170,66 +170,6 @@ 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,9 +11,10 @@ import {
} from '@openedx/paragon/icons';
import { uniqBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useState } from 'react';
import { useDispatch } 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';
@@ -23,8 +24,7 @@ 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 { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import { dismissError } from '../data/slice';
import messages from './messages';
const PageAlerts = ({
@@ -48,7 +48,7 @@ const PageAlerts = ({
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
localStorage.getItem(discussionAlertDismissKey) === null,
);
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
const { data: pasteFileNotices, setData: setPasteFileNotices } = usePasteFileNotices(courseId);
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
const navigate = useNavigate();
@@ -247,16 +247,16 @@ const PageAlerts = ({
const newFilesPasteAlert = () => {
const onDismiss = () => {
dispatch(removePasteFileNotices(['newFiles']));
setPasteFileNotices({ ...pasteFileNotices, newFiles: [] });
};
if (newFiles?.length) {
if (pasteFileNotices?.newFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: newFiles.length })}
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: pasteFileNotices.newFiles.length })}
description={intl.formatMessage(
messages.newFileAlertDesc,
{ newFilesLen: newFiles.length, newFilesStr: newFiles.join(', ') },
{ newFilesLen: pasteFileNotices.newFiles.length, newFilesStr: pasteFileNotices.newFiles.join(', ') },
)}
dismissible
show
@@ -279,16 +279,16 @@ const PageAlerts = ({
const errorFilesPasteAlert = () => {
const onDismiss = () => {
dispatch(removePasteFileNotices(['errorFiles']));
setPasteFileNotices({ ...pasteFileNotices, errorFiles: [] });
};
if (errorFiles?.length) {
if (pasteFileNotices?.errorFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(messages.errorFileAlertTitle)}
description={intl.formatMessage(
messages.errorFileAlertDesc,
{ errorFilesLen: errorFiles.length, errorFilesStr: errorFiles.join(', ') },
{ errorFilesLen: pasteFileNotices.errorFiles.length, errorFilesStr: pasteFileNotices.errorFiles.join(', ') },
)}
dismissible
show
@@ -303,19 +303,22 @@ const PageAlerts = ({
const conflictingFilesPasteAlert = () => {
const onDismiss = () => {
dispatch(removePasteFileNotices(['conflictingFiles']));
setPasteFileNotices({ ...pasteFileNotices, conflictingFiles: [] });
};
if (conflictingFiles?.length) {
if (pasteFileNotices?.conflictingFiles?.length) {
return (
<AlertMessage
title={intl.formatMessage(
messages.conflictingFileAlertTitle,
{ conflictingFilesLen: conflictingFiles.length },
{ conflictingFilesLen: pasteFileNotices.conflictingFiles.length },
)}
description={intl.formatMessage(
messages.conflictingFileAlertDesc,
{ conflictingFilesLen: conflictingFiles.length, conflictingFilesStr: conflictingFiles.join(', ') },
{
conflictingFilesLen: pasteFileNotices.conflictingFiles.length,
conflictingFilesStr: pasteFileNotices.conflictingFiles.join(', '),
},
)}
dismissible
show

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
act,
render,
@@ -22,9 +21,10 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
let mockNotices = {};
jest.mock('@src/course-outline/data/apiHooks', () => ({
...jest.requireActual('@src/course-outline/data/apiHooks'),
usePasteFileNotices: () => ({ data: mockNotices }),
}));
jest.mock('../../course-libraries/data/apiHooks', () => ({
@@ -72,7 +72,7 @@ describe('<PageAlerts />', () => {
},
});
store = initializeStore();
useSelector.mockReturnValue({});
mockNotices = {};
});
it('renders null when no alerts are present', async () => {
@@ -174,11 +174,11 @@ describe('<PageAlerts />', () => {
});
it('renders new & error files alert', async () => {
useSelector.mockReturnValue({
mockNotices = {
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 () => {
useSelector.mockReturnValue({
mockNotices = {
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,16 +1,15 @@
/* eslint-disable import/named */
import React, { useMemo } from 'react';
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
ActionRow,
} from '@openedx/paragon';
import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks';
import { 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';
@@ -26,22 +25,6 @@ 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) {
@@ -52,10 +35,6 @@ 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,8 +26,6 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
handleAddSubsectionFromLibrary: jest.fn(),
handleNewSubsectionSubmit: jest.fn(),
setCurrentSelection,
}),
}));
@@ -99,7 +97,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
isSectionsExpanded
isSelfPaced={false}
isCustomRelativeDatesActive={false}
resetScrollState={jest.fn()}
{...props}
>
<span>children</span>
@@ -321,6 +318,7 @@ describe('<SectionCard />', () => {
it('should open align sidebar', async () => {
const user = userEvent.setup();
const mockSetCurrentPageKey = jest.fn();
const mockSetSelectedContainerState = jest.fn();
const testSidebarPage = {
component: CourseInfoSidebar,
@@ -346,6 +344,7 @@ describe('<SectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -369,5 +368,9 @@ describe('<SectionCard />', () => {
currentId: section.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: section.id,
sectionId: section.id,
});
});
});

View File

@@ -1,7 +1,6 @@
import {
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import {
Bubble, Button, useToggle,
} from '@openedx/paragon';
@@ -14,7 +13,6 @@ 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';
@@ -24,8 +22,9 @@ 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 } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
import moment from 'moment';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import messages from './messages';
interface SectionCardProps {
@@ -41,7 +40,6 @@ interface SectionCardProps {
index: number,
canMoveItem: (oldIndex: number, newIndex: number) => boolean,
onOrderChange: (oldIndex: number, newIndex: number) => void,
resetScrollState: () => void,
}
const SectionCard = ({
@@ -57,12 +55,10 @@ const SectionCard = ({
onDuplicateSubmit,
isSectionsExpanded,
onOrderChange,
resetScrollState,
}: SectionCardProps) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const {
@@ -71,6 +67,7 @@ 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
@@ -111,6 +108,10 @@ 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]);
@@ -152,13 +153,13 @@ const SectionCard = ({
}, [activeId, overId]);
useEffect(() => {
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
if (currentRef.current && (scrollState?.id === section.id || isScrolledToElement)) {
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop, true);
resetScrollState();
resetScrollState().catch((error) => handleResponseErrors(error));
}
}, [isScrolledToElement]);
}, [isScrolledToElement, scrollState, resetScrollState]);
useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the section is expanded
@@ -167,11 +168,13 @@ const SectionCard = ({
}, [locatorId, setIsExpanded]);
const handleOnPostChangeSync = useCallback(() => {
dispatch(fetchCourseSectionQuery([section.id]));
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [dispatch, section, courseId, queryClient]);
}, [section, courseId, queryClient]);
// re-create actions object for customizations
const actions = { ...sectionActions };
@@ -199,6 +202,13 @@ const SectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: section.id,
sectionId: section.id,
});
};
const handleOpenHighlightsModal = () => {
onOpenHighlightsModal(section);
};
@@ -284,6 +294,7 @@ const SectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

@@ -31,8 +31,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
handleAddAndOpenUnit: handleOnAddUnitFromLibrary,
handleAddSubsection: {},
handleAddSection: {},
handleAddBlock: {},
setCurrentSelection,
}),
}));
@@ -127,7 +126,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}
onPasteClick={jest.fn()}
resetScrollState={jest.fn()}
isSectionsExpanded={false}
{...props}
>
@@ -344,6 +342,7 @@ 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,
});
});
@@ -416,6 +415,7 @@ 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,6 +441,7 @@ describe('<SubsectionCard />', () => {
stopCurrentFlow: jest.fn(),
openContainerInfoSidebar: jest.fn(),
clearSelection: jest.fn(),
setSelectedContainerState: mockSetSelectedContainerState,
}));
setConfig({
...getConfig(),
@@ -465,5 +466,10 @@ describe('<SubsectionCard />', () => {
subsectionId: subsection.id,
sectionId: section.id,
});
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
});
});

View File

@@ -1,7 +1,6 @@
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';
@@ -15,7 +14,6 @@ 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';
@@ -26,8 +24,9 @@ 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 } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
import moment from 'moment';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import messages from './messages';
interface SubsectionCardProps {
@@ -43,8 +42,11 @@ interface SubsectionCardProps {
getPossibleMoves: (index: number, step: number) => void,
onOrderChange: (section: XBlock, moveDetails: any) => void,
onOpenConfigureModal: () => void,
onPasteClick: (parentLocator: string, sectionId: string) => void,
resetScrollState: () => void,
onPasteClick: (
parentLocator: string,
subsectionId: string,
sectionId: string
) => void,
}
const SubsectionCard = ({
@@ -61,13 +63,11 @@ 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 } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
@@ -80,6 +80,7 @@ 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 {
@@ -146,6 +147,10 @@ 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]);
@@ -162,12 +167,22 @@ const SubsectionCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: subsection.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleOnPostChangeSync = useCallback(() => {
dispatch(fetchCourseSectionQuery([section.id]));
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [dispatch, section, queryClient, courseId]);
}, [section, queryClient, courseId]);
const handleSubsectionMoveUp = () => {
onOrderChange(section, moveUpDetails);
@@ -177,7 +192,7 @@ const SubsectionCard = ({
onOrderChange(section, moveDownDetails);
};
const handlePasteButtonClick = () => onPasteClick(id, section.id);
const handlePasteButtonClick = () => onPasteClick(id, id, section.id);
const titleComponent = (
<TitleButton
@@ -212,13 +227,13 @@ const SubsectionCard = ({
useEffect(() => {
// if this items has been newly added, scroll to it.
if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) {
if (currentRef.current && (scrollState?.id === subsection.id || isScrolledToElement)) {
// Align element closer to the top of the screen if scrolling for search result
const alignWithTop = !!isScrolledToElement;
scrollToElement(currentRef.current, alignWithTop, true);
resetScrollState();
resetScrollState().catch((error) => handleResponseErrors(error));
}
}, [isScrolledToElement]);
}, [isScrolledToElement, scrollState, resetScrollState]);
useEffect(() => {
// If the locatorId is set/changed, we need to make sure that the subsection is expanded
@@ -290,6 +305,7 @@ const SubsectionCard = ({
onClickSync={openSyncModal}
onClickCard={(e) => onClickCard(e, true)}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

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

View File

@@ -5,14 +5,12 @@ 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';
@@ -24,8 +22,9 @@ 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 } from '@src/course-outline/data/apiHooks';
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } 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 {
@@ -61,9 +60,8 @@ const UnitCard = ({
discussionsSettings,
}: UnitCardProps) => {
const currentRef = useRef(null);
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const locatorId = searchParams.get('show');
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
const namePrefix = 'unit';
@@ -79,6 +77,7 @@ 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 {
@@ -140,6 +139,14 @@ const UnitCard = ({
});
};
const handleClickManageTags = () => {
setSelectedContainerState({
currentId: unit.id,
subsectionId: subsection.id,
sectionId: section.id,
});
};
const handleUnitMoveUp = () => {
onOrderChange(section, moveUpDetails);
};
@@ -154,11 +161,13 @@ const UnitCard = ({
};
const handleOnPostChangeSync = useCallback(() => {
dispatch(fetchCourseSectionQuery([section.id]));
queryClient.invalidateQueries({
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
});
if (courseId) {
invalidateLinksQuery(queryClient, courseId);
}
}, [dispatch, section, queryClient, courseId]);
}, [section, queryClient, courseId]);
const onClickCard = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
@@ -194,18 +203,23 @@ 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 && (unit.shouldScroll || isScrolledToElement)) {
if (currentRef.current && (scrollState?.id === unit.id || 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]);
}, [isScrolledToElement, scrollState, resetScrollState]);
if (!isHeaderVisible) {
return null;
@@ -269,6 +283,7 @@ const UnitCard = ({
onClickSync={openSyncModal}
onClickCard={onClickCard}
onClickDuplicate={onDuplicateSubmit}
onClickManageTags={handleClickManageTags}
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}

View File

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

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

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

View File

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

@@ -0,0 +1,96 @@
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,6 +4,8 @@ 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

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

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

@@ -0,0 +1,64 @@
/* 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

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

@@ -1,46 +0,0 @@
/* 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

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

View File

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

118
src/course-team/hooks.tsx Normal file
View File

@@ -0,0 +1,118 @@
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,6 +13,11 @@ 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,6 +25,7 @@ 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,
@@ -89,6 +90,7 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentData.applyMock();
const {
block_id: id,
@@ -120,10 +122,10 @@ jest.mock('@src/studio-home/hooks', () => ({
* This can be used to mimic events like deletion or other actions
* sent from Backbone or other sources via postMessage.
*
* @param {string} type - The type of the message event (e.g., 'deleteXBlock').
* @param {Object} payload - The payload data for the message event.
* @param type - The type of the message event (e.g., 'deleteXBlock').
* @param payload - The payload data for the message event.
*/
function simulatePostMessageEvent(type, payload) {
function simulatePostMessageEvent(type: string, payload?: object) {
const messageEvent = new MessageEvent('message', {
data: { type, payload },
});
@@ -329,7 +331,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.deleteXBlock, {
@@ -420,7 +422,7 @@ describe('<CourseUnit />', () => {
)).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(await screen.findByText(
@@ -483,10 +485,7 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.onPost(postXBlockBaseApiUrl())
.replyOnce(200, { locator: '1234567890' });
const updatedCourseVerticalChildren = [
@@ -518,7 +517,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.duplicateXBlock, {
@@ -564,7 +563,7 @@ describe('<CourseUnit />', () => {
expect(xblockIframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
@@ -614,16 +613,14 @@ describe('<CourseUnit />', () => {
it('checks courseUnit title changing when edit query is successfully', async () => {
const user = userEvent.setup();
render(<RootWrapper />);
let editTitleButton = null;
let titleEditField = null;
const newDisplayName = `${unitDisplayName} new`;
axiosMock
.onPost(getXBlockBaseApiUrl(blockId, {
.onPost(getXBlockBaseApiUrl(blockId), {
metadata: {
display_name: newDisplayName,
},
}))
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -632,7 +629,6 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
@@ -651,15 +647,14 @@ describe('<CourseUnit />', () => {
},
});
await waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
titleEditField = within(unitHeaderTitle)
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
});
const unitHeaderTitle = await screen.findByTestId('unit-header-title');
const editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
let titleEditField = within(unitHeaderTitle)
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
expect(titleEditField).not.toBeInTheDocument();
await user.click(editTitleButton);
titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
await user.clear(titleEditField);
@@ -678,7 +673,7 @@ describe('<CourseUnit />', () => {
const user = userEvent.setup();
const { courseKey, locator } = courseCreateXblockMock;
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(500, {});
render(<RootWrapper />);
@@ -693,7 +688,7 @@ describe('<CourseUnit />', () => {
it('handle creating Problem xblock and showing editor modal', async () => {
const user = userEvent.setup();
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
@@ -757,17 +752,17 @@ describe('<CourseUnit />', () => {
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const user = userEvent.setup();
render(<RootWrapper />);
let units = null;
let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
await waitFor(async () => {
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -786,7 +781,7 @@ describe('<CourseUnit />', () => {
const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
await user.click(addNewUnitBtn);
expect(units.length).toEqual(updatedCourseUnits.length);
@@ -824,18 +819,18 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
const newDisplayName = `${unitDisplayName} new`;
axiosMock
.onPost(getXBlockBaseApiUrl(blockId, {
.onPost(getXBlockBaseApiUrl(blockId), {
metadata: {
display_name: newDisplayName,
},
}))
})
.reply(200, { dummy: 'value' })
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -843,7 +838,6 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
@@ -877,7 +871,7 @@ describe('<CourseUnit />', () => {
const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true });
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
@@ -948,12 +942,13 @@ describe('<CourseUnit />', () => {
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore();
});
it('handles creating Video xblock and showing editor modal', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
const user = userEvent.setup();
render(<RootWrapper />);
@@ -1158,11 +1153,12 @@ describe('<CourseUnit />', () => {
const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage });
expect(makeVisibilityBtn).toBeInTheDocument();
expect(cancelBtn).toBeInTheDocument();
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
expect(within(modalNotification)
.getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
@@ -1250,8 +1246,9 @@ describe('<CourseUnit />', () => {
.getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
expect(within(modalNotification)
.getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument();
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage });
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
expect(actionBtn).toBeInTheDocument();
@@ -1396,17 +1393,17 @@ describe('<CourseUnit />', () => {
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null;
let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
await waitFor(() => {
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -1423,7 +1420,7 @@ describe('<CourseUnit />', () => {
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units.length).toEqual(updatedCourseUnits.length);
expect(mockedUsedNavigate).toHaveBeenCalled();
@@ -1457,7 +1454,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.copyXBlock, {
@@ -1493,7 +1490,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
});
});
@@ -1520,12 +1517,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1573,12 +1570,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1628,12 +1625,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1806,7 +1803,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(async () => {
const currentUnit = currentSubsection.child_info.children[0];
const currentUnit = currentSubsection.child_info!.children[0];
const currentUnitItemBtn = screen.getByRole('button', {
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
@@ -1846,13 +1843,13 @@ describe('<CourseUnit />', () => {
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
const dismissButton = screen.queryByRole('button', {
const dismissButton = screen.getByRole('button', {
name: /dismiss/i, hidden: true,
});
const undoButton = screen.queryByRole('button', {
const undoButton = screen.getByRole('button', {
name: messages.undoMoveButton.defaultMessage, hidden: true,
});
const newLocationButton = screen.queryByRole('button', {
const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
@@ -1892,7 +1889,7 @@ describe('<CourseUnit />', () => {
callbackFn: requestData.callbackFn,
}), store.dispatch);
const newLocationButton = screen.queryByRole('button', {
const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
await user.click(newLocationButton);
@@ -2246,6 +2243,7 @@ describe('<CourseUnit />', () => {
];
sidebarContent.forEach(({ query, type, name }) => {
// @ts-ignore
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
});
@@ -2271,10 +2269,10 @@ describe('<CourseUnit />', () => {
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
axiosMock
.onPost(postXBlockBaseApiUrl({
.onPost(postXBlockBaseApiUrl(), {
parent_locator: blockId,
duplicate_source_locator: targetChild.block_id,
}))
})
.replyOnce(200, { locator: '1234567890' });
axiosMock
@@ -2939,9 +2937,10 @@ describe('<CourseUnit />', () => {
await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: blockId });
simulatePostMessageEvent(messageTypes.openManageTags, { contentId: mockContentData.textXBlock });
await screen.findByText('Align');
await screen.findByText(mockContentData.textXBlockData.displayName);
});
describe('Add sidebar', () => {
@@ -2970,7 +2969,7 @@ describe('<CourseUnit />', () => {
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse((req.body ?? ''));
const requestData = JSON.parse((req.body ?? '') as string);
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise Instantsearch will update the UI and change the query,
@@ -3244,4 +3243,21 @@ 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

@@ -48,6 +48,7 @@ import MoveModal from './move-modal';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext';
import { UnitSidebarPagesProvider } from './unit-sidebar/UnitSidebarPagesContext';
import { UNIT_VISIBILITY_STATES } from './constants';
import { isUnitPageNewDesignEnabled } from './utils';
@@ -242,178 +243,180 @@ const CourseUnit = () => {
return (
<UnitSidebarProvider readOnly={readOnly}>
<Container fluid className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
<AlertMessage
key="xblock-moved-alert"
data-testid="xblock-moved-alert"
show={movedXBlockParams.isSuccess}
variant="success"
icon={CheckCircleIcon}
title={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelTitle)
: intl.formatMessage(messages.alertMoveSuccessTitle)}
description={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? undefined : [
<Button
onClick={handleRollbackMovedXBlock}
key="xblock-moved-alert-undo-move-button"
>
{intl.formatMessage(messages.undoMoveButton)}
</Button>,
<Button
onClick={handleNavigateToTargetUnit}
key="xblock-moved-alert-new-location-button"
>
{intl.formatMessage(messages.newLocationButton)}
</Button>,
]}
onClose={handleCloseXBlockMovedAlert}
/>
) : null}
</TransitionReplace>
{courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage
title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText,
{
link: (
<Alert.Link
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
</Alert.Link>
),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
breadcrumbs={(
<Breadcrumbs
courseId={courseId}
parentUnitId={sequenceId}
/>
)}
headerActions={(
<CourseUnitHeaderActionsSlot
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
unitTitle={unitTitle}
verticalBlocks={courseVerticalChildren.children}
/>
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div>
{isUnitVerticalType && (
<Sequence
courseId={courseId}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
)}
<div className="d-flex align-items-baseline">
<div className="flex-fill">
{currentlyVisibleToStudents && (
<UnitSidebarPagesProvider>
<Container fluid className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
key="xblock-moved-alert"
data-testid="xblock-moved-alert"
show={movedXBlockParams.isSuccess}
variant="success"
icon={CheckCircleIcon}
title={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelTitle)
: intl.formatMessage(messages.alertMoveSuccessTitle)}
description={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? undefined : [
<Button
onClick={handleRollbackMovedXBlock}
key="xblock-moved-alert-undo-move-button"
>
{intl.formatMessage(messages.undoMoveButton)}
</Button>,
<Button
onClick={handleNavigateToTargetUnit}
key="xblock-moved-alert-new-location-button"
>
{intl.formatMessage(messages.newLocationButton)}
</Button>,
]}
onClose={handleCloseXBlockMovedAlert}
/>
)}
{staticFileNotices && (
<PasteNotificationAlert
staticFileNotices={staticFileNotices}
courseId={courseId}
/>
)}
{blockId && (
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
) : null}
</TransitionReplace>
{courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage
title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText,
{
link: (
<Alert.Link
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
</Alert.Link>
),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
&& /* istanbul ignore next */ (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
/* istanbul ignore next */
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}
{!readOnly && blockId && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
breadcrumbs={(
<Breadcrumbs
courseId={courseId}
parentUnitId={sequenceId}
/>
)}
<MoveModal
isOpenModal={isMoveModalOpen}
openModal={openMoveModal}
closeModal={closeMoveModal}
courseId={courseId}
/>
<IframePreviewLibraryXBlockChanges />
headerActions={(
<CourseUnitHeaderActionsSlot
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
unitTitle={unitTitle}
verticalBlocks={courseVerticalChildren.children}
/>
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div>
{!isUnitLegacyLibraryType && (
<CourseAuthoringUnitSidebarSlot
{isUnitVerticalType && (
<Sequence
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
)}
</div>
</section>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<SavingErrorAlert
savingStatus={savingStatus}
errorMessage={errorMessage}
/>
</div>
<div className="d-flex align-items-baseline">
<div className="flex-fill">
{currentlyVisibleToStudents && (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
/>
)}
{staticFileNotices && (
<PasteNotificationAlert
staticFileNotices={staticFileNotices}
courseId={courseId}
/>
)}
{blockId && (
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
&& /* istanbul ignore next */ (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
/* istanbul ignore next */
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}
{!readOnly && blockId && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
)}
<MoveModal
isOpenModal={isMoveModalOpen}
openModal={openMoveModal}
closeModal={closeMoveModal}
courseId={courseId}
/>
<IframePreviewLibraryXBlockChanges />
</div>
{!isUnitLegacyLibraryType && (
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
)}
</div>
</section>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<SavingErrorAlert
savingStatus={savingStatus}
errorMessage={errorMessage}
/>
</div>
</UnitSidebarPagesProvider>
</UnitSidebarProvider>
);
};

View File

@@ -14,7 +14,7 @@ const expectedCourseItemDataWithUnit = {
childInfo: {
children: [
{
id: 'unitId',
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
},
],
},
@@ -64,7 +64,12 @@ 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/unitId`);
expect(mockNavigate).toHaveAttribute(
'data-to',
`/course/${courseId}/container/${encodeURIComponent(
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
)}`,
);
});
});

View File

@@ -2,14 +2,15 @@ import { LoadingSpinner } from '@src/generic/Loading';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useParams, Navigate } from 'react-router-dom';
import { useCourseItemData } from '../course-outline/data/apiHooks';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { XBlock } from '@src/data/types';
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(subsectionId);
const { data: courseItemData, isLoading } = useCourseItemData<XBlock>(subsectionId);
let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id;
if (isLoading) {

View File

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

View File

@@ -4,7 +4,8 @@ import classNames from 'classnames';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import Loading from '../../generic/Loading';
import Loading from '@src/generic/Loading';
import { RequestStatus } from '../../data/constants';
import SequenceNavigation from './sequence-navigation/SequenceNavigation';
import messages from './messages';

View File

@@ -91,7 +91,7 @@ describe('<HeaderNavigations />', () => {
expect(infoButton).toBeInTheDocument();
await user.click(infoButton);
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info');
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info', null);
});
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');
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add', null);
});
});

View File

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

View File

@@ -11,6 +11,7 @@ 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';
@@ -21,13 +22,7 @@ type HeaderTitleProps = {
isTitleEditFormOpen: boolean;
handleTitleEdit: () => void;
handleTitleEditSubmit: (title: string) => void;
handleConfigureSubmit: (
id: string,
isVisible: boolean,
groupAccess: boolean,
isDiscussionEnabled: boolean,
closeModalFn: (value: boolean) => void
) => void;
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
};
/**
@@ -56,14 +51,12 @@ const HeaderTitle = ({
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(
currentItemData.id,
arg[0],
arg[1],
arg[2],
closeConfigureModal,
);
const onConfigureSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
handleConfigureSubmit({
...variables,
unitId: currentItemData.id,
closeModalFn: closeConfigureModal,
});
};
useEffect(() => {

View File

@@ -14,6 +14,7 @@ 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,
@@ -73,7 +74,8 @@ 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 sequenceId = courseUnit.ancestorInfo?.ancestors[0]?.id;
const sectionId = courseUnit.ancestorInfo?.ancestors[1]?.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;
@@ -98,19 +100,17 @@ export const useCourseUnit = ({
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
};
const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => {
const handleConfigureSubmit = (variables: ConfigureUnitData & { closeModalFn?: () => void }) => {
dispatch(editCourseUnitVisibilityAndData(
id,
variables.unitId,
PUBLISH_TYPES.republish,
isVisible,
groupAccess,
isDiscussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
variables.isVisibleToStaffOnly,
variables.groupAccess,
variables.discussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: variables.unitId }),
blockId,
));
if (typeof closeModalFn === 'function') {
closeModalFn();
}
variables.closeModalFn?.();
};
const handleTitleEditSubmit = (displayName) => {
@@ -139,18 +139,26 @@ export const useCourseUnit = ({
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
const unitXBlockActions = {
handleDelete: (XBlockId) => {
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
handleDelete: async (XBlockId: string) => {
// oxlint-disable-next-line typescript-eslint(await-thenable)
await dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
},
handleDuplicate: (XBlockId) => {
handleDuplicate: (XBlockId: string) => {
dispatch(duplicateUnitItemQuery(
blockId,
XBlockId,
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
(courseKey: string, locator: string) => sendMessageToIframe(
messageTypes.completeXBlockDuplicating,
{ courseKey, locator },
),
));
},
handleUnlink: async (XBlockId) => {
await unlinkDownstream(XBlockId);
handleUnlink: async (XBlockId: string) => {
await unlinkDownstream({
downstreamBlockId: XBlockId,
subsectionId: sequenceId,
sectionId,
});
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
},
};

View File

@@ -1,4 +1,5 @@
import { render, screen, initializeMocks } from '@src/testUtils';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { UnitSidebarProvider } from './UnitSidebarContext';
@@ -16,9 +17,11 @@ jest.mock('react-router-dom', () => ({
}));
const renderComponent = () => render(
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>,
<IframeProvider>
<UnitSidebarProvider readOnly={false}>
<UnitAlignSidebar />
</UnitSidebarProvider>
</IframeProvider>,
);
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,18 +9,19 @@ import { useUnitSidebarContext } from './UnitSidebarContext';
*/
export const UnitAlignSidebar = () => {
const { blockId } = useParams();
const { currentComponentId, setCurrentPageKey } = useUnitSidebarContext();
const { selectedComponentId, setCurrentPageKey } = useUnitSidebarContext();
const sidebarContentId = currentComponentId || blockId;
const sidebarContentId = selectedComponentId || 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');
setCurrentPageKey('align', null);
}, [setCurrentPageKey]);
return (
@@ -30,7 +31,7 @@ export const UnitAlignSidebar = () => {
? contentData.displayName : ''
}
contentId={sidebarContentId || ''}
onBackBtnClick={currentComponentId ? handleBack : undefined}
onBackBtnClick={selectedComponentId ? handleBack : undefined}
/>
);
};

View File

@@ -1,8 +1,9 @@
import { Sidebar } from '@src/generic/sidebar';
import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar';
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { isUnitPageNewDesignEnabled } from '../utils';
import { useUnitSidebarPages } from './sidebarPages';
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { useUnitSidebarPagesContext } from './UnitSidebarPagesContext';
export type UnitSidebarProps = {
legacySidebarProps: LegacySidebarProps,
@@ -22,7 +23,7 @@ export const UnitSidebar = ({
toggle,
} = useUnitSidebarContext();
const sidebarPages = useUnitSidebarPages();
const sidebarPages = useUnitSidebarPagesContext();
if (!isUnitPageNewDesignEnabled()) {
return (

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) => void;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys, componentId?: string | null) => void;
currentTabKey?: string;
setCurrentTabKey: (tabKey: string | undefined) => 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;
selectedComponentId?: string;
setSelectedComponentId: (componentId?: string) => void;
isOpen: boolean;
open: () => void;
toggle: () => void;
@@ -32,6 +32,7 @@ export const UnitSidebarProvider = ({
children?: React.ReactNode,
readOnly: boolean,
}) => {
const { sendMessageToIframe } = useIframe();
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<UnitSidebarPageKeys>(
'info',
'sidebar',
@@ -39,16 +40,23 @@ export const UnitSidebarProvider = ({
(value: UnitSidebarPageKeys) => value,
);
const [currentTabKey, setCurrentTabKey] = useState<string>();
const [currentComponentId, setCurrentComponentId] = useState<string>();
const [selectedComponentId, setSelectedComponentId] = useState<string>();
const [isOpen, open,, toggle] = useToggle(true);
const setCurrentPageKey = useCallback(/* istanbul ignore next */ (
pageKey: UnitSidebarPageKeys,
componentId?: string,
componentId?: string | null,
) => {
// Reset tab
setCurrentTabKey(undefined);
setCurrentPageKeyState(pageKey);
setCurrentComponentId(componentId);
if (componentId !== undefined) {
setSelectedComponentId(componentId === null ? undefined : componentId);
}
if (componentId === null) {
// Deselect the component
sendMessageToIframe(messageTypes.clearSelection, null);
}
open();
}, [open]);
@@ -58,7 +66,8 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
currentComponentId,
selectedComponentId,
setSelectedComponentId,
isOpen,
open,
toggle,
@@ -69,7 +78,8 @@ export const UnitSidebarProvider = ({
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
currentComponentId,
selectedComponentId,
setSelectedComponentId,
isOpen,
open,
toggle,
@@ -84,9 +94,11 @@ export const UnitSidebarProvider = ({
);
};
export function useUnitSidebarContext(): UnitSidebarContextData {
export function useUnitSidebarContext(raiseError?: true): UnitSidebarContextData;
export function useUnitSidebarContext(raiseError?: boolean): UnitSidebarContextData | undefined;
export function useUnitSidebarContext(raiseError: boolean = true): UnitSidebarContextData | undefined {
const ctx = useContext(UnitSidebarContext);
if (ctx === undefined) {
if (ctx === undefined && raiseError) {
/* istanbul ignore next */
throw new Error('useUnitSidebarContext() was used in a component without a <UnitSidebarProvider> ancestor.');
}

View File

@@ -0,0 +1,104 @@
import { createContext, useContext, useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { Info, Plus, Tag } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import { InfoSidebar } from './unit-info/InfoSidebar';
import { AddSidebar } from './AddSidebar';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import messages from './messages';
export type UnitSidebarPages = {
info: SidebarPage;
add?: SidebarPage;
align?: SidebarPage;
};
const getUnitSidebarPages = (readOnly: boolean, hasComponentSelected: boolean) => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
return {
info: {
component: InfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
...(!readOnly && {
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
disabled: hasComponentSelected,
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
},
}),
...(showAlignSidebar && {
align: {
component: UnitAlignSidebar,
icon: Tag,
title: messages.sidebarButtonAlign,
},
}),
};
};
/**
* Context for the Unit Sidebar Pages.
*
* This could be used in plugins to add new pages to the sidebar.
*
* @example
*
* ```tsx
* export function UnitOutlineSidebarWrapper(
* { component, pluginProps }: { component: React.ReactNode, pluginProps: UnitOutlineAspectsPageProps},
* ) {
* const sidebarPages = useUnitSidebarPagesContext();
* const AnalyticsPage = useCallback(() => <UnitOutlineAspectsPage {...pluginProps} />, [pluginProps]);
*
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* return (
* <UnitSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </UnitSidebarPagesContext.Provider>
* );
* }
*/
export const UnitSidebarPagesContext = createContext<UnitSidebarPages | undefined>(undefined);
type UnitSidebarPagesProviderProps = {
children: React.ReactNode;
};
export const UnitSidebarPagesProvider = ({ children }: UnitSidebarPagesProviderProps) => {
const { readOnly, selectedComponentId } = useUnitSidebarContext();
const hasComponentSelected = selectedComponentId !== undefined;
const sidebarPages = useMemo(
() => getUnitSidebarPages(readOnly, hasComponentSelected),
[readOnly, hasComponentSelected],
);
return (
<UnitSidebarPagesContext.Provider value={sidebarPages}>
{children}
</UnitSidebarPagesContext.Provider>
);
};
export const useUnitSidebarPagesContext = (): UnitSidebarPages => {
const ctx = useContext(UnitSidebarPagesContext);
if (ctx === undefined) { throw new Error('useUnitSidebarPages must be used within an UnitSidebarPagesProvider'); }
return ctx;
};

View File

@@ -71,6 +71,11 @@ 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

@@ -1,46 +0,0 @@
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';
export type UnitSidebarPages = {
info: SidebarPage;
align?: SidebarPage;
add?: SidebarPage;
};
/**
* Sidebar pages for the unit sidebar
*
* This has been separated from the context to avoid a cyclical import
* if you want to use the context in the sidebar pages.
*/
export const useUnitSidebarPages = (): UnitSidebarPages => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
const { readOnly } = useUnitSidebarContext();
return {
info: {
component: UnitInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
...(!readOnly && {
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
},
}),
...(showAlignSidebar && {
align: {
component: UnitAlignSidebar,
icon: Tag,
title: messages.sidebarButtonAlign,
},
}),
};
};

View File

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

@@ -0,0 +1,19 @@
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 = () => {
};
/**
* Main component that renders the tabs of the info sidebar.
* Component that renders the tabs of the info sidebar for units.
*/
export const UnitInfoSidebar = () => {
const intl = useIntl();
@@ -205,7 +205,7 @@ export const UnitInfoSidebar = () => {
}, []);
return (
<div>
<>
<SidebarTitle
title={currentItemData.displayName}
icon={getItemIcon('unit')}
@@ -233,6 +233,6 @@ export const UnitInfoSidebar = () => {
</div>
</Tab>
</Tabs>
</div>
</>
);
};

View File

@@ -16,6 +16,7 @@ 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,11 +1,12 @@
import { useMemo } from 'react';
import { debounce } from 'lodash';
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 { 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 { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
/**
@@ -32,6 +33,7 @@ export const useMessageHandlers = ({
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
handleXBlockSelected,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -45,7 +47,7 @@ export const useMessageHandlers = ({
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({
courseXBlockDropdownHeight,
}: { courseXBlockDropdownHeight: number }) => setIframeOffset(courseXBlockDropdownHeight),
}) => setIframeOffset(courseXBlockDropdownHeight),
[messageTypes.editXBlock]: ({ id }) => handleShowLegacyEditXBlockModal(id),
[messageTypes.closeXBlockEditorModal]: handleCloseLegacyEditorXBlockModal,
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
@@ -63,6 +65,7 @@ export const useMessageHandlers = ({
payload.type,
payload.locator,
),
[messageTypes.xblockSelected]: ({ contentId }) => handleXBlockSelected(contentId),
}), [
courseId,
handleDeleteXBlock,
@@ -71,5 +74,6 @@ export const useMessageHandlers = ({
handleManageXBlockAccess,
handleScrollToXBlock,
copyToClipboard,
handleXBlockSelected,
]);
};

View File

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

View File

@@ -1,4 +1,5 @@
import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types';
import { ConfigureUnitData } from '@src/course-outline/data/types';
import { UserPartitionTypes } from '@src/data/types';
export interface XBlockActionsTypes {
canCopy: boolean;
@@ -31,46 +32,11 @@ export interface XBlockContainerIframeProps {
blockId: string;
isUnitVerticalType: boolean,
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDelete: (XBlockId: string | null) => Promise<void> | void;
handleDuplicate: (XBlockId: string | null) => void;
handleUnlink: (XBlockId: string | null) => void;
};
courseVerticalChildren: Array<XBlockTypes>;
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => 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,25 +1,20 @@
import { getConfig } from '@edx/frontend-platform';
import { AccessManagedXBlockDataTypes } from '@src/data/types';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { FormattedAccessManagedXBlockDataTypes, XBlockTypes } from './types';
import { 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,
): FormattedAccessManagedXBlockDataTypes => ({
): AccessManagedXBlockDataTypes => ({
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 probide students with additional course content. For example, you can create
defaultMessage: `You can create and edit custom pages to provide 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,6 +92,7 @@ export const waffleFlagDefaults = {
useNewGroupConfigurationsPage: true,
useReactMarkdownEditor: true,
useVideoGalleryFlow: false,
enableAuthzCourseAuthoring: false,
} as const;
export type WaffleFlagName = keyof typeof waffleFlagDefaults;

View File

@@ -29,20 +29,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server:
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
// Then, the server responds with a new value:
resolveResponse([200, { useNewCourseOutlinePage: false }]);
// Now, we're no longer loading and we have the new value:
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
});
it('uses the default values if there\'s an error', async () => {
@@ -53,20 +53,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server:
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
// Then, the server responds with an error
resolveResponse([500, {}]);
// Now, we're no longer loading, we have an error state, and we still have the default value:
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('error');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('error');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
});
it('uses the global flag values while loading the course-specific flags', async () => {
@@ -81,9 +81,9 @@ describe('useWaffleFlags', () => {
// Check the global flag:
render(<FlagComponent />);
await waitFor(() => {
await waitFor(async () => {
// Once it loads the flags from the server, the global 'false' value will override the default 'true':
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
});
// Now check the course-specific flag:
@@ -91,16 +91,16 @@ describe('useWaffleFlags', () => {
render(<FlagComponent courseId={courseId} />);
// Now, the course-specific value is loading but in the meantime we use the global default:
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
// Now the server responds: the course-specific flag is ON:
resolveResponse([200, { useNewCourseOutlinePage: true }]);
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
});
});

View File

@@ -130,3 +130,38 @@ 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 };
};
}

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