Compare commits

..

110 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
9610f0791f feat: add games xblock editor
With this Commit, games xblock editor is in place now!
- copy code from https://github.com/openedx-unsupported/frontend-lib-content-components/pull/371/files to authoring MFE
  - It includes refactoring in .scss files, useIntl, replacing deprecated dependencies, fixing reducers, fixed cancel/close editor button, fix dragging the cards, edit some styles and also removed duplicate styling etc.
2025-11-17 14:08:16 +05: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
Navin Karkera
2dc087f87a feat: differentiate between renamed and data edit in sync preview diff [FC-0112] (#2577)
Use `downstream_customized` field from upstream link to determine whether the text component was locally renamed or content updated or both and display correct notes in preview diff.
Also update libraries v2 alert wording as per https://github.com/openedx/frontend-app-authoring/issues/2169#issuecomment-3434735901
2025-10-29 17:24:04 -05:00
Javier Ontiveros
9b77a40284 feat: add restore from file UI for libraries v2 (#2558) 2025-10-29 21:37:35 +00:00
renovate[bot]
871d98828c chore(deps): update dependency @openedx/paragon to v23.15.1 (#2569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 09:31:41 -07:00
renovate[bot]
f116740184 chore(deps): update github artifact actions (#2571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 10:49:02 -07:00
renovate[bot]
cd967a9878 chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#2568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 10:47:21 -07:00
Farhaan Bukhsh
d8805bf2b4 chore: Updates frontend-component-header to include new slots (#2576)
Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2025-10-27 16:07:51 +00:00
Diana Olarte
0972b7e62d feat: [FC-0099] redirect to admin console MFE (#2570)
* feat: redirect to admin console MFE

This PR redirects to admin console MFE if the URL is configured, to leverage the new experience of team management this is part of the AuthZ project https://github.com/openedx/openedx-authz/tree/main/docs/decisions

* refactor: split the logic into 2 variables for readability
2025-10-27 15:59:25 +00:00
Kshitij Sobti
15a728d0e7 feat: Add slots for video and file upload components and alerts (#1523)
This change add plugin slots for the file and video upload components, and the alerts components on those pages.
2025-10-24 14:39:50 -07:00
Braden MacDonald
157e2464aa test: fix test code improperly using/missing 'await' (#2560) 2025-10-24 21:31:51 +00:00
Braden MacDonald
c54c21e2b4 test: fix warnings when StrictDict values are checked with propTypes (#2565) 2025-10-24 14:16:07 -07:00
Muhammad Anas
dc05ccfd16 feat: add never_but_include_grade visibility option (#2489)
This PR introduces a new visibility option for assignment scores:

“Never show individual assessment results, but show overall assessment results after the due date.”

With this option, learners cannot see question-level correctness or scores at any time. However, once the due date has passed, they can view their overall score in the total grades section on the Progress page.
2025-10-24 15:08:32 -03:00
Chris Chávez
106f22b3c2 chore: Enable legacy libraries migration by default (#2561) 2025-10-22 18:20:55 -05:00
Chris Chávez
76d8b2e03a feat: Add error messages for partial migration [FC-0107] (#2555)
Adds the error messages for partial migrations
2025-10-22 17:07:24 -05:00
Kyle McCormick
5ce61fa5e5 feat: Add ability to create Legacy Libraries (#2551)
This adds a CreateLegacyLibrary component. It functions the same as
CreateLibrary, but it calls the V1 (legacy) creation REST API rather the V2
(new/beta) REST API.

This reinstates, in the MFE, something that was possible using the legacy
frontend until it was prematurely removed by
https://github.com/openedx/edx-platform/pull/37454. 

We plan to re-remove this ability between Ulmo and Verawood as part of:
https://github.com/openedx/edx-platform/issues/32457.
So, we have intentionally avoided factoring out common logic between
CreateLibrary and CreateLegacyLibrary, ensuring that the latter
remains easy to remove and clean up.
2025-10-22 21:33:59 +00:00
Chris Chávez
46fa17ea83 fix: UI fixes in legacy library migrator
- Keep state of all migration steps on nevigation
- Reword alert in confirm dialog
- Add scroll to help sidebar in migration
- Keep the same migration filter
2025-10-22 21:24:52 +00:00
Chris Chávez
9f1604110b feat: Deleted/Added level diff in sync modal [FC-0097] (#2549)
- Adds the Deleted and the Added level diff in sync container modal.
- Fix the icons in the sync container modal
2025-10-22 20:53:12 +00:00
Muhammad Anas
82a3c2815b feat: enable markdown to OLX conversion (#2518) 2025-10-21 16:58:20 -07:00
renovate[bot]
191be55b2e chore(deps): update dependency @tanstack/react-query to v5.90.5 (#2545)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 17:55:40 -07:00
renovate[bot]
8f7e48421f chore(deps): update actions/setup-node action to v6 (#2546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 17:32:11 -07:00
dependabot[bot]
b6de9a8883 chore(deps): bump actions/setup-node from 5 to 6 (#2547)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  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-10-20 17:29:38 -07:00
Javier Ontiveros
7bfc73073b [feature] add backup view for libraries v2 (#2532)
* feat: add backup view for libraries v2

* chore: updated paths and cleanup

* chore: cleanup text

* chore: added test

* chore: fix contracts after rebase

* chore: more tests to improve coverage

* chore: more test for coverage

* chore: more test for coverage

* chore: fixed lint issues

* chore: update naming for a more semantic one

* chore: changed fireEvent to userEvent

* chore: improved queryKeys

* chore: lint cleanup

* chore: changed tests and time to 1min

* chore: even more tests

* chore: split hook for library menu items

* chore: fixed typo on refactor

* chore: improved test to use available mocks

* chore: change from jest.mocks to spyon

* chore: update test based on commets

* chore: update test to get URL from a better place

* chore: added extra getters for new endpoints

* chore: update test to prevent issues with useContentLibrary

* chore: added comments for clarity

* chore: lint fix

* chore: updated url handle to use full URL

* chore: linting fixes
2025-10-20 16:51:05 -06:00
renovate[bot]
98009b3e6a chore(deps): update dependency @edx/frontend-component-footer to v14.9.3 2025-10-20 16:34:05 -03:00
Navin Karkera
e80930e06f fix: add components button in item bank children page (#2491)
`Add components` button in item bank children page was not working.
2025-10-20 12:09:58 -05:00
edX requirements bot
66dad5ff32 chore: update browserslist DB (#2544)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-20 00:22:22 +00:00
bydawen
e4ea69266f fix: pointer-events issue on toast container (#2139) 2025-10-17 14:26:03 -07:00
Rômulo Penido
8b6a350808 feat: add copy container menu (#2538)
Adds copy support for Library Containers and updates the useClipboard hook to provide info on copied containers.
2025-10-17 14:11:18 -05:00
renovate[bot]
a56faf8ca7 fix(deps): update dependency @tinymce/tinymce-react to v6 (#2536)
* fix(deps): update dependency @tinymce/tinymce-react to v6

* refactor: validating typing of tinymce-react usage (no runtime changes)

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-10-16 22:07:27 +00:00
renovate[bot]
77215eeb5e chore(deps): update dependency @openedx/paragon to v23.14.9 (#2533)
* chore(deps): update dependency @openedx/paragon to v23.14.9

* test: update tests because of paragon role=presentation modal changes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-10-16 16:53:39 +00:00
Chris Chávez
311bef67ed feat: Shows an alert in container sync if the only change is a local override to a text component [FC-0097] (#2516)
- Implements the alert described in https://github.com/openedx/frontend-app-authoring/issues/2438#issuecomment-3358670967
2025-10-15 19:17:56 -05:00
Chris Chávez
195249ef26 feat: Legacy libraries migration help sidebar [FC-0097] (#2503)
- Implements the Legacy Libraries Migration Help Sidebar
- Shows the sidebar in the studio home
- Shows the sidebar in the Legacy Libraries Migration Page
2025-10-15 20:35:08 +00:00
Ihor Romaniuk
4a26a86c90 fix: answer range format validation in numerical input problems (#2426) 2025-10-15 11:33:31 -07:00
vladislavkeblysh
411d4f053c fix: fixed delete for additional video url fields in video editor (#2470) 2025-10-15 09:47:03 -07:00
Chris Chávez
9c0b545b2f feat: Connect bulk migration backend with frontend (#2493)
- Connects the `Confirm` button with the bulk migrate backend
- Updates the library page to get the migration task status and refresh the component on success.
2025-10-14 00:04:36 +00:00
Ahtesham Quraish
cd36407457 fix: [Library browse] Unintended scroll upon unit selection #2452 (#2472) 2025-10-13 09:22:22 -05:00
Chris Chávez
46d2465064 fix: Multiple UI/UX improvements (#2529)
This includes multiple improvements described in https://github.com/openedx/frontend-app-authoring/issues/2528
2025-10-13 09:21:00 -05:00
edX requirements bot
6c829b9421 chore: update browserslist DB (#2534)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-13 00:22:24 +00:00
Kyle McCormick
1d6fdc39fd feat: frontend-component-header to 8.0.0, to rm Maintenance link (#2521)
BREAKING CHANGE: Removes Maintenance link in header, part of
https://github.com/openedx/edx-platform/issues/36263

Full diff:
https://github.com/openedx/frontend-component-header/compare/v6.2.0...v8.0.0
2025-10-10 12:38:02 -04:00
Ahtesham Quraish
04e9a253ba fix: library card icons are not vertically aligned #2473 (#2474) 2025-10-10 09:01:13 -07:00
Ahtesham Quraish
8470d7cd4d fix: Rename field should use full available width #2337 (#2436) 2025-10-10 09:00:40 -07:00
renovate[bot]
b774084a10 chore(deps): update dependency @openedx/paragon to v23.14.5 (#2530)
* chore(deps): update dependency @openedx/paragon to v23.14.5

* fix: correct type of ContainerPropsType

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-10-09 17:14:33 +00:00
MuPp3t33r
aadccc748c fix: course assets state persistence bug (#2401) 2025-10-08 11:36:26 -07:00
Navin Karkera
c4a439df47 fix: show before and after title in diff preview (#2509)
Fix display of Before and After display name in section/subsection sync preview modal.
2025-10-07 14:52:22 -05:00
renovate[bot]
8fe5fb6a20 chore(deps): update dependency universal-cookie to v8 (#2512)
* fix(deps): update dependency universal-cookie to v8

* refactor: validate typing of universal-cookie usages

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-10-06 23:08:46 +00:00
renovate[bot]
0315c05e11 chore(deps): update dependency fast-xml-parser to v5 (#2515)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 22:41:17 +00:00
renovate[bot]
a5d51ce4f4 chore(deps): update dependency @types/react to v18.3.26 (#2513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 15:25:18 -07:00
renovate[bot]
3a6378e569 chore(deps): update dependency @testing-library/jest-dom to v6.9.1 (#2508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 14:40:55 -07:00
renovate[bot]
e37f2e0071 chore(deps): update dependency @types/react to v18.3.25 (#2507)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 14:40:18 -07:00
edX requirements bot
835de77385 chore: update browserslist DB (#2506)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-06 00:21:27 +00:00
renovate[bot]
7f23e9b585 chore(deps): update dependency @openedx/paragon to v23.14.4 (#2504)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-04 00:30:06 +00:00
renovate[bot]
32ed2f183b chore(deps): update codemirror (#2487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-04 00:29:54 +00:00
renovate[bot]
292068af6e chore(deps): update dependency @tanstack/react-query to v5.90.2 (#2488)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 17:17:05 -07:00
Chris Chávez
a975f3b716 feat: New modal to sync changes for standalone text components [FC-0097] (#2449)
Adds a new sync modal when a Text component has local changes.
2025-09-30 09:45:13 -05:00
Chris Chávez
1c7ad2f725 feat: Migrate Legacy Libraries Flow [FC-0097] (#2425)
- Creates all steps of the flow described in https://github.com/openedx/frontend-app-authoring/issues/2201
2025-09-29 18:02:15 -05:00
renovate[bot]
7ba3db0187 chore(deps): update dependency @openedx/paragon to v23.14.3 (#2485)
* chore(deps): update dependency @openedx/paragon to v23.14.3

* fix: ref was invalid and causing console error

"Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?"

(Paragon does not support refs for IconButtonWithTooltip, although it could be added at any time.)

* fix: react query console error in tags drawer

"No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn."

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-09-29 21:07:49 +00:00
edX requirements bot
fdf98a1400 chore: update browserslist DB (#2486)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-29 00:21:05 +00:00
Chris Chávez
56d3eede64 feat: Adding loading and done states to the publish library button [FC-0097] (#2237)
Adds loading and done states to the publish library button.
2025-09-26 15:15:23 +00:00
oleksandr.buhaienko
c5de944d72 test: Remove support for Node 20 2025-09-26 10:29:24 -03:00
Rômulo Penido
523dd1f389 feat: add library v2 alert (#2413)
Adds an Alert to the Legacy Library Page to notify the user of the process of deprecating Legacy Libraries and a Button to open the Migrate Library interface.
2025-09-25 16:01:57 -05:00
Navin Karkera
cffc4d77c9 feat: migration filter and search bar in legacy libraries tab [FC-0097] (#2421)
Adds search bar and migration filter in legacy libraries tab
2025-09-25 17:00:26 +00:00
Navin Karkera
25160347b3 feat: container library-course sync diff prevew [FC-0097] (#2464)
Container sync preview implemented
2025-09-25 11:13:58 -05:00
Navin Karkera
d63680083d feat: show migration status in libraries list [FC-0097] (#2417)
Adds migration status to library cards in legacy libraries tab in studio home.
Also converts javascript files to typescript and replaces redux with react query for related api calls.
2025-09-25 10:49:13 -05:00
bydawen
39e5f89b45 build: Upgrade to Node 24 (#2459) 2025-09-22 09:49:04 -07:00
edX requirements bot
7e81b52583 chore: update browserslist DB (#2469)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-22 00:22:14 +00:00
Rômulo Penido
1efa94d410 fix: update delete and remove modals design [FC-0097] (#2453)
Changes the Remove/Delete Component/Container dialogs according to the design. It also standardized the messages from Components and Containers.
2025-09-18 16:27:35 -05:00
bydawen
d98a34ac3f test: Add Node 24 to CI matrix (#2443) 2025-09-18 10:09:51 -07:00
Akanshu Aich
8b530481de fix: remove unused ENABLE_HOME_PAGE_COURSE_API_V2 config reference (#2461) 2025-09-18 09:49:03 -07:00
Muhammad Faraz Maqsood
9f6a882e61 fix: course optimizer issues (#2450)
- don't show `Scan results` heading until there are some results to show.
- change spinner from paragon with spinner icon which looks better than spinner itself.
- disable `update all` button when single update prev Link is in progress.

Co-authored-by: Muhammad Faraz  Maqsood <faraz.maqsood@A006-01130.local>
2025-09-18 10:44:18 +05:00
renovate[bot]
b95b3a60ad chore(deps): update dependency @tanstack/react-query to v5 (#2404)
* fix(deps): update dependency @tanstack/react-query to v5

* chore: update for compatibility with React Query v5

* chore: update for compatibility with React Query v5

* test: update tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-09-17 16:10:08 -07:00
Navin Karkera
61c87fe6a6 feat: allow editing imported unit blocks (#2405)
Allows authors to edit imported unit display name in outline.
2025-09-17 12:27:04 -05:00
renovate[bot]
c21b664a8b chore(deps): update codemirror to v6.4.10 (#2432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 10:50:08 -07:00
Chris Chávez
71376fa22b feat: ENABLE_LEGACY_LIBRARY_MIGRATOR flag added (#2440)
- Adds the `ENABLE_LEGACY_LIBRARY_MIGRATOR` flag. **Reason:** The migrator frontend is finishing before the backend; this flag is mainly to hide it until it is fully connected and working with the backend.
- Puts the migration warning under the new flag.
2025-09-12 11:20:31 -05:00
Chris Chávez
23b4f4731e fix: Use the correct branch of openedx/.github repo for add-cc-to-board (#2441) 2025-09-11 12:33:10 -07:00
Chris Chávez
ab645ad86b feat: add-to-cc-board Github action created. (#2302)
- This is a label trigger to add an Issue or PR to the front-end [Core Contributor project board](https://github.com/orgs/openedx/projects/80).
- This action uses the `Core Contributor assignee` label as a trigger.
- This action reuses https://github.com/openedx/.github/pull/169
- When you add the label to an issue or PR, it is automatically added to the board https://github.com/orgs/openedx/projects/80
2025-09-10 17:10:38 -05:00
Navin Karkera
720b591add feat: display only one card action overflow menu (#2427)
Instead of stopping whole click event propagation from actions to card element, specifically stop click event if the source target is actions menu.
2025-09-08 16:47:17 -05:00
renovate[bot]
87239ab723 chore(deps): update dependency @types/react to v18.3.24 (#2431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 09:41:52 -07:00
Asad Ali
0117c1eae3 fix: allow thumbnail upload on Videos page if no thumbnail (#2388)
* fix: allow thumbnail upload if no thumbnail

* fix: improve thumbnail upload impl

* test: fix tests

* test: fix tests

* fix: do not show thumbnail upload if not allowed

* test: fix coverage

* test: add thumbnail test

* fix: display thumbnail overlay when video status is success
2025-09-08 09:40:32 -07:00
Ahtesham Quraish
0f7c8de882 fix: files page web url missing #2409 (#2420)
Co-authored-by: Ahtesham Quraish <ahtesham.quraish@A006-01455.local>
2025-09-08 09:33:19 -07:00
dependabot[bot]
387c45a5b2 chore(deps): bump actions/setup-node from 4 to 5 (#2433)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  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-09-08 09:00:29 -07:00
edX requirements bot
6377fbd896 chore: update browserslist DB (#2430)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-08 00:22:14 +00:00
Chris Chávez
67fab054ab feat: Secondary publish workflow for components [FC-0097] (#2399)
- Adds the new publish button and the new confirm publish box for components.
- Deletes the old confirm publish modal for components
- Adds the publish button next to the open button for containers 
- Update changes to grand-parent and grand-child items.
2025-09-05 17:12:40 +00:00
renovate[bot]
a7860b8392 chore(deps): update dependency @edx/frontend-component-header to v6.6.1 (#2407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 02:20:41 +00:00
renovate[bot]
3082eca91c chore(deps): update dependency @edx/frontend-component-footer to v14.9.1 (#2415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 19:07:46 -07:00
Rômulo Penido
2fb04d670f feat: add legacy library alert (#2412)
Adds an Alert to the Legacy Library Page to notify the user of the process of deprecating Legacy Libraries and a Button to open the Migrate Library interface.
2025-09-02 14:23:49 -05:00
Samuel Allan
f79b65c273 fix: update frontend-build to fix install issues (#2387)
Earlier versions of @openedx/frontend-build used on older version of
'sharp', which caused intermittent installation issues. The version of
'sharp' was updated in @openedx/frontend-build to fix these issues, so
the frontend-build version can be updated here, to fix the issues in
this project too. See
https://github.com/openedx/frontend-build/issues/664 and
https://github.com/openedx/frontend-build/pull/665 for more information.

The frontend-build dependency was updated by:

```
npm install --package-lock-only @openedx/frontend-build
```

Private-ref: https://tasks.opencraft.com/browse/BB-9953
2025-09-02 09:48:30 -07:00
Pandi Ganesh
472d77823f feat: Enhance Course Optimizer Page with Previous Run Links and Improved UI (#2356)
* feat: enhance course optimizer page design in studio

* feat: enhance course optimizer with prev run links update

* fix: increase container size and resolve style issues

* fix: enhance code structure and i18n support
2025-09-02 17:01:28 +05:00
edX requirements bot
09f4304daa chore: update browserslist DB (#2414)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-01 00:23:43 +00:00
456 changed files with 27685 additions and 6483 deletions

2
.env
View File

@@ -36,6 +36,7 @@ ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
@@ -48,3 +49,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'

View File

@@ -37,6 +37,7 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
@@ -51,3 +52,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'

View File

@@ -33,10 +33,10 @@ ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries

View File

@@ -14,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'`

15
.github/workflows/add-to-cc-board.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Trigger to add Issue or PR to a Core Contributor project board
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
add-to-cc-board:
if: github.event.label.name == 'Core Contributor assignee'
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
with:
board_name: cc-frontend-apps
secrets:
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
20
24

View File

@@ -11,4 +11,5 @@ coverage:
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js"

9638
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
@@ -59,17 +59,17 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-build": "^14.6.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.40.1",
"@tinymce/tinymce-react": "^3.14.0",
"@tanstack/react-query": "5.90.7",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^4.0.10",
"fast-xml-parser": "^5.0.0",
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
@@ -97,7 +97,7 @@
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"tinymce": "^5.10.4",
"universal-cookie": "^4.0.4",
"universal-cookie": "^8.0.0",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"

View File

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

View File

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

View File

@@ -1,11 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.proctoring.alert.error': {
id: 'authoring.proctoring.alert.error',
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
description: 'Alert message for proctoring settings save error.',
},
'authoring.proctoring.alert.forbidden': {
id: 'authoring.proctoring.alert.forbidden',
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',

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

@@ -0,0 +1,16 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'chapter',
blockTypeDisplay: 'Section',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Chapter 1',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
};

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -22,7 +21,6 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
@@ -86,10 +84,10 @@ describe('<SettingCard />', () => {
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await (async () => {
await user.tab(); // blur off of the input.
await waitFor(() => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -30,7 +30,7 @@ const SettingCard = ({
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
@@ -118,7 +118,7 @@ SettingCard.propTypes = {
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.PropTypes.oneOfType([
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,

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);

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -30,6 +30,7 @@ const initialState = {
};
const defaultProps = {
index: 0,
...signatoriesMock[0],
showDeleteButton: true,
isEdit: true,
@@ -62,31 +63,36 @@ describe('Signatory Component', () => {
it('handles input change', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
renderSignatory({ ...defaultProps, handleChange });
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe';
await user.type(input, newInputValue, { name: 'signatories[0].name' });
expect(handleChange).not.toHaveBeenCalled();
expect(input.value).not.toBe(newInputValue);
await user.type(input, newInputValue);
waitFor(() => {
expect(handleChange).toHaveBeenCalledWith(expect.anything());
expect(input.value).toBe(newInputValue);
await waitFor(() => {
// This is not a great test; handleChange() gets called for each key press:
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length);
// And the input value never actually changes because it's a controlled component
// and we pass the name in as a prop, which hasn't changed.
// expect(input.value).toBe(newInputValue);
});
});
it('opens image upload modal on button click', async () => {
const user = userEvent.setup();
const { getByRole, queryByRole } = renderSignatory(defaultProps);
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
const replaceButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
expect(queryByRole('presentation')).not.toBeInTheDocument();
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
await user.click(replaceButton);
expect(getByRole('presentation')).toBeInTheDocument();
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
});
it('shows confirm modal on delete icon click', async () => {

View File

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

View File

@@ -7,6 +7,7 @@ export const STATEFUL_BUTTON_STATES = {
default: 'default',
pending: 'pending',
error: 'error',
disable: 'disable',
};
export const USER_ROLES = {
@@ -61,6 +62,8 @@ export const COURSE_BLOCK_NAMES = ({
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
itembank: { id: 'itembank', name: 'Problem Bank' },
legacyLibraryContent: { id: 'library_content', name: 'Randomized Content Block' },
});
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
@@ -107,3 +110,9 @@ export const iframeMessageTypes = {
xblockEvent: 'xblock-event',
xblockScroll: 'xblock-scroll',
};
export const BROKEN = 'broken';
export const LOCKED = 'locked';
export const MANUAL = 'manual';

View File

@@ -0,0 +1,26 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Stack } from '@openedx/paragon';
import messages from './messages';
interface Props {
title: React.ReactNode;
children: React.ReactNode;
side: 'Before' | 'After';
}
const ChildrenPreview = ({ title, children, side }: Props) => {
const intl = useIntl();
const sideTitle = side === 'Before'
? intl.formatMessage(messages.diffBeforeTitle)
: intl.formatMessage(messages.diffAfterTitle);
return (
<Stack direction="vertical">
<span className="text-center">{sideTitle}</span>
<span className="mt-2 mb-3 text-md text-gray-800">{title}</span>
{children}
</Stack>
);
};
export default ChildrenPreview;

View File

@@ -0,0 +1,162 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { getLibraryContainerApiUrl } from '@src/library-authoring/data/api';
import { mockGetContainerChildren, mockGetContainerMetadata } from '@src/library-authoring/data/api.mocks';
import { initializeMocks, render, screen } from '@src/testUtils';
import { CompareContainersWidget } from './CompareContainersWidget';
import { mockGetCourseContainerChildren } from './data/api.mock';
mockGetCourseContainerChildren.applyMock();
mockGetContainerChildren.applyMock();
let axiosMock: MockAdapter;
describe('CompareContainersWidget', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
test('renders the component with a title', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect((await screen.findAllByText('subsection block 0')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 00')).length).toEqual(1);
expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3);
expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3);
expect((await screen.findAllByText('subsection block 1')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 2')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 11')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 22')).length).toEqual(1);
expect(screen.queryByText(
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
)).not.toBeInTheDocument();
});
test('renders loading spinner when data is pending', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionIdLoading);
axiosMock.onGet(url).reply(() => new Promise(() => {}));
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionIdLoading}
downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading}
/>);
const spinner = await screen.findAllByRole('status');
expect(spinner.length).toEqual(4);
expect(spinner[0].textContent).toEqual('Loading...');
expect(spinner[1].textContent).toEqual('Loading...');
expect(spinner[2].textContent).toEqual('Loading...');
expect(spinner[3].textContent).toEqual('Loading...');
});
test('calls onRowClick when a row is clicked and updates diff view', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// left i.e. before side block
let block = await screen.findByText('subsection block 00');
await user.click(block);
// Breadcrumbs - shows old and new name
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
// Back breadcrumb
const backbtns = await screen.findAllByRole('button', { name: 'Back' });
expect(backbtns.length).toEqual(2);
// Go back
await user.click(backbtns[0]);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// right i.e. after side block
block = await screen.findByText('subsection block 0');
// After side click also works
await user.click(block);
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
});
test('should show removed container diff state', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// left i.e. before side block
const block = await screen.findByText('subsection block 00');
await user.click(block);
const removedRows = await screen.findAllByText('This unit was removed');
await user.click(removedRows[0]);
expect(await screen.findByText('This unit has been removed')).toBeInTheDocument();
});
test('should show new added container diff state', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId="block-v1:UNIX+UX1+2025_T3+type@section+block@0-new"
/>);
const blocks = await screen.findAllByText('This subsection will be added in the new version');
await user.click(blocks[0]);
expect(await screen.findByText(/this subsection is new/i)).toBeInTheDocument();
});
test('should show alert if the only change is a single text component with local overrides', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertSingleText}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect(screen.getByText(
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
)).toBeInTheDocument();
expect(screen.getByText(/Html block 11/i)).toBeInTheDocument();
});
test('should show alert if the only changes is multiple text components with local overrides', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertMultipleText}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect(screen.getByText(
/the only change is to which have been edited in this course\. accepting will not remove local edits\./i,
)).toBeInTheDocument();
expect(screen.getByText(/2 text blocks/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,300 @@
import { useCallback, useMemo, useState } from 'react';
import {
Alert,
Breadcrumb, Button, Card, Icon, Stack,
} from '@openedx/paragon';
import { ArrowBack, Add, Delete } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
import ErrorAlert from '@src/generic/alert-error';
import { LoadingSpinner } from '@src/generic/Loading';
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
import { BoldText } from '@src/utils';
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
import ChildrenPreview from './ChildrenPreview';
import ContainerRow from './ContainerRow';
import { useCourseContainerChildren } from './data/apiHooks';
import {
ContainerChild, ContainerChildBase, ContainerState, WithState,
} from './types';
import { diffPreviewContainerChildren, isRowClickable } from './utils';
import messages from './messages';
interface ContainerInfoProps {
upstreamBlockId: string;
downstreamBlockId: string;
isReadyToSyncIndividually?: boolean;
}
interface Props extends ContainerInfoProps {
parent: ContainerInfoProps[];
onRowClick: (row: WithState<ContainerChild>) => void;
onBackBtnClick: () => void;
state?: ContainerState;
// This two props are used to show an alert for the changes to text components with local overrides.
// They may be removed in the future.
localUpdateAlertCount: number;
localUpdateAlertBlockName: string;
}
/**
* Actual implementation of the displaying diff between children of containers.
*/
const CompareContainersWidgetInner = ({
upstreamBlockId,
downstreamBlockId,
parent,
state,
onRowClick,
onBackBtnClick,
localUpdateAlertCount,
localUpdateAlertBlockName,
}: Props) => {
const intl = useIntl();
const { data, isError, error } = useCourseContainerChildren(downstreamBlockId, parent.length === 0);
// There is the case in which the item is removed, but it still exists
// in the library, for that case, we avoid bringing the children.
const {
data: libData,
isError: isLibError,
error: libError,
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
const {
data: containerData,
isError: isContainerTitleError,
error: containerTitleError,
} = useContainer(upstreamBlockId);
const result = useMemo(() => {
if ((!data || !libData) && !['added', 'removed'].includes(state || '')) {
return [undefined, undefined];
}
return diffPreviewContainerChildren(data?.children || [], libData as ContainerChildBase[] || []);
}, [data, libData]);
const renderBeforeChildren = useCallback(() => {
if (!result[0] && state !== 'added') {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (state === 'added') {
return (
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
<Icon src={Add} className="big-icon" />
<FormattedMessage
{...messages.newContainer}
values={{
containerType: getBlockType(upstreamBlockId),
}}
/>
</Stack>
);
}
return result[0]?.map((child) => (
<ContainerRow
key={child.id}
title={child.name}
containerType={child.blockType}
state={child.state}
originalName={child.originalName}
side="Before"
onClick={() => onRowClick(child)}
/>
));
}, [result]);
const renderAfterChildren = useCallback(() => {
if (!result[1] && state !== 'removed') {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (state === 'removed') {
return (
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
<Icon src={Delete} className="big-icon" />
<FormattedMessage
{...messages.deletedContainer}
values={{
containerType: getBlockType(upstreamBlockId),
}}
/>
</Stack>
);
}
return result[1]?.map((child) => (
<ContainerRow
key={child.id}
title={child.name}
containerType={child.blockType}
state={child.state}
side="After"
onClick={() => onRowClick(child)}
/>
));
}, [result]);
const getTitleComponent = useCallback((title?: string | null) => {
if (!title) {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (parent.length === 0) {
return title;
}
return (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={[
{
// This raises failed prop-type error as label expects a string but it works without any issues
label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>,
onClick: onBackBtnClick,
variant: 'link',
className: 'px-0 text-gray-900',
},
{
label: title,
variant: 'link',
className: 'px-0 text-gray-900',
disabled: true,
},
]}
linkAs={Button}
/>
);
}, [parent]);
let beforeTitle: string | undefined | null = data?.displayName;
let afterTitle = containerData?.publishedDisplayName;
if (!data && state === 'added') {
beforeTitle = containerData?.publishedDisplayName;
}
if (!containerData && state === 'removed') {
afterTitle = data?.displayName;
}
if (isError || (isLibError && state !== 'removed') || (isContainerTitleError && state !== 'removed')) {
return <ErrorAlert error={error || libError || containerTitleError} />;
}
return (
<div className="compare-changes-widget row justify-content-center">
{localUpdateAlertCount > 0 && (
<Alert variant="info">
<FormattedMessage
{...messages.localChangeInTextAlert}
values={{
blockName: localUpdateAlertBlockName,
count: localUpdateAlertCount,
b: BoldText,
}}
/>
</Alert>
)}
<div className="col col-6 p-1">
<Card className="compare-card p-4">
<ChildrenPreview title={getTitleComponent(beforeTitle)} side="Before">
{renderBeforeChildren()}
</ChildrenPreview>
</Card>
</div>
<div className="col col-6 p-1">
<Card className="compare-card p-4">
<ChildrenPreview title={getTitleComponent(afterTitle)} side="After">
{renderAfterChildren()}
</ChildrenPreview>
</Card>
</div>
</div>
);
};
/**
* CompareContainersWidget component. Displays a diff of set of child containers from two different sources
* and allows the user to select the container to view. This is a wrapper component that maintains current
* source state. Actual implementation of the diff view is done by CompareContainersWidgetInner.
*/
export const CompareContainersWidget = ({
upstreamBlockId,
downstreamBlockId,
isReadyToSyncIndividually = false,
}: ContainerInfoProps) => {
const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & {
state?: ContainerState;
parent:(ContainerInfoProps & { state?: ContainerState })[];
}>({
upstreamBlockId,
downstreamBlockId,
parent: [],
state: 'modified',
});
const { data } = useCourseContainerChildren(downstreamBlockId, true);
let localUpdateAlertBlockName = '';
let localUpdateAlertCount = 0;
// Show this alert if the only change is text components with local overrides.
// We decided not to put this in `CompareContainersWidgetInner` because if you enter a child,
// the alert would disappear. By keeping this call in CompareContainersWidget,
// the alert remains in the modal regardless of whether you navigate within the children.
if (!isReadyToSyncIndividually && data?.upstreamReadyToSyncChildrenInfo
&& data.upstreamReadyToSyncChildrenInfo.every(value => value.downstreamCustomized.length > 0 && value.blockType === 'html')
) {
localUpdateAlertCount = data.upstreamReadyToSyncChildrenInfo.length;
if (localUpdateAlertCount === 1) {
localUpdateAlertBlockName = data.upstreamReadyToSyncChildrenInfo[0].name;
}
}
const onRowClick = (row: WithState<ContainerChild>) => {
if (!isRowClickable(row.state, row.blockType as ContainerType)) {
return;
}
setCurrentContainerState((prev) => ({
upstreamBlockId: row.id!,
downstreamBlockId: row.downstreamId!,
state: row.state,
parent: [...prev.parent, {
upstreamBlockId: prev.upstreamBlockId,
downstreamBlockId: prev.downstreamBlockId,
state: prev.state,
}],
}));
};
const onBackBtnClick = () => {
setCurrentContainerState((prev) => {
// istanbul ignore if: this should never happen
if (prev.parent.length < 1) {
return prev;
}
const prevParent = prev.parent[prev.parent.length - 1];
return {
upstreamBlockId: prevParent!.upstreamBlockId,
downstreamBlockId: prevParent!.downstreamBlockId,
state: prevParent!.state,
parent: prev.parent.slice(0, -1),
};
});
};
return (
<CompareContainersWidgetInner
upstreamBlockId={currentContainerState.upstreamBlockId}
downstreamBlockId={currentContainerState.downstreamBlockId}
parent={currentContainerState.parent}
state={currentContainerState.state}
onRowClick={onRowClick}
onBackBtnClick={onBackBtnClick}
localUpdateAlertCount={localUpdateAlertCount}
localUpdateAlertBlockName={localUpdateAlertBlockName}
/>
);
};

View File

@@ -0,0 +1,96 @@
import userEvent from '@testing-library/user-event';
import {
fireEvent, initializeMocks, render, screen,
} from '../testUtils';
import ContainerRow from './ContainerRow';
import messages from './messages';
describe('<ContainerRow />', () => {
beforeEach(() => {
initializeMocks();
});
test('renders with default props', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" />);
expect(await screen.findByText('Test title')).toBeInTheDocument();
});
test('renders with modified state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="modified" />);
expect(await screen.findByText(
messages.modifiedDiffBeforeMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('renders with removed state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="removed" />);
expect(await screen.findByText(
messages.removedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const onClick = jest.fn();
const user = userEvent.setup();
render(<ContainerRow
title="Test title"
containerType="subsection"
side="Before"
state="modified"
onClick={onClick}
/>);
const titleDiv = await screen.findByText('Test title');
const card = titleDiv.closest('.clickable');
expect(card).not.toBe(null);
await user.click(card!);
expect(onClick).toHaveBeenCalled();
});
test('calls onClick when pressed enter or space', async () => {
const onClick = jest.fn();
const user = userEvent.setup();
render(<ContainerRow
title="Test title"
containerType="subsection"
side="Before"
state="modified"
onClick={onClick}
/>);
const titleDiv = await screen.findByText('Test title');
const card = titleDiv.closest('.clickable');
expect(card).not.toBe(null);
fireEvent.select(card!);
await user.keyboard('{enter}');
expect(onClick).toHaveBeenCalled();
});
test('renders with originalName', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="locallyRenamed" originalName="Modified name" />);
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
});
test('renders with local content update', async () => {
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyContentUpdated" />);
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
});
test('renders with rename and local content update', async () => {
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyRenamedAndContentUpdated" originalName="Modified name" />);
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
});
test('renders with moved state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="moved" />);
expect(await screen.findByText(
messages.movedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('renders with added state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="added" />);
expect(await screen.findByText(
messages.addedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,132 @@
import {
ActionRow, Card, Icon, Stack,
} from '@openedx/paragon';
import type { MessageDescriptor } from 'react-intl';
import { useMemo } from 'react';
import {
Cached, ChevronRight, Delete, Done, Plus,
} from '@openedx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getItemIcon } from '@src/generic/block-type-utils';
import { ContainerType } from '@src/generic/key-utils';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import messages from './messages';
import { ContainerState } from './types';
import { isRowClickable } from './utils';
export interface ContainerRowProps {
title: string;
containerType: ContainerType | keyof typeof COMPONENT_TYPES | string;
state?: ContainerState;
side: 'Before' | 'After';
originalName?: string;
onClick?: () => void;
}
interface StateContext {
className: string;
icon: React.ComponentType;
message?: MessageDescriptor;
message2?: MessageDescriptor;
}
const ContainerRow = ({
title, containerType, state, side, originalName, onClick,
}: ContainerRowProps) => {
const isClickable = isRowClickable(state, containerType as ContainerType);
const stateContext: StateContext = useMemo(() => {
let message: MessageDescriptor | undefined;
let message2: MessageDescriptor | undefined;
switch (state) {
case 'added':
message = side === 'Before' ? messages.addedDiffBeforeMessage : messages.addedDiffAfterMessage;
return { className: 'text-white bg-success-500', icon: Plus, message };
case 'modified':
message = side === 'Before' ? messages.modifiedDiffBeforeMessage : messages.modifiedDiffAfterMessage;
return { className: 'text-white bg-warning-900', icon: Cached, message };
case 'removed':
message = side === 'Before' ? messages.removedDiffBeforeMessage : messages.removedDiffAfterMessage;
return { className: 'text-white bg-danger-600', icon: Delete, message };
case 'locallyRenamed':
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
case 'locallyContentUpdated':
message = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
case 'locallyRenamedAndContentUpdated':
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
message2 = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
return {
className: 'bg-light-300 text-light-300 ', icon: Done, message, message2,
};
case 'moved':
message = side === 'Before' ? messages.movedDiffBeforeMessage : messages.movedDiffAfterMessage;
return { className: 'bg-light-300 text-light-300', icon: Done, message };
default:
return { className: 'bg-light-300 text-light-300', icon: Done, message };
}
}, [state, side]);
return (
<Card
isClickable={isClickable}
onClick={onClick}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.();
}
}}
className="mb-2 rounded shadow-sm border border-light-100"
>
<Stack direction="horizontal" gap={0}>
<div
className={`px-1 align-self-stretch align-content-center rounded-left ${stateContext.className}`}
>
<Icon size="sm" src={stateContext.icon} />
</div>
<ActionRow className="p-2">
<Stack direction="vertical" gap={2}>
<Stack direction="horizontal" gap={2}>
<Icon
src={getItemIcon(containerType)}
screenReaderText={containerType}
title={title}
/>
<span className="small font-weight-bold">{title}</span>
</Stack>
{stateContext.message ? (
<div className="d-flex flex-column">
<span className="micro">
<FormattedMessage
{...stateContext.message}
values={{
blockType: containerType,
name: originalName,
}}
/>
</span>
{stateContext.message2 && (
<span className="micro">
<FormattedMessage
{...stateContext.message2}
values={{
blockType: containerType,
name: originalName,
}}
/>
</span>
)}
</div>
) : (
<span className="micro">&nbsp;</span>
)}
</Stack>
<ActionRow.Spacer />
{isClickable && <Icon size="md" src={ChevronRight} />}
</ActionRow>
</Stack>
</Card>
);
};
export default ContainerRow;

View File

@@ -0,0 +1,116 @@
/* istanbul ignore file */
import { CourseContainerChildrenData, type UpstreamReadyToSyncChildrenInfo } from '@src/course-unit/data/types';
import * as unitApi from '@src/course-unit/data/api';
/**
* Mock for `getLibraryContainerChildren()`
*
* This mock returns a fixed response for the given container ID.
*/
export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> {
let numChildren: number = 3;
let blockType: string;
let displayName: string;
let upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[] = [];
switch (containerId) {
case mockGetCourseContainerChildren.unitId:
blockType = 'text';
displayName = 'unit block 00';
break;
case mockGetCourseContainerChildren.sectionId:
blockType = 'subsection';
displayName = 'Test Title';
break;
case mockGetCourseContainerChildren.subsectionId:
blockType = 'unit';
displayName = 'subsection block 00';
break;
case mockGetCourseContainerChildren.sectionShowsAlertSingleText:
blockType = 'subsection';
displayName = 'Test Title';
upstreamReadyToSyncChildrenInfo = [{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
name: 'Html block 11',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
}];
break;
case mockGetCourseContainerChildren.sectionShowsAlertMultipleText:
blockType = 'subsection';
displayName = 'Test Title';
upstreamReadyToSyncChildrenInfo = [
{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
name: 'Html block 11',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@2',
name: 'Html block 22',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
},
];
break;
case mockGetCourseContainerChildren.unitIdLoading:
case mockGetCourseContainerChildren.sectionIdLoading:
case mockGetCourseContainerChildren.subsectionIdLoading:
return new Promise(() => { });
default:
blockType = 'section';
displayName = 'section block 00';
numChildren = 0;
break;
}
const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`,
name: `${blockType} block ${idx}${idx}`,
blockType,
upstreamLink: {
upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`,
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
}
));
return Promise.resolve({
canPasteComponent: true,
isPublished: false,
children,
displayName,
upstreamReadyToSyncChildrenInfo,
});
}
mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0';
mockGetCourseContainerChildren.subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0';
mockGetCourseContainerChildren.sectionId = 'block-v1:UNIX+UX1+2025_T3+type@section+block@0';
mockGetCourseContainerChildren.sectionShowsAlertSingleText = 'block-v1:UNIX+UX1+2025_T3+type@section2+block@0';
mockGetCourseContainerChildren.sectionShowsAlertMultipleText = 'block-v1:UNIX+UX1+2025_T3+type@section3+block@0';
mockGetCourseContainerChildren.unitIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@loading';
mockGetCourseContainerChildren.subsectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@loading';
mockGetCourseContainerChildren.sectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@section+block@loading';
mockGetCourseContainerChildren.childTemplate = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'unit',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
};
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetCourseContainerChildren.applyMock = () => {
jest.spyOn(unitApi, 'getCourseContainerChildren').mockImplementation(mockGetCourseContainerChildren);
};

View File

@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { getCourseContainerChildren } from '@src/course-unit/data/api';
import { getCourseKey } from '@src/generic/key-utils';
export const containerComparisonQueryKeys = {
all: ['containerComparison'],
/**
* Base key for a course
*/
course: (courseKey: string) => [...containerComparisonQueryKeys.all, courseKey],
/**
* Key for a single container
*/
container: (getUpstreamInfo: boolean, usageKey?: string) => {
if (usageKey === undefined) {
return [undefined, undefined, getUpstreamInfo.toString()];
}
const courseKey = getCourseKey(usageKey);
return [...containerComparisonQueryKeys.course(courseKey), usageKey, getUpstreamInfo.toString()];
},
};
export const useCourseContainerChildren = (usageKey?: string, getUpstreamInfo?: boolean) => (
useQuery({
enabled: !!usageKey,
queryFn: () => getCourseContainerChildren(usageKey!, getUpstreamInfo),
// If we first get data with a valid `usageKey` and then the `usageKey` changes to undefined, an error occurs.
queryKey: containerComparisonQueryKeys.container(getUpstreamInfo || false, usageKey),
})
);

View File

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

View File

@@ -0,0 +1,101 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error: {
id: 'course-authoring.container-comparison.diff.error.message',
defaultMessage: 'Unexpected error: Failed to fetch container data',
description: 'Generic error message',
},
removedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.removed-message',
defaultMessage: 'This {blockType} will be removed in the new version',
description: 'Description for removed component in before section of diff preview',
},
removedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.removed-message',
defaultMessage: 'This {blockType} was removed',
description: 'Description for removed component in after section of diff preview',
},
modifiedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.modified-message',
defaultMessage: 'This {blockType} will be modified',
description: 'Description for modified component in before section of diff preview',
},
modifiedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.modified-message',
defaultMessage: 'This {blockType} was modified',
description: 'Description for modified component in after section of diff preview',
},
addedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.added-message',
defaultMessage: 'This {blockType} will be added in the new version',
description: 'Description for added component in before section of diff preview',
},
addedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.added-message',
defaultMessage: 'This {blockType} was added',
description: 'Description for added component in after section of diff preview',
},
renamedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.locally-updated-message',
defaultMessage: 'Library Name: {name}',
description: 'Description for locally updated component in before section of diff preview',
},
renamedUpdatedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.locally-updated-message',
defaultMessage: 'Library name remains overwritten',
description: 'Description for locally updated component in after section of diff preview',
},
locallyContentUpdatedBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.locally-content-updated-message',
defaultMessage: 'This {blockType} was edited locally',
description: 'Description for locally content updated component in before section of diff preview',
},
locallyContentUpdatedAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.locally-content-updated-message',
defaultMessage: 'Local edit will remain',
description: 'Description for locally content updated component in after section of diff preview',
},
movedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.moved-message',
defaultMessage: 'This {blockType} will be moved in the new version',
description: 'Description for moved component in before section of diff preview',
},
movedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.moved-message',
defaultMessage: 'This {blockType} was moved',
description: 'Description for moved component in after section of diff preview',
},
breadcrumbAriaLabel: {
id: 'course-authoring.container-comparison.diff.breadcrumb.ariaLabel',
defaultMessage: 'Title breadcrumb',
description: 'Aria label text for breadcrumb in diff preview',
},
diffBeforeTitle: {
id: 'course-authoring.container-comparison.diff.before.title',
defaultMessage: 'Before',
description: 'Before section title text',
},
diffAfterTitle: {
id: 'course-authoring.container-comparison.diff.after.title',
defaultMessage: 'After',
description: 'After section title text',
},
localChangeInTextAlert: {
id: 'course-authoring.container-comparison.text-with-local-change.alert',
defaultMessage: 'The only change is to {count, plural, one {text block <b>{blockName}</b> which has been edited} other {<b>{count} text blocks</b> which have been edited}} in this course. Accepting will not remove local edits.',
description: 'Alert to show if the only change is on text components with local overrides.',
},
newContainer: {
id: 'course-authoring.container-comparison.new-container.text',
defaultMessage: 'This {containerType} is new',
description: 'Text to show in the comparison when a container is new.',
},
deletedContainer: {
id: 'course-authoring.container-comparison.deleted-container.text',
defaultMessage: 'This {containerType} has been removed',
description: 'Text to show in the comparison when a container is removed.',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
import { UpstreamInfo } from '@src/data/types';
export type ContainerState = 'removed' | 'added' | 'modified' | 'childrenModified' | 'locallyContentUpdated' | 'locallyRenamed' | 'locallyRenamedAndContentUpdated' | 'moved';
export type WithState<T> = T & { state?: ContainerState, originalName?: string };
export type WithIndex<T> = T & { index: number };
export type CourseContainerChildBase = {
name: string;
id: string;
upstreamLink: UpstreamInfo;
blockType: string;
};
export type ContainerChildBase = {
displayName: string;
id: string;
containerType?: string;
blockType?: string;
} & ({
containerType: string;
} | {
blockType: string;
});
export type ContainerChild = {
name: string;
id?: string;
downstreamId?: string;
blockType: string;
};

View File

@@ -0,0 +1,359 @@
import { ContainerChildBase, CourseContainerChildBase } from './types';
import { diffPreviewContainerChildren } from './utils';
export const getMockCourseContainerData = (
type: 'added|deleted' | 'moved|deleted' | 'all' | 'locallyEdited',
): [CourseContainerChildBase[], ContainerChildBase[]] => {
switch (type) {
case 'moved|deleted':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'added|deleted':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:added-unit-1',
displayName: 'Added unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'all':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:added-unit-1',
displayName: 'Added unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'locallyEdited':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: ['data'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: ['display_name', 'data'],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit - remote edit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
displayName: 'New unit remote edit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags - remote edit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
default:
throw new Error();
}
};
describe('diffPreviewContainerChildren', () => {
it('should handle moved and deleted', () => {
const [a, b] = getMockCourseContainerData('moved|deleted');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed takes precendence over moved
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][2].state).toEqual('locallyRenamed');
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][2].name).toEqual(a[0].name);
});
it('should handle add and delete', () => {
const [a, b] = getMockCourseContainerData('added|deleted');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// No change, state=undefined
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[0][0].originalName).toEqual(b[0].displayName);
expect(result[1][0].state).toEqual('locallyRenamed');
// Deleted entry
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][0].name).toEqual(a[0].name);
expect(result[0][3].name).toEqual(result[1][3].name);
expect(result[0][3].state).toEqual('added');
expect(result[1][3].state).toEqual('added');
});
it('should handle add, delete and moved', () => {
const [a, b] = getMockCourseContainerData('all');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed takes precendence over moved
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][4].state).toEqual('locallyRenamed');
expect(result[1][4].id).toEqual(result[0][0].id);
// Deleted entry
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][1].name).toEqual(result[0][1].name);
// added entry
expect(result[0][2].state).toEqual('added');
expect(result[1][2].state).toEqual('added');
expect(result[1][2].id).toEqual(result[0][2].id);
});
it('should handle locally edited content', () => {
const [a, b] = getMockCourseContainerData('locallyEdited');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][0].state).toEqual('locallyRenamed');
expect(result[1][0].id).toEqual(result[0][0].id);
// content updated
expect(result[0][1].state).toEqual('locallyContentUpdated');
expect(result[1][1].state).toEqual('locallyContentUpdated');
expect(result[1][1].id).toEqual(result[0][1].id);
// renamed and content updated
expect(result[0][2].state).toEqual('locallyRenamedAndContentUpdated');
expect(result[1][2].state).toEqual('locallyRenamedAndContentUpdated');
expect(result[1][2].id).toEqual(result[0][2].id);
});
});

View File

@@ -0,0 +1,143 @@
import { UpstreamInfo } from '@src/data/types';
import { ContainerType, normalizeContainerType } from '@src/generic/key-utils';
import {
ContainerChild,
ContainerChildBase,
ContainerState,
CourseContainerChildBase,
WithIndex,
WithState,
} from './types';
export function checkIsReadyToSync(link: UpstreamInfo): boolean {
return (link.versionSynced < (link.versionAvailable || 0))
|| (link.versionSynced < (link.versionDeclined || 0))
|| ((link.readyToSyncChildren?.length || 0) > 0);
}
/**
* Compares two arrays of container children (`a` and `b`) to determine the differences between them.
* It generates two lists indicating which elements have been added, modified, moved, or removed.
*/
export function diffPreviewContainerChildren<A extends CourseContainerChildBase, B extends ContainerChildBase>(
a: A[],
b: B[],
idKey: string = 'id',
): [WithState<ContainerChild>[], WithState<ContainerChild>[]] {
const mapA = new Map<any, WithIndex<A>>();
const mapB = new Map<any, WithIndex<ContainerChild>>();
for (let index = 0; index < a.length; index++) {
const element = a[index];
mapA.set(element.upstreamLink?.upstreamRef, { ...element, index });
}
const updatedA: WithState<ContainerChild>[] = Array(a.length);
const addedA: Array<WithIndex<ContainerChild>> = [];
const updatedB: WithState<ContainerChild>[] = [];
for (let index = 0; index < b.length; index++) {
const newVersion = b[index];
const oldVersion = mapA.get(newVersion.id);
if (!oldVersion) {
// This is a newly added component
addedA.push({
id: newVersion.id,
name: newVersion.displayName,
blockType: (newVersion.containerType || newVersion.blockType)!,
index,
});
updatedB.push({
name: newVersion.displayName,
blockType: (newVersion.blockType || newVersion.containerType)!,
id: newVersion.id,
state: 'added',
});
} else {
// It was present in previous version
let state: ContainerState | undefined;
const displayName = oldVersion.upstreamLink.downstreamCustomized.includes('display_name') ? oldVersion.name : newVersion.displayName;
let originalName: string | undefined;
// FIXME: This logic doesn't work when the content is updated locally and the upstream display name is updated.
// `isRenamed` becomes true.
// We probably need to differentiate between `contentModified` and `rename` in the backend or
// send `downstream_customized` field to the frontend and use it here.
const isRenamed = displayName !== newVersion.displayName && displayName === oldVersion.name;
const isContentModified = oldVersion.upstreamLink.downstreamCustomized.includes('data');
if (index !== oldVersion.index) {
// has moved from its position
state = 'moved';
}
if ((oldVersion.upstreamLink.downstreamCustomized.length || 0) > 0) {
if (isRenamed) {
state = 'locallyRenamed';
originalName = newVersion.displayName;
}
if (isContentModified) {
state = 'locallyContentUpdated';
}
if (isRenamed && isContentModified) {
state = 'locallyRenamedAndContentUpdated';
}
} else if (checkIsReadyToSync(oldVersion.upstreamLink)) {
// has a new version ready to sync
state = 'modified';
}
// Insert in its original index
updatedA.splice(oldVersion.index, 1, {
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
id: oldVersion.upstreamLink.upstreamRef,
downstreamId: oldVersion.id,
state,
originalName,
});
updatedB.push({
name: displayName,
blockType: (newVersion.blockType || newVersion.containerType)!,
id: newVersion.id,
downstreamId: oldVersion.id,
state,
});
// Delete it from mapA as it is processed.
mapA.delete(newVersion.id);
}
}
// If there are remaining items in mapA, it means they were deleted in newVersion;
mapA.forEach((oldVersion) => {
updatedA.splice(oldVersion.index, 1, {
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
id: oldVersion.upstreamLink.upstreamRef,
downstreamId: oldVersion.id,
state: 'removed',
});
updatedB.splice(oldVersion.index, 0, {
id: oldVersion.upstreamLink.upstreamRef,
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
downstreamId: oldVersion.id,
state: 'removed',
});
});
// Create a map for id with index of newly updatedB array
for (let index = 0; index < updatedB.length; index++) {
const element = updatedB[index];
mapB.set(element[idKey], { ...element, index });
}
// Use new mapB for getting new index for added elements
addedA.forEach((addedRow) => {
updatedA.splice(mapB.get(addedRow.id)?.index!, 0, { ...addedRow, state: 'added' });
});
return [updatedA, updatedB];
}
export function isRowClickable(state?: ContainerState, blockType?: ContainerType) {
return state && blockType && ['modified', 'added', 'removed'].includes(state) && [
ContainerType.Section,
ContainerType.Subsection,
ContainerType.Unit,
].includes(blockType);
}

View File

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

View File

@@ -37,3 +37,9 @@
min-height: 100vh;
}
}
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
// https://github.com/openedx/frontend-app-authoring/issues/1898
#toast-root[data-focus-on-hidden] {
pointer-events: initial !important;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ mockUseLibBlockMetadata.applyMock();
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let mockShowToast: (message: string, action?: ToastActionData) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
@@ -114,7 +114,7 @@ describe('<CourseLibraries />', () => {
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
waitFor(() => expect(alert).not.toBeInTheDocument());
await waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn);
@@ -327,4 +327,19 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('should show sync modal with local changes', async () => {
const itemIndex = 3;
const user = userEvent.setup();
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
});
});

View File

@@ -32,14 +32,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
onReview,
}) => {
const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => {
if (isLoading) {
if (isPending) {
return;
}
if (outOfSyncCount === 0) {
@@ -50,7 +50,7 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
}, [outOfSyncCount, lastPublishedDate, isPending, data]);
const dismissAlert = () => {
setShowAlert(false);

View File

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

View File

@@ -11,6 +11,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -26,6 +27,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -41,6 +43,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"downstreamIsModified": true,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -56,6 +59,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -71,6 +75,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -86,6 +91,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},

View File

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

View File

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

View File

@@ -33,10 +33,10 @@ jest.mock('react-redux', () => ({
expect(newBtn).toBeInTheDocument();
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
expect(useBtn).toBeInTheDocument();
userEvent.click(newBtn);
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
userEvent.click(useBtn);
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
await userEvent.click(newBtn);
await waitFor(() => expect(newClickHandler).toHaveBeenCalled());
await userEvent.click(useBtn);
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
});
});
});

View File

@@ -7,6 +7,7 @@ import {
import CardHeader from './CardHeader';
import TitleButton from './TitleButton';
import messages from './messages';
import { RequestStatus } from '../../data/constants';
const onExpandMock = jest.fn();
const onClickMenuButtonMock = jest.fn();
@@ -232,16 +233,6 @@ describe('<CardHeader />', () => {
});
});
it('check is field disabled when isDisabledEditField is true', async () => {
renderComponent({
...cardHeaderProps,
isFormOpen: true,
isDisabledEditField: true,
});
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
});
it('check editing is enabled when isDisabledEditField is false', async () => {
renderComponent({ ...cardHeaderProps });
@@ -254,8 +245,8 @@ describe('<CardHeader />', () => {
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
});
it('check editing is disabled when isDisabledEditField is true', async () => {
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
it('check editing is disabled when saving is in progress', async () => {
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();

View File

@@ -24,6 +24,7 @@ import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
import TagCount from '@src/generic/tag-count';
import { useEscapeClick } from '@src/hooks';
import { XBlockActions } from '@src/data/types';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
@@ -41,7 +42,6 @@ interface CardHeaderProps {
isFormOpen: boolean;
onEditSubmit: (titleValue: string) => void;
closeForm: () => void;
isDisabledEditField: boolean;
onClickDelete: () => void;
onClickUnlink: () => void;
onClickDuplicate: () => void;
@@ -69,6 +69,7 @@ interface CardHeaderProps {
extraActionsComponent?: ReactNode,
onClickSync?: () => void;
readyToSync?: boolean;
savingStatus?: RequestStatusType;
}
const CardHeader = ({
@@ -83,7 +84,6 @@ const CardHeader = ({
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickUnlink,
onClickDuplicate,
@@ -103,6 +103,7 @@ const CardHeader = ({
extraActionsComponent,
onClickSync,
readyToSync,
savingStatus,
}: CardHeaderProps) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -119,6 +120,7 @@ const CardHeader = ({
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
const { data: contentTagCount } = useContentTagsCount(cardId);
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
useEffect(() => {
const locatorId = searchParams.get('show');
@@ -172,7 +174,7 @@ const CardHeader = ({
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
disabled={isSaving}
/>
</Form.Group>
) : (
@@ -186,7 +188,7 @@ const CardHeader = ({
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
disabled={isDisabledEditField}
disabled={isSaving}
/>
</>
)}
@@ -238,7 +240,7 @@ const CardHeader = ({
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isDisabledEditField}
disabled={isSaving}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
@@ -246,7 +248,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isDisabledEditField}
disabled={isSaving}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}

View File

@@ -1,7 +1,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { XBlock } from '@src/data/types';
import { CourseOutline } from './types';
import { CourseOutline, CourseDetails } from './types';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -9,6 +9,8 @@ export const getCourseOutlineIndexApiUrl = (
courseId: string,
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded,
@@ -46,7 +48,7 @@ export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl
/**
* Get course outline index.
* @param {string} courseId
* @returns {Promise<courseOutline>}
* @returns {Promise<CourseOutline>}
*/
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
const { data } = await getAuthenticatedHttpClient()
@@ -55,6 +57,18 @@ export async function getCourseOutlineIndex(courseId: string): Promise<CourseOut
return camelCaseObject(data);
}
/**
* Get course details.
* @param {string} courseId
* @returns {Promise<CourseDetails>}
*/
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseDetailsApiUrl(courseId));
return camelCaseObject(data);
}
/**
*
* @param courseId

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import { getCourseItem } from './api';
import { getCourseDetails, getCourseItem } from './api';
export const courseOutlineQueryKeys = {
all: ['courseOutline'],
@@ -9,7 +9,7 @@ export const courseOutlineQueryKeys = {
*/
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
};
/**
@@ -22,7 +22,7 @@ export const useCreateCourseBlock = (
) => useMutation({
mutationFn: createCourseXblock,
onSettled: async (data) => {
callback?.(data.locator, data.parent_locator);
callback?.(data?.locator, data.parent_locator);
},
});
@@ -33,3 +33,10 @@ export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
enabled: enabled && itemId !== undefined,
})
);
export const useCourseDetails = (courseId?: string) => (
useQuery({
queryKey: courseOutlineQueryKeys.courseDetails(courseId),
queryFn: courseId ? () => getCourseDetails(courseId) : skipToken,
})
);

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment';
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
@@ -64,9 +65,11 @@ import {
} from './data/thunk';
import { useCreateCourseBlock } from './data/apiHooks';
import { getCourseItem } from './data/api';
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags(courseId);
@@ -156,7 +159,7 @@ const useCourseOutline = ({ courseId }) => {
data.shouldScroll = true;
// Page should scroll to newly added subsection.
dispatch(addSubsection({ parentLocator, data }));
} catch (error) {
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
@@ -171,7 +174,7 @@ const useCourseOutline = ({ courseId }) => {
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch (error) {
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
@@ -245,6 +248,8 @@ const useCourseOutline = ({ courseId }) => {
const handleEditSubmit = (itemId, sectionId, displayName) => {
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
// Invalidate container diff queries to update sync diff preview
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
};
const handleDeleteItemSubmit = () => {

View File

@@ -1,30 +1,31 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { uniqBy } from 'lodash';
import { getConfig } from '@edx/frontend-platform';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Campaign as CampaignIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
Error as ErrorIcon,
} from '@openedx/paragon/icons';
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import {
Campaign as CampaignIcon,
Error as ErrorIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
} from '@openedx/paragon/icons';
import { uniqBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
import { RequestStatus } from '../../data/constants';
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
import AlertMessage from '../../generic/alert-message';
import AlertProctoringError from '../../generic/AlertProctoringError';
import messages from './messages';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { API_ERROR_TYPES } from '../constants';
import { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import { API_ERROR_TYPES } from '../constants';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
import messages from './messages';
const PageAlerts = ({
courseId,
@@ -437,6 +438,7 @@ const PageAlerts = ({
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
{renderOutOfSyncAlert()}
<CourseOutlinePageAlertsSlot />
</>
);
};

View File

@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const unit = {
id: 'unit-1',
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
};
const subsection = {
id: '123',
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -43,7 +43,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const section = {
id: '123',
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
displayName: 'Section Name',
category: 'chapter',
published: true,
@@ -71,7 +71,10 @@ const section = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:section:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
@@ -88,7 +91,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
@@ -187,7 +189,9 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
// url encode subsection.id
const subsectionIdUrl = encodeURIComponent(subsection.id);
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -199,7 +203,9 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
// url encode subsection.id
const unitIdUrl = encodeURIComponent(unit.id);
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -231,7 +237,6 @@ describe('<SectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -251,7 +256,6 @@ describe('<SectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -11,7 +11,7 @@ import classNames from 'classnames';
import { useQueryClient } from '@tanstack/react-query';
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
import { RequestStatus } from '@src/data/constants';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -39,7 +39,7 @@ interface SectionCardProps {
onOpenPublishModal: () => void,
onOpenConfigureModal: () => void,
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus: string,
savingStatus?: RequestStatusType,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -144,7 +144,9 @@ const SectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'section',
};
}, [upstreamInfo]);
@@ -301,7 +303,7 @@ const SectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -52,7 +52,7 @@ const unit = {
};
const subsection: XBlock = {
id: '123',
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -75,12 +75,15 @@ const subsection: XBlock = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:subsection:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
const section: XBlock = {
id: '123',
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -115,7 +118,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}
@@ -322,7 +324,7 @@ describe('<SubsectionCard />', () => {
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2,
parentLocator: '123',
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
category: 'vertical',
libraryContentKey: containerKey,
});
@@ -339,7 +341,6 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -359,7 +360,6 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -11,7 +11,7 @@ import { isEmpty } from 'lodash';
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
import { RequestStatus } from '@src/data/constants';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -40,7 +40,7 @@ interface SubsectionCardProps {
isCustomRelativeDatesActive: boolean,
onOpenPublishModal: () => void,
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus: string,
savingStatus?: RequestStatusType,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -126,7 +126,9 @@ const SubsectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'subsection',
};
}, [upstreamInfo]);
@@ -303,7 +305,7 @@ const SubsectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const section = {
id: '1',
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -34,7 +34,7 @@ const section = {
} satisfies Partial<XBlock> as XBlock;
const subsection = {
id: '12',
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
displayName: 'Subsection Name',
published: true,
visibilityState: 'live',
@@ -48,7 +48,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const unit = {
id: '123',
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
displayName: 'unit Name',
category: 'vertical',
published: true,
@@ -65,7 +65,10 @@ const unit = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:unit:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
@@ -81,7 +84,6 @@ const renderComponent = (props?: object) => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
getTitleLink={(id) => `/some/${id}`}
@@ -108,7 +110,10 @@ describe('<UnitCard />', () => {
const { findByTestId } = renderComponent();
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute(
'href',
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
);
});
it('hides header based on isHeaderVisible flag', async () => {
@@ -199,7 +204,6 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -219,7 +223,6 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -13,8 +13,7 @@ import { useQueryClient } from '@tanstack/react-query';
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { RequestStatus } from '@src/data/constants';
import { isUnitReadOnly } from '@src/course-unit/data/utils';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import TitleLink from '@src/course-outline/card-header/TitleLink';
@@ -33,7 +32,7 @@ interface UnitCardProps {
onOpenPublishModal: () => void;
onOpenConfigureModal: () => void;
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus: string;
savingStatus?: RequestStatusType;
onOpenDeleteModal: () => void;
onOpenUnlinkModal: () => void;
onDuplicateSubmit: () => void;
@@ -104,12 +103,12 @@ const UnitCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'unit',
};
}, [upstreamInfo]);
const readOnly = isUnitReadOnly(unit);
// re-create actions object for customizations
const actions = { ...unitActions };
// add actions to control display of move up & down menu buton.
@@ -247,7 +246,7 @@ const UnitCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

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

View File

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

View File

@@ -2218,7 +2218,7 @@ describe('<CourseUnit />', () => {
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
waitFor(() => {
await waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
@@ -2291,19 +2291,17 @@ describe('<CourseUnit />', () => {
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
.map((child) => (child.block_id === targetBlockId
? { ...child, block_type: 'html' }
: child));
// Convert the second child from drag and drop to HTML:
const targetChild = updatedCourseVerticalChildrenMock.children[1];
targetChild.block_type = 'html';
targetChild.name = 'Test HTML Block';
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
duplicate_source_locator: targetChild.block_id,
}))
.replyOnce(200, { locator: '1234567890' });
@@ -2311,21 +2309,20 @@ describe('<CourseUnit />', () => {
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock);
render(<RootWrapper />);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => {
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId,
});
});
waitFor(() => {
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
expect(mockedUsedNavigate)
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
// After duplicating, the editor modal will open:
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
simulatePostMessageEvent(messageTypes.duplicateXBlock, { usageId: targetChild.block_id });
simulatePostMessageEvent(messageTypes.newXBlockEditor, { blockType: 'html', usageId: targetChild.block_id });
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
});
@@ -2353,14 +2350,14 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
// Disable the "Edit" button
// Edit button should be enabled even for library imported units
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editButton = within(unitHeaderTitle).getByRole(
'button',
{ name: 'Edit' },
);
expect(editButton).toBeInTheDocument();
expect(editButton).toBeDisabled();
expect(editButton).toBeEnabled();
// The "Publish" button should still be enabled
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
@@ -2371,14 +2368,6 @@ describe('<CourseUnit />', () => {
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeEnabled();
// Disable the "Manage Tags" button
const manageTagsButton = screen.getByRole(
'button',
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
);
expect(manageTagsButton).toBeInTheDocument();
expect(manageTagsButton).toBeDisabled();
// Does not render the "Add Components" section
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
});

View File

@@ -14,7 +14,7 @@ import { fetchCourseSectionVerticalData } from '../data/thunk';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { courseSectionVerticalMock } from '../__mocks__';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import AddComponent from './AddComponent';
import AddComponent, { AddComponentProps } from './AddComponent';
import messages from './messages';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { messageTypes } from '../constants';
@@ -56,13 +56,11 @@ jest.mock('../../generic/hooks/context/hooks', () => ({
}),
}));
const renderComponent = (props) => render(
const renderComponent = (props?: AddComponentProps) => render(
<IframeProvider>
<AddComponent
blockId={blockId}
isUnitVerticalType
parentLocator={blockId}
addComponentTemplateData={{}}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
{...props}
/>
@@ -94,7 +92,7 @@ describe('<AddComponent />', () => {
),
});
expect(btn).toBeInTheDocument();
if (component.beta) {
if (componentTemplates[component].beta) {
expect(within(btn).queryByText('Beta')).toBeInTheDocument();
}
});

View File

@@ -1,5 +1,4 @@
import { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -7,28 +6,64 @@ import {
ActionRow, Button, StandardModal, useToggle,
} from '@openedx/paragon';
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
import { useWaffleFlags } from '../../data/apiHooks';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn';
import messages from './messages';
import { ComponentPicker } from '../../library-authoring/component-picker';
import { ContentType } from '../../library-authoring/routes';
import { messageTypes } from '../constants';
import { useIframe } from '../../generic/hooks/context/hooks';
import { useEventListener } from '../../generic/hooks';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
import { useWaffleFlags } from '@src/data/apiHooks';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ComponentPicker } from '@src/library-authoring/component-picker';
import { ContentType } from '@src/library-authoring/routes';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useEventListener } from '@src/generic/hooks';
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
import EditorPage from '@src/editors/EditorPage';
import { SelectedComponent } from '@src/library-authoring';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { messageTypes } from '../constants';
import messages from './messages';
import AddComponentButton from './add-component-btn';
import ComponentModalView from './add-component-modals/ComponentModalView';
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
type ComponentTemplateData = {
displayName: string,
category?: string,
type: string,
beta?: boolean,
templates: Array<{
boilerplateName?: string,
category?: string,
displayName: string,
supportLevel?: string | boolean,
}>,
supportLegend: {
allowUnsupportedXblocks?: boolean,
documentationLabel?: string,
showLegend?: boolean,
},
};
export interface AddComponentProps {
isSplitTestType?: boolean,
isUnitVerticalType?: boolean,
parentLocator: string,
handleCreateNewCourseXBlock: (
args: object,
callback?: (args: { courseKey: string, locator: string }) => void
) => void,
isProblemBankType?: boolean,
addComponentTemplateData?: {
blockId: string,
parentLocator?: string,
model: ComponentTemplateData,
},
}
const AddComponent = ({
parentLocator,
isSplitTestType,
isUnitVerticalType,
isProblemBankType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}) => {
}: AddComponentProps) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -36,16 +71,16 @@ const AddComponent = ({
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData.parentLocator || parentLocator;
const blockId = addComponentTemplateData?.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState(null);
const [courseId, setCourseId] = useState(null);
const [newBlockId, setNewBlockId] = useState(null);
const [blockType, setBlockType] = useState<string | null>(null);
const [courseId, setCourseId] = useState<string | null>(null);
const [newBlockId, setNewBlockId] = useState<string | null>(null);
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState([]);
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
@@ -84,7 +119,7 @@ const AddComponent = ({
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, blockId, sequenceId]);
const handleLibraryV2Selection = useCallback((selection) => {
const handleLibraryV2Selection = useCallback((selection: SelectedComponent) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
category: selection.blockType,
@@ -94,7 +129,7 @@ const AddComponent = ({
closeAddLibraryContentModal();
}, [usageId]);
const handleCreateNewXBlock = (type, moduleName) => {
const handleCreateNewXBlock = (type: string, moduleName?: string) => {
switch (type) {
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
@@ -156,16 +191,16 @@ const AddComponent = ({
}
};
if (isUnitVerticalType || isSplitTestType) {
if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
return (
<div className="py-4">
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
<>
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component) => {
{componentTemplates.map((component: ComponentTemplateData) => {
const { type, displayName, beta } = component;
let modalParams;
let modalParams: { open: () => void, close: () => void, isOpen: boolean };
if (!component.templates.length) {
return null;
@@ -268,7 +303,7 @@ const AddComponent = ({
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && (
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
<div className="editor-page">
<EditorPage
courseId={courseId}
@@ -288,32 +323,4 @@ const AddComponent = ({
return null;
};
AddComponent.propTypes = {
isSplitTestType: PropTypes.bool.isRequired,
isUnitVerticalType: PropTypes.bool.isRequired,
parentLocator: PropTypes.string.isRequired,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
addComponentTemplateData: {
blockId: PropTypes.string.isRequired,
model: PropTypes.shape({
displayName: PropTypes.string.isRequired,
category: PropTypes.string,
type: PropTypes.string.isRequired,
templates: PropTypes.arrayOf(
PropTypes.shape({
boilerplateName: PropTypes.string,
category: PropTypes.string,
displayName: PropTypes.string.isRequired,
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
}),
),
supportLegend: PropTypes.shape({
allowUnsupportedXblocks: PropTypes.bool,
documentationLabel: PropTypes.string,
showLegend: PropTypes.bool,
}),
}),
},
};
export default AddComponent;

View File

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

View File

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

View File

@@ -1,218 +0,0 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Edit course unit display name.
* @param {string} unitId
* @param {string} displayName
* @returns {Promise<Object>}
*/
export async function editUnitDisplayName(unitId, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Fetch vertical block data from the container_handler endpoint.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getVerticalData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**
* Creates a new course XBlock.
* @param {Object} options - The options for creating the XBlock.
* @param {string} options.type - The type of the XBlock.
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
* @param {string} options.parentLocator - The parent locator.
* @param {string} [options.displayName] - The display name.
* @param {string} [options.boilerplate] - The boilerplate.
* @param {string} [options.stagedContent] - The staged content.
* @param {string} [options.libraryContentKey] - component key from library if being imported.
*/
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), body);
return data;
}
/**
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
* and toggling visibility to students.
* @param {string} unitId - The ID of the course unit.
* @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges).
* @param {boolean} isVisible - The visibility status for students.
* @param {boolean} groupAccess - Access group key set.
* @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled.
* @returns {Promise<any>} A promise that resolves with the response data.
*/
export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) {
const body = {
publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
visible_to_staff_only: isVisible ? true : null,
group_access: groupAccess || null,
discussion_enabled: isDiscussionEnabled,
},
} : {}),
};
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), body);
return camelCaseObject(data);
}
/**
* Get an object containing course section vertical children data.
* @param {string} itemId
* @returns {Promise<Object>}
*/
export async function getCourseVerticalChildren(itemId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseVerticalChildrenApiUrl(itemId));
const camelCaseData = camelCaseObject(data);
return updateXBlockBlockIdToId(camelCaseData);
}
/**
* Delete a unit item.
* @param {string} itemId
* @returns {Promise<Object>}
*/
export async function deleteUnitItem(itemId) {
const { data } = await getAuthenticatedHttpClient()
.delete(getXBlockBaseApiUrl(itemId));
return data;
}
/**
* Duplicate a unit item.
* @param {string} itemId
* @param {string} XBlockId
* @returns {Promise<Object>}
*/
export async function duplicateUnitItem(itemId, XBlockId) {
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), {
parent_locator: itemId,
duplicate_source_locator: XBlockId,
});
return data;
}
/**
* @typedef {Object} courseOutline
* @property {string} id - The unique identifier of the course.
* @property {string} displayName - The display name of the course.
* @property {string} category - The category of the course (e.g., "course").
* @property {boolean} hasChildren - Whether the course has child items.
* @property {boolean} unitLevelDiscussions - Indicates if unit-level discussions are available.
* @property {Object} childInfo - Information about the child elements of the course.
* @property {string} childInfo.category - The category of the child (e.g., "chapter").
* @property {string} childInfo.display_name - The display name of the child element.
* @property {Array<Object>} childInfo.children - List of children within the child_info (could be empty).
*/
/**
* Get an object containing course outline data.
* @param {string} courseId - The identifier of the course.
* @returns {Promise<courseOutline>} - The course outline data.
*/
export async function getCourseOutlineInfo(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseOutlineInfoUrl(courseId));
return camelCaseObject(data);
}
/**
* @typedef {Object} moveInfo
* @property {string} moveSourceLocator - The locator of the source block being moved.
* @property {string} parentLocator - The locator of the parent block where the source is being moved to.
* @property {number} sourceIndex - The index position of the source block.
*/
/**
* Move a unit item to new unit.
* @param {string} sourceLocator - The ID of the item to be moved.
* @param {string} targetParentLocator - The ID of the XBlock associated with the item.
* @returns {Promise<moveInfo>} - The move information.
*/
export async function patchUnitItem(sourceLocator, targetParentLocator) {
const { data } = await getAuthenticatedHttpClient()
.patch(postXBlockBaseApiUrl(), {
parent_locator: targetParentLocator,
move_source_locator: sourceLocator,
});
return camelCaseObject(data);
}
/**
* Accept the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function acceptLibraryBlockChanges(blockId) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId));
}
/**
* Ignore the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function ignoreLibraryBlockChanges(blockId) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}

188
src/course-unit/data/api.ts Normal file
View File

@@ -0,0 +1,188 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { CourseContainerChildrenData, CourseOutlineData, MoveInfoData } from './types';
import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getXBlockBaseApiUrl = (itemId: string) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId: string, getUpstreamInfo: boolean = false) => `${getStudioBaseUrl()}/api/contentstore/v1/container/${itemId}/children?get_upstream_info=${getUpstreamInfo}`;
export const getCourseOutlineInfoUrl = (courseId: string) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId: string) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Edit course unit display name.
*/
export async function editUnitDisplayName(unitId: string, displayName: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Fetch vertical block data from the container_handler endpoint.
*/
export async function getVerticalData(unitId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitImportedFromLib(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**
* Creates a new course XBlock.
*/
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}: {
type: string,
category?: string, // The category of the XBlock. Defaults to the type if not provided.
parentLocator: string,
displayName?: string,
boilerplate?: string,
stagedContent?: string,
libraryContentKey?: string, // component key from library if being imported.
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), body);
return data;
}
/**
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
* and toggling visibility to students.
*/
export async function handleCourseUnitVisibilityAndData(
unitId: string,
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
isVisible: boolean, // The visibility status for students.
groupAccess: boolean,
isDiscussionEnabled: boolean,
): Promise<object> {
const body = {
publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
visible_to_staff_only: isVisible ? true : null,
group_access: groupAccess || null,
discussion_enabled: isDiscussionEnabled,
},
} : {}),
};
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), body);
return camelCaseObject(data);
}
/**
* Get an object containing course vertical children data.
*/
export async function getCourseContainerChildren(
itemId: string,
getUpstreamInfo: boolean = false,
): Promise<CourseContainerChildrenData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseVerticalChildrenApiUrl(itemId, getUpstreamInfo));
const camelCaseData = camelCaseObject(data);
return updateXBlockBlockIdToId(camelCaseData) as CourseContainerChildrenData;
}
/**
* Delete a unit item.
*/
export async function deleteUnitItem(itemId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.delete(getXBlockBaseApiUrl(itemId));
return data;
}
/**
* Duplicate a unit item.
*/
export async function duplicateUnitItem(itemId: string, XBlockId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), {
parent_locator: itemId,
duplicate_source_locator: XBlockId,
});
return data;
}
/**
* Get an object containing course outline data.
*/
export async function getCourseOutlineInfo(courseId: string): Promise<CourseOutlineData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseOutlineInfoUrl(courseId));
return camelCaseObject(data);
}
/**
* Move a unit item to new unit.
*/
export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise<MoveInfoData> {
const { data } = await getAuthenticatedHttpClient()
.patch(postXBlockBaseApiUrl(), {
parent_locator: targetParentLocator,
move_source_locator: sourceLocator,
});
return camelCaseObject(data);
}
/**
* Accept the changes from upstream library block in course
*/
export async function acceptLibraryBlockChanges({
blockId,
overrideCustomizations = false,
}: {
blockId: string,
overrideCustomizations?: boolean,
}) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId), { override_customizations: overrideCustomizations });
}
/**
* Ignore the changes from upstream library block in course
*/
export async function ignoreLibraryBlockChanges({ blockId } : { blockId: string }) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}

View File

@@ -3,17 +3,17 @@ import { camelCaseObject } from '@edx/frontend-platform';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import { handleResponseErrors } from '../../generic/saving-error-alert';
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
} from '@src/generic/processing-notification/data/slice';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { RequestStatus } from '@src/data/constants';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { updateModel, updateModels } from '@src/generic/model-store';
import { messageTypes } from '../constants';
import {
editUnitDisplayName,
getVerticalData,
createCourseXblock,
getCourseVerticalChildren,
getCourseContainerChildren,
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
@@ -58,7 +58,7 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) {
localStorage.removeItem('staticFileNotices');
dispatch(fetchSequenceSuccess({ sequenceId }));
return true;
} catch (error) {
} catch {
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
dispatch(fetchSequenceFailure({ sequenceId }));
return false;
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
}
const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
@@ -163,7 +163,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
localStorage.removeItem('staticFileNotices');
}
}
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
if (callback) {
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
}
try {
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
blockIds.map(blockId => getCourseContainerChildren(blockId)),
);
const allChildren = childrenDataArray.reduce(
(acc, data) => acc.concat(data.children || []),
@@ -204,7 +204,7 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
}
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
} catch {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -239,7 +239,7 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
callback(courseKey, locator);
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));

View File

@@ -0,0 +1,55 @@
import { UpstreamInfo, XBlock } from '@src/data/types';
import { ContainerType } from '@src/generic/key-utils';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
export interface MoveInfoData {
/**
* The locator of the source block being moved.
*/
moveSourceLocator: string;
/**
* The locator of the parent block where the source is being moved to.
*/
parentLocator: string;
/**
* The index position of the source block.
*/
sourceIndex: number;
}
export interface CourseOutlineData {
id: string;
displayName: string;
category: string;
hasChildren: boolean;
unitLevelDiscussions: boolean;
childInfo: {
category: string;
displayName: string;
children: XBlock[];
}
}
export interface ContainerChildData {
blockId: string;
blockType: ContainerType | keyof typeof COMPONENT_TYPES;
id: string;
name: string;
upstreamLink: UpstreamInfo;
}
export interface UpstreamReadyToSyncChildrenInfo {
id: string;
name: string;
upstream: string;
blockType: string;
downstreamCustomized: string[];
}
export interface CourseContainerChildrenData {
canPasteComponent: boolean;
children: ContainerChildData[];
isPublished: boolean;
displayName: string;
upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[];
}

View File

@@ -102,7 +102,7 @@ export const updateXBlockBlockIdToId = (data: object): object => {
* @param unit - uses the 'upstreamInfo' object if found.
* @returns True if readOnly, False if editable.
*/
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
!!upstreamInfo
&& !!upstreamInfo.upstreamRef
&& upstreamInfo.upstreamRef.startsWith('lct:')

View File

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

View File

@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a disabled edit button', async () => {
it('Units sourced from upstream show a enabled edit button', async () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
@@ -95,7 +95,7 @@ describe('<HeaderTitle />', () => {
const { getByRole } = renderComponent();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});

View File

@@ -72,6 +72,10 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const isProblemBankType = [
COURSE_BLOCK_NAMES.legacyLibraryContent.id,
COURSE_BLOCK_NAMES.itembank.id,
].includes(unitCategory);
const headerNavigationsActions = {
handleViewLive: () => {
@@ -254,6 +258,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
isProblemBankType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,

View File

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

View File

@@ -1,29 +1,32 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import {
act,
render as baseRender,
screen,
initializeMocks,
waitFor,
} from '../../testUtils';
} from '@src/testUtils';
import { ToastActionData } from '@src/generic/toast-context';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
const usageKey = 'some-id';
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
const defaultEventData: LibraryChangesMessageData = {
displayName: 'Test block',
downstreamBlockId: usageKey,
upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1,
isContainer: false,
isLocallyModified: false,
blockType: 'html',
};
const mockSendMessageToIframe = jest.fn();
jest.mock('../../generic/hooks/context/hooks', () => ({
jest.mock('@src/generic/hooks/context/hooks', () => ({
useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {},
@@ -45,7 +48,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let mockShowToast: (message: string, action?: ToastActionData) => void;
describe('<IframePreviewLibraryXBlockChanges />', () => {
beforeEach(() => {
@@ -60,7 +63,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
});
@@ -132,4 +134,59 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('should render modal of text with local changes', async () => {
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
});
it('update changes works', async () => {
const user = userEvent.setup();
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
await user.click(acceptBtn);
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
await user.click(confirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('keep changes work', async () => {
const user = userEvent.setup();
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
await user.click(ignoreBtn);
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
await user.click(ignoreConfirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.delete.length).toEqual(1);
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
});

View File

@@ -1,28 +1,111 @@
import { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
useCallback, useContext, useMemo, useState,
} from 'react';
import {
ActionRow, Button, Icon, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Warning } from '@openedx/paragon/icons';
import { Info } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useEventListener } from '../../generic/hooks';
import { ToastContext } from '@src/generic/toast-context';
import Loading from '@src/generic/Loading';
import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
import AlertMessage from '@src/generic/alert-message';
import LoadingButton from '@src/generic/loading-button';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useEventListener } from '@src/generic/hooks';
import { getItemIcon } from '@src/generic/block-type-utils';
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined;
const ConfirmationModal = ({
modalType,
onClose,
updateAndRefresh,
}: {
modalType: ConfirmationModalType,
onClose: () => void,
updateAndRefresh: (accept: boolean, overrideCustomizations: boolean) => void,
}) => {
const intl = useIntl();
const {
title,
description,
btnLabel,
btnVariant,
accept,
overrideCustomizations,
} = useMemo(() => {
let resultTitle: string | undefined;
let resultDescription: string | undefined;
let resutlBtnLabel: string | undefined;
let resultAccept: boolean = false;
let resultOverrideCustomizations: boolean = false;
let resultBtnVariant: 'danger' | 'primary' = 'danger';
switch (modalType) {
case 'ignore':
resultTitle = intl.formatMessage(messages.confirmationTitle);
resultDescription = intl.formatMessage(messages.confirmationDescription);
resutlBtnLabel = intl.formatMessage(messages.confirmationConfirmBtn);
break;
case 'update':
resultTitle = intl.formatMessage(messages.updateToPublishedLibraryContentTitle);
resultDescription = intl.formatMessage(messages.updateToPublishedLibraryContentBody);
resutlBtnLabel = intl.formatMessage(messages.updateToPublishedLibraryContentConfirm);
resultAccept = true;
resultOverrideCustomizations = true;
break;
case 'keep':
resultTitle = intl.formatMessage(messages.keepCourseContentTitle);
resultDescription = intl.formatMessage(messages.keepCourseContentBody);
resutlBtnLabel = intl.formatMessage(messages.keepCourseContentButton);
resultBtnVariant = 'primary';
break;
default:
break;
}
return {
title: resultTitle,
description: resultDescription,
btnLabel: resutlBtnLabel,
accept: resultAccept,
btnVariant: resultBtnVariant,
overrideCustomizations: resultOverrideCustomizations,
};
}, [modalType]);
return (
<DeleteModal
isOpen={modalType !== undefined}
close={onClose}
variant="warning"
title={title}
description={description}
onDeleteSubmit={() => updateAndRefresh(accept, overrideCustomizations)}
btnLabel={btnLabel}
buttonVariant={btnVariant}
/>
);
};
export interface LibraryChangesMessageData {
displayName: string,
downstreamBlockId: string,
upstreamBlockId: string,
upstreamBlockVersionSynced: number,
isLocallyModified?: boolean,
isContainer: boolean,
blockType?: string | null,
isReadyToSyncIndividually?: boolean,
}
export interface PreviewLibraryXBlockChangesProps {
@@ -45,27 +128,41 @@ export const PreviewLibraryXBlockChanges = ({
const { showToast } = useContext(ToastContext);
const intl = useIntl();
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
const getBody = useCallback(() => {
if (!blockData) {
return <Loading />;
}
if (blockData.isContainer) {
return (
<CompareContainersWidget
upstreamBlockId={blockData.upstreamBlockId}
downstreamBlockId={blockData.downstreamBlockId}
isReadyToSyncIndividually={blockData.isReadyToSyncIndividually}
/>
);
}
return (
<CompareChangesWidget
usageKey={blockData.upstreamBlockId}
oldUsageKey={blockData.downstreamBlockId}
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published"
isContainer={blockData.isContainer}
hasLocalChanges={isTextWithLocalChanges}
showNewTitle={isTextWithLocalChanges}
/>
);
}, [blockData]);
}, [blockData, isTextWithLocalChanges]);
const updateAndRefresh = useCallback(async (accept: boolean) => {
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
@@ -75,30 +172,58 @@ export const PreviewLibraryXBlockChanges = ({
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
try {
await mutation.mutateAsync(blockData.downstreamBlockId);
await mutation.mutateAsync({
blockId: blockData.downstreamBlockId,
overrideCustomizations,
});
postChange(accept);
} catch (e) {
} catch {
showToast(intl.formatMessage(failureMsg));
} finally {
closeModal();
}
}, [blockData]);
const itemIcon = getItemIcon(blockData.blockType || '');
// Build title
const defaultTitle = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: <Icon size="lg" src={itemIcon} />,
},
);
const title = blockData.displayName
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: <Icon size="lg" src={itemIcon} />,
})
: defaultTitle;
// Build aria label
const defaultAriaLabel = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: '',
},
);
const ariaLabel = blockData.displayName
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: '',
})
: defaultAriaLabel;
return (
<ModalDialog
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={title}
title={ariaLabel}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -106,43 +231,57 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
<div className="d-flex preview-title">
{title}
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{isTextWithLocalChanges && (
<AlertMessage
show
variant="info"
icon={Info}
title={intl.formatMessage(messages.localEditsAlert)}
/>
)}
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<LoadingButton
onClick={() => updateAndRefresh(true)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
<Button
variant="tertiary"
onClick={openConfirmModal}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.cancelBtn} />
</ModalDialog.CloseButton>
{isTextWithLocalChanges ? (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('update')}
>
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} />
</Button>
) : (
<LoadingButton
onClick={() => updateAndRefresh(true, false)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
)}
{isTextWithLocalChanges ? (
<Button
onClick={() => setConfirmationModalType('keep')}
>
<FormattedMessage {...messages.keepCourseContentButton} />
</Button>
) : (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('ignore')}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
)}
</ActionRow>
</ModalDialog.Footer>
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
variant="warning"
title={intl.formatMessage(messages.confirmationTitle)}
description={intl.formatMessage(messages.confirmationDescription)}
onDeleteSubmit={() => updateAndRefresh(false)}
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
<ConfirmationModal
modalType={confirmationModalType}
onClose={() => setConfirmationModalType(undefined)}
updateAndRefresh={updateAndRefresh}
/>
</ModalDialog>
);

View File

@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'authoring.course-unit.preview-changes.modal-title',
defaultMessage: 'Preview changes: {blockTitle}',
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}',
description: 'Preview changes modal title text',
},
defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Container',
defaultMessage: 'Preview changes: {itemIcon} Container',
description: 'Preview changes modal default title text for containers',
},
defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
defaultMessage: 'Preview changes: Component',
defaultMessage: 'Preview changes: {itemIcon} Component',
description: 'Preview changes modal default title text for components',
},
acceptChangesBtn: {
@@ -36,11 +36,6 @@ const messages = defineMessages({
defaultMessage: 'Failed to ignore changes',
description: 'Toast message to display when ignore changes call fails',
},
cancelBtn: {
id: 'authoring.course-unit.preview-changes.cancel-btn',
defaultMessage: 'Cancel',
description: 'Preview changes modal cancel button text.',
},
confirmationTitle: {
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
defaultMessage: 'Ignore these changes?',
@@ -56,10 +51,45 @@ const messages = defineMessages({
defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
},
olderVersionPreviewAlert: {
id: 'course-authoring.review-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
localEditsAlert: {
id: 'course-authoring.review-tab.preview.loal-edits-alert',
defaultMessage: 'This library content has local edits.',
description: 'Alert message stating that the content has local edits',
},
updateToPublishedLibraryContentButton: {
id: 'course-authoring.review-tab.preview.update-to-published.button.text',
defaultMessage: 'Update to published library content',
description: 'Label of the button to update a content to the published library content',
},
updateToPublishedLibraryContentTitle: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.title',
defaultMessage: 'Update to published library content?',
description: 'Title of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentBody: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.body',
defaultMessage: 'Updating this block will discard local changes. Any edits made within this course will be discarded, and cannot be recovered',
description: 'Body of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentConfirm: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.confirm',
defaultMessage: 'Discard local edits and update',
description: 'Label of the button in the modal to update a content to the published library content',
},
keepCourseContentButton: {
id: 'course-authoring.review-tab.preview.keep-course-content.button.text',
defaultMessage: 'Keep course content',
description: 'Label of the button to keep the content of a course component',
},
keepCourseContentTitle: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.title',
defaultMessage: 'Keep course content?',
description: 'Title of the modal to keep the content of a course component',
},
keepCourseContentBody: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.body',
defaultMessage: 'This will keep the locally edited course content. If the component is published again in its library, you can choose to update to published library content',
description: 'Body of the modal to keep the content of a course component',
},
});

View File

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

View File

@@ -31,6 +31,7 @@ export const useMessageHandlers = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -50,6 +51,7 @@ export const useMessageHandlers = ({
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
[messageTypes.refreshIframe]: handleRefreshIframe,
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),

View File

@@ -46,6 +46,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const intl = useIntl();
const dispatch = useDispatch();
// Useful to reload iframe
const [iframeKey, setIframeKey] = useState(0);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
@@ -182,6 +184,12 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
dispatch(hideProcessingNotification());
};
const handleRefreshIframe = () => {
// Updating iframeKey forces the iframe to re-render.
/* istanbul ignore next */
setIframeKey((prev) => prev + 1);
};
const messageHandlers = useMessageHandlers({
courseId,
dispatch,
@@ -199,6 +207,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
});
useIframeMessages(messageHandlers);
@@ -268,6 +277,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
/>
) : null}
<iframe
key={iframeKey}
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
name="xblock-iframe"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,11 +48,23 @@ export interface XBlockPrereqs {
blockDisplayName: string;
}
export interface UpstreamChildrenInfo {
name: string;
upstream: string;
id: string;
}
export interface UpstreamInfo {
readyToSync: boolean,
upstreamRef: string,
versionSynced: number,
versionAvailable: number | null,
versionDeclined: number | null,
errorMessage: string | null,
downstreamCustomized: string[],
hasTopLevelParent?: boolean,
readyToSyncChildren?: UpstreamChildrenInfo[],
isReadyToSyncIndividually?: boolean,
}
export interface XBlock {

View File

@@ -1,28 +1,38 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-unused-vars */
/* eslint-disable import/extensions */
/* eslint-disable import/no-unresolved */
/**
* This is an example component for an xblock Editor
* It uses pre-existing components to handle the saving of a the result of a function into the xblock's data.
* To use run npm run-script addXblock <your>
*/
/* istanbul ignore file */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Spinner } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Form,
Spinner,
Collapsible,
Icon,
IconButton,
Dropdown,
} from '@openedx/paragon';
import {
DeleteOutline,
Add,
ExpandMore,
ExpandLess,
InsertPhoto,
MoreHoriz,
Check,
} from '@openedx/paragon/icons';
import {
actions,
selectors,
} from '../../data/redux';
import {
RequestKeys,
} from '../../data/constants/requests';
import './index.scss';
import EditorContainer from '../EditorContainer';
// This 'module' self-import hack enables mocking during tests.
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from '.';
import { actions, selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
import SettingsOption from '../ProblemEditor/components/EditProblemView/SettingsWidget/SettingsOption';
import Button from '../../sharedComponents/Button';
import DraggableList, { SortableItem } from '../../../generic/DraggableList';
import messages from './messages';
export const hooks = {
getContent: () => ({
@@ -30,77 +40,498 @@ export const hooks = {
}),
};
export const thumbEditor = ({
export const GameEditor = ({
onClose,
// redux
blockValue,
lmsEndpointUrl,
blockFailed,
blockFinished,
initializeEditor,
exampleValue,
// inject
intl,
}) => (
<EditorContainer
getContent={module.hooks.getContent}
onClose={onClose}
>
<div>
{exampleValue}
// settings
settings,
shuffleTrue,
shuffleFalse,
timerTrue,
timerFalse,
type,
updateType,
// list
list,
updateTerm,
updateTermImage,
updateDefinition,
updateDefinitionImage,
toggleOpen,
setList,
addCard,
removeCard,
isDirty,
}) => {
const intl = useIntl();
// State for list
const [state, setState] = React.useState(list);
React.useEffect(() => { setState(list); }, [list]);
// Non-reducer functions go here
const getDescriptionHeader = () => {
// Function to determine what the header will say based on type
switch (type) {
case 'flashcards':
return 'Flashcard terms';
case 'matching':
return 'Matching terms';
default:
return 'Undefined';
}
};
const getDescription = () => {
// Function to determine what the description will say based on type
switch (type) {
case 'flashcards':
return 'Enter your terms and definitions below. Learners will review each card by viewing the term, then flipping to reveal the definition.';
case 'matching':
return 'Enter your terms and definitions below. Learners must match each term with the correct definition.';
default:
return 'Undefined';
}
};
const saveTermImage = (index) => {
const id = `term_image_upload|${index}`;
const file = document.getElementById(id).files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
updateTermImage({ index, termImage: event.target.result });
};
reader.readAsDataURL(file);
}
};
const removeTermImage = (index) => {
const id = `term_image_upload|${index}`;
document.getElementById(id).value = '';
updateTermImage({ index, termImage: '' });
};
const saveDefinitionImage = (index) => {
const id = `definition_image_upload|${index}`;
const file = document.getElementById(id).files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
updateDefinitionImage({ index, definitionImage: event.target.result });
};
reader.readAsDataURL(file);
}
};
const removeDefintionImage = (index) => {
const id = `definition_image_upload|${index}`;
document.getElementById(id).value = '';
updateDefinitionImage({ index, definitionImage: '' });
};
const moveCardUp = (index) => {
if (index === 0) { return; }
const temp = state.slice();
[temp[index], temp[index - 1]] = [temp[index - 1], temp[index]];
setState(temp);
};
const moveCardDown = (index) => {
if (index === state.length - 1) { return; }
const temp = state.slice();
[temp[index + 1], temp[index]] = [temp[index], temp[index + 1]];
setState(temp);
};
const loading = (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
screenreadertext={intl.formatMessage(messages.loadingSpinner)}
/>
</div>
<div className="editor-body h-75 overflow-auto">
{!blockFinished
? (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
// Use a messages.js file for intl messages.
screenreadertext={intl.formatMessage('Loading Spinner')}
/>
);
const termImageDiv = (card, index) => (
<div className="card-image-area d-flex align-items-center align-self-stretch">
<img className="card-image" src={card.term_image} alt="TERM_IMG" />
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt="DEL_IMG"
variant="primary"
onClick={() => removeTermImage(index)}
/>
</div>
);
const termImageUploadButton = (card, index) => (
<IconButton
src={InsertPhoto}
iconAs={Icon}
alt="IMG"
variant="primary"
onClick={() => document.getElementById(`term_image_upload|${index}`).click()}
/>
);
const definitionImageDiv = (card, index) => (
<div className="card-image-area d-flex align-items-center align-self-stretch">
<img className="card-image" src={card.definition_image} alt="DEF_IMG" />
<IconButton
src={DeleteOutline}
iconAs={Icon}
alt="DEL_IMG"
variant="primary"
onClick={() => removeDefintionImage(index)}
/>
</div>
);
const definitionImageUploadButton = (card, index) => (
<IconButton
src={InsertPhoto}
iconAs={Icon}
alt="IMG"
variant="primary"
onClick={() => document.getElementById(`definition_image_upload|${index}`).click()}
/>
);
const timerSettingsOption = (
<SettingsOption
className="sidebar-timer d-flex flex-column align-items-start align-self-stretch"
title="Timer"
summary={settings.timer ? 'On' : 'Off'}
isCardCollapsibleOpen="true"
>
<>
<div className="settings-description">Measure the time it takes learners to match all terms and definitions. Used to calculate a learner&apos;s score.</div>
<Button
onClick={() => timerFalse()}
variant={!settings.timer ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
Off
</Button>
<Button
onClick={() => timerTrue()}
variant={settings.timer ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
On
</Button>
</>
</SettingsOption>
);
const page = (
<div className="page-body d-flex align-items-start">
<div className="terms d-flex flex-column align-items-start align-self-stretch">
<div className="description d-flex flex-column align-items-start align-self-stretch">
<div className="description-header">
{getDescriptionHeader()}
</div>
)
: (
<p>
Your Editor Goes here.
You can get at the xblock data with the blockValue field.
here is what is in your xblock: {JSON.stringify(blockValue)}
</p>
)}
<div className="description-body align-self-stretch">
{getDescription()}
</div>
</div>
<DraggableList
className="d-flex flex-column align-items-start align-self-stretch"
itemList={state}
setState={setState}
updateOrder={() => (newList) => setList(newList)}
>
{
state.map((card, index) => (
<SortableItem
id={card.id}
key={card.id}
buttonClassName="draggable-button"
componentStyle={{
background: 'white',
borderRadius: '6px',
padding: '24px',
marginBottom: '16px',
boxShadow: '0px 1px 5px #ADADAD',
position: 'relative',
width: '100%',
flexDirection: 'column',
flexFlow: 'nowrap',
}}
>
<Collapsible.Advanced
className="card"
defaultOpen
onOpen={() => toggleOpen({ index, isOpen: true })}
onClose={() => toggleOpen({ index, isOpen: false })}
>
<input
type="file"
id={`term_image_upload|${index}`}
hidden
onChange={() => saveTermImage(index)}
/>
<input
type="file"
id={`definition_image_upload|${index}`}
hidden
onChange={() => saveDefinitionImage(index)}
/>
<Collapsible.Trigger className="card-heading">
<div className="drag-spacer" />
<div className="card-heading d-flex align-items-center align-self-stretch">
<div className="card-number">{index + 1}</div>
{!card.editorOpen ? (
<div className="preview-block position-relative w-100">
<span className="align-middle">
<span className="preview-term">
{type === 'flashcards' ? (
<span className="d-inline-block align-middle pr-2">
{card.term_image !== ''
? <img className="img-preview" src={card.term_image} alt="TERM_IMG_PRV" />
: <Icon className="img-preview" src={InsertPhoto} />}
</span>
)
: ''}
{card.term !== '' ? card.term : <span className="text-gray">No text</span>}
</span>
<span className="preview-definition">
{type === 'flashcards' ? (
<span className="d-inline-block align-middle pr-2">
{card.definition_image !== ''
? <img className="img-preview" src={card.definition_image} alt="DEF_IMG_PRV" />
: <Icon className="img-preview" src={InsertPhoto} />}
</span>
)
: ''}
{card.definition !== '' ? card.definition : <span className="text-gray">No text</span>}
</span>
</span>
</div>
)
: <div className="card-spacer d-flex align-self-stretch" />}
<Dropdown onToggle={(isOpen, e) => e.stopPropagation()}>
<Dropdown.Toggle
className="card-dropdown"
as={IconButton}
src={MoreHoriz}
iconAs={Icon}
variant="primary"
/>
<Dropdown.Menu>
<Dropdown.Item onClick={() => moveCardUp(index)}>Move up</Dropdown.Item>
<Dropdown.Item onClick={() => moveCardDown(index)}>Move down</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={() => removeCard({ index })}>Delete</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
<Collapsible.Visible whenClosed>
<div>
<IconButton
src={ExpandMore}
iconAs={Icon}
alt="EXPAND"
variant="primary"
/>
</div>
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<div>
<IconButton
src={ExpandLess}
iconAs={Icon}
alt="COLLAPSE"
variant="primary"
/>
</div>
</Collapsible.Visible>
</Collapsible.Trigger>
<div className="card-body p-0">
<Collapsible.Body>
<div className="card-body-divider">
<div className="card-divider" />
</div>
<div className="card-term d-flex flex-column align-items-start align-self-stretch">
Term
{(type !== 'matching' && card.term_image !== '') && termImageDiv(card, index)}
<div className="card-input-line d-flex align-items-start align-self-stretch">
<Form.Control
className="d-flex flex-column align-items-start align-self-stretch"
id={`term|${index}`}
placeholder="Enter your term"
value={card.term}
onChange={(e) => updateTerm({ index, term: e.target.value })}
/>
{type !== 'matching' && termImageUploadButton(card, index)}
</div>
</div>
<div className="card-divider" />
<div className="card-definition d-flex flex-column align-items-start align-self-stretch">
Definition
{(type !== 'matching' && card.definition_image !== '') && definitionImageDiv(card, index)}
<div className="card-input-line d-flex align-items-start align-self-stretch">
<Form.Control
className="d-flex flex-column align-items-start align-self-stretch"
id={`definition|${index}`}
placeholder="Enter your definition"
value={card.definition}
onChange={(e) => updateDefinition({ index, definition: e.target.value })}
/>
{type !== 'matching' && definitionImageUploadButton(card, index)}
</div>
</div>
</Collapsible.Body>
</div>
</Collapsible.Advanced>
</SortableItem>
))
}
</DraggableList>
<Button
className="add-button"
onClick={() => addCard()}
>
<IconButton
src={Add}
iconAs={Icon}
alt="ADD"
variant="primary"
/>
Add
</Button>
</div>
<div className="sidebar d-flex flex-column align-items-start flex-shrink-0">
<SettingsOption
className="sidebar-type d-flex flex-column align-items-start align-self-stretch"
title="Type"
summary={type.substr(0, 1).toUpperCase() + type.substr(1)}
isCardCollapsibleOpen="true"
>
<Button
onClick={() => updateType('flashcards')}
className="type-button"
>
<span className="small text-primary-500">Flashcards</span>
<span hidden={type !== 'flashcards'}><Icon src={Check} className="text-success" /></span>
</Button>
<div className="card-divider" />
<Button
onClick={() => updateType('matching')}
className="type-button"
>
<span className="small text-primary-500">Matching</span>
<span hidden={type !== 'matching'}><Icon src={Check} className="text-success" /></span>
</Button>
</SettingsOption>
<SettingsOption
className="sidebar-shuffle d-flex flex-column align-items-start align-self-stretch"
title="Shuffle"
summary={settings.shuffle ? 'On' : 'Off'}
isCardCollapsibleOpen="true"
>
<>
<div className="settings-description">Shuffle the order of terms shown to learners when reviewing.</div>
<Button
onClick={() => shuffleFalse()}
variant={!settings.shuffle ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
Off
</Button>
<Button
onClick={() => shuffleTrue()}
variant={settings.shuffle ? 'primary' : 'outline-primary'}
className="toggle-button rounded-0"
>
On
</Button>
</>
</SettingsOption>
{type === 'matching' && timerSettingsOption}
</div>
</div>
</EditorContainer>
);
thumbEditor.defaultProps = {
blockValue: null,
lmsEndpointUrl: null,
);
// Page content goes here
return (
<EditorContainer
getContent={hooks.getContent}
onClose={onClose}
isDirty={() => isDirty}
>
<div className="editor-body h-75 overflow-auto">
{!blockFinished ? loading : page}
</div>
</EditorContainer>
);
};
thumbEditor.propTypes = {
GameEditor.propTypes = {
onClose: PropTypes.func.isRequired,
// redux
blockValue: PropTypes.shape({
data: PropTypes.shape({ data: PropTypes.string }),
}),
lmsEndpointUrl: PropTypes.string,
blockFailed: PropTypes.bool.isRequired,
blockFinished: PropTypes.bool.isRequired,
initializeEditor: PropTypes.func.isRequired,
// inject
intl: intlShape.isRequired,
list: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
updateTerm: PropTypes.func.isRequired,
updateTermImage: PropTypes.func.isRequired,
updateDefinition: PropTypes.func.isRequired,
updateDefinitionImage: PropTypes.func.isRequired,
toggleOpen: PropTypes.func.isRequired,
setList: PropTypes.func.isRequired,
addCard: PropTypes.func.isRequired,
removeCard: PropTypes.func.isRequired,
settings: PropTypes.shape({
shuffle: PropTypes.bool.isRequired,
timer: PropTypes.bool.isRequired,
}).isRequired,
shuffleTrue: PropTypes.func.isRequired,
shuffleFalse: PropTypes.func.isRequired,
timerTrue: PropTypes.func.isRequired,
timerFalse: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
updateType: PropTypes.func.isRequired,
isDirty: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
blockValue: selectors.app.blockValue(state),
lmsEndpointUrl: selectors.app.lmsEndpointUrl(state),
blockFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchBlock }),
blockFinished: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchBlock }),
// TODO fill with redux state here if needed
exampleValue: selectors.game.exampleValue(state),
settings: selectors.game.settings(state),
type: selectors.game.type(state),
list: selectors.game.list(state),
isDirty: selectors.game.isDirty(state),
});
export const mapDispatchToProps = {
initializeEditor: actions.app.initializeEditor,
// TODO fill with dispatches here if needed
// shuffle
shuffleTrue: actions.game.shuffleTrue,
shuffleFalse: actions.game.shuffleFalse,
// timer
timerTrue: actions.game.timerTrue,
timerFalse: actions.game.timerFalse,
// type
updateType: actions.game.updateType,
// list
updateTerm: actions.game.updateTerm,
updateTermImage: actions.game.updateTermImage,
updateDefinition: actions.game.updateDefinition,
updateDefinitionImage: actions.game.updateDefinitionImage,
toggleOpen: actions.game.toggleOpen,
setList: actions.game.setList,
addCard: actions.game.addCard,
removeCard: actions.game.removeCard,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(thumbEditor));
export default connect(mapStateToProps, mapDispatchToProps)(GameEditor);

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import { FeedbackBox } from './components/Feedback';
import * as hooks from './hooks';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
import { answerRangeFormatRegex } from '../../../data/OLXParser';
const AnswerOption = ({
answer,
@@ -48,6 +49,11 @@ const AnswerOption = ({
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
: undefined;
const validateAnswerRange = (value) => {
const cleanedValue = value.replace(/^\s+|\s+$/g, '');
return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue);
};
const getInputArea = () => {
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
return (
@@ -77,8 +83,9 @@ const AnswerOption = ({
);
}
// Return Answer Range View
const isValidValue = validateAnswerRange(answer.title);
return (
<div>
<Form.Group isInvalid={!isValidValue}>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
@@ -88,10 +95,15 @@ const AnswerOption = ({
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
/>
{!isValidValue && (
<Form.Control.Feedback type="invalid">
<FormattedMessage {...messages.answerRangeErrorText} />
</Form.Control.Feedback>
)}
<div className="pgn__form-switch-helper-text">
<FormattedMessage {...messages.answerRangeHelperText} />
</div>
</div>
</Form.Group>
);
};

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