Compare commits

...

174 Commits

Author SHA1 Message Date
Neo
c0b07f06af Andal LND - Remove footer slots from course authoring pages, custom branding
Some checks failed
Lint Commit Messages / commitlint (pull_request) Has been cancelled
Lockfile Version check / version-check (pull_request) Has been cancelled
validate / tests (pull_request) Has been cancelled
validate / coverage (pull_request) Has been cancelled
2026-03-31 13:38:29 +07:00
Neo
c751c8b19a Andal Learning branding: Apply orange color #ff4f00 to buttons/navbar/tabs, hide footer
Some checks failed
validate / tests (push) Has been cancelled
validate / coverage (push) Has been cancelled
Lockfile Version check / version-check (push) Has been cancelled
Update Browserslist DB / update-browserslist (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:52:43 +07: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
renovate[bot]
7a8b4ced01 chore(deps): update dependency fast-xml-parser to v5.3.6 [security] (#2886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 23:18:47 +00:00
Muhammad Faraz Maqsood
e116e10845 fix: localize rerun button text (#2887)
* fix: localize rerun button text

* chore: use <FormattedMessage /> instead of formatMessage()

---------

Co-authored-by: Muhammad Faraz  Maqsood <faraz.maqsood@A006-01130.local>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2026-02-18 22:58:12 +00:00
MuPp3t33r
0b357a1be1 fix: Replace duplicate hardcoded max file size with constant (#2875) 2026-02-18 14:26:08 -08:00
edX requirements bot
e281681168 chore: update browserslist DB (#2881)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-16 00:32:33 +00:00
Chris Chávez
c39923fc81 fix: Nits on UX/UI in the new sidebars in course outline and unit page [FC-0114] (#2871)
- Fixes the open nits/issues listed in https://github.com/openedx/frontend-app-authoring/issues/2868
- Fixes the small nit in the back button in the import workflow: https://github.com/openedx/frontend-app-authoring/issues/2524#issuecomment-3849649651
- Fixes the small nit in the unsupported text in the import workflow: https://github.com/openedx/frontend-app-authoring/issues/2525#issuecomment-3862737018. This fix depends on https://github.com/openedx/openedx-platform/pull/38005
2026-02-13 15:09:28 -05:00
Chris Chávez
4d401a9c22 feat: Unit align sidebar [FC-0114] (#2856)
Implements the align sidebar in the unit page for the unit and components
2026-02-13 15:07:53 -05:00
renovate[bot]
72421969d9 chore(deps): update dependency @tanstack/react-query to v5.90.21 (#2858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 14:28:28 -05:00
renovate[bot]
0aad03420b chore(deps): update codemirror (#2857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 19:28:19 +00:00
renovate[bot]
45811ce6de chore(deps): update dependency fast-xml-parser to v5.3.5 (#2867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 11:14:30 -08:00
renovate[bot]
d1c3fb96de chore(deps): update dependency @types/react to v18.3.28 (#2866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 10:38:36 -08:00
Chris Chávez
2d5421e09f feat: Unit Add sidebar [FC-0114] (#2837)
Implements the new Unit add Sidebar
2026-02-09 13:17:04 -05:00
Chris Chávez
a16087a1d0 fix: Nits on the style of library icon [FC-0114] (#2863) 2026-02-09 11:59:24 -05:00
Navin Karkera
bb6b2ab33c feat: container info sidebar and add sidebar updates (#2830)
* Adds section, subsection and unit sidebar info tab in course outline as described in https://github.com/openedx/frontend-app-authoring/issues/2638
* Updates the sidebar design and behaviour as per https://github.com/openedx/frontend-app-authoring/issues/2826
* Updates course outline to use react query and removes redux store usage as much as possible. Updated parts that require absolutely cannot work without redux without heavy refactoring (will require quiet some time) to work in tandem with react-query.
2026-02-09 11:03:27 -05:00
renovate[bot]
eae178a9ec chore(deps): update dependency lodash to v4.17.23 [security] (#2861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 00:44:37 +00:00
edX requirements bot
2ab1219f36 chore: update browserslist DB (#2865)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-09 00:28:05 +00:00
Rômulo Penido
7173b71cc4 fix: update sidebar design (#2852) 2026-02-05 15:44:46 -05:00
Brian Smith
f900ace15c chore(deps): regenerate package-lock.json (#2862)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-02-02 13:58:30 -08:00
Braden MacDonald
d44425c68f test: lint using oxlint as well as eslint (#2847)
PR enables the use of oxlint (type-aware) alongside eslint, on a trial basis. Oxlint supports most of the same rules, plus more, is eslint compatible, and is much, much faster.
2026-02-02 11:47:31 -05:00
Braden MacDonald
6effb4d39e chore: fix various lint/type issues found by oxlint (#2850) 2026-01-31 01:01:11 +00:00
Rômulo Penido
b0066e547c feat: create context for sidebarPages (#2827)
Creates the `OutlineSidebarPagesContext` so we can add new pages using plugins, as in https://github.com/openedx/frontend-plugin-aspects/pull/115
2026-01-30 17:52:52 -05:00
Rômulo Penido
6558c2b1ed fix: access restricted label refresh (#2846) 2026-01-30 17:09:42 -05:00
Braden MacDonald
747c2bc1b0 chore: clean up ValidationResult for NumericalInput problems (#2849) 2026-01-29 09:48:40 -08:00
Braden MacDonald
27e709912d fix: don't use eval() to parse OLX (#2848) 2026-01-29 10:39:48 -05:00
Braden MacDonald
9d9d7a7167 fix: resolve incorrect or missing 'await' usages (#2592)
* chore: fix incorrect or missing 'await' usages
* test: fix broken test
* chore: ignore lines causing patch coverage to fail
2026-01-28 16:48:13 -08:00
renovate[bot]
7e53e1143f chore(deps): update codemirror (#2840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 16:32:53 -08:00
renovate[bot]
66656753b4 fix(deps): update dependency @edx/browserslist-config to v1.5.1 (#2844)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 10:41:07 -08:00
Irtaza Akram
05de984fa4 fix: show deafult grading method 2026-01-28 08:57:04 -05:00
Irtaza Akram
f6da58a950 fix: test 2026-01-28 08:57:04 -05:00
Irtaza Akram
5e5c92f487 fix: type error 2026-01-28 08:57:04 -05:00
Irtaza Akram
10705bc921 fix: test failures 2026-01-28 08:57:04 -05:00
Irtaza Akram
2e836a55cf fix: add grading method view 2026-01-28 08:57:04 -05:00
Braden MacDonald
292a457834 chore: rename more files from .js to .ts (#2842)
* chore: rename more files from .js to .ts

* fix: fix some little issues caught by TS

* fix: fix type of 'certificateId'
2026-01-27 13:16:47 -08:00
renovate[bot]
5d97a98294 chore(deps): update dependency @edx/frontend-platform to v8.5.4 (#2838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 17:46:53 -08:00
renovate[bot]
53923d0db4 chore(deps): update dependency @testing-library/react to v16.3.2 (#2841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 15:44:27 -08:00
Chris Chávez
5157cbcfb2 feat: New Unit info sidebar [FC-0114] (#2822)
- Implements the basics for the Unit Sidebar:
    - Splits the sidebar in legacy sidebar and in the new sidebar
- Implements the Unit Info Sidebar:
    - Implements a new design for the visibility and publish status card.
    - Implements the new Visibility field.
    - Implements the settings tab for the sidebar. Implements all the new form to edit the 
      settings in the sidebar.
2026-01-26 16:18:32 -05:00
Chris Chávez
ef93e95dd7 feat: New header in course unit page [FC-0114] (#2751)
- `ENABLE_UNIT_PAGE_NEW_DESIGN` flag created
- New Status Bard implemented in the header of the course unit page.
- New buttons added in the header of the course unit page.
- Which user roles will this change impact? "Course Author".
2026-01-26 12:52:50 -05:00
Braden MacDonald
1626d6808d chore: rename all 'messages.js' to 'messages.ts' (#2836) 2026-01-26 12:22:09 -05:00
edX requirements bot
b1c3151ca1 chore: update browserslist DB (#2839)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-26 00:24:23 +00:00
renovate[bot]
bea1619537 chore(deps): update dependency @openedx/paragon to v23.19.1 (#2765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 23:00:25 +00:00
Navin Karkera
89e327b633 feat: course outline header update [FC-0114] (#2823)
Modifies new course outline header and actions
2026-01-23 09:08:19 -05:00
Braden MacDonald
0db79f3527 fix: clean up invalid breadcrumb usages in library updates (#2811) 2026-01-22 09:45:34 -08:00
Chris Chávez
82e24193a8 refactor: Use preview migration API & feat: Block import when the import would exceed the block limit [FC-0112] (#2700)
- Use the new preview migration API implemented in https://github.com/openedx/edx-platform/pull/37818
- Clean the code for the preview.
- Implements the commented in https://github.com/openedx/frontend-app-authoring/issues/2525#issuecomment-3554310315
2026-01-20 14:44:35 -05:00
renovate[bot]
f57d33a74e chore(deps): update react-router monorepo to v6.30.3 (#2819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 16:35:50 -08:00
Chris Chávez
0b2b8e142c feat: New align sidebar for Course Outline [FC-0114] (#2812)
- Adds the align sidebar for Course and Section/Subsection/Unit cards (https://github.com/openedx/frontend-app-authoring/issues/2625)
- Add a new library lock icon to tags imported from upstream (https://github.com/openedx/frontend-app-authoring/issues/2234)
2026-01-19 11:59:43 -05:00
edX requirements bot
d5352b8632 chore: update browserslist DB (#2828)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-19 00:24:56 +00:00
Chris Chávez
3076dc7598 fix: Bug when adding spaces to containers names in course outline (#2825)
- Fixes the bug introduced in https://github.com/openedx/frontend-app-authoring/pull/2732
- Removes the unnecessary `preventDefault`
2026-01-15 09:12:35 -05:00
Navin Karkera
a23a4da0a2 feat: add content in location in course outline [FC-0114] (#2820)
- Add container in-location in course outline using new add sidebar.
- Creates the placeholder card while creating a container
2026-01-15 07:29:26 -05:00
Chris Chávez
4cda17e046 feat: Make sections/subsections/units selectable in course outline [FC-0114] (#2732) 2026-01-12 21:12:46 -05:00
renovate[bot]
969f7a2858 chore(deps): update dependency @types/lodash to v4.17.23 (#2818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 09:47:14 -08:00
edX requirements bot
12f1c2b1f5 chore: update browserslist DB (#2817)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-12 00:26:34 +00:00
Muhammad Anas
6464f37e2a feat: plugin slot for marketing banner on Schedule & Details page (#2748)
* feat: add Page Banner Slot for Schedule and Details Page

* fix: js to ts

* fix: remove js

* fix: lint issues

* fix: issues

* fix: lint issues

* fix: issues

* fix: issue

* fix: issue
2026-01-09 15:21:12 -08:00
renovate[bot]
5641daf68d chore(deps): update dependency react-responsive to v10 (#2784)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 11:16:35 -08:00
Navin Karkera
3c22e4bbe1 feat: Add sidebar and library dropdown filter [FC-0114] (#2778)
* Add flow in course outline sidebar. Allows author to add new section/subsection/unit or any container from existing libraries via sidebar.
* Adds library dropdown filter and collections dropdown filter in add sidebar. Allows authors to filter containers by selected libraries and collections.
2026-01-09 12:14:48 -05:00
renovate[bot]
a7cbfead75 chore(deps): update codemirror to v6.5.3 (#2771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 00:52:38 +00:00
renovate[bot]
7b0223cefc chore(deps): update dependency @tanstack/react-query to v5.90.16 (#2781)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 16:39:20 -08:00
renovate[bot]
478749028b chore(deps): update dependency @testing-library/react to v16.3.1 (#2770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 09:49:31 -08:00
Rômulo Penido
220924233e feat: course outline sidebar (#2731)
implements the new sidebar design for the Course Outline
2026-01-06 08:13:25 -05:00
edX requirements bot
122414cb73 chore: update browserslist DB (#2780)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-05 00:25:41 +00:00
Rômulo Penido
fd0395ba03 fix: make sidebar (and content) fill the whole page (#2777) 2025-12-31 18:15:47 -05:00
Navin Karkera
38dfb68286 refactor: course import analysis and details page in libraries (#2774)
* Updates analysis details body text
* Updates partial banner text
* Rounds percentage of supported blocks
* Removes unsupported children blocks from total counts and percentages.
* Updates spacing in analysis page.
2025-12-31 10:36:44 -05:00
Navin Karkera
0f20267cc4 feat: course import filter in library and fix list order (#2773)
* Adds filter to show/hide previously imported courses in course import page in libraries.
* Fix issue with ordering of courses listing.
2025-12-29 09:37:44 -05:00
Rômulo Penido
7f10575b52 fix: import summary text (#2772)
Updates the text on the import summary shown on partial imports.
2025-12-24 12:28:31 -05:00
Chris Chávez
ef6bd073c0 refactor: Migrate accessibilityPage variable in Redux store to React Query (#2760) 2025-12-23 09:18:13 -05:00
Navin Karkera
f4d20eba45 feat: bulk update legacy library references (#2764)
Shows an alert in course outline and review tab of course libraries page when the course contains legacy library content blocks that depend on libraries that are already migrated to library v2, i.e. the blocks are ready to be converted into item banks that can make use of these new v2 libraries.
Authors can click on a single button to convert all references in a single go. The button launches a background task which is then polled by the frontend and the status is presented to the Author.
2025-12-22 12:54:54 -05:00
edX requirements bot
68a4b04475 chore: update browserslist DB (#2769)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-22 00:24:49 +00:00
Diana Villalvazo
c2592a7e6e Add external links override support (#2730)
* feat: add external links override support

* fix: add note, fix 404 url and fix unrelated typo

* test: fix
2025-12-19 13:06:03 -06:00
Jesus Balderrama
9072bb66b7 feat: better validation for NumericalInput problem editor (#2615)
* feat(form): add validation to NumericalInput to accept only numeric values

* style(format): fix spaces and update message to camelCase

* fix(content): update text for clarity

Co-authored-by: Kyle McCormick <kyle@kylemccormick.me>

* feat(validation): validation added to numeric input with new endpoint to see if is a valid math expression

* fix(content): change in input validation to use react query instead of redux

* fix(content): change in types to avoid ci errors

* fix(content): remove unnecessary code after changing to react query

* fix(content): change numeric input validation path to new url and loader added

* feat: returning data in camelcase, improve UI in validation

* feat: tests added to problem editor

---------

Co-authored-by: Kyle McCormick <kyle@kylemccormick.me>
2025-12-19 10:38:19 -08:00
Areeb Sajjad
8b9f156149 fix: keyboard accessibility for time picker (#2613) 2025-12-17 16:56:54 -08:00
María Fernanda Magallanes
9c70fd9216 fix: only show library actions available for the user (#2712)
* fix: only show the options available for the user

* test: fix and add tests

* fix: improve following the best practices

* fix: apply the changes for collections and containers
2025-12-17 16:49:54 -08:00
Max Sokolski
4df44ab6cf fix: coerce multiple choice text elements to String (#2752) 2025-12-17 16:22:31 -08:00
renovate[bot]
0405156f91 chore(deps): update dependency @reduxjs/toolkit to v2.11.2 (#2755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 23:55:12 +00:00
renovate[bot]
1e2e711eaf chore(deps): update dependency fast-xml-parser to v5.3.3 (#2754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 15:42:14 -08:00
Navin Karkera
3eeca244d7 feat: add temporary message alert in sections settings tab in libraries (#2734)
- add temporary message alert in sections settings tab in libraries 
- increase sidebar width to remove `More` option and display all tabs
together
2025-12-17 14:21:13 -05:00
Navin Karkera
ae67be83a0 feat: new course outline header [FC-0114] (#2735)
Adds new header and subheader to course outline. Converts existing js code to ts.
2025-12-17 13:13:19 -05:00
Navin Karkera
6f37118960 refactor: update placeholder block and import details page (#2761)
* Updates placeholder block color and icon.
* Moves `View Imported Content` & `Retry import` buttons in import details page inside alert at the top.
* Updates page title to include Import status in import details page.
2025-12-17 11:43:12 -05:00
Chris Chávez
41a326f7b4 feat: New library sync icon [FC-0114] (#2739)
Updates the library sync icon with new states: error, broken, ready to sync and with overrides
2025-12-16 14:54:28 -05:00
dependabot[bot]
b7dd6706c2 chore(deps): bump actions/upload-artifact from 5 to 6 (#2756)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  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>
2025-12-15 17:59:54 +00:00
dependabot[bot]
64ab7ea4fe chore(deps): bump actions/download-artifact from 6 to 7 (#2757)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-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>
2025-12-15 09:42:37 -08:00
edX requirements bot
89d2393d6e chore: update browserslist DB (#2753)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-15 00:24:21 +00:00
Santhosh Kumar
42acb53201 feat!: remove "Create Zendesk Tickets for suspicious attempts" setting from Proctored Exam Settings (#2517)
BREAKING CHANGE: This PR removes the deprecated “Create Zendesk Tickets for suspicious attempts”
setting from the Proctored Exam Settings modal in the frontend-app-authoring
MFE. This option was previously used with PSI and Zendesk to generate support
tickets for suspicious exam attempts. Since both systems are retired, the
setting no longer serves a purpose and has been fully removed.

Part of: https://github.com/openedx/edx-platform/issues/36329
2025-12-11 17:05:05 -05:00
David Ormsbee
225c82d037 fix: support "in progress" status for lib upload
When uploading a library archive file during the creation of a new
library, the code prior to this commit did not properly handle the "In
Progress" state, which is when the celery task doing the archive
processing is actively running. Note that this is distinct from the
"Pending" state, which is when the task is waiting in the queue to be
run (which in practice should almost never happen unless there is an
operational issue).

Since celery tasks run in-process during local development, the task
was always finished by the time that the browser made a call to check
on the status. The problem only happened on slower sandboxes, where
processing truly runs asynchronously and might take a few seconds.
Because this case wasn't handled, the frontend would never poll for
updates either, so the upload was basically lost as far as the user
was concerned.
2025-12-11 12:58:06 -05:00
Muhammad Arslan
a596bf5d38 fix: bugs with some static asset paths in TinyMCE editors (#2702)
* fix: only convert assets static file path

* fix: direct static file is fixed

* fix: setting newContent now load the content in html as well
2025-12-11 09:19:40 -08:00
Kyle McCormick
67a5694ca3 fix: Generalize a couple remaining Proctortrack references (#2746)
Just a few leftovers from: https://github.com/openedx/frontend-app-authoring/pull/2645

Full context: https://github.com/openedx/edx-platform/issues/36329
2025-12-11 00:07:29 -05:00
dependabot[bot]
833a838348 chore(deps): bump node-forge from 1.3.1 to 1.3.2 (#2708)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.1 to 1.3.2.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-10 15:21:18 -08:00
renovate[bot]
3cf1ce2b5d chore(deps): update dependency @reduxjs/toolkit to v2 (#2369)
* fix(deps): update dependency @reduxjs/toolkit to v2

* chore: minor updates to work with RTK version 2

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-12-10 22:46:58 +00:00
dependabot[bot]
7eea38e623 chore(deps): bump express from 4.21.2 to 4.22.1 (#2726)
Bumps [express](https://github.com/expressjs/express) from 4.21.2 to 4.22.1.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/v4.22.1/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.2...v4.22.1)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 4.22.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-10 14:38:49 -08:00
renovate[bot]
128de3ffc4 chore(deps): update react-router monorepo to v6.30.2 (#2742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 14:03:43 -08:00
Anton Melser
6724e1a296 docs: Generify currently supported node version (#2744)
- remove some non-meaningful trailing whitespace
2025-12-10 19:37:30 +00:00
Areeb Sajjad
8fc00709b4 chore: upgrade react-datepicker (#2709) 2025-12-10 11:29:19 -08:00
renovate[bot]
f367a7641d fix(deps): update dependency @tanstack/react-query to v5.90.12 (#2741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 14:41:03 -05:00
edX requirements bot
49d809b809 chore: update browserslist DB (#2740)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-08 00:24:10 +00:00
Chris Chávez
dad736f9d1 refactor: Migration of course details to React query (#2724)
- Migrates the `courseDetails` part from the Redux Store to React Query.
- Creates a new `CourseAuthoringContext` 
- Update the pages in `<CourseAuthoringRoutes>` to use the newly created context.
- Migrates some files to Typescript
- Migrates some tests to use `src/testUtils.tsx`
2025-12-06 00:14:32 +00:00
Chris Chávez
50f4f70671 feat: Import Course Details Page [FC-0112] (#2664)
Implements all the states for the Import Course Details
2025-12-05 12:03:55 -05:00
Rodrigo Mendez
0796e897ae feat: Implement querying openedx-authz for publish permissions (#2685) 2025-12-04 14:40:32 -05:00
Chris Chávez
b76b003e01 refactor: Migrate Help Urls from Redux store to React Query (#2714) 2025-12-02 22:00:06 +00:00
renovate[bot]
e95758cc55 chore(deps): update dependency @types/lodash to v4.17.21 (#2716)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 18:07:35 -05:00
renovate[bot]
c7ab2b2837 chore(deps): update dependency @types/react to v18.3.27 (#2717)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 18:07:02 -05:00
edX requirements bot
dfe87f983b chore: update browserslist DB (#2715)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-01 00:25:55 +00:00
Muhammad Anas
f9e09f0601 fix: replace hardcoded edX with platformName in basicBannerTitle message (#2711) 2025-11-28 13:26:00 -05:00
Navin Karkera
0c1554b337 feat: placeholder blocks for failed import blocks (#2703)
Adds placeholder blocks in home page and respective collections tab in library for failed blocks during import from course.
2025-11-27 12:42:21 -05:00
Daniel Wong
294fe42942 feat: add support for origin server and user info (#2663)
* feat: add support for origin server and user info

* test: add coverage for restore archive summary

* test: increase coverage for restore archive summary

* fix: address comments
2025-11-27 10:04:15 -05:00
Chris Chávez
95ec41d2e7 feat: dock buttons to the bottom in import course stepper (#2694) 2025-11-26 18:18:41 -05:00
renovate[bot]
e620d70793 chore(deps): update dependency @openedx/paragon to v23.18.1 (#2701)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 18:15:14 -05:00
Rahat Ali
fa562a691b refactor: Replace ProctorTrack references with Generic Checks (#2645)
This pull request removes frontend-level handling of the ProctorTrack
proctoring provider from the frontend-app-authoring application.

As part of the broader ProctorTrack deprecation effort
(openedx/edx-platform#36329), the backend now provides generic proctoring
configuration rules instead of vendor-specific conditions. The frontend has
been updated to rely on these generic rules while still performing client-side
validation.

By removing the ProctorTrack-specific logic and using the backend’s generic
configuration model, this change keeps frontend-app-authoring aligned with
current backend behavior and avoids relying on deprecated vendor-specific
handling.

Co-authored-by: Taimoor  Ahmed <taimoor.ahmed@A006-01711.local>
2025-11-26 12:26:30 -05:00
Kyle McCormick
70c19a3ffb fix: "Back up" is two words when used as a verb (#2696)
There is a new menu item "Backup to local archive". Backup is the correct
spelling when using it as a noun or adjective, but the menu item uses as a
verb, so it should be two words, back up, i.e. "Back up to local archive"
2025-11-26 17:15:51 +00:00
Navin Karkera
ef36156d55 fix: consider children blocks of unsupported blocks while analyzing course import (#2684)
The analysis step before importing a course considers the parent block while counting unsupported blocks but does not include children in the unsupported count.

We fetch usage_keys of all unsupported blocks and fetch the children blocks that contain these usage_keys in their breadcrumb field i.e., they are part of the unsupported blocks.
2025-11-25 16:24:01 -05:00
dependabot[bot]
735159311d chore(deps): bump actions/checkout from 5 to 6 (#2692)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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>
2025-11-24 16:02:45 -05:00
edX requirements bot
4fd52b9a4d chore: update browserslist DB (#2691)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-24 00:23:39 +00:00
Braden MacDonald
4a81ddbe74 refactor: convert <UnitButton> and <UnitIcon> to TSX (#2649) 2025-11-20 14:29:00 -05:00
Navin Karkera
2215fc53cc fix: don't revert to advanced editor if block contains copied_from fields (#2661) 2025-11-19 15:43:52 -05:00
renovate[bot]
23ab74da14 chore(deps): update dependency formik to v2.4.9 (#2655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 18:19:25 -05:00
dependabot[bot]
1d385c6b4d chore(deps): bump js-yaml (#2659)
Bumps [js-yaml](https://github.com/nodeca/js-yaml).

Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 13:45:45 -05:00
renovate[bot]
acbd1de548 chore(deps): update dependency react-datepicker to v8 (#2572)
* fix(deps): update dependency react-datepicker to v8

* chore(deps): update code to work with react-datepicker v8

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-11-18 13:10:38 -05:00
Rômulo Penido
650bb62fa7 fix: migrate library alert text (#2651)
This PR updates the alert texts on the Migrate Libraries Stepper
2025-11-18 12:09:03 -05:00
Navin Karkera
e2f1aedf9a feat: import analysis step (#2657)
Shows course analysis information in review import details step in course import stepper page. Also handles alerts based on the import status, like, reimport or unsupported number of blocks.
2025-11-18 11:41:27 -05:00
Kyle McCormick
5fadccabe2 fix: Rename builtin discussion providers, "edX" -> "Open edX" (#2660) 2025-11-18 14:55:33 +00:00
renovate[bot]
daed664404 chore(deps): update dependency fast-xml-parser to v5.3.2 (#2594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 18:07:45 +00:00
Asad Ali
495a682926 fix: do not reload multiple tabs on block save (#2600) 2025-11-17 12:54:07 -05:00
renovate[bot]
6f41cd76da chore(deps): update codemirror (#2535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 17:07:01 +00:00
renovate[bot]
64489e1b4d chore(deps): update dependency @openedx/paragon to v23.18.0 (#2643)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 17:06:10 +00:00
renovate[bot]
91c813b214 fix(deps): update dependency @tanstack/react-query to v5.90.10 (#2654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 17:05:46 +00:00
Chris Chávez
2e7a29465f refactor: Update title in ImportStepperModal (#2658)
Updates the title of one card from "Import Details" to "Analysis Details"
2025-11-17 16:51:44 +00:00
edX requirements bot
0f58329cb4 chore: update browserslist DB (#2653)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-17 00:22:55 +00:00
Chris Chávez
54cfbeb756 feat: import course in library stepper [FC-0112] (#2567)
- Implemented the course import stepper described in https://github.com/openedx/frontend-app-authoring/issues/2524
- Adds the new `ENABLE_COURSE_IMPORT_IN_LIBRARY` flag
2025-11-14 13:07:00 -05:00
Muhammad Anas
7cf01de84c fix: grading settings save button stuck in pending state (#2614) 2025-11-14 08:54:57 -05:00
Navin Karkera
a1abd43a11 refactor: rename team access navbar and sidebar (#2644) 2025-11-13 20:27:06 -05:00
Muhammad Anas
8f06263e27 fix: unit button active state (#2617) 2025-11-13 12:08:18 -05:00
Braden MacDonald
e10ab270dd chore: don't name unused errors in catch expressions (#2591) 2025-11-12 18:11:22 -05:00
Braden MacDonald
a5d65abea2 chore: fix no-base-to-string (#2597) 2025-11-12 13:34:00 -05:00
Braden MacDonald
5ec00236cb chore: fix unnecessary 'undefined' types (#2589) 2025-11-12 13:26:01 -05:00
Devasia Joseph
2530b01b82 fix: restrict single select questions to one correct answer (#2618) 2025-11-12 17:43:42 +05:00
renovate[bot]
13c51ce5a8 chore(deps): update dependency @tanstack/react-query to v5.90.7 (#2611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 10:49:02 -05:00
edX requirements bot
b2cfafc00e chore: update browserslist DB (#2610)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-10 00:22:49 +00:00
Rômulo Penido
6d619b9c40 feat: add course import page [FC-0112] (#2580)
Adds the Library Import Home, which lists course migrations to the library
2025-11-07 16:32:11 -05:00
Muhammad Arslan
6afe6095a5 fix: self-closing script tag fixed for TinyMceEditor (#2510)
(not really valid HTML but preserves backwards compatibility)
2025-11-06 09:38:04 -08:00
Muhammad Arslan
1b357cb2b6 fix: broken Course Overview editor on Schedule & Details page (#2599) 2025-11-05 15:15:31 -08:00
Chris Chávez
2de987b254 style: Update some texts in legacy libraries migration flow (#2601) 2025-11-05 18:13:32 -05:00
renovate[bot]
4299bf16b4 chore(deps): update dependency @openedx/paragon to v23.16.0 (#2583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 15:07:54 -08:00
Navin Karkera
5cda284cdb fix: pass readOnly flag to Header in library authoring page (#2598) 2025-11-04 17:56:37 -05:00
Navin Karkera
436ac3155d feat: nav dropdowns in library authoring view (#2556)
Updates navbar in library authoring page to include `Team Access` and `Import` menu options. Clicking on `Team Access` button opens Team management modal.

As per this new PR: https://github.com/openedx/frontend-app-authoring/pull/2570, if admin console url is set, it should be used instead of team access modal. So updated this PR accordingly.
2025-11-03 17:06:50 -05:00
renovate[bot]
86a7e06a3c chore(deps): update dependency @tanstack/react-query to v5.90.6 (#2595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 09:31:38 -08:00
Navin Karkera
bd82c1d33d fix: publish status of container on adding new children (#2587)
Updates publish status of container when adding new child components to a unit or other containers.
2025-11-03 10:01:30 -05:00
Navin Karkera
75ae9d549c feat: handle duplicate children in container pages [FC-0112] (#2584)
If we have duplicate container or component in parent page in library, clicking on one of them selects both and removing any one from the parent blocks removes all instances.
This PR handles duplicates by including index/order_number of each child component in the url and using it to exclude a single instance and update parent structure.
2025-11-03 09:59:37 -05:00
edX requirements bot
cec074e6d4 chore: update browserslist DB (#2593)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-03 00:23:01 +00:00
Braden MacDonald
36c9eba66d refactor: remove remaining injectIntl(), ban it using eslint (#2585)
This finished the removal of `injectIntl` from this codebase, and configures a new eslint rule to ban it completely.
2025-10-30 20:10:52 -05:00
982 changed files with 28458 additions and 17830 deletions

6
.env
View File

@@ -36,15 +36,17 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -37,6 +37,9 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
@@ -45,9 +48,8 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''

View File

@@ -33,12 +33,14 @@ ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'

View File

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

View File

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

View File

@@ -11,13 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
name: code-coverage-report
path: coverage/*.*
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download code coverage results
uses: actions/download-artifact@v6
uses: actions/download-artifact@v8
with:
pattern: code-coverage-report
path: coverage

23
.oxlintrc.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn"
},
"rules": {
"eslint/no-unused-vars": "off",
"typescript/unbound-method": "off", // 🛑 TEMPORARY
"typescript/no-floating-promises": ["error", {
"allowForKnownSafeCalls": [
// queryClient.invalidateQueries returns a promise that can be awaited
// if you want to do something after all the subsequent refetches are
// complete, but we rarely if ever want that; we usually want to
// continue invalidating more things immediately. So we don't usually
// want to await this.
"invalidateQueries",
]
}]
},
"ignorePatterns": [
"webpack.dev-tutor.config.js",
]
}

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

View File

@@ -40,7 +40,7 @@ Cloning and Setup
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports node 20.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
@@ -97,7 +97,7 @@ Troubleshooting
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
@@ -175,10 +175,6 @@ Feature: New Proctoring Exams View
Requirements
------------
* ``edx-platform`` Django settings:
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
Configuration
@@ -196,7 +192,6 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
* Enable proctored exams for the course
* Allow opting out of proctored exams
* Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
Feature: Advanced Settings
==========================
@@ -239,7 +234,7 @@ Configuration
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
Tagging/Taxonomy functionality.
@@ -273,7 +268,7 @@ Troubleshooting
========================
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733)

View File

@@ -0,0 +1,80 @@
Override External URLs
======================
What is getExternalLinkUrl?
---------------------------
The `getExternalLinkUrl` function is a utility from `@edx/frontend-platform` that allows for centralized management of external URLs. It enables the override of external links through configuration, making it possible to customize external references without modifying the source code directly.
URLs wrapped with getExternalLinkUrl
------------------------------------
Use cases:
1. **Accessibility Page** (`src/accessibility-page/AccessibilityPage.jsx`)
- `COMMUNITY_ACCESSIBILITY_LINK` - Points to community accessibility resources: https://www.edx.org/accessibility
2. **Course Outline** (if applicable)
- Documentation links
- Help resources
3. **Other pages** (search for `getExternalLinkUrl` usage across the codebase)
- Help documentation
- External tool integrations
Following external URLs are wrapped with `getExternalLinkUrl` in the authoring application:
- 'https://www.edx.org/accessibility'
- 'https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_multi_select.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_dropdown.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/manage_numerical_input_problem.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_text_input.html'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/social_sharing.html'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_problem_types.html#advanced-problem-types'
- 'https://docs.openedx.org/en/latest/educators/references/course_development/parent_child_components.html'
- 'https://openai.com/api-data-privacy'
- 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/create_new_library.html'
- 'https://bigbluebutton.org/privacy-policy/'
- 'https://creativecommons.org/about'
Note: as new external URLs are added to the codebase, more URLs will be wrapped with `getExternalLinkUrl` and this list may not always be up to date.
How to Override External URLs
-----------------------------
To override external URLs, you can use the frontend platform's configuration system.
This object should be added to the config object defined in the env.config.[js,jsx,ts,tsx], and must be named externalLinkUrlOverrides.
1. **Environment Configuration**
Add the URL overrides to your environment configuration:
.. code-block:: javascript
const config = {
// Other config options...
externalLinkUrlOverrides: {
'https://www.edx.org/accessibility': 'https://your-custom-domain.com/accessibility',
// Add other URL overrides here
}
};
Examples
--------
**Original URL:** Default community accessibility link
**Override:** Your institution's accessibility policy page
.. code-block:: javascript
// In your app configuration
getExternalLinkUrl('https://www.edx.org/accessibility')
// Returns: 'https://your-custom-domain.com/accessibility'
// Instead of the default Open edX community link
Benefits
--------
- **Customization**: Institutions can point to their own resources
- **Maintainability**: URLs can be changed without code modifications
- **Consistency**: Centralized URL management across the application
- **Flexibility**: Different environments can have different external links

9271
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"oxlint": "oxlint --type-aware --deny-warnings",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
@@ -43,13 +44,14 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.0",
"@edx/browserslist-config": "1.5.1",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/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",
@@ -60,35 +62,35 @@
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.6.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/frontend-plugin-framework": "^1.8.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.5",
"@reduxjs/toolkit": "2.11.2",
"@tanstack/react-query": "5.90.21",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"file-saver": "^2.0.5",
"formik": "2.4.6",
"formik": "2.4.9",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"lodash": "4.17.23",
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datepicker": "^4.13.0",
"react-datepicker": "^8.10.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.30.1",
"react-router-dom": "6.30.1",
"react-responsive": "10.0.1",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-select": "5.10.2",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "4.4.5",
@@ -116,6 +118,8 @@
"fetch-mock-jest": "^1.5.1",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"oxlint": "^1.42.0",
"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

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, getExternalLinkUrl } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -93,7 +93,7 @@ const BbbSettings = ({
<span data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
<Hyperlink
destination="https://bigbluebutton.org/privacy-policy/"
destination={getExternalLinkUrl('https://bigbluebutton.org/privacy-policy/')}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon

View File

@@ -4,21 +4,16 @@ import {
getByRole,
getAllByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -40,17 +35,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -74,16 +72,9 @@ const mockStore = async ({
describe('BBB Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('Plan dropdown to be visible and enabled in UI', async () => {

View File

@@ -1,3 +1,4 @@
// oxlint-disable unicorn/no-thenable
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
@@ -11,6 +12,7 @@ import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-m
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
@@ -25,7 +27,7 @@ const LiveSettings = ({
const intl = useIntl();
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
const { courseId } = useCourseAuthoringContext();
const availableProviders = useSelector((state) => state.live.appIds);
const {
piiSharingAllowed, selectedAppId, enabled, status,
@@ -71,6 +73,7 @@ const LiveSettings = ({
};
const handleSettingsSave = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(saveLiveConfiguration(courseId, values, navigate));
};

View File

@@ -8,20 +8,14 @@ import {
queryByText,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -44,17 +38,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<PagesAndResourcesProvider courseId={courseId}>
<CourseAuthoringProvider>
<LiveSettings onClose={() => {}} />
</CourseAuthoringProvider>
</PagesAndResourcesProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -77,16 +74,11 @@ const mockStore = async ({
describe('LiveSettings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
const mocks = initializeMocks({
initialState,
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('Live Configuration modal is visible', async () => {

View File

@@ -3,19 +3,14 @@ import {
queryByTestId,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
@@ -38,17 +33,20 @@ ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
<CourseAuthoringProvider courseId={courseId}>
<PagesAndResourcesProvider courseId={courseId}>
<LiveSettings onClose={() => {}} />
</PagesAndResourcesProvider>
</CourseAuthoringProvider>,
{
path: liveSettingsUrl,
routerProps: {
initialEntries: [liveSettingsUrl],
},
params: {
courseId,
},
},
);
container = wrapper.container;
};
@@ -71,16 +69,9 @@ const mockStore = async ({
describe('Zoom Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks({ initialState });
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
test('LTI fields are visible when pii sharing is enabled', async () => {

View File

@@ -48,8 +48,9 @@ const ORASettings = ({ onClose }) => {
event.preventDefault();
success = success && await handleSettingsSave(formValues);
await setSaveError(!success);
setSaveError(!success);
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateModel({
modelType: 'courseApps',
model: {

View File

@@ -128,7 +128,7 @@ describe('ORASettings', () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
const checkbox = screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await waitFor(() => {

View File

@@ -22,6 +22,7 @@ import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useCourseAuthoringContext } from 'CourseAuthoring/CourseAuthoringContext';
import messages from './messages';
@@ -32,7 +33,6 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: false,
escalationEmail: '',
allowOptingOut: false,
createZendeskTickets: false,
};
const [formValues, setFormValues] = useState(initialFormValues);
const [loading, setLoading] = useState(true);
@@ -41,6 +41,7 @@ const ProctoringSettings = ({ onClose }) => {
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [requiresEscalationEmailProviders, setRequiresEscalationEmailProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
@@ -65,7 +66,7 @@ const ProctoringSettings = ({ onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const courseDetails = useModel('courseDetails', courseId);
const { courseDetails } = useCourseAuthoringContext();
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
@@ -78,18 +79,14 @@ const ProctoringSettings = ({ onClose }) => {
const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target;
if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
if (['allowOptingOut'].includes(name)) {
// Form.Radio expects string values, so convert back to a boolean here
setFormValues({ ...formValues, [name]: value === 'true' });
} else if (name === 'proctoringProvider') {
const newFormValues = { ...formValues, proctoringProvider: value };
if (value === 'proctortrack') {
setFormValues({ ...newFormValues, createZendeskTickets: false });
if (requiresEscalationEmailProviders.includes(value)) {
setFormValues({ ...newFormValues });
setShowEscalationEmail(true);
} else if (value === 'software_secure') {
setFormValues({ ...newFormValues, createZendeskTickets: true });
setShowEscalationEmail(false);
} else if (isLtiProvider(value)) {
setFormValues(newFormValues);
setShowEscalationEmail(true);
@@ -116,14 +113,13 @@ const ProctoringSettings = ({ onClose }) => {
enable_proctored_exams: formValues.enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
create_zendesk_tickets: formValues.createZendeskTickets,
},
};
if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
}
if (formValues.proctoringProvider === 'proctortrack') {
if (requiresEscalationEmailProviders.includes(formValues.proctoringProvider)) {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
}
@@ -160,7 +156,7 @@ const ProctoringSettings = ({ onClose }) => {
event.preventDefault();
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
if (
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
(requiresEscalationEmailProviders.includes(formValues.proctoringProvider) || isLtiProviderSelected)
&& !EmailValidator.validate(formValues.escalationEmail)
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
) {
@@ -387,29 +383,6 @@ const ProctoringSettings = ({ onClose }) => {
</Form.Group>
</fieldset>
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])}
</Form.Label>
<Form.RadioSet
name="createZendeskTickets"
value={formValues.createZendeskTickets.toString()}
onChange={handleChange}
>
<Form.Radio value="true" data-testid="createZendeskTicketsYes">
{intl.formatMessage(messages['authoring.proctoring.yes'])}
</Form.Radio>
<Form.Radio value="false" data-testid="createZendeskTicketsNo">
{intl.formatMessage(messages['authoring.proctoring.no'])}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</fieldset>
)}
</>
);
}
@@ -527,6 +500,7 @@ const ProctoringSettings = ({ onClose }) => {
setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date);
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
setRequiresEscalationEmailProviders(settingsResponse.data.requires_escalation_email_providers);
// The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service.
@@ -554,10 +528,11 @@ const ProctoringSettings = ({ onClose }) => {
selectedProvider = proctoredExamSettings.proctoring_provider;
}
const isProctortrack = selectedProvider === 'proctortrack';
const requiresEscalationEmailProvidersList = settingsResponse.data.requires_escalation_email_providers;
const isEscalationEmailRequired = requiresEscalationEmailProvidersList.includes(selectedProvider);
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
if (isProctortrack || ltiProviderSelected) {
if (isEscalationEmailRequired || ltiProviderSelected) {
setShowEscalationEmail(true);
}
@@ -570,7 +545,6 @@ const ProctoringSettings = ({ onClose }) => {
proctoringProvider: selectedProvider,
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
// In order to keep our email input component controlled, we use the empty string as the default
// and perform this conversion during GETs and POSTs.

View File

@@ -1,18 +1,15 @@
import React from 'react';
import {
render, screen, cleanup, waitFor, fireEvent, act,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
initializeMocks,
} from 'CourseAuthoring/testUtils';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { mergeConfig } from '@edx/frontend-platform';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { CourseAuthoringProvider } from 'CourseAuthoring/CourseAuthoringContext';
import { getCourseDetailsUrl } from 'CourseAuthoring/data/api';
import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
@@ -20,57 +17,57 @@ const defaultProps = {
courseId,
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let store;
const intlWrapper = children => (
<AppProvider store={store}>
const renderComponent = children => (
<CourseAuthoringProvider courseId={defaultProps.courseId}>
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
<IntlProvider locale="en">
{children}
</IntlProvider>
{children}
</PagesAndResourcesProvider>
</AppProvider>
</CourseAuthoringProvider>
);
let axiosMock;
describe('ProctoredExamSettings', () => {
/**
* @param {boolean} isAdmin
* @param {string | undefined} org
*/
function setupApp(isAdmin = true, org = undefined) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore({
models: {
courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
const user = {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
};
const mocks = initializeMocks({
user,
initialState: {
models: {
courseApps: {
proctoring: {},
},
},
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getCourseDetailsUrl(courseId, user.username))
.reply(200, {
courseId,
name: 'Course Test',
start: Date(),
...(org ? { org } : {}),
});
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
if (org) {
axiosMock.onGet(`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers?org=${org}`)
.reply(200, [{ name: 'test_lti', verbose_name: 'LTI Provider' }]);
}
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
@@ -85,54 +82,20 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z',
});
}
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => {
setupApp();
});
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Does not update zendesk ticket field for any other provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
});
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
@@ -146,13 +109,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
@@ -161,8 +124,6 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
@@ -172,8 +133,6 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true);
@@ -183,8 +142,6 @@ describe('ProctoredExamSettings', () => {
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides unsupported fields when lti provider is selected', async () => {
@@ -194,13 +151,11 @@ describe('ProctoredExamSettings', () => {
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
});
describe('Validation with invalid escalation email', () => {
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
const proctoringProvidersRequiringEscalationEmail = ['test_lti'];
beforeEach(async () => {
axiosMock.onGet(
@@ -209,14 +164,21 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_provider: 'lti_external',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z',
});
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
escalation_email: 'test@example.com',
});
axiosMock.onPatch(
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
).reply(204, {});
@@ -225,13 +187,13 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -252,10 +214,10 @@ describe('ProctoredExamSettings', () => {
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectElement = screen.getByDisplayValue('proctortrack');
const selectElement = screen.getByDisplayValue('LTI Provider');
fireEvent.change(selectElement, { target: { value: provider } });
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
@@ -278,7 +240,7 @@ describe('ProctoredExamSettings', () => {
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
@@ -296,7 +258,7 @@ describe('ProctoredExamSettings', () => {
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -319,7 +281,7 @@ describe('ProctoredExamSettings', () => {
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
@@ -340,9 +302,9 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
@@ -351,13 +313,13 @@ describe('ProctoredExamSettings', () => {
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const proctoringBackendSelect = screen.getByDisplayValue('LTI Provider');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
expect(screen.queryByTestId('escalationEmail')).toBeNull();
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
fireEvent.change(proctoringBackendSelect, { target: { value: provider } });
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
@@ -365,7 +327,7 @@ describe('ProctoredExamSettings', () => {
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
screen.getByDisplayValue('LTI Provider');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
@@ -383,9 +345,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2099-01-01T00:00:00Z',
};
@@ -395,9 +357,9 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2013-01-01T00:00:00Z',
};
@@ -409,8 +371,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
@@ -418,8 +380,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -428,8 +390,8 @@ describe('ProctoredExamSettings', () => {
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -437,8 +399,8 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -446,18 +408,18 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('software_secure');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not include lti_external as a selectable option', async () => {
const courseData = {
...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['lti_external', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -467,10 +429,10 @@ describe('ProctoredExamSettings', () => {
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = {
...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['lti_external', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -483,7 +445,7 @@ describe('ProctoredExamSettings', () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
@@ -497,18 +459,20 @@ describe('ProctoredExamSettings', () => {
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// only outgoing request should be for studio settings
expect(axiosMock.history.get.length).toBe(1);
// (1) for studio settings
// (2) waffle flags
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});
it('Selected LTI proctoring provider is shown on page load', async () => {
const courseData = { ...mockGetFutureCourseData };
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.available_proctoring_providers = ['lti_external', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData);
axiosMock.onGet(
@@ -516,7 +480,7 @@ describe('ProctoredExamSettings', () => {
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
@@ -527,24 +491,22 @@ describe('ProctoredExamSettings', () => {
});
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
it('Hides opting out for non edX staff', async () => {
setupApp(false);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
it('Shows opting out for edX staff', async () => {
setupApp(true);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
});
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
render(renderComponent(<ProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
@@ -554,7 +516,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -566,7 +528,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
@@ -578,7 +540,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -597,7 +559,7 @@ describe('ProctoredExamSettings', () => {
});
it('Disable button while submitting', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -606,15 +568,30 @@ describe('ProctoredExamSettings', () => {
expect(submitButton).toHaveAttribute('disabled');
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
it('Makes API call successfully with proctoring_escalation_email if test_lti', async () => {
// Setup mock to include test_lti as available provider
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
},
available_proctoring_providers: ['software_secure', 'mockproc', 'lti_external'],
requires_escalation_email_providers: ['test_lti'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
expect(escalationEmail.value).toEqual('proctortrack@example.com');
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
expect(escalationEmail.value).toEqual('test_lti@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -622,11 +599,15 @@ describe('ProctoredExamSettings', () => {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'proctortrack@example.com',
create_zendesk_tickets: false,
proctoring_provider: 'lti_external',
proctoring_escalation_email: 'test_lti@example.com',
},
});
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
escalation_email: 'test_lti@example.com',
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
@@ -637,10 +618,10 @@ describe('ProctoredExamSettings', () => {
});
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
it('Makes API call successfully without proctoring_escalation_email if not requiring escalation email', async () => {
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
// make sure we have not selected a provider requiring escalation email
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
@@ -651,7 +632,6 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -665,7 +645,7 @@ describe('ProctoredExamSettings', () => {
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
@@ -692,7 +672,7 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
create_zendesk_tickets: true,
proctoring_escalation_email: 'test_lti@example.com',
},
});
@@ -706,7 +686,7 @@ describe('ProctoredExamSettings', () => {
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// update exam service config
@@ -722,7 +702,6 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -744,13 +723,13 @@ describe('ProctoredExamSettings', () => {
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
available_proctoring_providers: ['software_secure', 'mockproc'],
requires_escalation_email_providers: [],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
// does not update exam service config
@@ -762,7 +741,6 @@ describe('ProctoredExamSettings', () => {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
@@ -780,7 +758,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -798,7 +776,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -816,7 +794,7 @@ describe('ProctoredExamSettings', () => {
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -835,7 +813,7 @@ describe('ProctoredExamSettings', () => {
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await act(async () => render(renderComponent(<ProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
@@ -862,27 +840,5 @@ describe('ProctoredExamSettings', () => {
expect(document.activeElement).toEqual(successAlert);
});
});
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: false,
},
});
});
});
});

View File

@@ -81,11 +81,6 @@ const messages = defineMessages({
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
description: 'Label for radio selection allowing proctored exam opt out',
},
'authoring.proctoring.createzendesk.label': {
id: 'authoring.proctoring.createzendesk.label',
defaultMessage: 'Create Zendesk tickets for suspicious attempts',
description: 'Label for Zendesk ticket creation radio select.',
},
'authoring.proctoring.error.single': {
id: 'authoring.proctoring.error.single',
defaultMessage: 'There is 1 error in this form.',

View File

@@ -107,6 +107,7 @@ const TeamSettings = ({
)
.when('enabled', {
is: true,
// oxlint-disable-next-line unicorn/no-thenable
then: Yup.array().min(1),
})
.default([])

View File

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

View File

@@ -1,4 +1,5 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import {
ActionRow,
Alert,
@@ -238,8 +239,10 @@ const SettingsModal = ({
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
if (enabled) {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(updateXpertSettings(courseId, values));
} else {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
success = await dispatch(removeXpertSettings(courseId));
}
@@ -276,7 +279,7 @@ const SettingsModal = ({
<div className="py-1">
<Hyperlink
className="text-primary-500"
destination="https://openai.com/api-data-privacy"
destination={getExternalLinkUrl('https://openai.com/api-data-privacy')}
target="_blank"
rel="noreferrer noopener"
>

View File

@@ -0,0 +1,172 @@
import { getConfig } from '@edx/frontend-platform';
import {
createContext, useContext, useMemo, useState,
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
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 { RequestStatusType } from './data/constants';
type ModalState = {
value?: XBlock | UnitXBlock;
subsectionId?: string;
sectionId?: string;
};
export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseUsageKey: string;
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
isUnlinkModalOpen: boolean;
currentUnlinkModalData?: ModalState;
openUnlinkModal: (value: ModalState) => void;
closeUnlinkModal: () => void;
isPublishModalOpen: boolean;
currentPublishModalData?: ModalState;
openPublishModal: (value: ModalState) => void;
closePublishModal: () => void;
currentSelection?: SelectionState;
setCurrentSelection: React.Dispatch<React.SetStateAction<SelectionState | undefined>>;
};
/**
* Course Authoring Context.
* Always available when we're in the context of a single course.
*
* Get this using `useCourseAuthoringContext()`
*
*/
const CourseAuthoringContext = createContext<CourseAuthoringContextData | undefined>(undefined);
type CourseAuthoringProviderProps = {
children?: React.ReactNode;
courseId: string;
};
export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const { courseStructure } = useSelector(getOutlineIndexData);
const { id: courseUsageKey } = courseStructure || {};
const [
isUnlinkModalOpen,
currentUnlinkModalData,
openUnlinkModal,
closeUnlinkModal,
] = useToggleWithValue<ModalState>();
const [
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
] = useToggleWithValue<ModalState>();
/**
* This will hold the state of current item that is being operated on,
* For example:
* - the details of container that is being edited.
* - the details of container of which see more dropdown is open.
* It is mostly used in modals which should be soon be replaced with its equivalent in sidebar.
*/
const [currentSelection, setCurrentSelection] = useState<SelectionState | undefined>();
const getUnitUrl = (locator: string) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
};
/**
* Open the unit page for a given locator.
*/
const openUnitPage = async (locator: string) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
navigate(url);
} else {
window.location.assign(url);
}
};
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
}), [
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleAddBlock,
handleAddAndOpenUnit,
getUnitUrl,
openUnitPage,
isUnlinkModalOpen,
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isPublishModalOpen,
currentPublishModalData,
openPublishModal,
closePublishModal,
currentSelection,
setCurrentSelection,
]);
return (
<CourseAuthoringContext.Provider value={context}>
{children}
</CourseAuthoringContext.Provider>
);
};
export function useCourseAuthoringContext(): CourseAuthoringContextData {
const ctx = useContext(CourseAuthoringContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useCourseAuthoringContext() was used in a component without a <CourseAuthoringProvider> ancestor.');
}
return ctx;
}

View File

@@ -4,9 +4,9 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
import { CourseAuthoringProvider } from './CourseAuthoringContext';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -19,6 +19,12 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
const renderComponent = children => render(
<CourseAuthoringProvider courseId={courseId}>
{children}
</CourseAuthoringProvider>,
);
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
@@ -35,14 +41,13 @@ describe('Editor Pages Load no header', () => {
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(200, {
response: { status: 200 },
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
</CourseAuthoringPage>
,
);
@@ -51,9 +56,9 @@ describe('Editor Pages Load no header', () => {
test('renders loading wheel on non editor pages', async () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
const wrapper = renderComponent(
<CourseAuthoringPage>
<PagesAndResources />
</CourseAuthoringPage>
,
);
@@ -70,7 +75,6 @@ describe('Course authoring page', () => {
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
@@ -78,11 +82,10 @@ describe('Course authoring page', () => {
).reply(500, {
response: { status: 500 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
const wrapper = renderComponent(<CourseAuthoringPage />);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -92,8 +95,8 @@ describe('Course authoring page', () => {
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
const wrapper = renderComponent(
<CourseAuthoringPage>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
,
@@ -114,7 +117,7 @@ describe('Course authoring page', () => {
mockPathname = '/editor/';
await mockStoreDenied();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
const wrapper = renderComponent(<CourseAuthoringPage />);
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

View File

@@ -1,40 +1,35 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { useCourseAuthoringContext } from './CourseAuthoringContext';
const CourseAuthoringPage = ({ courseId, children }) => {
interface Props {
children?: React.ReactNode;
}
const CourseAuthoringPage = ({ children }: Props) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const { courseId, courseDetails, courseDetailStatus } = useCourseAuthoringContext();
const courseNumber = courseDetails?.number;
const courseOrg = courseDetails?.org;
const courseTitle = courseDetails?.name;
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
@@ -61,22 +56,15 @@ const CourseAuthoringPage = ({ courseId, children }) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}
{children}
{!inProgress && !isEditor && <StudioFooterSlot />}
</div>
);
};
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -1,153 +0,0 @@
import React from 'react';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
export default CourseAuthoringRoutes;

View File

@@ -48,7 +48,11 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
const user = {
userId: 1,
username: 'username',
};
const { axiosMock } = initializeMocks({ user });
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
@@ -61,11 +65,7 @@ describe('<CourseAuthoringRoutes>', () => {
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
expect(mockComponentFn).toHaveBeenCalled();
});
});
@@ -93,11 +93,7 @@ describe('<CourseAuthoringRoutes>', () => {
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
expect(mockComponentFn).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,186 @@
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import { Textbooks } from './textbooks';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import {
CourseOutline,
OutlineSidebarProvider,
OutlineSidebarPagesProvider,
} from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
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:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
* /course/:courseId/editor/:blockType/:blockId
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
if (courseId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing courseId.');
}
return (
<CourseAuthoringProvider courseId={courseId}>
<CourseAuthoringPage>
<Routes>
<Route
path="/"
element={(
<PageWrap>
<OutlineSidebarPagesProvider>
<OutlineSidebarProvider>
<CourseOutline />
</OutlineSidebarProvider>
</OutlineSidebarPagesProvider>
</PageWrap>
)}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages /></PageWrap>}
/>
<Route
path="/subsection/:subsectionId"
element={<PageWrap><SubsectionUnitRedirect /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit /></IframeProvider></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={<PageWrap><VideoSelectorContainer /></PageWrap>}
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam /></PageWrap>}
/>
<Route
path="group_configurations"
element={<PageWrap><GroupConfigurations /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings /></PageWrap>}
/>
<Route
path="import"
element={(
<PageWrap>
<CourseImportProvider>
<CourseImportPage />
</CourseImportProvider>
</PageWrap>
)}
/>
<Route
path="export"
element={(
<PageWrap>
<CourseExportProvider>
<CourseExportPage />
</CourseExportProvider>
</PageWrap>
)}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist /></PageWrap>}
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates /></PageWrap> : null}
/>
<Route
path="textbooks"
element={<PageWrap><Textbooks /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
</CourseAuthoringProvider>
);
};
export default CourseAuthoringRoutes;

View File

@@ -1,46 +0,0 @@
import {
render,
screen,
} from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import AccessibilityBody from './index';
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>
</AppProvider>
</IntlProvider>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({});
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,29 @@
import {
initializeMocks,
render,
screen,
} from '@src/testUtils';
import AccessibilityBody from './index';
const renderComponent = () => {
render(
<AccessibilityBody
communityAccessibilityLink="http://example.com"
email="example@example.com"
/>,
);
};
describe('<AccessibilityBody />', () => {
describe('renders', () => {
beforeEach(async () => {
initializeMocks();
});
it('contains links', () => {
renderComponent();
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
});
});
});

View File

@@ -1,5 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
@@ -8,6 +6,9 @@ import messages from './messages';
const AccessibilityBody = ({
communityAccessibilityLink,
email,
}: {
communityAccessibilityLink: string,
email: string,
}) => (
<div className="mt-5">
<header>
@@ -90,9 +91,4 @@ const AccessibilityBody = ({
</div>
);
AccessibilityBody.propTypes = {
communityAccessibilityLink: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
};
export default AccessibilityBody;

View File

@@ -1,56 +1,31 @@
import {
initializeMocks,
render,
screen,
} from '@testing-library/react';
} from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { RequestStatus } from '../../data/constants';
import AccessibilityForm from './index';
import { getZendeskrUrl } from '../data/api';
import messages from './messages';
let axiosMock;
let store;
const defaultProps = {
accessibilityEmail: 'accessibilityTest@test.com',
};
const initialState = {
accessibilityPage: {
savingStatus: '',
},
};
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AccessibilityForm {...defaultProps} />
</AppProvider>
</IntlProvider>,
<AccessibilityForm {...defaultProps} />,
);
};
describe('<AccessibilityPolicyForm />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
});
describe('renders', () => {
@@ -86,14 +61,23 @@ describe('<AccessibilityPolicyForm />', () => {
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
});
it('renders in progress state', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(
() => new Promise(() => {
// always in pending
}),
);
await user.click(submitButton);
expect(screen.getByRole('button', { name: /submitting/i })).toBeInTheDocument();
});
it('shows correct success message', async () => {
axiosMock.onPost(getZendeskrUrl()).reply(200);
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
@@ -108,9 +92,6 @@ describe('<AccessibilityPolicyForm />', () => {
await user.click(submitButton);
const { savingStatus } = store.getState().accessibilityPage;
expect(savingStatus).toEqual(RequestStatus.FAILED);
expect(screen.getAllByRole('alert')).toHaveLength(1);
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();

View File

@@ -1,5 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, FormattedTime, useIntl,
} from '@edx/frontend-platform/i18n';
@@ -7,26 +5,22 @@ import {
ActionRow, Alert, Form, Stack, StatefulButton,
} from '@openedx/paragon';
import { RequestStatus } from '../../data/constants';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import submitAccessibilityForm from '../data/thunks';
import { STATEFUL_BUTTON_STATES } from '@src/constants';
import useAccessibility from './hooks';
import messages from './messages';
const AccessibilityForm = ({
accessibilityEmail,
}) => {
const AccessibilityForm = ({ accessibilityEmail }: { accessibilityEmail: string }) => {
const intl = useIntl();
const {
errors,
values,
isFormFilled,
dispatch,
mutation,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
} = useAccessibility({ name: '', email: '', message: '' }, intl);
} = useAccessibility({ name: '', email: '', message: '' });
const formFields = [
{
@@ -55,7 +49,7 @@ const AccessibilityForm = ({
};
const handleSubmit = () => {
dispatch(submitAccessibilityForm(values));
mutation.mutateAsync(values).catch(() => {});
};
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
@@ -66,7 +60,7 @@ const AccessibilityForm = ({
<h2 className="my-4">
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
</h2>
{savingStatus === RequestStatus.SUCCESSFUL && (
{savingStatus === 'success' && (
<Alert variant="success">
<Stack gap={2}>
<div className="mb-2">
@@ -86,7 +80,7 @@ const AccessibilityForm = ({
</Stack>
</Alert>
)}
{savingStatus === RequestStatus.FAILED && (
{savingStatus === 'error' && (
<Alert variant="danger">
<div data-testid="rate-limit-alert">
<FormattedMessage
@@ -125,7 +119,7 @@ const AccessibilityForm = ({
onClick={handleSubmit}
disabled={!isFormFilled}
state={
savingStatus === RequestStatus.IN_PROGRESS
savingStatus === 'pending'
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default
}
@@ -136,8 +130,4 @@ const AccessibilityForm = ({
);
};
AccessibilityForm.propTypes = {
accessibilityEmail: PropTypes.string.isRequired,
};
export default AccessibilityForm;

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
import { useSubmitAccessibilityForm } from '../data/apiHooks';
import { AccessibilityFormData } from '../data/api';
const useAccessibility = (initialValues, intl) => {
const dispatch = useDispatch();
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
const useAccessibility = (initialValues: AccessibilityFormData) => {
const intl = useIntl();
const [isFormFilled, setFormFilled] = useState(false);
const validationSchema = Yup.object().shape({
name: Yup.string().required(
@@ -29,29 +29,27 @@ const useAccessibility = (initialValues, intl) => {
enableReinitialize: true,
validateOnBlur: false,
validationSchema,
/* istanbul ignore next */
onSubmit: () => {},
});
const mutation = useSubmitAccessibilityForm(handleReset);
useEffect(() => {
setFormFilled(Object.values(values).every((i) => i));
}, [values]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
handleReset();
}
}, [savingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
return {
errors,
values,
isFormFilled,
dispatch,
mutation,
handleBlur,
handleChange,
hasErrorField,
savingStatus,
savingStatus: mutation.status,
};
};

View File

@@ -1,4 +1,3 @@
// @ts-check
import { initializeMocks, render, screen } from '../testUtils';
import AccessibilityPage from './index';

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from '../header';
import messages from './messages';
@@ -23,17 +22,17 @@ const AccessibilityPage = () => {
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<Container size="xl" className="px-4">
<AccessibilityBody
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
{...{
email: ACCESSIBILITY_EMAIL,
communityAccessibilityLink: getExternalLinkUrl(COMMUNITY_ACCESSIBILITY_LINK),
}}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container>
<StudioFooterSlot />
</>
);
};
AccessibilityPage.propTypes = {};
export default AccessibilityPage;

View File

@@ -8,12 +8,20 @@ ensureConfig([
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
export interface AccessibilityFormData {
name: string;
email: string;
message: string;
}
/**
* Posts the form data to zendesk endpoint
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function postAccessibilityForm({ name, email, message }) {
export async function postAccessibilityForm({
name,
email,
message,
}: AccessibilityFormData) {
const data = {
name,
tags: ['studio_a11y'],

View File

@@ -0,0 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { AccessibilityFormData, postAccessibilityForm } from './api';
/**
* Mutation to submit accessibility form
*/
export const useSubmitAccessibilityForm = (handleSuccess: (e: any) => void) => (
useMutation({
mutationFn: (formData: AccessibilityFormData) => postAccessibilityForm(formData),
onSuccess: handleSuccess,
})
);

View File

@@ -1,23 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'accessibilityPage',
initialState: {
savingStatus: '',
},
reducers: {
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,24 +0,0 @@
import { RequestStatus } from '../../data/constants';
import { postAccessibilityForm } from './api';
import { updateSavingStatus } from './slice';
function submitAccessibilityForm({ email, name, message }) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await postAccessibilityForm({ email, name, message });
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
/* istanbul ignore else */
if (error.response && error.response.status === 429) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} else {
/* istanbul ignore next */
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
}
};
}
export default submitAccessibilityForm;

View File

@@ -1,144 +0,0 @@
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(
<AdvancedSettings courseId={courseId} />,
{ 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,59 +1,73 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useState } from 'react';
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 Placeholder from '../editors/Placeholder';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { COURSE_PERMISSIONS } from '@src/authz/constants';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import SubHeader from '@src/generic/sub-header/SubHeader';
import AlertMessage from '@src/generic/alert-message';
import getPageHeadTitle from '@src/generic/utils';
import Placeholder from '@src/editors/Placeholder';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
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 = ({ courseId }) => {
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 courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { courseId, courseDetails } = useCourseAuthoringContext();
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),
@@ -61,30 +75,34 @@ const AdvancedSettings = ({ courseId }) => {
},
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 />
@@ -106,31 +124,42 @@ const AdvancedSettings = ({ courseId }) => {
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) && (
@@ -140,7 +169,11 @@ const AdvancedSettings = ({ courseId }) => {
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 ? (
@@ -193,8 +226,8 @@ const AdvancedSettings = ({ courseId }) => {
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>
@@ -236,9 +269,8 @@ const AdvancedSettings = ({ courseId }) => {
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isFailed={Boolean(queryError)}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
@@ -249,18 +281,18 @@ const AdvancedSettings = ({ courseId }) => {
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)}
@@ -278,8 +310,4 @@ const AdvancedSettings = ({ courseId }) => {
);
};
AdvancedSettings.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default AdvancedSettings;

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
advancedModules: {
deprecated: false,
displayName: 'Advanced Module List',

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().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 (err) {
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 (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

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

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

@@ -0,0 +1,20 @@
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};
export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
};

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

@@ -0,0 +1,41 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
PermissionValidationAnswer,
PermissionValidationQuery,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from '@src/authz/types';
import { getApiUrl } from './utils';
export const validateUserPermissions = async (
query: PermissionValidationQuery,
): Promise<PermissionValidationAnswer> => {
// Convert the validations query object into an array for the API request
const request: PermissionValidationRequestItem[] = Object.values(query);
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/permissions/validate/me'),
request,
);
// Convert the API response back into the expected answer format
const result: PermissionValidationAnswer = {};
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
const key = Object.keys(query).find(
(k) => query[k].action === item.action
&& query[k].scope === item.scope,
);
if (key) {
result[key] = item.allowed;
}
});
// Fill any missing keys with false
Object.keys(query).forEach((key) => {
if (!(key in result)) {
result[key] = false;
}
});
return result;
};

View File

@@ -0,0 +1,168 @@
import { act, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useUserPermissions } from './apiHooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const singlePermission = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
};
const mockValidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
];
const mockEmptyPermissions = [
// No permissions returned
];
const multiplePermissions = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
canWrite: {
action: 'example.write',
scope: 'lib:example-org:test-lib',
},
};
const mockValidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
];
describe('useUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns allowed true when permission is valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
});
it('returns allowed false when permission is invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('returns allowed true when multiple permissions are valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
expect(result.current.data!.canWrite).toBe(true);
});
it('returns allowed false when multiple permissions are invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
expect(result.current.data!.canWrite).toBe(false);
});
it('returns allowed false when the permission is not included in the server response', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('handles error when the API call fails', async () => {
const mockError = new Error('API Error');
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockRejectedValue(new Error('API Error')),
});
try {
act(() => {
renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
});
} catch (error) {
expect(error).toEqual(mockError); // Check for the expected error
}
});
});

View File

@@ -0,0 +1,37 @@
import { skipToken, useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';
const adminConsoleQueryKeys = {
all: ['authz'],
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
};
/**
* React Query hook to validate if the current user has permissions over a certain object in the instance.
* It helps to:
* - Determine whether the current user can access certain object.
* - Provide role-based rendering logic for UI components.
*
* @param permissions - A key/value map of objects and actions to validate.
* The key is an arbitrary string to identify the permission check,
* and the value is an object containing the action and optional scope.
*
* @example
* const { isLoading, data } = useUserPermissions({
* canRead: {
* action: "content_libraries.view_library",
* scope: "lib:OpenedX:CSPROB"
* }
* });
* if (data.canRead) { ... }
*
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
enabled: boolean = true,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
retry: false,
});

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

@@ -0,0 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;

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

@@ -0,0 +1,16 @@
export interface PermissionValidationRequestItem {
action: string;
scope?: string;
}
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
allowed: boolean;
}
export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}
export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}

View File

@@ -1,6 +1,7 @@
// @ts-check
import userEvent from '@testing-library/user-event';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { initializeMocks, render, waitFor } from '../testUtils';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
@@ -14,7 +15,11 @@ let axiosMock;
let store;
const courseId = 'course-123';
const renderComponent = (props) => render(<Certificates courseId={courseId} {...props} />);
const renderComponent = (props) => render(
<CourseAuthoringProvider courseId={courseId}>
<Certificates {...props} />
</CourseAuthoringProvider>,
);
describe('Certificates', () => {
beforeEach(async () => {

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
@@ -21,7 +21,8 @@ const MODE_COMPONENTS = {
[MODE_STATES.editAll]: CertificateEditForm,
};
const Certificates = ({ courseId }) => {
const Certificates = () => {
const { courseId } = useCourseAuthoringContext();
const {
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
} = useCertificates({ courseId });
@@ -50,8 +51,4 @@ const Certificates = ({ courseId }) => {
);
};
Certificates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default Certificates;

View File

@@ -1,4 +1,4 @@
module.exports = [
export default [
{
id: 1,
courseTitle: 'Course Title 1',

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
certificates: [

View File

@@ -1,4 +1,4 @@
module.exports = [
export default [
{
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
},

View File

@@ -7,7 +7,7 @@ const CertificateSection = ({
<section {...rest}>
<Stack className="justify-content-between mb-2.5" direction="horizontal">
<h2 className="lead section-title mb-0">{title}</h2>
{actions && actions}
{actions}
</Stack>
<hr className="mt-0 mb-4" />
<div>

View File

@@ -25,6 +25,7 @@ const useCertificatesList = (courseId) => {
}));
const handleSubmit = async (values) => {
// oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise.
await dispatch(updateCourseCertificate(courseId, values));
setEditModes({});
dispatch(setMode(MODE_STATES.view));

View File

@@ -5,17 +5,15 @@ import { prepareCertificatePayload } from '../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
export const getCertificatesApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
export const getCertificateApiUrl = (courseId: string) => `${getApiBaseUrl()}/certificates/${courseId}`;
export const getUpdateCertificateApiUrl = (courseId: string, certificateId: number) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
export const getUpdateCertificateActiveStatusApiUrl = (path: string) => `${getApiBaseUrl()}${path}`;
/**
* Gets certificates for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCertificates(courseId) {
export async function getCertificates(courseId: string): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient()
.get(getCertificatesApiUrl(courseId));
@@ -24,12 +22,11 @@ export async function getCertificates(courseId) {
/**
* Create course certificate.
* @param {string} courseId
* @param {object} certificatesData
* @returns {Promise<Object>}
*/
export async function createCertificate(courseId, certificatesData) {
export async function createCertificate(
courseId: string,
certificatesData: Record<string, any>,
): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient()
.post(
getCertificateApiUrl(courseId),
@@ -41,11 +38,11 @@ export async function createCertificate(courseId, certificatesData) {
/**
* Update course certificate.
* @param {string} courseId
* @param {object} certificateData
* @returns {Promise<Object>}
*/
export async function updateCertificate(courseId, certificateData) {
export async function updateCertificate(
courseId: string,
certificateData: Record<string, any>,
): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient()
.post(
getUpdateCertificateApiUrl(courseId, certificateData.id),
@@ -57,11 +54,8 @@ export async function updateCertificate(courseId, certificateData) {
/**
* Delete course certificate.
* @param {string} courseId
* @param {object} certificateId
* @returns {Promise<Object>}
*/
export async function deleteCertificate(courseId, certificateId) {
export async function deleteCertificate(courseId: string, certificateId: number): Promise<Record<string, any>> {
const { data } = await getAuthenticatedHttpClient()
.delete(
getUpdateCertificateApiUrl(courseId, certificateId),
@@ -71,11 +65,8 @@ export async function deleteCertificate(courseId, certificateId) {
/**
* Activate/deactivate course certificate.
* @param {string} courseId
* @param {object} activationStatus
* @returns {Promise<Object>}
*/
export async function updateActiveStatus(path, activationStatus) {
export async function updateActiveStatus(path: string, activationStatus: unknown): Promise<Record<string, any>> {
const body = {
is_active: activationStatus,
};

View File

@@ -4,7 +4,7 @@ export const MODE_STATES = {
view: 'view',
editAll: 'edit_all',
create: 'create',
};
} as const;
export const ACTIVATION_MESSAGES = {
activating: 'Activating',

View File

@@ -1,22 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
export const getSavingStatus = (state) => state.certificates.savingStatus;
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
export const getErrorMessage = (state) => state.certificates.errorMessage;
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = state => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
export const getComponentMode = state => state.certificates.componentMode;
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -0,0 +1,25 @@
/* eslint-disable max-len */
import { createSelector } from '@reduxjs/toolkit';
import type { DeprecatedReduxState } from '@src/store';
export const getLoadingStatus = (state: DeprecatedReduxState) => state.certificates.loadingStatus;
export const getSavingStatus = (state: DeprecatedReduxState) => state.certificates.savingStatus;
export const getSavingImageStatus = (state: DeprecatedReduxState) => state.certificates.savingImageStatus;
export const getErrorMessage = (state: DeprecatedReduxState) => state.certificates.errorMessage;
// Commenting this one out as it seems to be unused:
// export const getSendRequestErrors = (state: DeprecatedReduxState) => state.certificates.sendRequestErrors.developer_message;
export const getCertificates = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificates;
export const getHasCertificateModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.hasCertificateModes;
export const getCourseModes = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseModes;
export const getCertificateActivationUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateActivationHandlerUrl;
export const getCertificateWebViewUrl = (state: DeprecatedReduxState) => state.certificates.certificatesData.certificateWebViewUrl;
export const getIsCertificateActive = (state: DeprecatedReduxState) => state.certificates.certificatesData.isActive;
export const getComponentMode = (state: DeprecatedReduxState) => state.certificates.componentMode;
export const getCourseNumber = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumber;
export const getCourseNumberOverride = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseNumberOverride;
export const getCourseTitle = (state: DeprecatedReduxState) => state.certificates.certificatesData.courseTitle;
export const getHasCertificates = createSelector(
[getCertificates],
(certificates) => certificates && certificates.length > 0,
);

View File

@@ -7,7 +7,7 @@ import { MODE_STATES } from './constants';
const slice = createSlice({
name: 'certificates',
initialState: {
certificatesData: {},
certificatesData: {} as Record<string, any>,
componentMode: MODE_STATES.noModes,
loadingStatus: RequestStatus.PENDING,
savingStatus: '',

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