Compare commits

..

205 Commits

Author SHA1 Message Date
Lucas Calviño
a2bfb1fb7b [ROLES-47] Permissions definitions for Schedule & Details (#854) 2024-03-08 15:07:56 -03:00
hilary sinkoff
c754a5e519 feat: Add permissions checks for group_configuration, grading, outline (#829)
* feat: update header options for access control, course outline access checks, grade-settings access checks, and view only for grading page
2024-02-16 18:21:43 +00:00
hsinkoff
1e9146a5b9 fix: update tests missed on rebase 2024-02-16 18:21:43 +00:00
hilary sinkoff
a518fada29 feat: Access for Import/Export Pages Based on Permissions (#804)
* feat: import/export page access based on permissions
2024-02-16 18:21:43 +00:00
Lucas Calviño
69d9ea318e docs: Add permissions check architecture 2024-02-16 18:21:43 +00:00
Lucas Calviño
e74e1ff5aa feat: [ROLES-41] Permission checks (#718)
* feat: Permission check (#718)

This feature allows to fetch the User Permissions and check on every
page for the right permission to allow the user to make actions or even
to see the content depending on the page and the permission.

Co-authored-by: hsinkoff <hsinkoff@2u.com>
2024-02-16 18:21:43 +00:00
Lucas Calviño
1137dae97a feat: [ROLES-26] Helper function for ingesting permission data (#670)
* feat: Add UserPermissions api, specs, feature flag api
2024-02-16 18:21:43 +00:00
Ihor Romaniuk
51c5f9c4dc refactor: Unit page - refactoring breadcrumbs, view live and preview links buttons (#827) 2024-02-14 13:38:54 -05:00
Navin Karkera
60c1a0343c feat: proctoring & prerequisite settings and page alerts (#816)
* feat: add proctoring exam link to actions

* feat: prerequisite settings in advanced tab

* refactor: use formik for configuration modal in outline

* feat: proctoring exam settings in subsection configuration

test: prereq & proctoring settings

* feat: outline alerts

test: outline page alerts

* refactor: replace highlights badge with bubble

* feat: discussion badge in outline

* refactor: status bar style and date format

* Fix spacing between checklist and highlights button
* Fix title alignment in status bar
* Align learn more link to center with respect to button
* Update start date display in local format

* fix: unit url

* refactor: redesign item header

* move status to end of card
* move edit icon next to title
* make it visible on hover

* test: improve coverage

* refactor: update messages and alert colors
2024-02-13 16:32:32 -05:00
Ihor Romaniuk
1555e9f88e feat: [FC-0044] Unit page - add new component section (#828)
* feat: Course unit - add new component section

* feat: Course unit - make Discussion and Drag-and-Drop button functional

* feat: Course unit - make Problem button functional

* feat: Unit page - make Video button functional
2024-02-09 14:27:00 -05:00
Chris Chávez
3938015aaa feat: Add export ID on Taxonomy details and import new Taxonomy (#814)
Adds a new prompt on the import new taxonomy workflow to enter the export_id, and adds the export_id on the Taxonomy page details.

Implements  modular-learning#183 '[Tagging] An "Export ID" identifies each Taxonomy'
2024-02-09 09:27:17 +05:30
Rômulo Penido
a318c322b2 fix: revert code due to wrong merge conflict resolution (#824) 2024-02-08 21:48:07 +05:30
Peter Kulko
b234344aab feat: [FC-0044] Course unit page - Unit switch widget with a New unit creation button (#809)
* feat: added Unit switch widget with a New unit button

* refactor: refactoring after review

* refactor: changed the variable name
2024-02-08 10:15:21 -05:00
Adolfo R. Brandes
4850302175 fix: Runtime config support for feature flags
This makes sure the following feature flags work with dynamic runtime
configuration:

* ENABLE_NEW_EDITOR_PAGES
* ENABLE_UNIT_PAGE
* ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN
* ENABLE_TAGGING_TAXONOMY_PAGES

We also remove flags from the `.env*` files that are no longer in use.
2024-02-06 16:05:28 -03:00
Navin Karkera
815ddbe94e feat: copy & paste units
refactor: paste component

fix: lint issues and delete unused hook

test: add test

fix: update api for npm broadcast channel
2024-02-05 14:01:38 -05:00
Navin Karkera
2cb907e731 feat: xblock status component
feat: add custom relative dates flag to state

refactor: add gated status type

refactor: alert style

feat: add status text to units

test: add tests

fix: lint issues

refactor: break up xblock status component

fix: selector for isCustomRelativeDatesActive

fix: prereq default value
2024-02-05 14:01:38 -05:00
Ihor Romaniuk
9c52b8b6c5 feat: [FC-0044] Unit page header section (#808)
* feat: create Unit page and add page header functionality

* fix: after code review

---------

Co-authored-by: monteri <lansevermore>
2024-02-05 11:58:35 -05:00
Omar Al-Ithawi
056a15bedb feat: tutor-mfe compatiblilty for atlas pull (#817)
- install atlas
 - remove `--filter` to pull all languages by default
 - use ATLAS_OPTIONS to allow custom `--filter`
 - include frontend-platform, ai-translations and lib-contents in `atlas pull` command

Refs: FC-0012 OEP-58
2024-02-02 14:16:01 -05:00
Kristin Aoki
18537e3f62 fix: model update for usage locations (#819) 2024-02-02 11:21:44 -05:00
Rômulo Penido
24c48bc3ea feat: add search highlight/expand and "no tags" message (#799)
This change makes minor improvements in the search taxonomy UI.  It expands taxonomies that match the search and highlight the search term, and adds a "No tag found with search term '....'" message.
2024-02-02 20:39:30 +05:30
Jillian
49d4fd44a3 fix: fixed typo in updateContentTaxonomyTags URL [FC-0036] (#815)
* fix: fixed typo in updateContentTaxonomyTags URL
* fix: use params instead of urlencoding
2024-02-02 12:00:04 +05:30
Braden MacDonald
c7aef6e467 fix: minor TypeScript error - not sure how it got onto master 2024-02-01 01:43:48 +05:30
Braden MacDonald
d6338de8bc docs: fix incorrect waffle flag stated. 2024-02-01 01:43:48 +05:30
Jillian
b56b5d9b16 Use object permissions in Tagging frontend [FC-0036] (#787)
Uses the permissions added to the Tagging REST API by openedx/openedx-learning#138 to decide what actions (e.g. import, export, edit, delete) to present to the current user when viewing Tagging-related content.
2024-01-29 14:47:31 +05:30
Kristin Aoki
90bc242ddd fix: update video page load order (#810)
* fix: asset card/row menu appearing for videos

* fix: page load time

* fix: video status messages
2024-01-25 17:04:34 -05:00
dependabot[bot]
f8aa157c93 chore(deps): bump follow-redirects from 1.15.1 to 1.15.5 (#806)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.1 to 1.15.5.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.1...v1.15.5)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-24 17:19:11 -05:00
Syed Ali Abbas Zaidi
34fbadfd6a feat: migrate enzyme to RTL (#770) 2024-01-24 16:57:28 -05:00
Michael Roytman
6d431e5746 Add settings modal for Xpert Learning Assistant feature. (#794)
* feat: modify AppSettingsModal to add bodyChildren prop and to make the learnMoreText prop optional

This commit adds a new bodyChildren prop to the AppSettingsModal component. This prop is meant to be used by a parent to pass through React components that should be rendered between the enable toggle and the form. This allows parents to specify additional UI that doesn't belong in the form. For example, additional documentation about the feature or additional links are examples of additional UI that can be rendered this way.

This commit modifies the learnMoreText prop to the AppSettingsModal component optional. The learnMoreText prop is used as the text for the "learn more configuration" link. This link is rendered only if the corresponding documentationLink is provided, and this link is optional. Therefore, the corresponding learnMoreText prop should also be optional.

* feat: modify PagesAndResources to support additional pages in the "content permissions" section

This commit modifies the way that the PagesAndResources component renders pages in the "content permissions" section to enable additional pages in this section beyond just the Xpert unit summaries feature.

* feat: add settings modal for Xpert Learning Assistant feature

This commit adds a settings modal for the Xpert Learning Assistant feature.
2024-01-24 12:00:17 -05:00
Jorg Are
9e06065fd3 feat: replace ai translations edx component with the openedx version (#803) 2024-01-24 17:48:49 +01:00
Navin Karkera
09eef604f7 refactor: replace time picker by text box in advanced tab 2024-01-24 09:50:16 -05:00
Navin Karkera
5a2dbad343 test: improve coverage for section, subsection & unit configuration 2024-01-24 09:50:16 -05:00
Moncef Abboud
13cb1d3539 feat: add unit configuration modal 2024-01-24 09:50:16 -05:00
Navin Karkera
5a27d50d2a refactor: use time picker in advanced tab
fixes issues related to form autosuggest

fix: hide header only for advanced tab time picker
2024-01-24 09:50:16 -05:00
Stephannie Jimenez
ffec32cba8 feat: add subsection configuration modal
test: add render and API tests

fix: fix non saving options and add review style changes

fix: remove additional tab in the section configuration

fix: remove isSubsection state, fix css issues and fix tests

fix: add review changes, fix advanced tab hour selection and update tests

test: fix failing test in courseOutline.test.jsx

fix: remove unused state, add TODO comment, fix stack rendering and NaN values

feat: show previous state in autosuggest if an invalid option is provided and fix warnings

test: fix failing test
2024-01-24 09:50:16 -05:00
Navin Karkera
53118a4e0b feat: move up & down menu action for sections, subsections & units
test: add tests for move options

refactor: disable move option instead of hiding

fix: incorrect variable name in tests

feat: move up & down menu action for units

test: add tests for unit move options
2024-01-24 09:50:16 -05:00
Sid Verma
d2f63b8b16 feat: Add drag-n-drop support to course unit, refactor tests.
chore: address review feedback
2024-01-24 09:50:16 -05:00
Navin Karkera
0e829974ef feat: add colored left border to items in course outline
refactor: move common styles to conditional sortable element
2024-01-24 09:50:16 -05:00
Navin Karkera
eb0c61ce6d refactor: course outline badge status logic 2024-01-24 09:50:16 -05:00
Navin Karkera
b417cd64a0 feat: use actions and other flags to control item actions
Uses action flags from API to control display of delete, duplicate, child new button and dragging.
Use isHeaderVisible flag to control display of subsection headers.

All these changes prepare outline for entrance exam section display.

feat: use actions flags for subsections

test: actions
2024-01-24 09:50:16 -05:00
Sid Verma
70b4795650 feat: Add drag and drop support to subsections
feat: Update tests, fix bugs in drag and drop elements

chore: address review feedback
2024-01-24 09:50:16 -05:00
Rômulo Penido
3842b046cd fix: minor react errors in course authoring mfe [FC-0036] (#789)
* fix: remove console warnings and add missing typing checks
* fix: TagData <> TagListData swap names
* fix: toast needs show property
* fix: remove type guard from tagsCount
* fix: apply suggestions from code review
Co-authored-by: Jillian <jill@opencraft.com>
2024-01-24 16:59:33 +05:30
Raymond Zhou
c2ad1b8c99 fix: error handling for save api call (#805) 2024-01-23 15:55:24 -05:00
Kristin Aoki
bdb4ffe69d fix: gallery card text and load (#795) 2024-01-16 17:06:50 -05:00
Kristin Aoki
0a053d32ce feat: add missing transcript setting update date (#793) 2024-01-16 16:32:37 -05:00
Kristin Aoki
859819f0f0 feat: bump frontend-lib-content-components (#798) 2024-01-16 16:15:48 -05:00
Navin Karkera
008d619236 feat: video sharing option dropdown (#779)
* feat: video sharing option dropdown

* test: video sharing option

* fix: lint issues

* refactor: messages for video sharing options

* test: add failure test for video sharing

* refactor: rename course block api url
2024-01-16 10:50:16 -05:00
Rômulo Penido
b59ecafc83 feat: refined ux update a taxonomy by downloading and uploading [FC-0036] (#732)
This PR improves the import tags functionality for existing taxonomies implemented at #675.

Co-authored-by: Jillian <jill@opencraft.com>
Co-authored-by: Braden MacDonald <mail@bradenm.com>
2024-01-16 12:00:15 +05:30
Rômulo Penido
1fef358f55 feat: assign taxonomy to organizations [FC-0036] (#760)
This PR adds a UI to assign organizations to a Taxonomy.

Co-authored-by: Jillian <jill@opencraft.com>
2024-01-12 13:10:56 +05:30
Kristin Aoki
bfcd3e6ff9 fix: mov files not being allowed on video upload (#792) 2024-01-11 12:19:09 -05:00
Kristin Aoki
433a87795c feat: bump frontend-lib-content-components (#791) 2024-01-11 10:18:26 -05:00
connorhaugh
a3975f47e2 fix: dont allow course detail page to break editor (#790)
At the moment, editors served from V1 libraries are broken because they use the course authoring MFE url (because they use editors in the same way courses do).
2024-01-11 09:44:29 -05:00
Jesper Hodge
0debaecad6 fix: export page timestamp (#785)
* fix: export page timestamp

* fix: tests
2024-01-10 16:59:50 -05:00
Kristin Aoki
97da4d1d61 fix: video upload api body and error control (#782) 2024-01-10 10:29:33 -05:00
Navin Karkera
faf90d1fa7 feat: unit list
refactor: hide tooltip based on arg

refactor: card header to include title link

feat: delete unit option

feat: duplicate unit option

refactor: title click handler name and remove unwanted scss properties

test: new unit and edit unit option

test: add delete unit and combine it with section and subsection test

test: add duplicate unit test and combine it with section & subsection test

refactor: replace act call by oneline

test: add publish unit & subsection test and combine it with section test

refactor: add jest-expect-message to add custom msg to tests

fix: lint issues

test: fix unit card tests

refactor: remove unnecessary css and message

refactor: pass title as component to card header

refactor: extract status badge to a component

fix: lint issues

refactor: rename status badge component

test: fix card header tests

refactor: new item button styling

feat: show loading spinner while sections are loading

refactor: new button style
2024-01-10 09:38:23 -05:00
Braden MacDonald
1e23ce1062 fix: react-router basepath was not set, breaking this MFE on tutor instances (#784) 2024-01-10 18:59:50 +05:30
kenclary
9ad192054b fix: configure webpack to fix paid powerpaste plugin (#783) 2024-01-09 16:15:34 -05:00
Kristin Aoki
bee3758d18 feat: update usage location call (#764) 2024-01-09 12:18:03 -05:00
Kristin Aoki
cae7f9bc22 feat: bump frontend-lib-content-components (#780) 2024-01-08 14:28:37 -05:00
Yusuf Musleh
138f1d29df feat: Add v2 lib components content tags support (#771)
Add support for fetching content data for Library V2 components in content tags drawer.
2024-01-08 15:12:32 +05:30
Rômulo Penido
6c0fc09075 feat: add import taxonomy feature [FC-0036] (#675)
This change adds a new button in the Taxonomy List to allow users to create new taxonomies by importing a CSV/JSON file.
2024-01-08 12:38:03 +05:30
ayesha waris
2205506b26 chore: removed reported_content_email_notifications_flag dependency (#775) 2024-01-05 17:01:18 +05:00
Artur Gaspar
2e070c9a12 feat: error page on invalid course key (#761) 2024-01-04 10:28:19 -05:00
Kristin Aoki
52b75e0b06 fix: uploading progress percentage (#763) 2024-01-04 09:36:17 -05:00
Yusuf Musleh
278862127b feat: Add filter taxonomies by org (#755)
This implements filtering taxonomies on the taxonomy list page by selecting organization name, all taxonomies, or unassigned taxonomies.
2024-01-04 17:50:32 +05:30
renovate[bot]
4ffebdac77 fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.9 (#762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-21 11:37:04 -05:00
Jesper Hodge
782faddbf8 chore: update flcc to version 1.177.8 (#759) 2023-12-20 17:52:16 -05:00
Navin Karkera
df532b36ab test: improve coverage for course outline
refactor: remove delete unit hook and thunk till unit list is implemented

test: additional tests for sections

test: additional tests for subsections

test: replace query calls by button clicks
2023-12-20 10:20:48 -05:00
Navin Karkera
b0cb53ab44 refactor: reuse drag-n-drop component from lib-components
refactor: subsection and drag component style

refactor: subsection styling

refactor: generalize message ids for card header
2023-12-20 10:20:48 -05:00
Navin Karkera
580b8cbdb4 fix: handle scrolling with drag-n-drop
test: update tests

fix: scroll to element only when required

test: fix subsection component render

refactor: use textarea for highlights
2023-12-20 10:20:48 -05:00
Stephannie Jimenez
48ab324100 feat: add drag n drop functionality to section cards
feat: use react-dnd library for drag and drop implementation

style: fix linting issues

fix: finalize section order on drop instead of hover

fix: prevent same index drag to start request and restore state on error

fix: restore sectionlist order

fix: prevent drag event while editing the text

style: fix linting issues

test: fix failing tests

test: add missing hooks to SectionCard component in test

test: add wrapping to SectionCard test component

test: add tests for checking the API that sets the ordering

fix: merge scroll-to-bottom with drag and drop implementations

fix: fix linting issues
2023-12-20 10:20:48 -05:00
Navin Karkera
f79bebceeb feat: add subsection component
refactor: update publish modal to handle subsections and units

refactor: rename current section state and handlers

refactor: generalize edit title for section, subsection and unit

feat: generalize delete modal

feat: generalize publish modal

refactor: use currentSection and currentSubsection to improve delete item

feat: generalize duplication functionality

feat: generalize add new item for sections and subsections

test: fix subsection tests

fix: lint issues and test arguments

test: fix card header, delete and publish modal tests

fix: invalid use of delete subsection query for unit

refactor: use current section for highlights modal

feat: add auto scroll to subsection and improve scroll behaviour

fix: jsdoc types
2023-12-20 10:20:48 -05:00
Stephannie Jimenez
91ba00346c feat: add auto scroll to new sections when created
fix: rename util function and remove unused eslint comment

fix: fix tests by mocking scrollIntoView function

test: add assertion for checking call to mock function
2023-12-20 10:20:48 -05:00
Moncef Abboud
7286b21f5a feat: add Section Configure 2023-12-20 10:20:48 -05:00
Navin Karkera
134b75568a feat: section list and new section button
Also refactor api and hooks

fix: publish button behaviour and card header tests

fix: warning in highlights and publish modal test

fix: courseoutline tests

test: add test for new section functionality

fix(lint): lint issues

refactor: remove unnecessary css in CardHeader

refactor: rename emptyPlaceholder test file

refactor: replace ternary operator with 'and' condition

refactor: add black color to expand/collapse button

refactor: display only changed subsection and units in publish modal

refactor: update messages and css

refactor: wrap urls in function call

refactor: fix jsdoc types

refactor: use helmet for document title
2023-12-20 10:20:48 -05:00
vladislavkeblysh
59071424b3 feat: course outline - sections list
* feat: [2u-259] add components

* feat: [2u-259] fix sidebar

* feat: [2u-259] add tests, fix links

* feat: [2u-259] fix messages

* feat: [2u-159] fix reducer and sidebar

* feat: [2u-259] fix reducer

* feat: [2u-259] remove warning from selectors

* feat: [2u-259] remove indents

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course Outline  - Sections list (#59)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* fix: [2u-342] fix translates and indents

* fix: [2u-342] fix constants and expand block

* feat: [2u-336] remove new section from menu

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Content empty (#72)

* feat: [2u-324] add component

* feat: [2u-324] add translates

* feat: [2u-324] update tests

* feat: [2u-324] update branch

* fix: [2u-324] fixed empty handler

feat: Course outline - Section Publish (#61)

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-354] refactor modal

* fix: [2u-354] removed comments

* fix: [2u-354] fix indents

* fix: [2u-354] removed translates duplicates

* fix: [2u-354] rename handlers

feat: Course outline - Update section card (#71)

* feat: [2u-615] update section card

* fix: [2u-615] fix handler names

* fix: [2u-615] fix indents

* fix: [2u-615] add empty handler

* fix: [2u-615] fix data test id name

* fix: [2u-615] fix styles

fix: [2u-696] add saving processing for higlights and enable highlights (#78)

feat: Course outline - Section Edit (#70)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-348] add description for api

* fix: [2u-348] fix useEscapeClick

* fix: [2u-348] remove useEffect from CardHeader

* fix: [2u-348] fixed handlers and tests

* fix: [2u-348] fixed handlers and tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Section Delete (#74)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-510] add delete api, add delete modal

* fix: [2u-510] fixed tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Section duplicate (#88)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-510] add delete api, add delete modal

* feat: [2u-360] add api

* feat: [2u-360] add slice

* feat: [2u-360] add tests

* fix: [2u-360] fixed tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

fix: Course outline - Highlights links (#89)

* fix: fixed doc urls

* fix: fixed components

feat: Course outline - Collapse all sections (#75)

* feat: added collapse all section logic

* fix: fixed tests

fix: final revision commits

fix: increase code coverage on the page
2023-12-20 10:20:48 -05:00
renovate[bot]
f938d08361 fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.7 (#757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-19 14:25:06 -05:00
Jorg Are
f78e8a5671 fix: bump frontend-component-ai-translations-edx version (#756) 2023-12-19 18:08:55 +01:00
Kristin Aoki
4c7faad987 feat: break up load api to tab specific (#740) 2023-12-19 09:10:05 -05:00
Chris Chávez
bf46008878 style: UX Refinements on taxonomy pages [FC-0036] (#723)
This change makes the following updates to the UX of the taxonomy pages:

* On the taxonomies list, display the full name of taxonomies in a tooltip if it's longer than what's displayed
* On the taxonomy detail page, please change the title of the "Value" column to "Tag name"
* On taxonomy detail page, remove the "child tags" column and put it in parentheses instead
* Update tags count color
* Several minor issues brought up here: https://github.com/openedx/modular-learning/issues/105#issuecomment-1829412705. 
* Fix issue with scroll position not being reset on navigation
2023-12-19 09:44:44 +05:30
Rômulo Penido
a37d13f788 feat: add download template button to taxonomy list [FC-0036] (#674)
This commit adds a new button in the Taxonomy List to allow users to download a sample taxonomy template in the format used to import taxonomies.
2023-12-19 09:31:48 +05:30
Kristin Aoki
c68b2e3b06 Revert "chore(deps): update dependency @edx/frontend-build to v13.0.14 (#695)" (#753)
This reverts commit cb8bf2cd89.
2023-12-18 12:53:50 -05:00
renovate[bot]
cb8bf2cd89 chore(deps): update dependency @edx/frontend-build to v13.0.14 (#695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 09:55:30 -05:00
renovate[bot]
089d8a8f79 fix(deps): update dependency @edx/frontend-component-footer to v12.6.1 (#688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 09:47:00 -05:00
renovate[bot]
de9072d506 fix(deps): update dependency react-datepicker to v4.24.0 (#726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 09:33:47 -05:00
renovate[bot]
279f8f2a6c fix(deps): update dependency react-textarea-autosize to v8.5.3 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 09:10:25 -05:00
Yusuf Musleh
7a4c9a36b6 feat: Search Content Tags (#737)
This change adds the ability to search content tags in the content tags
drawer, in order to filter tags. This change also refactors the way data
is loaded from the server, handling pre-loaded data and pagination.
2023-12-18 11:16:22 +05:30
Kristin Aoki
476f779e76 fix: files page timeout (#749) 2023-12-15 16:55:24 -05:00
Raymond Zhou
75eb0c307e fix: datatable state persistence issues (#746) 2023-12-15 14:52:59 -05:00
Jorg Are
da5d64ad9e fix: update transcript settings default logic (#745)
* fix: update transcript settings default logic

* fix: remove extra state logic
2023-12-15 10:52:12 -05:00
Peter Kulko
ad8fe53348 chore: added start:with-theme npm script (#747) 2023-12-15 10:15:04 +02:00
Jesper Hodge
94725dfe3c Fix undo reverts and update flcc to working version (#748)
* Revert "Fix  tinymce editor problems (#743)"

This reverts commit e6ce05571f.

* chore: update flcc to working version

* chore: update flcc to version that disables plugins
2023-12-14 15:30:05 -05:00
Jesper Hodge
e6ce05571f Fix tinymce editor problems (#743)
Internal issue: https://2u-internal.atlassian.net/servicedesk/customer/portal/9/CR-6328?created=true

Reverted 6 merged PRs due to problems.

scroll was not working on editors
potential problems with editor content loading

------------------------------------------------------


* Revert "fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.4 (#742)"

This reverts commit cc40e9d6cb.

* Revert "feat: add escalation email field for LTI-based proctoring providers (#736)"

This reverts commit 0f483dc4e1.

* Revert "fix: video downloads (#728)"

This reverts commit c5abd21569.

* Revert "fix: import api to chunk file (#734)"

This reverts commit 6f7a992847.

* Revert "feat: Taxonomy delete dialog (#684)"

This reverts commit 1eff489158.

* Revert "fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.1 (#727)"

This reverts commit dcabb77218.
2023-12-12 18:23:26 -05:00
renovate[bot]
cc40e9d6cb fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.4 (#742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 16:14:03 -05:00
Michael Roytman
0f483dc4e1 feat: add escalation email field for LTI-based proctoring providers (#736)
This commit adds an escalation email field for LTI-based proctoring providers to the Proctoring modal on the Pages & Resources page. This field behaves identically to the Proctortrack escalation email.
2023-12-12 14:28:23 -05:00
Kristin Aoki
c5abd21569 fix: video downloads (#728) 2023-12-12 11:00:40 -05:00
Kristin Aoki
6f7a992847 fix: import api to chunk file (#734) 2023-12-12 10:28:33 -05:00
Chris Chávez
1eff489158 feat: Taxonomy delete dialog (#684)
This adds:
    New submenu 'Delete' on the Taxonomy card menu
    Delete Dialog with the functionality to delete a Taxonomy
    Show a Toast after delete the Taxonomy
    Enable export for System defined Taxonomies
2023-12-12 17:54:39 +05:30
renovate[bot]
dcabb77218 fix(deps): update dependency @edx/frontend-lib-content-components to v1.177.1 (#727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-11 13:11:53 -05:00
Jorg Are
67cda575a5 fix: bump ai translations component (#739) 2023-12-11 09:16:40 -05:00
Kristin Aoki
195c9e416c fix: grading segment number ranges (#729) 2023-12-08 13:26:36 -05:00
Kristin Aoki
5db6b2049f fix: wrong min count alert showing (#730) 2023-12-08 13:21:36 -05:00
Yusuf Musleh
c9b73a5008 feat: Add content tags tree state + editing (#704)
This commit adds the add/remove functionality of content tags where the
state is stored and changes are updated in the backend through the API.
Changes are reflected in the UI automatically.
2023-12-08 13:55:57 +05:30
Kristin Aoki
56ad86ee60 feat: update usage metrics to be a hyperlink (#717) 2023-12-07 12:30:15 -05:00
Navin Karkera
04c14274fd feat: course outline page (#694)
* feat: Course outline Top level page (#36)

* feat: [2u-259] add components

* feat: [2u-259] fix sidebar

* feat: [2u-259] add tests, fix links

* feat: [2u-259] fix messages

* feat: [2u-159] fix reducer and sidebar

* feat: [2u-259] fix reducer

* feat: [2u-259] remove warning from selectors

* feat: [2u-259] remove indents

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline Status Bar (#50)

* feat: [2u-259] add components

* feat: [2u-259] fix sidebar

* feat: [2u-259] add tests, fix links

* feat: [2u-259] fix messages

* feat: [2u-159] fix reducer and sidebar

* feat: add checklist

* feat: [2u-259] fix reducer

* feat: [2u-259] remove warning from selectors

* feat: [2u-259] remove indents

* feat: [2u-259] add api, enable modal

* feat: [2u-259] add tests

* feat: [2u-259] add translates

* feat: [2u-271] fix transalates

* feat: [2u-281] fix isQuery pending, utils, hooks

* feat: [2u-281] fix useScrollToHashElement

* feat: [2u-271] fix imports

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course Outline Reindex (#55)

* feat: [2u-277] add alerts

* feat: [2u-277] add translates

* feat: [2u-277] fix tests

* fix: [2u-277] fix slice and hook

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

fix: Course outline tests (#56)

* fix: fixed course outline status bar tests

* fix: fixed course outline status bar tests

* fix: fixed course outline enable highlights modal tests

* fix: enable modal tests

fix: increase code coverage on the page

* refactor: improve course outline page

feat: lms live link

chore: update outline link

fix: course outline link

refactor: remove unnecessary css and rename test file

refactor: remove unnecessary css from outlineSidebar

test: make use of message variable instead of hardcoded text

refactor: remove unnecessary h5 class

test: use test id for detecting component

refactor: update course outline url and some default messages

---------

Co-authored-by: vladislavkeblysh <138868841+vladislavkeblysh@users.noreply.github.com>
2023-12-06 10:06:29 -05:00
Jorg Are
bebbc1535b feat: add ai translations component to transcript settings (#722) 2023-12-05 16:22:46 -05:00
renovate[bot]
1636226572 fix(deps): update dependency @edx/frontend-lib-content-components to v1.176.4 (#720)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-01 12:33:36 -05:00
Maria Grimaldi
2fbcfc03dd fix: remove unnecessary course-v1 from courseId string (#687) 2023-11-30 15:56:38 -05:00
Kristin Aoki
ac1fc43250 fix: visibility of transcript dropdowns (#719) 2023-11-30 13:21:34 -05:00
Kristin Aoki
a2dceac62f feat: add notification of transcription error (#715) 2023-11-28 16:55:00 -05:00
Kristin Aoki
2402769d9d fix: sort of boolean columns (#705) 2023-11-28 13:13:54 -05:00
Feanil Patel
7030d6c1ba build: Updating workflow commitlint.yml.
The .github/workflows/commitlint.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-11-27 12:18:56 -05:00
renovate[bot]
1edc7d3329 fix(deps): update dependency @edx/frontend-lib-content-components to v1.176.0 (#709)
* fix(deps): update dependency @edx/frontend-lib-content-components to v1.176.0

* fix: mock frontend-components-tinymce-advanced-plugins for jest

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Ken Clary <kclary@edx.org>
2023-11-23 15:34:29 -05:00
Braden MacDonald
352ef35ac2 feat: display all child tags in the "bare bones" taxonomy detail page (#703)
Also includes:
- feat: set <title> on taxonomy list page and taxonomy detail page
- fix: display all taxonomies on the list page, even if > 10
- refactor: separate out loading spinner component
2023-11-23 01:44:17 +05:30
Kristin Aoki
f9b008e8e8 feat: original fetch to includes usage metrics (#701) 2023-11-22 13:54:13 -05:00
Kristin Aoki
251259e4bd feat: add new libray button (#710) 2023-11-22 13:42:37 -05:00
Adolfo R. Brandes
a622f8e86e fix: Fix data API URL handling
All configuration calls must handled asynchronously, otherwise they risk
failure in runtime configuration scenarios.
2023-11-21 16:57:04 -03:00
Rômulo Penido
02cdccc77c feat: bare bones taxonomy detail page [FC-0036] (#655)
* feat: System-defined tooltip added

* feat: Taxonomy card menu added. Export menu item added

* feat: Modal for export taxonomy

* feat: Connect with export API

* test: Tests for API and selectors

* feat: Use windows.location.href to call the export endpoint

* test: ExportModal.test added

* style: Delete unnecesary code

* docs: README updated with taxonomy feature

* style: TaxonomyCard updated to a better code style

* style: injectIntl replaced by useIntl on taxonomy pages and components

* refactor: Move and rename taxonomy UI components to match 0002 ADR

* refactor: Move api to data to match with 0002 ADR

* test: Refactor ExportModal tests

* chore: Fix validations

* chore: Lint

* refactor: Moving hooks to apiHooks

* feat: add taxonomy detail page

* fix: address nits in PR review

* refactor: move data/selectors to data/apiHooks

and fix tests to mock useQuery.

* fix: address nits in PR review

* fix: replace taxonomy menu ModalPopup with Dropdown menu

Avoids clicking through to the card when using the menu button to hide
a card's menu.

* fix: change taxonomy URLs

* /taxonomy-list is now /taxonomies, and there's a temporary redirect
* /taxonomy-list/🆔 is now /taxonomy/🆔

---------

Co-authored-by: Christofer <christofer@opencraft.com>
Co-authored-by: XnpioChV <xnpiochv@gmail.com>
Co-authored-by: Christofer Chavez <christofer@example.com>
Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2023-11-20 17:15:31 -03:00
Yusuf Musleh
375006deb1 feat: Implement Content Tags Drawer
This implements a side drawer widget for content taxonomy tags.
It includes displaying the object's tags, along with their
lineage (ancestor tags) data. It also implements the listing the
available taxonomy tags (including nesting ones) to select from
to apply to this unit.

Note: The editing of tags (adding/removing) will be added in a future
PR.

* feat: Add initial UnitTaxonomyTagsDrawer widget
* feat: Add fetching unit taxonomy tags from backend
* feat: Add fetching/group tags with taxonomies
* feat: Add fetch Unit data and display name
* feat: Add Taxonomy Tags dropdown selector
* feat: Add TagBubble for tag styling
* chore: Add distinct keys to elements + remove logs
* feat: Add close drawer with ESC- keypress
* feat: Make dropdown selectors keyboard accessible
* chore: Fix issues causing validation to fail
* test: Add coverage tests for UnitTaxonomyDrawer
* feat: Incorporate tags lineage data from API
* refactor: Remove/replace deprecated injectIntl
* test: Remove redux store related code + fix warnings
* feat: Use <Loading /> instead of loading string
* docs: Add docs string to TaxonomyTagsCollapsible
* feat: Use <Spinner/> to allow mutiple loading to show
* feat: Rename UnitTaxonomyTagDrawer -> ContentTagsDrawer
* feat: Add ContentTagsTree component to render Tags
* feat: Only fetch tags when dropdowns are opened
* refactor: Simply dropdown close/open states
* feat: Use built in class styles instead of custom
* feat: Replace hardcoded values with scss variables
* refactor: follow existing structure for reactQuery/APIs
* feat: Change tag bubble outline color
* feat: Add TagOutlineIcon for implicit tags
* feat: Make aria label internationalized
* feat: Replace custom styles with builtin classes
* fix: Fix bug with closing drawer
* refactor: Simplify content tags fetching code
* refactor: Simplify getTaxonomyListApiUrl
2023-11-20 16:56:46 -03:00
Kristin Aoki
9b053de0b7 fix: filter overwritten by sort (#702) 2023-11-20 14:06:36 -05:00
Kristin Aoki
a62c53eb00 fix: pagination style (#697) 2023-11-20 09:18:36 -05:00
Kristin Aoki
08d895b2e0 fix: list more info menu styles (#696) 2023-11-17 09:48:32 -05:00
Kristin Aoki
eb3ee3a6b2 fix: transcript tab layout (#686)
* fix: transcript tab layout

* fix: console warnings for missing config values
2023-11-17 09:48:13 -05:00
Kristin Aoki
af0124d4e6 fix: general page layout and colors (#693) 2023-11-16 18:05:50 -05:00
Kristin Aoki
3d37bc056d fix: page specific messages (#691) 2023-11-16 16:40:57 -05:00
dependabot[bot]
a25bc0670e chore(deps-dev): bump semver from 5.7.1 to 5.7.2 (#665)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-16 14:02:38 -05:00
dependabot[bot]
0f4662265a chore(deps): bump tinymce from 5.10.5 to 5.10.9 (#685)
Bumps [tinymce](https://github.com/tinymce/tinymce/tree/HEAD/modules/tinymce) from 5.10.5 to 5.10.9.
- [Changelog](https://github.com/tinymce/tinymce/blob/5.10.9/modules/tinymce/CHANGELOG.md)
- [Commits](https://github.com/tinymce/tinymce/commits/5.10.9/modules/tinymce)

---
updated-dependencies:
- dependency-name: tinymce
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-16 13:52:56 -05:00
Kristin Aoki
79bb38a098 fix: use styles from frontend-content-header (#690) 2023-11-16 13:38:59 -05:00
Jesper Hodge
ed1c83fe7f chore: update decode-uri-component (#692) 2023-11-16 13:33:51 -05:00
dependabot[bot]
b0bd80d8d1 build(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#401)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-16 13:02:56 -05:00
renovate[bot]
9aef1a88ba fix(deps): update dependency @edx/frontend-component-header to v4.10.1 (#689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-16 12:59:43 -05:00
renovate[bot]
0f80e27978 fix(deps): update dependency @reduxjs/toolkit to v1.9.7 (#679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-16 12:51:52 -05:00
dependabot[bot]
c5fc16b77a build(deps): bump @xmldom/xmldom from 0.7.5 to 0.7.8 (#376)
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.7.5 to 0.7.8.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.7.5...0.7.8)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-16 12:13:57 -05:00
dependabot[bot]
d5f0691fc3 chore(deps): bump @babel/traverse from 7.22.17 to 7.23.2 (#664)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.17 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-16 10:53:29 -05:00
Emad Rad
91019b4a51 feat: Persian language support added (#553)
* fix: corrected typos

justify-contnt-center -> justify-content-center
Visiblity -> Visibility
Wraper -> Wrapper
closeAssetinfo -> closeAssetInfo
RestictDatesInput -> RestrictDatesInput
isOnSmallcreen -> isOnSmallScreen
Repsonse -> Response
configuation -> configuration
seconary -> secondary
comparesion -> comparison

* feat: Persian language (fa_IR) added

* refactor: better variable name for languages

* chore: sort languages alphabetically
2023-11-16 09:01:08 +05:30
renovate[bot]
2804f38d4f chore(deps): update dependency axios-mock-adapter to v1.22.0 (#678)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 16:48:29 -05:00
renovate[bot]
416ac4fbdc chore(deps): update dependency @edx/frontend-build to v13.0.5 (#676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 16:48:15 -05:00
renovate[bot]
14e3c258fb fix(deps): update dependency @edx/brand to v1.2.3 (#667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 16:47:48 -05:00
Kristin Aoki
ce9db575a6 fix: table search filter (#683) 2023-11-14 13:21:11 -05:00
Chris Chávez
1ee80b68ec feat: Taxonomy export menu [FC-0036] (#645)
* feat: System-defined tooltip added
* feat: Taxonomy card menu added. Export menu item added
* feat: Modal for export taxonomy
* feat: Connect with export API
* test: Tests for API and selectors
* feat: Use windows.location.href to call the export endpoint
* test: ExportModal.test added
* style: Delete unnecessary code
* docs: README updated with taxonomy feature
* style: TaxonomyCard updated to a better code style
* style: injectIntl replaced by useIntl on taxonomy pages and components
* refactor: Move and rename taxonomy UI components to match 0002 ADR
* refactor: Move api to data to match with 0002 ADR
* test: Refactor ExportModal tests
* chore: Fix validations
* chore: Lint
* refactor: Moving hooks to apiHooks
* style: Nit on return null

---------

Co-authored-by: Rômulo Penido <romulo@dash.dev.br>
Co-authored-by: Christofer Chavez <christofer@example.com>
2023-11-14 13:08:37 -05:00
Kristin Aoki
7c7ea1fbc2 fix: active transcript preference not loading (#682) 2023-11-14 10:43:01 -05:00
Kristin Aoki
3378c8e170 fix: combine filter and sort into one modal (#680) 2023-11-13 15:22:44 -05:00
Kristin Aoki
2fbb490cbb fix: total file count update on add and delete (#681) 2023-11-13 14:46:31 -05:00
Zachary Hancock
e41efba0cd feat: opt out is not supported by lti proctoring (#673)
This toggle does nothing if an LTI tool is selected. We should hide it in that case.
2023-11-13 09:14:54 -05:00
Zachary Hancock
7c7b3cdc07 feat: remove old/duplicate proctoring component (#671) 2023-11-09 08:55:25 -05:00
Kristin Aoki
78eb512836 refactor: files-and-videos folder (#672) 2023-11-08 15:54:47 -05:00
Kristin Aoki
3dac6aa188 fix: modal exit redirect (#659) 2023-11-07 16:09:25 -05:00
renovate[bot]
4a3d1a1787 fix(deps): update dependency @edx/frontend-lib-content-components to v1.175.1 (#663)
* fix(deps): update dependency @edx/frontend-lib-content-components to v1.175.1

* fix: upgrade paragon

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: KristinAoki <kaoki@2u.com>
2023-11-07 15:43:44 -05:00
renovate[bot]
2cfde7d3f4 fix(deps): update dependency @edx/frontend-component-header to v4.9.3 (#651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-07 14:47:50 -05:00
Kristin Aoki
05e90b59d2 fix: fidelity typo in preference api (#662) 2023-11-07 14:05:08 -05:00
Kristin Aoki
02a683f09a fix: end date error when certificate row not shown (#668) 2023-11-07 11:31:21 -05:00
renovate[bot]
f61f7429bd chore(deps): update dependency @edx/frontend-build to v13.0.4 (#641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 15:37:34 -05:00
renovate[bot]
09f908b019 fix(deps): update dependency @edx/frontend-component-footer to v12.5.1 (#650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 15:25:11 -05:00
renovate[bot]
d5cc56756e fix(deps): update dependency react-transition-group to v4.4.5 (#647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 13:26:16 -05:00
renovate[bot]
77a355ee8d chore(deps): update dependency @edx/browserslist-config to v1.2.0 (#649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 09:55:07 -05:00
renovate[bot]
7bcce0b9d9 fix(deps): update font awesome (#648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 09:43:19 -05:00
renovate[bot]
e1602258dc chore(deps): update dependency glob to v7.2.3 (#643)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 09:40:08 -05:00
renovate[bot]
78ef3c3f37 fix(deps): update dependency moment to v2.29.4 [security] (#630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 09:35:33 -05:00
renovate[bot]
890d664746 chore(deps): update dependency @testing-library/jest-dom to v5.17.0 (#653)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-06 09:18:13 -05:00
Kristin Aoki
a28338df30 feat: add video page (#640) 2023-11-06 08:51:21 -05:00
Jesper Hodge
221fcf77dc feat: change filter status text (#657)
Changed the filter status text on the files and uploads table to display also the number of files, even when a filter is applied.

https://2u-internal.atlassian.net/browse/TNL-11086 (internal ticket)

I just copied most of paragon's FilterStatus component, made some adjustments, and then overrode the default component.
2023-10-27 10:25:51 -04:00
Stanislav
378b0e93eb fix: Missed favicon in Safari (#633)
Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2023-10-25 14:07:26 -04:00
sundasnoreen12
a69711942b fix: fixed issue of unable to call handle submit function on button click (#656) 2023-10-25 22:58:46 +05:00
Braden MacDonald
0679022f7a docs: some updates to the readme (#625) 2023-10-25 10:29:51 -04:00
Syed Ali Abbas Zaidi
d497b01c45 feat: upgrade react router to v6 (#519)
## Ticket
[React Router Upgrade to v6](https://github.com/openedx/platform-roadmap/issues/276).

## Description
This PR upgrades React Router from `v5` to `v6`.
2023-10-20 16:52:23 -04:00
Jesper Hodge
682c3b64b2 chore: adjust renovate config (#637) 2023-10-20 13:53:42 -04:00
Feanil Patel
9715429ed0 chore: Update to the new version of brand-openedx in the new scope. (#646)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 13:29:49 -04:00
Chris Chávez
ad4d9b9c63 Taxonomy list page [FC-0036] (#622) 2023-10-20 11:55:20 -04:00
Jesper Hodge
85a19f7971 chore: change files page title (#639)
Changed "Files & Uploads" page title to just "Files"
2023-10-19 11:21:46 -04:00
Peter Kulko
6705f638c0 fix: fixed sidebar margin top (#73) (#593) 2023-10-18 13:42:44 -04:00
Jesper Hodge
618831f1eb fix: info modal and list view thumbnails (#636) 2023-10-17 12:17:03 -04:00
renovate[bot]
6287e8c01b fix(deps): update dependency @edx/frontend-lib-content-components to v1.175.0 (#631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-17 12:05:34 -04:00
Jesper Hodge
beb035b3e1 fix file upload thumbnails (#629)
Internal issue: https://2u-internal.atlassian.net/browse/TNL-11085

File upload gallery cards had distorted thumbnails. The goal is to fix this.
I centered the thumbnails instead of stretching them. I had to change the card layout a bit to do that, so in order to make everything look fine, I worked a bit on margins and paddings and font-size in order to bring this close to the figma mockups. It's not perfect because when you resize the browser window, the grid does some resizing that doesn't look as good as in figma, but I think it should be good enough for now.
2023-10-17 10:22:49 -04:00
Kristin Aoki
5c101b09d4 feat: update list view to be table (#628) 2023-10-16 14:58:28 -04:00
Jesper Hodge
7132136a91 chore: disable renovate automerge and add flcc rules (#624)
* chore: disable renovate automerge and add flcc rules

* chore: add dependency dashboard
2023-10-11 16:58:52 -04:00
Muhammad Abdullah Waheed
03bf93ad13 feat: babel-plugin-react-intl to babel-plugin-formatjs migration (#621)
* feat: babel-plugin-react-intl to babel-plugin-formatjs migration

* fix: upgraded frontend-build to fix security issue
2023-10-11 11:38:36 -04:00
Navin Karkera
65859924c2 fix: await async saveSetting func to remove unwanted err msg (#610) 2023-10-11 10:42:43 -04:00
Bilal Qamar
97d0a1ce61 feat: update react & react-dom to v17 (#514)
* feat: update react & react-dom to v17

* refactor: updated package-lock

* refactor: updated failing tests

* refactor: updated FilesAndUploads test to resolve delay issue

* refactor: updated DiscussionSettings tests

* refactor: downgraded frontend-lib-content-components

* refactor: resolved lint issue

* refactor: bumped frontend-lib-content-components version

* refactor: updated CollapsibleStateWithAction test suit

* refactor: update FilesAndUploads test
2023-10-11 11:08:26 +05:00
German
3fe35344f0 feat: update copy for xpert summary card (#619) 2023-10-03 17:18:04 -03:00
edx-transifex-bot
bbca5a29b7 chore(i18n): update translations (#616)
Co-authored-by: Jenkins <sre+jenkins@edx.org>
2023-10-02 15:44:34 -04:00
Kristin Aoki
2a6a816baf feat: update footer and header to use frontend-component version (#618) 2023-10-02 15:06:33 -04:00
Mashal Malik
73f7d5d5f5 refactor: add @openedx in renovate automate configuration (#617) 2023-10-02 10:16:08 -04:00
Kristin Aoki
0871ce345a fix: studio home load screen (#615) 2023-09-29 14:29:31 -04:00
Kristin Aoki
01ddac380f fix: studio home UI bugs (#611) 2023-09-28 18:36:51 -04:00
Kristin Aoki
4840666664 fix: export download link prefix (#614) 2023-09-28 12:14:25 -04:00
Kristin Aoki
21e4ece669 fix: bump frontend-lib-content-components (#613) 2023-09-27 12:58:01 -04:00
Kristin Aoki
887a628c23 fix: export and import UI bugs (#612) 2023-09-27 12:10:45 -04:00
Kyrylo Kholodenko
2ea876ae4f feat: implement import page (#587) 2023-09-25 12:07:08 -04:00
Kristin Aoki
c47c800cfa fix: advanced settings card alignment (#608) 2023-09-22 17:29:38 -04:00
Kristin Aoki
ef9633af35 fix: course updates UI bugs (#606)
* fix: change edit and delete buttons to icons

* fix: padding and button color

* fix: delete buttons and variant

* fix: date error icon color

* fix: page explanation text
2023-09-22 15:51:04 -04:00
Kristin Aoki
217b86e616 fix: missing header items (#607) 2023-09-22 14:56:14 -04:00
sundasnoreen12
37aabc4948 fix: update toggle state based on api response (#604)
* fix: update toggle state based on api response

* refactor: added statefulbutton instead of button

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-09-22 16:58:49 +05:00
ruzniaievdm
e099243437 feat: create Studio Home Page MFE (#589) 2023-09-19 10:04:43 -04:00
Kristin Aoki
6f238bdbe0 fix: bump frontend-lib-content-components (#602) 2023-09-15 14:02:18 -04:00
Kristin Aoki
77dfd0296c fix: bump frontend-lib-content-components (#601) 2023-09-14 09:42:04 -04:00
Kyrylo Kholodenko
1888993113 feat: implement export page (#586) 2023-09-14 09:07:24 -04:00
Kristin Aoki
fb28693854 fix: bump frontend-lib-content-components (#600) 2023-09-12 10:45:31 -04:00
Kristin Aoki
7f8c6f2d61 feat: update header to be keyboard accessible (#597) 2023-09-12 10:19:29 -04:00
Kristin Aoki
15984473b4 fix: bump frontend-lib-content-components (#595) 2023-09-07 09:53:20 -04:00
ruzniaievdm
b03ecf1562 fix: reworked grading deadline (#584) 2023-09-07 09:18:08 -04:00
Kristin Aoki
fdc5916ada fix: course team UI bugs (#592) 2023-09-06 14:45:17 -04:00
Kristin Aoki
a54d351e9c fix: schedule and details UI bugs (#588) 2023-09-06 12:32:19 -04:00
Kristin Aoki
62cde57556 fix: grading page UI bugs (#591) 2023-09-06 11:20:12 -04:00
Kristin Aoki
2bd8037d7b feat: change head title depending on page (#582) 2023-09-06 11:02:16 -04:00
Kyrylo Kholodenko
a1793efcc0 feat: add help-urls (#585) 2023-09-05 14:17:39 -04:00
Kristin Aoki
ed2eed5110 feat: add file zip on download (#580) 2023-09-05 10:23:47 -04:00
698 changed files with 71094 additions and 16577 deletions

14
.env
View File

@@ -23,6 +23,7 @@ PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
@@ -30,17 +31,12 @@ ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
ENABLE_UNIT_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''

View File

@@ -25,6 +25,7 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='Your Plaform Name Here'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
@@ -32,17 +33,12 @@ ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
ENABLE_UNIT_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'

View File

@@ -22,20 +22,15 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL='support@example.com'
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true
ENABLE_NEW_IMPORT_PAGE = true
ENABLE_NEW_EXPORT_PAGE = true
ENABLE_UNIT_PAGE = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
ENABLE_UNIT_PAGE=true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"

3
.gitignore vendored
View File

@@ -20,3 +20,6 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
# Local environment overrides
.env.private

View File

@@ -8,7 +8,7 @@
"ignoreUnits": ["\\.5"]
}],
"property-no-vendor-prefix": [true, {
"ignoreProperties": ["animation", "filter"]
"ignoreProperties": ["animation", "filter", "transform", "transition"]
}],
"value-no-vendor-prefix": [true, {
"ignoreValues": ["fill-available"]

View File

@@ -1,6 +1,6 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA,it_IT,pt_PT,de_DE"
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
@@ -8,14 +8,14 @@ i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
transifex_temp = ./temp/babel-plugin-formatjs
precommit:
npm run lint
npm audit
requirements:
npm install
npm ci
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -54,12 +54,15 @@ pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
&& atlas pull $(ATLAS_OPTIONS) \
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
$(intl_imports) paragon frontend-component-footer frontend-app-course-authoring
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
endif
# This target is used by Travis.
@@ -72,6 +75,7 @@ validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test
npm run build

View File

@@ -12,7 +12,6 @@ This is the Course Authoring micro-frontend, currently under development by `2U
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
************
Getting Started
************
@@ -32,6 +31,11 @@ to the `relevant tutor-mfe documentation`_ to get started using it.
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Configuration
=============
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
Cloning and Startup
===================
@@ -60,7 +64,7 @@ Cloning and Startup
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
********
Features
********
@@ -69,14 +73,12 @@ Feature: Pages and Resources Studio Tab
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
.. image:: ./docs/readme-images/feature-pages-resources.png
Requirements
------------
The following are external requirements for this feature to function correctly:
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
The following are requirements for this feature to function correctly:
* ``edx-platform`` Waffle flags:
@@ -125,15 +127,13 @@ For a particular course, this page allows one to:
Feature: New React XBlock Editors
=================================
.. image:: ./docs/readme-images/feature-problem-editor.png
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
@@ -145,7 +145,7 @@ Configuration
In additional to the standard settings, the following local configuration item is required:
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
Feature Description
-------------------
@@ -159,12 +159,13 @@ When a corresponding waffle flag is set, upon editing a block in Studio, the vie
Feature: New Proctoring Exams View
==================================
.. image:: ./docs/readme-images/feature-proctored-exams.png
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
@@ -190,8 +191,85 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
* Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
Feature: Advanced Settings
==========================
.. image:: ./docs/readme-images/feature-advanced-settings.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
Feature: Files & Uploads
==========================
.. image:: ./docs/readme-images/feature-files-uploads.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
Feature Description
-------------------
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
Feature: Course Updates
==========================
.. image:: ./docs/readme-images/feature-course-updates.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
Feature: Import/Export Pages
============================
.. image:: ./docs/readme-images/feature-export.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
Feature: Tagging/Taxonomy Pages
================================
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
Requirements
------------
* ``edx-platform`` Waffle flags:
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
Configuration
-------------
In additional to the standard settings, the following local configuration items are required:
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
**********
Developing
**********
@@ -200,8 +278,7 @@ Developing
If your devstack includes the default Demo course, you can visit the following URLs to see content:
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
Troubleshooting
========================
@@ -212,7 +289,7 @@ Troubleshooting
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733)
*********
Deploying
*********
@@ -236,6 +313,7 @@ internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
@@ -259,6 +337,7 @@ For more information about these options, see the `Getting Help`_ page.
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
@@ -267,6 +346,7 @@ noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
@@ -281,6 +361,7 @@ beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
****************************
@@ -297,6 +378,7 @@ file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
Reporting Security Issues
*************************
@@ -310,4 +392,4 @@ Please do not report security issues in public, and email security@openedx.org i
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
:alt: Codecov
:alt: Codecov

View File

@@ -8,3 +8,6 @@ coverage:
default:
target: auto
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/index.js"

View File

@@ -0,0 +1,21 @@
Background
==========
This is a summary of the technical decisions made for the Roles & Permissions
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
The ``frontend-app-course-authoring`` was already created when the
Permissions project started, so it already had a coding style, store
management and its own best practices.
We aligned to these requirements.
Frontend Architecture
---------------------
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
* Developing locally:
https://github.com/openedx/frontend-app-course-authoring#readme
* **React.js** application ``version: 17.0.2``
* **Redux** store management ``version: 4.0.5``
* It uses **Thunk** for adding to Redux the ability of returning
functions.

View File

@@ -0,0 +1,66 @@
Local Development & Testing
===========================
Backend
~~~~~~~
The backend endpoints lives in the ``edx-platform`` repo, specifically
in this file: ``openedx/core/djangoapps/course_roles/views.py``
For quickly testing the different permissions and the flag change you
can tweak the values directly in the above file.
* ``UserPermissionsView`` is in charge of returning the permissions, so
for sending the permissions you want to check, you could do something
like this:
.. code-block:: python
permissions = {
'user_id': user_id,
'course_key': str(course_key),
#'permissions': sorted(permission.value.name for permission in permissions_set),
'permissions': ['the_permissions_being_tested']
}
return Response(permissions)
By making this change, the permissions object will be bypassed and
send a plain array with the specific permissions being tested.
* ``UserPermissionsFlagView`` is in charge of returning the flag value
(boolean), so you can easily turn the boolean like this:
.. code-block:: python
#payload = {'enabled': use_permission_checks()}
payload = {'enabled': true}
return Response(payload)
Flags
~~~~~
Youll need at least 2 flags to start:
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
All flags for enabling pages in the Studio MFE are listed
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
Flags can be added by:
^^^^^^^^^^^^^^^^^^^^^^
* Enter to ``http://localhost:18000/admin/``.
* Log in as an admin.
* Go to ``http://localhost:18000/admin/waffle/flag/``.
* Click on ``+ADD FLAG`` button at the top right of the page and add
the flag you need.
Testing
~~~~~~~
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
Or you can test one file at a time: ``npm run test path-to-file``.

View File

@@ -0,0 +1,62 @@
Permissions Check implementation
================================
For the permissions checks we basically hit 2 endpoints from the
``edx-platform`` repo:
* **Permissions**:
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
Which will return this structure:
.. code-block:: js
permissions = {
'user_id': [user_id],
'course_key': [course_key],
'permissions': ['permission_1', 'permission_2']
}
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
this method from any page and pass the permission we want to check for the current logged in user.
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
permission:
1. First, we import the hook (line 1).
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
3. Finally we use the const for showing or hiding a button (line 8).
.. code-block:: js
1. import { useUserPermissions } from '../generic/hooks';
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
3. <SubHeader
4. title={intl.formatMessage(messages.headingTitle)}
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
6. headerActions={(
7. isAllowActions ||
8. hasManageAllUsersPerm
9. ) && (
10. <Button
11. variant="primary"
12. iconBefore={IconAdd}
13. size="sm"
14. onClick={openForm}
15. >
16. {intl.formatMessage(messages.addNewMemberButton)}
17. </Button>
18. )}
19. />

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -2,16 +2,17 @@ const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'jest-expect-message',
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
],
snapshotSerializers: [
'enzyme-to-json/serializer',
],
moduleNameMapper: {
'^lodash-es$': 'lodash',
},
modulePathIgnorePatterns: [
'/src/pages-and-resources/utils.test.jsx',
],
});

16895
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,15 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"i18n_extract": "fedx-scripts formatjs extract",
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
"start:with-theme": "paragon install-theme && npm start && npm install",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
"husky": {
"hooks": {
@@ -34,57 +36,70 @@
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-enterprise-hotjar": "^1.2.1",
"@edx/frontend-lib-content-components": "^1.169.3",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "^20.45.4",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",
"@fortawesome/free-solid-svg-icons": "5.11.2",
"@fortawesome/react-fontawesome": "0.1.9",
"@reduxjs/toolkit": "1.5.0",
"@dnd-kit/sortable": "^8.0.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-ai-translations": "^1.4.0",
"@edx/frontend-component-footer": "^12.3.0",
"@edx/frontend-component-header": "^4.7.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-lib-content-components": "^1.178.2",
"@edx/frontend-platform": "5.6.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/paragon": "^21.5.6",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"moment": "2.29.2",
"moment": "2.29.4",
"prop-types": "15.7.2",
"react": "16.14.0",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "16.14.0",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-ranger": "^2.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.1",
"react-transition-group": "4.4.5",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "12.8.6",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.5",
"@edx/react-unit-test-utils": "^1.7.0",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "^2.3.0",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",
"@edx/typescript-config": "^1.0.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"axios-mock-adapter": "1.20.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "^3.6.2",
"glob": "7.1.6",
"husky": "3.1.0",
"react-test-renderer": "16.9.0",
"reactifex": "1.1.1"
"axios-mock-adapter": "1.22.0",
"glob": "7.2.3",
"husky": "^7.0.4",
"jest-canvas-mock": "^2.5.2",
"jest-expect-message": "^1.1.3",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"ts-loader": "^9.5.0"
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
}
}

View File

@@ -4,7 +4,7 @@
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,19 +1,33 @@
{
"extends": [
"config:base",
"schedule:daily",
"schedule:weekly",
":rebaseStalePrs",
":semanticCommits"
":semanticCommits",
":dependencyDashboard"
],
"timezone": "America/New_York",
"patch": {
"automerge": true
"automerge": false
},
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"extends": [
"schedule:daily"
],
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
"automerge": false
},
{
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false,
"schedule": [
"after 1am",
"before 11pm"
]
}
]
}

View File

@@ -5,14 +5,17 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { Footer } from '@edx/frontend-lib-content-components';
import Header from './studio-header/Header';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
import { getUserPermissions } from './generic/data/selectors';
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
@@ -37,26 +40,16 @@ AppHeader.defaultProps = {
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
supportEmail={process.env.SUPPORT_EMAIL}
platformName={process.env.SITE_NAME}
lmsBaseUrl={process.env.LMS_BASE_URL}
studioBaseUrl={process.env.STUDIO_BASE_URL}
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
/>
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
const userPermissions = useSelector(getUserPermissions);
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchUserPermissionsEnabledFlag());
if (!userPermissions) {
dispatch(fetchUserPermissionsQuery(courseId));
}
}, [courseId]);
const courseDetail = useModel('courseDetails', courseId);
@@ -65,10 +58,16 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const showHeader = !pathname.includes('/editor');
const isEditor = pathname.includes('/editor');
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
return (
<NotFoundAlert />
);
}
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
@@ -80,8 +79,8 @@ const CourseAuthoringPage = ({ courseId, children }) => {
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? showHeader && <Loading />
: (showHeader && (
{inProgress ? !isEditor && <Loading />
: (!isEditor && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
@@ -91,7 +90,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && showHeader && <AppFooter />}
{!inProgress && !isEditor && <StudioFooter />}
</div>
);
};

View File

@@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -24,6 +25,19 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('Editor Pages Load no header', () => {
const mockStoreSuccess = async () => {
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
@@ -33,18 +47,6 @@ describe('Editor Pages Load no header', () => {
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('renders no loading wheel on editor pages', async () => {
mockPathname = '/editor/';
await mockStoreSuccess();
@@ -76,3 +78,56 @@ describe('Editor Pages Load no header', () => {
expect(wrapper.queryByRole('status')).toBeInTheDocument();
});
});
describe('Course authoring page', () => {
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
const mockStoreNotFound = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(404, {
response: { status: 404 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
const mockStoreError = async () => {
axiosMock.onGet(
`${courseDetailApiUrl}/${courseId}?username=abc123`,
).reply(500, {
response: { status: 500 },
});
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId} />
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
await mockStoreError();
// Currently, loading errors are not handled, so we wait for the child
// content to be rendered -which happens when request status is no longer
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
});
});

View File

@@ -1,20 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Switch, useRouteMatch } from 'react-router';
import { PageRoute } from '@edx/frontend-platform/react';
import Placeholder from '@edx/frontend-lib-content-components';
import {
Navigate, Routes, Route, useParams,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { PageWrap } from '@edx/frontend-platform/react';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import FilesAndUploads from './files-and-uploads';
import { FilesPage, VideosPage } from './files-and-videos';
import { AdvancedSettings } from './advanced-settings';
import { CourseOutline } from './course-outline';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -32,91 +37,81 @@ import { CourseUpdates } from './course-updates';
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = ({ courseId }) => {
const { path } = useRouteMatch();
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute path={`${path}/outline`}>
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/course_info`}>
<CourseUpdates courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/assets`}>
<FilesAndUploads courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/videos`}>
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/custom-pages`}>
<CustomPages courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/container/:blockId`}>
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<VideoSelectorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/settings/details`}>
<ScheduleAndDetails courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/grading`}>
<GradingSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/course_team`}>
<CourseTeam courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/advanced`}>
<AdvancedSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/import`}>
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/export`}>
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
</Switch>
<Routes>
<Route
path="/"
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
/>
<Route
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
/>
<Route
path="videos"
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
/>
<Route
path="pages-and-resources/*"
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
/>
<Route
path="proctored-exam-settings"
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
/>
<Route
path="custom-pages/*"
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
path={path}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>
))}
<Route
path="editor/course-videos/:blockId"
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="editor/:blockType/:blockId?"
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
/>
<Route
path="settings/details"
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/grading"
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="course_team"
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/advanced"
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
/>
<Route
path="import"
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);
};
CourseAuthoringRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseAuthoringRoutes;

View File

@@ -8,13 +8,19 @@ import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId,
}),
}));
// Mock the TinyMceWidget from frontend-lib-content-components
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
@@ -25,20 +31,10 @@ jest.mock('@edx/frontend-lib-content-components', () => ({
})),
}));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: `/course/${courseId}`,
}),
}));
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
mockComponentFn(props);
return pagesAndResourcesMockText;
});
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
mockComponentFn(props);
return proctoredExamSeetingsMockText;
});
jest.mock('./editors/EditorContainer', () => (props) => {
mockComponentFn(props);
return editorContainerMockText;
@@ -65,39 +61,16 @@ describe('<CourseAuthoringRoutes>', () => {
store = initializeStore();
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
<CourseAuthoringRoutes courseId={courseId} />
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/pages-and-resources']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
@@ -107,9 +80,9 @@ describe('<CourseAuthoringRoutes>', () => {
it('renders the EditorContainer component when the course editor route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/video/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/video/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
@@ -125,9 +98,9 @@ describe('<CourseAuthoringRoutes>', () => {
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/course-videos/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);

View File

@@ -9,6 +9,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
import Placeholder from '@edx/frontend-lib-content-components';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
@@ -23,11 +24,13 @@ import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import { useAdvancedSettings } from './hooks';
import getPageHeadTitle from '../generic/utils';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false);
@@ -38,34 +41,66 @@ const AdvancedSettings = ({ intl, courseId }) => {
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const viewOnly = checkPermission('view_course_settings');
const showPermissionDeniedAlert = userPermissionsEnabled && (
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
);
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
loadingSettingsStatus,
savingStatus,
} = useAdvancedSettings({
dispatch,
courseId,
intl,
setIsQueryPending,
setShowSuccessAlert,
setIsEditableState,
showSaveSettingsPrompt,
showErrorModal,
setErrorFields,
hasInternetConnectionError,
});
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [savingStatus]);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (showPermissionDeniedAlert) {
return (
<PermissionDeniedAlert />
);
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-contnt-center m-6">
<div className="row justify-content-center m-6">
<Placeholder />
</div>
);
@@ -110,7 +145,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
return (
<>
<Container size="xl" className="px-4">
<Container size="xl" className="advanced-settings px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
<AlertProctoringError
@@ -137,11 +172,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
) : null}
</TransitionReplace>
</div>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
@@ -151,6 +181,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<article>
<div>
<section className="setting-items-policies">
@@ -195,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disableForm={viewOnly}
/>
);
})}

View File

@@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import {
render,
fireEvent,
waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
@@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
@@ -43,11 +51,23 @@ const RootWrapper = () => (
</AppProvider>
);
const permissionsMockStore = async (permissions) => {
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
const permissionDisabledMockStore = async () => {
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
};
describe('<AdvancedSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
const permissionsData = { permissions: ['view'] };
await permissionsMockStore(permissionsData);
const { queryByText } = render(<RootWrapper />);
await waitFor(() => {
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).toBeInTheDocument();
});
});
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
await permissionDisabledMockStore();
const { queryByText } = render(<RootWrapper />);
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
expect(permissionDeniedAlert).not.toBeInTheDocument();
});
it('should be view only if the permission is set for viewOnly', async () => {
const permissions = { permissions: ['view_course_settings'] };
await permissionsMockStore(permissions);
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByLabelText('Advanced Module List')).toBeDisabled();
});
});
});

View File

@@ -1,62 +0,0 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RequestStatus } from '../data/constants';
import { fetchCourseAppSettings, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import messages from './messages';
/* eslint-disable import/prefer-default-export */
export const useAdvancedSettings = ({
dispatch, courseId, intl, setIsQueryPending, setShowSuccessAlert, setIsEditableState, showSaveSettingsPrompt,
showErrorModal, setErrorFields, hasInternetConnectionError,
}) => {
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [savingStatus]);
return {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
loadingSettingsStatus,
savingStatus,
};
};

View File

@@ -1,23 +1,29 @@
@import "variables";
.setting-items-policies {
.setting-items-deprecated-setting {
float: right;
.advanced-settings {
.help-sidebar {
margin-top: 8.75rem;
}
.setting-items-policies {
.setting-items-deprecated-setting {
float: right;
margin-bottom: 1.75rem;
}
.instructions,
strong {
color: $text-color-base;
font-weight: 400;
}
}
.setting-card {
margin-bottom: 1.75rem;
}
.instructions,
strong {
color: $text-color-base;
font-weight: 400;
}
}
.setting-card {
margin-bottom: 1.75rem;
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
}
}
}
@@ -40,16 +46,11 @@
.form-control {
min-height: 2.75rem;
width: $setting-form-control-width;
}
.pgn__card-section {
max-width: $setting-form-control-width;
flex-grow: 1;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
flex-grow: 1;
}
.pgn__card-status {
@@ -59,8 +60,6 @@
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
display: flex;
flex-direction: revert;
}
}

View File

@@ -1,2 +1 @@
$text-color-base: $gray-700;
$setting-form-control-width: 34.375rem;

View File

@@ -27,6 +27,7 @@ const SettingCard = ({
setIsEditableState,
// injected
intl,
disableForm,
}) => {
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
@@ -58,8 +59,9 @@ const SettingCard = ({
return (
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex">
<Card.Body className="d-flex row m-0 align-items-center">
<Card.Header
className="col-6"
title={(
<ActionRow>
{capitalize(displayName)}
@@ -86,10 +88,11 @@ const SettingCard = ({
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
<ActionRow.Spacer />
</ActionRow>
)}
/>
<Card.Section>
<Card.Section className="col-6 flex-grow-1">
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}
@@ -98,6 +101,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disableForm}
/>
</Form.Group>
</Card.Section>
@@ -133,6 +137,7 @@ SettingCard.propTypes = {
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
disableForm: PropTypes.bool.isRequired,
};
export default injectIntl(SettingCard);

View File

@@ -6,7 +6,7 @@ import {
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import HelpSidebar from '../../generic/help-sidebar';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (

View File

@@ -1,3 +1,11 @@
.text-black {
color: $black;
}
.h-200px {
height: 200px;
}
.mw-300px {
max-width: 300px;
}

View File

@@ -4,8 +4,9 @@ export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p>&nbsp;</p>';
export const STATEFUL_BUTTON_STATES = {
pending: 'pending',
default: 'default',
pending: 'pending',
error: 'error',
};
export const USER_ROLES = {
@@ -19,7 +20,28 @@ export const BADGE_STATES = {
};
export const NOTIFICATION_MESSAGES = {
adding: 'Adding',
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
empty: '',
};
export const DEFAULT_TIME_STAMP = '00:00';
export const COURSE_CREATOR_STATES = {
unrequested: 'unrequested',
pending: 'pending',
granted: 'granted',
denied: 'denied',
disallowedForThisSite: 'disallowed_for_this_site',
};
export const DECODED_ROUTES = {
COURSE_UNIT: [
'/container/:blockId/:sequenceId',
'/container/:blockId',
],
};

View File

@@ -0,0 +1,223 @@
// @ts-check
import React from 'react';
import {
Badge,
Collapsible,
SelectableBox,
Button,
ModalPopup,
useToggle,
SearchField,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { debounce } from 'lodash';
import messages from './messages';
import './ContentTagsCollapsible.scss';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import ContentTagsTree from './ContentTagsTree';
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/**
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
* This includes both applied tags and tags that are available to select
* from a dropdown list.
*
* This component also handles all the logic with selecting/deselecting tags and keeps track of the
* tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
* state of the tags in the dropdown selectors.
*
* The `contentTags` that is passed are consolidated and converted to a tree structure. For example:
*
* FROM:
*
* [
* {
* "value": "DNA Sequencing",
* "lineage": [
* "Science and Research",
* "Genetics Subcategory",
* "DNA Sequencing"
* ]
* },
* {
* "value": "Virology",
* "lineage": [
* "Science and Research",
* "Molecular, Cellular, and Microbiology",
* "Virology"
* ]
* }
* ]
*
* TO:
*
* {
* "Science and Research": {
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* };
*
*
* It also keeps track of newly added tags as they are selected in the dropdown selectors.
* They are store in the same format above, and then merged to one tree that is used as the
* source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
*
* In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
* Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
* Here is an example of what the value of the "Virology" tag would be:
*
* "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
*
* @param {Object} props - The component props.
* @param {string} props.contentId - Id of the content object
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
*/
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
const intl = useIntl();
const { id, name, canTagObject } = taxonomyAndTagsData;
const {
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
const [isOpen, open, close] = useToggle(false);
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
const [searchTerm, setSearchTerm] = React.useState('');
const handleSelectableBoxChange = React.useCallback((e) => {
tagChangeHandler(e.target.value, e.target.checked);
}, []);
const handleSearch = debounce((term) => {
setSearchTerm(term.trim());
}, 500); // Perform search after 500ms
const handleSearchChange = React.useCallback((value) => {
if (value === '') {
// No need to debounce when search term cleared. Clear debounce function
handleSearch.cancel();
setSearchTerm('');
} else {
handleSearch(value);
}
}, []);
const modalPopupOnCloseHandler = React.useCallback((event) => {
close(event);
// Clear search term
setSearchTerm('');
}, []);
return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={id}>
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
</div>
<div className="d-flex taxonomy-tags-selector-menu">
{canTagObject && (
<Button
ref={setAddTagsButtonRef}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
)}
</div>
<ModalPopup
hasArrow
placement="bottom"
positionRef={addTagsButtonRef}
isOpen={isOpen}
onClose={modalPopupOnCloseHandler}
>
<div className="bg-white p-3 shadow">
<SelectableBox.Set
type="checkbox"
name="tags"
columns={1}
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
className="taxonomy-tags-selectable-box-set"
onChange={handleSelectableBoxChange}
value={checkedTags}
>
<SearchField
onSubmit={() => {}}
onChange={handleSearchChange}
className="mb-2"
/>
<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
</SelectableBox.Set>
</div>
</ModalPopup>
</Collapsible>
<div className="d-flex">
<Badge
variant="light"
pill
className={classNames('align-self-start', 'mt-3', {
invisible: contentTagsCount === 0,
})}
>
{contentTagsCount}
</Badge>
</div>
</div>
);
};
ContentTagsCollapsible.propTypes = {
contentId: PropTypes.string.isRequired,
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
canTagObject: PropTypes.bool.isRequired,
}).isRequired,
};
export default ContentTagsCollapsible;

View File

@@ -0,0 +1,29 @@
.taxonomy-tags-collapsible {
flex: 1;
border: none !important;
.collapsible-trigger {
border: none !important;
}
}
.taxonomy-tags-selector-menu {
button {
flex: 1;
}
}
.taxonomy-tags-selector-menu + div {
width: 100%;
}
.taxonomy-tags-selectable-box-set {
grid-auto-rows: unset !important;
grid-gap: unset !important;
overflow-y: scroll;
max-height: 20rem;
}
.pgn__modal-popup__arrow {
visibility: hidden;
}

View File

@@ -0,0 +1,262 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
fireEvent,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: jest.fn(),
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
}));
const data = {
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
taxonomyAndTagsData: {
id: 123,
name: 'Taxonomy 1',
canTagObject: true,
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 1.1',
lineage: ['Tag 1', 'Tag 1.1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
};
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} />
</IntlProvider>
);
ContentTagsCollapsibleComponent.propTypes = ContentTagsCollapsible.propTypes;
describe('<ContentTagsCollapsible />', () => {
beforeAll(() => {
jest.useFakeTimers(); // To account for debounce timer
});
afterAll(() => {
jest.useRealTimers(); // Restore real timers after the tests
});
async function getComponent(updatedData) {
const componentData = (!updatedData ? data : updatedData);
return render(
<ContentTagsCollapsibleComponent
contentId={componentData.contentId}
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
/>,
);
}
function setupTaxonomyMock() {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 2',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12346,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}, {
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12347,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
},
});
}
it('should render taxonomy tags data along content tags number badge', async () => {
const { container, getByText } = await getComponent();
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('badge').length).toBe(1);
expect(getByText('3')).toBeInTheDocument();
});
it('should render new tags as they are checked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// Wait for the dropdown selector for tags to open,
// Tag 3 should only appear there
expect(getByText('Tag 3')).toBeInTheDocument();
expect(getAllByText('Tag 3').length === 1);
const tag3 = getByText('Tag 3');
fireEvent.click(tag3);
// After clicking on Tag 3, it should also appear in amongst
// the tag bubbles in the tree
expect(getAllByText('Tag 3').length === 2);
});
it('should remove tag when they are unchecked in the dropdown', async () => {
setupTaxonomyMock();
const { container, getByText, getAllByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Check that Tag 2 appears in tag bubbles
expect(getByText('Tag 2')).toBeInTheDocument();
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// Wait for the dropdown selector for tags to open,
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
expect(getByText('Tag 3')).toBeInTheDocument();
// Get the Tag 2 checkbox and click on it
const tag2 = getAllByText('Tag 2')[1];
fireEvent.click(tag2);
// After clicking on Tag 2, it should be removed from
// the tag bubbles in so only the one in the dropdown appears
expect(getAllByText('Tag 2').length === 1);
});
it('should handle search term change', async () => {
const {
container, getByText, getByRole, getByDisplayValue,
} = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add tags" button to open dropdown
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// Get the search field
const searchField = getByRole('searchbox');
const searchTerm = 'memo';
// Trigger a change in the search field
userEvent.type(searchField, searchTerm);
await act(async () => {
// Fast-forward time by 500 milliseconds (for the debounce delay)
jest.advanceTimersByTime(500);
});
// Check that the search term has been set
expect(searchField).toHaveValue(searchTerm);
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
// Clear search
userEvent.clear(searchField);
// Check that the search term has been cleared
expect(searchField).toHaveValue('');
});
it('should close dropdown selector when clicking away', async () => {
setupTaxonomyMock();
const { container, getByText, queryByText } = await getComponent();
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
fireEvent.click(expandToggle);
// Click on "Add tags" button to open dropdown
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
fireEvent.click(addTagsButton);
// Wait for the dropdown selector for tags to open, Tag 3 should appear
// since it is not applied
expect(queryByText('Tag 3')).toBeInTheDocument();
// Simulate clicking outside the dropdown remove focus
userEvent.click(document.body);
// Simulate clicking outside the dropdown again to close it
userEvent.click(document.body);
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
// the page
expect(queryByText('Tag 3')).not.toBeInTheDocument();
});
it('should render taxonomy tags data without tags number badge', async () => {
const updatedData = { ...data };
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };
updatedData.taxonomyAndTagsData.contentTags = [];
const { container, getByText } = await getComponent(updatedData);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('invisible').length).toBe(1);
});
});

View File

@@ -0,0 +1,214 @@
// @ts-check
import React from 'react';
import { useCheckboxSetValues } from '@edx/paragon';
import { cloneDeep } from 'lodash';
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
/**
* Util function that consolidates two tag trees into one, sorting the keys in
* alphabetical order.
*
* @param {object} tree1 - first tag tree
* @param {object} tree2 - second tag tree
* @returns {object} merged tree containing both tree1 and tree2
*/
const mergeTrees = (tree1, tree2) => {
const mergedTree = cloneDeep(tree1);
const sortKeysAlphabetically = (obj) => {
const sortedObj = {};
Object.keys(obj)
.sort()
.forEach((key) => {
sortedObj[key] = obj[key];
if (obj[key] && typeof obj[key] === 'object') {
sortedObj[key].children = sortKeysAlphabetically(obj[key].children);
}
});
return sortedObj;
};
const mergeRecursively = (destination, source) => {
Object.entries(source).forEach(([key, sourceValue]) => {
const destinationValue = destination[key];
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
mergeRecursively(destinationValue, sourceValue);
} else {
// eslint-disable-next-line no-param-reassign
destination[key] = cloneDeep(sourceValue);
}
});
};
mergeRecursively(mergedTree, tree2);
return sortKeysAlphabetically(mergedTree);
};
/**
* Util function that removes the tag along with its ancestors if it was
* the only explicit child tag.
*
* @param {object} tree - tag tree to remove the tag from
* @param {string[]} tagsToRemove - full lineage of tag to remove.
* eg: ['grand parent', 'parent', 'tag']
*/
const removeTags = (tree, tagsToRemove) => {
if (!tree || !tagsToRemove.length) {
return;
}
const key = tagsToRemove[0];
if (tree[key]) {
removeTags(tree[key].children, tagsToRemove.slice(1));
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
// eslint-disable-next-line no-param-reassign
delete tree[key];
}
}
};
/*
* Handles all the underlying logic for the ContentTagsCollapsible component
*/
const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
const {
id, contentTags, canTagObject,
} = taxonomyAndTagsData;
// State to determine whether the tags are being updating so we can make a call
// to the update endpoint to the reflect those changes
const [updatingTags, setUpdatingTags] = React.useState(false);
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
// Keeps track of the content objects tags count (both implicit and explicit)
const [contentTagsCount, setContentTagsCount] = React.useState(0);
// Keeps track of the tree structure for tags that are add by selecting/unselecting
// tags in the dropdowns.
const [addedContentTags, setAddedContentTags] = React.useState({});
// To handle checking/unchecking tags in the SelectableBox
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
// Handles making requests to the update endpoint whenever the checked tags change
React.useEffect(() => {
// We have this check because this hook is fired when the component first loads
// and reloads (on refocus). We only want to make a request to the update endpoint when
// the user is updating the tags.
if (updatingTags) {
setUpdatingTags(false);
const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1)));
updateTags.mutate({ tags });
}
}, [contentId, id, canTagObject, checkedTags]);
// This converts the contentTags prop to the tree structure mentioned above
const appliedContentTags = React.useMemo(() => {
let contentTagsCounter = 0;
// Clear all the tags that have not been commited and the checked boxes when
// fresh contentTags passed in so the latest state from the backend is rendered
setAddedContentTags({});
clear();
// When an error occurs while updating, the contentTags query is invalidated,
// hence they will be recalculated, and the updateTags mutation should be reset.
if (updateTags.isError) {
updateTags.reset();
}
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
item.lineage.forEach((key, index) => {
if (!currentLevel[key]) {
const isExplicit = index === item.lineage.length - 1;
currentLevel[key] = {
explicit: isExplicit,
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
};
// Populating the SelectableBox with "selected" (explicit) tags
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value) : remove(value);
contentTagsCounter += 1;
}
currentLevel = currentLevel[key].children;
});
});
setContentTagsCount(contentTagsCounter);
return resultTree;
}, [contentTags, updateTags.isError]);
// This is the source of truth that represents the current state of tags in
// this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in
// the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by
// selecting/unselecting them in the dropdown) change, the tree is recomputed.
const tagsTree = React.useMemo(() => (
mergeTrees(appliedContentTags, addedContentTags)
), [appliedContentTags, addedContentTags]);
// Add tag to the tree, and while traversing remove any selected ancestor tags
// as they should become implicit
const addTags = (tree, tagLineage, selectedTag) => {
const value = [];
let traversal = tree;
tagLineage.forEach(tag => {
const isExplicit = selectedTag === tag;
if (!traversal[tag]) {
traversal[tag] = {
explicit: isExplicit,
children: {},
canChangeObjecttag: false,
canDeleteObjecttag: false,
};
} else {
traversal[tag].explicit = isExplicit;
}
// Clear out the ancestor tags leading to newly selected tag
// as they automatically become implicit
value.push(encodeURIComponent(tag));
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});
};
const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => {
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
const selectedTag = tagLineage.slice(-1)[0];
const addedTree = { ...addedContentTags };
if (checked) {
// We "add" the tag to the SelectableBox.Set inside the addTags method
addTags(addedTree, tagLineage, selectedTag);
} else {
// Remove tag from the SelectableBox.Set
remove(tagSelectableBoxValue);
// We remove them from both incase we are unselecting from an
// existing applied Tag or a newly added one
removeTags(addedTree, tagLineage);
removeTags(appliedContentTags, tagLineage);
}
setAddedContentTags(addedTree);
setUpdatingTags(true);
}, []);
return {
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
};
};
export default useContentTagsCollapsibleHelper;

View File

@@ -0,0 +1,119 @@
// @ts-check
import React, { useMemo, useEffect } from 'react';
import {
Container,
CloseButton,
Spinner,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import { extractOrgFromContentId } from './utils';
import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
const ContentTagsDrawer = () => {
const intl = useIntl();
const { contentId } = /** @type {{contentId: string}} */(useParams());
const org = extractOrgFromContentId(contentId);
const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId);
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
const closeContentTagsDrawer = () => {
// "*" allows communication with any origin
window.parent.postMessage('closeManageTagsDrawer', '*');
};
useEffect(() => {
const handleEsc = (event) => {
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
if (event.key === 'Escape' && !selectableBoxOpen) {
closeContentTagsDrawer();
}
};
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('keydown', handleEsc);
};
}, []);
const taxonomies = useMemo(() => {
if (taxonomyListData && contentTaxonomyTagsData) {
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
...taxonomy,
contentTags: /** @type {ContentTagData[]} */([]),
}));
const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
// eslint-disable-next-line array-callback-return
contentTaxonomies.map((contentTaxonomyTags) => {
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
if (contentTaxonomy) {
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
}
});
return taxonomiesList;
}
return [];
}, [taxonomyListData, contentTaxonomyTagsData]);
return (
<div className="mt-1">
<Container size="xl">
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentData.displayName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
? taxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} />
<hr />
</div>
))
: <Loading />}
</Container>
</div>
);
};
export default ContentTagsDrawer;

View File

@@ -0,0 +1,190 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render, fireEvent } from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
}),
}));
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
})),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDrawer />
</IntlProvider>
);
describe('<ContentTagsDrawer />', () => {
it('should render page and page title correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Manage tags')).toBeInTheDocument();
});
it('shows spinner before the content data query is complete', async () => {
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
it('shows spinner before the taxonomy tags query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
it('shows the content display name after the query is complete', async () => {
useContentData.mockReturnValue({
isSuccess: true,
data: {
displayName: 'Unit 1',
},
});
await act(async () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Unit 1')).toBeInTheDocument();
});
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
taxonomies: [
{
name: 'Taxonomy 1',
taxonomyId: 123,
canTagObject: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
{
name: 'Taxonomy 2',
taxonomyId: 124,
canTagObject: true,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
canDeleteObjecttag: true,
},
],
},
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: false,
}, {
id: 124,
name: 'Taxonomy 2',
description: 'This is a description 2',
canTagObject: false,
}],
});
await act(async () => {
const { container, getByText } = render(<RootWrapper />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('badge');
expect(tagCountBadges[0].textContent).toBe('2');
expect(tagCountBadges[1].textContent).toBe('1');
});
});
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { getByTestId } = render(<RootWrapper />);
// Find the CloseButton element by its test ID and trigger a click event
const closeButton = getByTestId('drawer-close-button');
fireEvent.click(closeButton);
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
postMessageSpy.mockRestore();
});
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
fireEvent.keyDown(container, {
key: 'Escape',
});
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
postMessageSpy.mockRestore();
});
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
// Simulate that the selectable box is open by adding an element with the data attribute
const selectableBox = document.createElement('div');
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
document.body.appendChild(selectableBox);
fireEvent.keyDown(container, {
key: 'Escape',
});
expect(postMessageSpy).not.toHaveBeenCalled();
// Remove the added element
document.body.removeChild(selectableBox);
postMessageSpy.mockRestore();
});
});

View File

@@ -0,0 +1,209 @@
// @ts-check
import React, { useEffect, useState, useCallback } from 'react';
import {
SelectableBox,
Icon,
Spinner,
Button,
} from '@edx/paragon';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import './ContentTagsDropDownSelector.scss';
import { useTaxonomyTagsData } from './data/apiHooks';
const HighlightedText = ({ text, highlight }) => {
if (!highlight) {
return <span>{text}</span>;
}
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, index) => (
// eslint-disable-next-line react/no-array-index-key -- using index because part is not unique
<React.Fragment key={index}>
{part.toLowerCase() === highlight.toLowerCase() ? <b>{part}</b> : part}
</React.Fragment>
))}
</span>
);
};
HighlightedText.propTypes = {
text: PropTypes.string.isRequired,
highlight: PropTypes.string,
};
HighlightedText.defaultProps = {
highlight: '',
};
const ContentTagsDropDownSelector = ({
taxonomyId, level, lineage, tagsTree, searchTerm,
}) => {
const intl = useIntl();
// This object represents the states of the dropdowns on this level
// The keys represent the index of the dropdown with
// the value true (open) false (closed)
const [dropdownStates, setDropdownStates] = useState(/** type Record<string, boolean> */ {});
const isOpen = (tagValue) => dropdownStates[tagValue];
const [numPages, setNumPages] = useState(1);
const parentTagValue = lineage.length ? decodeURIComponent(lineage[lineage.length - 1]) : null;
const { hasMorePages, tagPages } = useTaxonomyTagsData(taxonomyId, parentTagValue, numPages, searchTerm);
const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);
// Reset the page and tags state when search term changes
// and store search term to compare
if (prevSearchTerm !== searchTerm) {
setPrevSearchTerm(searchTerm);
setNumPages(1);
}
useEffect(() => {
if (tagPages.isSuccess) {
if (searchTerm) {
const expandAll = tagPages.data.reduce(
(acc, tagData) => ({
...acc,
[tagData.value]: !!tagData.childCount,
}),
{},
);
setDropdownStates(expandAll);
} else {
setDropdownStates({});
}
}
}, [searchTerm, tagPages.isSuccess]);
const clickAndEnterHandler = (tagValue) => {
// This flips the state of the dropdown at index false (closed) -> true (open)
// and vice versa. Initially they are undefined which is falsy.
setDropdownStates({ ...dropdownStates, [tagValue]: !dropdownStates[tagValue] });
};
const isImplicit = (tag) => {
// Traverse the tags tree using the lineage
let traversal = tagsTree;
lineage.forEach(t => {
traversal = traversal[t]?.children || {};
});
return (traversal[tag.value] && !traversal[tag.value].explicit) || false;
};
const loadMoreTags = useCallback(() => {
setNumPages((x) => x + 1);
}, []);
return (
<div style={{ marginLeft: `${level * 1 }rem` }}>
{tagPages.isLoading ? (
<div className="d-flex justify-content-center align-items-center flex-row">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
) : null }
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPages.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
>
<div className="d-flex">
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
data-selectable-box="taxonomy-tags"
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
isIndeterminate={isImplicit(tagData)}
disabled={isImplicit(tagData)}
>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
{ tagData.childCount > 0
&& (
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
/>
</div>
)}
</div>
</div>
{ tagData.childCount > 0 && isOpen(tagData.value) && (
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level + 1}
lineage={[...lineage, tagData.value]}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
)}
</React.Fragment>
))}
{ hasMorePages
? (
<div className="d-flex justify-content-center align-items-center flex-row">
<Button
variant="outline-primary"
onClick={loadMoreTags}
className="mb-2 taxonomy-tags-load-more-button"
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>
</div>
)
: null}
{ tagPages.data.length === 0 && !tagPages.isLoading && (
<div className="d-flex justify-content-center muted-text">
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
</div>
)}
</div>
);
};
ContentTagsDropDownSelector.defaultProps = {
lineage: [],
searchTerm: '',
};
ContentTagsDropDownSelector.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
lineage: PropTypes.arrayOf(PropTypes.string),
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
searchTerm: PropTypes.string,
};
export default ContentTagsDropDownSelector;

View File

@@ -0,0 +1,21 @@
.taxonomy-tags-arrow-drop-down {
cursor: pointer;
}
.taxonomy-tags-load-more-button {
flex: 1;
}
.pgn__selectable_box.taxonomy-tags-selectable-box {
box-shadow: none;
padding: 0;
}
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
opacity: 1 !important;
}
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
outline: none !important;
}

View File

@@ -0,0 +1,368 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
waitFor,
fireEvent,
} from '@testing-library/react';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
data: [],
},
})),
}));
const data = {
taxonomyId: 123,
level: 0,
tagsTree: {},
};
const ContentTagsDropDownSelectorComponent = ({
taxonomyId, level, lineage, tagsTree, searchTerm,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level}
lineage={lineage}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
</IntlProvider>
);
ContentTagsDropDownSelectorComponent.defaultProps = {
lineage: [],
searchTerm: '',
};
ContentTagsDropDownSelectorComponent.propTypes = ContentTagsDropDownSelector.propTypes;
describe('<ContentTagsDropDownSelector />', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should render taxonomy tags drop down selector loading with spinner', async () => {
await act(async () => {
const { getByRole } = render(
<ContentTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
}],
},
});
await act(async () => {
const { container, getByText } = render(
<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
});
});
it('should render taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
},
});
await act(async () => {
const { container, getByText } = render(
<ContentTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
});
it('should expand on click taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
},
};
const { container, getByText } = render(
<ContentTagsDropDownSelectorComponent
taxonomyId={dataWithTagsTree.taxonomyId}
level={dataWithTagsTree.level}
tagsTree={dataWithTagsTree.tagsTree}
/>,
);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.click(expandToggle);
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
},
});
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
},
};
const { container, getByText } = render(
<ContentTagsDropDownSelectorComponent
taxonomyId={dataWithTagsTree.taxonomyId}
level={dataWithTagsTree.level}
tagsTree={dataWithTagsTree.tagsTree}
/>,
);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
it('should render taxonomy tags drop down selector and change search term', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
}],
},
});
const initalSearchTerm = 'test 1';
await act(async () => {
const { rerender } = render(
<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={initalSearchTerm}
/>,
);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [],
},
});
const searchTerm = 'uncommon search term';
await act(async () => {
const { getByText } = render(
<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={searchTerm}
/>,
);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,81 @@
// @ts-check
import React from 'react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
/**
* Component that renders Tags under a Taxonomy in the nested tree format.
*
* Example:
*
* {
* "Science and Research": {
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* };
*
* @param {Object} props - The component props.
* @param {Object} props.tagsTree - Array of taxonomy tags that are applied to the content.
* @param {(
* tagSelectableBoxValue: string,
* checked: boolean
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
*/
const ContentTagsTree = ({ tagsTree, removeTagHandler }) => {
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
const updatedLineage = [...lineage, encodeURIComponent(key)];
if (tag[key] !== undefined) {
return (
<div key={`tag-${key}-level-${level}`}>
<TagBubble
key={`tag-${key}`}
value={key}
implicit={!tag[key].explicit}
level={level}
lineage={updatedLineage}
removeTagHandler={removeTagHandler}
canRemove={tag[key].canDeleteObjecttag}
/>
{ renderTagsTree(tag[key].children, level + 1, updatedLineage) }
</div>
);
}
return null;
});
return <>{renderTagsTree(tagsTree, 0, [])}</>;
};
ContentTagsTree.propTypes = {
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
canDeleteObjecttag: PropTypes.bool.isRequired,
}).isRequired,
).isRequired,
removeTagHandler: PropTypes.func.isRequired,
};
export default ContentTagsTree;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import ContentTagsTree from './ContentTagsTree';
const data = {
'Science and Research': {
explicit: false,
canDeleteObjecttag: false,
children: {
'Genetics Subcategory': {
explicit: false,
children: {
'DNA Sequencing': {
explicit: true,
children: {},
canDeleteObjecttag: true,
},
},
canDeleteObjecttag: false,
},
'Molecular, Cellular, and Microbiology': {
explicit: false,
children: {
Virology: {
explicit: true,
children: {},
canDeleteObjecttag: true,
},
},
canDeleteObjecttag: false,
},
},
},
};
const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={removeTagHandler} />
</IntlProvider>
);
ContentTagsTreeComponent.propTypes = ContentTagsTree.propTypes;
describe('<ContentTagsTree />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { getByText } = render(<ContentTagsTreeComponent tagsTree={data} removeTagHandler={() => {}} />);
expect(getByText('Science and Research')).toBeInTheDocument();
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
expect(getByText('DNA Sequencing')).toBeInTheDocument();
expect(getByText('Virology')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import {
Chip,
} from '@edx/paragon';
import { Tag, Close } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import TagOutlineIcon from './TagOutlineIcon';
const TagBubble = ({
value, implicit, level, lineage, removeTagHandler, canRemove,
}) => {
const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
const handleClick = React.useCallback(() => {
if (!implicit && canRemove) {
removeTagHandler(lineage.join(','), false);
}
}, [implicit, lineage, canRemove, removeTagHandler]);
return (
<div style={{ paddingLeft: `${level * 1}rem` }}>
<Chip
className={className}
variant="light"
iconBefore={!implicit ? Tag : TagOutlineIcon}
iconAfter={!implicit && canRemove ? Close : null}
onIconAfterClick={handleClick}
>
{value}
</Chip>
</div>
);
};
TagBubble.defaultProps = {
implicit: true,
level: 0,
canRemove: false,
};
TagBubble.propTypes = {
value: PropTypes.string.isRequired,
implicit: PropTypes.bool,
level: PropTypes.number,
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
removeTagHandler: PropTypes.func.isRequired,
canRemove: PropTypes.bool,
};
export default TagBubble;

View File

@@ -0,0 +1,5 @@
.tag-bubble.pgn__chip {
border-style: solid;
border-width: 2px;
background-color: transparent;
}

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, fireEvent } from '@testing-library/react';
import TagBubble from './TagBubble';
const data = {
value: 'Tag 1',
lineage: [],
removeTagHandler: jest.fn(),
};
const TagBubbleComponent = ({
value, implicit, level, lineage, removeTagHandler, canRemove,
}) => (
<IntlProvider locale="en" messages={{}}>
<TagBubble
value={value}
implicit={implicit}
level={level}
lineage={lineage}
removeTagHandler={removeTagHandler}
canRemove={canRemove}
/>
</IntlProvider>
);
TagBubbleComponent.defaultProps = {
implicit: true,
level: 0,
canRemove: false,
};
TagBubbleComponent.propTypes = TagBubble.propTypes;
describe('<TagBubble />', () => {
it('should render implicit tag', () => {
const { container, getByText } = render(
<TagBubbleComponent
value={data.value}
lineage={data.lineage}
removeTagHandler={data.removeTagHandler}
/>,
);
expect(getByText(data.value)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
});
it('should render explicit tag', () => {
const tagBubbleData = {
implicit: false,
canRemove: true,
...data,
};
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(0);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
});
it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
const tagBubbleData = {
implicit: false,
canRemove: true,
...data,
};
const { container } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
fireEvent.click(xButton);
expect(data.removeTagHandler).toHaveBeenCalled();
});
it('should not show "x" when canRemove is not allowed', async () => {
const tagBubbleData = {
implicit: false,
canRemove: false,
...data,
};
const { container } = render(
<TagBubbleComponent
value={tagBubbleData.value}
canRemove={tagBubbleData.canRemove}
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
expect(container.getElementsByClassName('pgn__chip__icon-after')[0]).toBeUndefined();
});
});

View File

@@ -0,0 +1,20 @@
const TagOutlineIcon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
role="img"
focusable="false"
aria-hidden="true"
{...props}
>
<path
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
/>
<circle cx="6.5" cy="6.5" r="1.5" />
</svg>
);
export default TagOutlineIcon;

View File

@@ -0,0 +1,63 @@
module.exports = {
id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
displayName: 'Unit 1.1.2',
category: 'vertical',
hasChildren: true,
editedOn: 'Nov 12, 2023 at 09:53 UTC',
published: false,
publishedOn: null,
studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
releasedToStudents: false,
releaseDate: null,
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '2030-01-01T00:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Lab',
'Midterm Exam',
'Final Exam',
],
hasChanges: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
staffOnlyMessage: false,
enableCopyPasteUnits: true,
useTaggingTaxonomyListPage: true,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
};

View File

@@ -0,0 +1,50 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
canTagObject: true,
tags: [
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
{
name: 'HierarchicalTaxonomy',
taxonomyId: 4,
canTagObject: true,
tags: [
{
value: 'hierarchical taxonomy tag 1.7.59',
lineage: [
'hierarchical taxonomy tag 1',
'hierarchical taxonomy tag 1.7',
'hierarchical taxonomy tag 1.7.59',
],
},
{
value: 'hierarchical taxonomy tag 2.13.46',
lineage: [
'hierarchical taxonomy tag 2',
'hierarchical taxonomy tag 2.13',
'hierarchical taxonomy tag 2.13.46',
],
},
{
value: 'hierarchical taxonomy tag 3.4.50',
lineage: [
'hierarchical taxonomy tag 3',
'hierarchical taxonomy tag 3.4',
'hierarchical taxonomy tag 3.4.50',
],
},
],
},
],
},
};

View File

@@ -0,0 +1,4 @@
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';

View File

@@ -0,0 +1,46 @@
module.exports = {
next: null,
previous: null,
count: 4,
numPages: 1,
currentPage: 1,
start: 0,
results: [
{
value: 'tag 1',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 635951,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
},
{
value: 'tag 2',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 636992,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
},
{
value: 'tag 3',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 638033,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
},
{
value: 'tag 4',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 639074,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
},
],
};

View File

@@ -0,0 +1,25 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
canTagObject: true,
tags: [
{
value: 'flat taxonomy tag 100',
lineage: [
'flat taxonomy tag 100',
],
},
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
],
},
};

View File

@@ -0,0 +1,82 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
/**
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
* @param {number} taxonomyId
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {string} the URL
*/
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
if (options.parentTag) {
url.searchParams.append('parent_tag', options.parentTag);
}
if (options.page) {
url.searchParams.append('page', String(options.page));
}
if (options.searchTerm) {
url.searchParams.append('search_term', options.searchTerm);
}
// Load in the full tree if children at once, if we can:
// Note: do not combine this with page_size (we currently aren't using page_size)
url.searchParams.append('full_depth_threshold', '1000');
return url.href;
};
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
/**
* Get all tags that belong to taxonomy.
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
*/
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
return camelCaseObject(data[contentId]);
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<import("./types.mjs").ContentData>}
*/
export async function getContentData(contentId) {
const url = contentId.startsWith('lb:')
? getLibraryContentDataApiUrl(contentId)
: getXBlockContentDataApiURL(contentId);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {number} taxonomyId The id of the taxonomy the tags belong to
* @param {string[]} tags The list of tags (values) to set on content object
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
const url = getContentTaxonomyTagsApiUrl(contentId);
const params = { taxonomy: taxonomyId };
const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params });
return camelCaseObject(data[contentId]);
}

View File

@@ -0,0 +1,119 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
import {
getTaxonomyTagsApiUrl,
getContentTaxonomyTagsApiUrl,
getXBlockContentDataApiURL,
getLibraryContentDataApiUrl,
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
} from './api';
let axiosMock;
describe('content tags drawer api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should get taxonomy tags data', async () => {
const taxonomyId = 123;
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyTagsApiUrl(taxonomyId));
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with parentTag', async () => {
const taxonomyId = 123;
const options = { parentTag: 'Sample Tag' };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('parent_tag=Sample+Tag');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with page', async () => {
const taxonomyId = 123;
const options = { page: 2 };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('page=2');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with searchTerm', async () => {
const taxonomyId = 123;
const options = { searchTerm: 'memo' };
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, options);
expect(axiosMock.history.get[0].url).toContain('search_term=memo');
expect(result).toEqual(taxonomyTagsMock);
});
it('should get content taxonomy tags data', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsApiUrl(contentId)).reply(200, contentTaxonomyTagsMock);
const result = await getContentTaxonomyTagsData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsApiUrl(contentId));
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
});
it('should get content data for course component', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
const result = await getContentData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId));
expect(result).toEqual(contentDataMock);
});
it('should get content data for V2 library component', async () => {
const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79';
axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock);
const result = await getContentData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId));
expect(result).toEqual(contentDataMock);
});
it('should update content taxonomy tags', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
const taxonomyId = 3;
const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock);
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`);
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
});
});

View File

@@ -0,0 +1,142 @@
// @ts-check
import { useMemo } from 'react';
import {
useQuery,
useQueries,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
} from './api';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
/**
* 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
*/
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
const queryClient = useQueryClient();
const queryFn = async ({ queryKey }) => {
const page = queryKey[3];
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
};
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
const queries = [];
for (let page = 1; page <= numPages; page++) {
queries.push(
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
);
}
const dataPages = useQueries({ queries });
const totalPages = dataPages[0]?.data?.numPages || 1;
const hasMorePages = numPages < totalPages;
const tagPages = useMemo(() => {
// Pre-load desendants if possible
const preLoadedData = new Map();
const newTags = dataPages.map(result => {
/** @type {TagData[]} */
const simplifiedTagsList = [];
result.data?.results?.forEach((tag) => {
if (tag.parentValue === parentTag) {
simplifiedTagsList.push(tag);
} else if (!preLoadedData.has(tag.parentValue)) {
preLoadedData.set(tag.parentValue, [tag]);
} else {
preLoadedData.get(tag.parentValue).push(tag);
}
});
return { ...result, data: simplifiedTagsList };
});
// Store the pre-loaded descendants into the query cache:
preLoadedData.forEach((tags, parentValue) => {
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
/** @type {TagListData} */
const cachedData = {
next: '',
previous: '',
count: tags.length,
numPages: 1,
currentPage: 1,
start: 0,
results: tags,
};
queryClient.setQueryData(queryKey, cachedData);
});
return newTags;
}, [dataPages]);
const flatTagPages = {
isLoading: tagPages.some(page => page.isLoading),
isError: tagPages.some(page => page.isError),
isSuccess: tagPages.every(page => page.isSuccess),
data: tagPages.flatMap(page => page.data),
};
return { hasMorePages, tagPages: flatTagPages };
};
/**
* 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)
*/
export const useContentTaxonomyTagsData = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId),
})
);
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
*/
export const useContentData = (contentId) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
})
);
/**
* 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 {number} taxonomyId The id of the taxonomy the tags belong to
*/
export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* any,
* any,
* {
* tags: string[]
* }
* >}
*/
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
},
});
};

View File

@@ -0,0 +1,175 @@
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import {
useTaxonomyTagsData,
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(() => ({
setQueryData: jest.fn(),
})),
useQueries: jest.fn(),
}));
jest.mock('./api', () => ({
updateContentTaxonomyTags: jest.fn(),
}));
describe('useTaxonomyTagsData', () => {
it('should call useQueries with the correct arguments', () => {
const taxonomyId = 123;
const mockData = {
results: [
{
value: 'tag 1',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 635951,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
},
{
value: 'tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 636992,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
},
{
value: 'tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'tag 2',
id: 636993,
subTagsUrl: null,
},
{
value: 'tag 4',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'tag 2',
id: 636994,
subTagsUrl: null,
},
],
};
useQueries.mockReturnValue([{
data: mockData,
isLoading: false,
isError: false,
isSuccess: true,
}]);
const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId));
// Assert that useQueries was called with the correct arguments
expect(useQueries).toHaveBeenCalledWith({
queries: [
{ queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
],
});
expect(result.current.hasMorePages).toEqual(false);
// Only includes the first 2 tags because the other 2 would be
// in the nested dropdown
expect(result.current.tagPages).toEqual(
{
isLoading: false,
isError: false,
isSuccess: true,
data: [
{
value: 'tag 1',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 635951,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
},
{
value: 'tag 2',
externalId: null,
childCount: 1,
depth: 0,
parentValue: null,
id: 636992,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
},
],
},
);
});
});
describe('useContentTaxonomyTagsData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsData(contentId);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsData(contentId);
expect(result).toEqual({ isSuccess: false });
});
});
describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentData(contentId);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentData(contentId);
expect(result).toEqual({ isSuccess: false });
});
});
describe('useContentTaxonomyTagsUpdater', () => {
it('should call the update content taxonomy tags function', async () => {
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
const contentId = 'testerContent';
const taxonomyId = 123;
const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId);
mutation.mutate({ tags: ['tag1', 'tag2'] });
expect(useMutation).toBeCalled();
const [config] = useMutation.mock.calls[0];
const { mutationFn } = config;
await act(async () => {
const tags = ['tag1', 'tag2'];
await mutationFn({ tags });
expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags);
});
});
});

View File

@@ -0,0 +1,60 @@
// @ts-check
/**
* @typedef {Object} Tag A tag that has been applied to some content.
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
* @property {boolean} canChangeObjecttag
* @property {boolean} canDeleteObjecttag
*/
/**
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
* @property {string} name
* @property {number} taxonomyId
* @property {boolean} canTagObject
* @property {Tag[]} tags
*/
/**
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
* @property {ContentTaxonomyTagData[]} taxonomies
*/
/**
* @typedef {Object} ContentActions
* @property {boolean} deleteable
* @property {boolean} draggable
* @property {boolean} childAddable
* @property {boolean} duplicable
*/
/**
* @typedef {Object} ContentData
* @property {string} id
* @property {string} displayName
* @property {string} category
* @property {boolean} hasChildren
* @property {string} editedOn
* @property {boolean} published
* @property {string} publishedOn
* @property {string} studioUrl
* @property {boolean} releasedToStudents
* @property {string|null} releaseDate
* @property {string} visibilityState
* @property {boolean} hasExplicitStaffLock
* @property {string} start
* @property {boolean} graded
* @property {string} dueDate
* @property {string} due
* @property {string|null} relativeWeeksDue
* @property {string|null} format
* @property {boolean} hasChanges
* @property {ContentActions} actions
* @property {string} explanatoryMessage
* @property {string} showCorrectness
* @property {boolean} discussionEnabled
* @property {boolean} ancestorHasStaffLock
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
*/

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawer } from './ContentTagsDrawer';

View File

@@ -0,0 +1,38 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headerSubtitle: {
id: 'course-authoring.content-tags-drawer.header.subtitle',
defaultMessage: 'Manage tags',
},
addTagsButtonText: {
id: 'course-authoring.content-tags-drawer.collapsible.add-tags.button',
defaultMessage: 'Add tags',
},
loadingMessage: {
id: 'course-authoring.content-tags-drawer.spinner.loading',
defaultMessage: 'Loading',
},
loadingTagsDropdownMessage: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
defaultMessage: 'Loading tags',
},
loadMoreTagsButtonText: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
defaultMessage: 'Load more',
},
noTagsFoundMessage: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"',
},
taxonomyTagsCheckboxAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
defaultMessage: '{tag} checkbox',
},
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',
},
});
export default messages;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];

View File

@@ -0,0 +1,511 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Container,
Layout,
Row,
TransitionReplace,
} from '@edx/paragon';
import { Helmet } from 'react-helmet';
import {
Add as IconAdd,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
} from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import { DraggableList } from '@edx/frontend-lib-content-components';
import { arrayMove } from '@dnd-kit/sortable';
import { LoadingSpinner } from '../generic/Loading';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import AlertMessage from '../generic/alert-message';
import getPageHeadTitle from '../generic/utils';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
import StatusBar from './status-bar/StatusBar';
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
import SectionCard from './section-card/SectionCard';
import SubsectionCard from './subsection-card/SubsectionCard';
import UnitCard from './unit-card/UnitCard';
import HighlightsModal from './highlights-modal/HighlightsModal';
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
import PublishModal from './publish-modal/PublishModal';
import ConfigureModal from './configure-modal/ConfigureModal';
import DeleteModal from './delete-modal/DeleteModal';
import PageAlerts from './page-alerts/PageAlerts';
import { useCourseOutline } from './hooks';
import messages from './messages';
import { useUserPermissions } from '../generic/hooks';
import { getUserPermissionsEnabled } from '../generic/data/selectors';
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
const CourseOutline = ({ courseId }) => {
const intl = useIntl();
const {
courseName,
savingStatus,
statusBarData,
courseActions,
sectionsList,
isCustomRelativeDatesActive,
isLoading,
isReIndexShow,
showErrorAlert,
showSuccessAlert,
isSectionsExpanded,
isEnableHighlightsModalOpen,
isInternetConnectionAlertFailed,
isDisabledReindexButton,
isHighlightsModalOpen,
isPublishModalOpen,
isConfigureModalOpen,
isDeleteModalOpen,
closeHighlightsModal,
closePublishModal,
handleConfigureModalClose,
closeDeleteModal,
openPublishModal,
openConfigureModal,
openDeleteModal,
headerNavigationsActions,
openEnableHighlightsModal,
closeEnableHighlightsModal,
handleEnableHighlightsSubmit,
handleInternetConnectionFailed,
handleOpenHighlightsModal,
handleHighlightsFormSubmit,
handleConfigureItemSubmit,
handlePublishItemSubmit,
handleEditSubmit,
handleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
getUnitUrl,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
handleCopyToClipboardClick,
handlePasteClipboardClick,
notificationDismissUrl,
discussionsSettings,
discussionsIncontextFeedbackUrl,
discussionsIncontextLearnmoreUrl,
deprecatedBlocksInfo,
proctoringErrors,
mfeProctoredExamSettingsUrl,
handleDismissNotification,
advanceSettingsUrl,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
const { checkPermission } = useUserPermissions();
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
const hasOutlinePermissions = !userPermissionsEnabled || (
userPermissionsEnabled && (checkPermission('manage_libraries') || checkPermission('manage_content'))
);
let initialSections = [...sectionsList];
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
} = useSelector(getProcessingNotification);
const finalizeSectionOrder = () => (newSections) => {
initialSections = [...sectionsList];
handleSectionDragAndDrop(newSections.map(section => section.id), () => {
setSections(() => initialSections);
});
};
const setSubsection = (index) => (updatedSubsection) => {
const section = { ...sections[index] };
section.childInfo = { ...section.childInfo };
section.childInfo.children = updatedSubsection();
setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)]);
};
const finalizeSubsectionOrder = (section) => () => (newSubsections) => {
initialSections = [...sectionsList];
handleSubsectionDragAndDrop(section.id, newSubsections.map(subsection => subsection.id), () => {
setSections(() => initialSections);
});
};
const setUnit = (sectionIndex, subsectionIndex) => (updatedUnits) => {
const section = { ...sections[sectionIndex] };
section.childInfo = { ...section.childInfo };
const subsection = { ...section.childInfo.children[subsectionIndex] };
subsection.childInfo = { ...subsection.childInfo };
subsection.childInfo.children = updatedUnits();
const updatedSubsections = [...section.childInfo.children];
updatedSubsections[subsectionIndex] = subsection;
section.childInfo.children = updatedSubsections;
setSections([...sections.slice(0, sectionIndex), section, ...sections.slice(sectionIndex + 1)]);
};
const finalizeUnitOrder = (section, subsection) => () => (newUnits) => {
initialSections = [...sectionsList];
handleUnitDragAndDrop(section.id, subsection.id, newUnits.map(unit => unit.id), () => {
setSections(() => initialSections);
});
};
/**
* Check if item can be moved by given step.
* Inner function returns false if the new index after moving by given step
* is out of bounds of item length.
* If it is within bounds, returns draggable flag of the item in the new index.
* This helps us avoid moving the item to a position of unmovable item.
* @param {Array} items
* @returns {(id, step) => bool}
*/
const canMoveItem = (items) => (id, step) => {
const newId = id + step;
const indexCheck = newId >= 0 && newId < items.length;
if (!indexCheck) {
return false;
}
const newItem = items[newId];
return newItem.actions.draggable;
};
/**
* Move section to new index
* @param {any} currentIndex
* @param {any} newIndex
*/
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
if (currentIndex === newIndex) {
return;
}
setSections((prevSections) => {
const newSections = arrayMove(prevSections, currentIndex, newIndex);
finalizeSectionOrder()(newSections);
return newSections;
});
};
/**
* Returns a function for given section which can move a subsection inside it
* to a new position
* @param {any} sectionIndex
* @param {any} section
* @param {any} subsections
* @returns {(currentIndex, newIndex) => void}
*/
const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => {
if (currentIndex === newIndex) {
return;
}
setSubsection(sectionIndex)(() => {
const newSubsections = arrayMove(subsections, currentIndex, newIndex);
finalizeSubsectionOrder(section)()(newSubsections);
return newSubsections;
});
};
/**
* Returns a function for given section & subsection which can move a unit
* inside it to a new position
* @param {any} sectionIndex
* @param {any} section
* @param {any} subsection
* @param {any} units
* @returns {(currentIndex, newIndex) => void}
*/
const updateUnitOrderByIndex = (
sectionIndex,
subsectionIndex,
section,
subsection,
units,
) => (currentIndex, newIndex) => {
if (currentIndex === newIndex) {
return;
}
setUnit(sectionIndex, subsectionIndex)(() => {
const newUnits = arrayMove(units, currentIndex, newIndex);
finalizeUnitOrder(section, subsection)()(newUnits);
return newUnits;
});
};
useEffect(() => {
setSections(sectionsList);
}, [sectionsList]);
if (!hasOutlinePermissions) {
return (
<PermissionDeniedAlert />
);
}
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
</Row>
);
}
return (
<>
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
<Container size="xl" className="px-4">
<section className="course-outline-container mb-4 mt-5">
<PageAlerts
notificationDismissUrl={notificationDismissUrl}
handleDismissNotification={handleDismissNotification}
discussionsSettings={discussionsSettings}
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
deprecatedBlocksInfo={deprecatedBlocksInfo}
proctoringErrors={proctoringErrors}
mfeProctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
advanceSettingsUrl={advanceSettingsUrl}
savingStatus={savingStatus}
/>
<TransitionReplace>
{showSuccessAlert ? (
<AlertMessage
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
show={showSuccessAlert}
variant="success"
icon={CheckCircleIcon}
title={intl.formatMessage(messages.alertSuccessTitle)}
description={intl.formatMessage(messages.alertSuccessDescription)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
/>
) : null}
</TransitionReplace>
<SubHeader
className="mt-5"
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={(
<HeaderNavigations
isReIndexShow={isReIndexShow}
isSectionsExpanded={isSectionsExpanded}
headerNavigationsActions={headerNavigationsActions}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={Boolean(sectionsList.length)}
courseActions={courseActions}
/>
)}
/>
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 12 }, { span: 12 }]}
xs={[{ span: 12 }, { span: 12 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<div>
<section className="course-outline-section">
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
<div className="pt-4">
{sections.length ? (
<>
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
{sections.map((section, sectionIndex) => (
<SectionCard
id={section.id}
key={section.id}
section={section}
index={sectionIndex}
canMoveItem={canMoveItem(sections)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
>
<DraggableList
itemList={section.childInfo.children}
setState={setSubsection(sectionIndex)}
updateOrder={finalizeSubsectionOrder(section)}
>
{section.childInfo.children.map((subsection, subsectionIndex) => (
<SubsectionCard
key={subsection.id}
section={section}
subsection={subsection}
index={subsectionIndex}
canMoveItem={canMoveItem(section.childInfo.children)}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onOrderChange={updateSubsectionOrderByIndex(
sectionIndex,
section,
section.childInfo.children,
)}
onPasteClick={handlePasteClipboardClick}
>
<DraggableList
itemList={subsection.childInfo.children}
setState={setUnit(sectionIndex, subsectionIndex)}
updateOrder={finalizeUnitOrder(section, subsection)}
>
{subsection.childInfo.children.map((unit, unitIndex) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
section={section}
isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
index={unitIndex}
canMoveItem={canMoveItem(subsection.childInfo.children)}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex(
sectionIndex,
subsectionIndex,
section,
subsection,
subsection.childInfo.children,
)}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
/>
))}
</DraggableList>
</SubsectionCard>
))}
</DraggableList>
</SectionCard>
))}
</DraggableList>
{courseActions.childAddable && (
<Button
data-testid="new-section-button"
className="mt-4"
variant="outline-primary"
onClick={handleNewSectionSubmit}
iconBefore={IconAdd}
block
>
{intl.formatMessage(messages.newSectionButton)}
</Button>
)}
</>
) : (
<EmptyPlaceholder
onCreateNewSection={handleNewSectionSubmit}
childAddable={courseActions.childAddable}
/>
)}
</div>
</section>
</div>
</article>
</Layout.Element>
<Layout.Element>
<OutlineSideBar courseId={courseId} />
</Layout.Element>
</Layout>
<EnableHighlightsModal
isOpen={isEnableHighlightsModalOpen}
close={closeEnableHighlightsModal}
onEnableHighlightsSubmit={handleEnableHighlightsSubmit}
/>
</section>
<HighlightsModal
isOpen={isHighlightsModalOpen}
onClose={closeHighlightsModal}
onSubmit={handleHighlightsFormSubmit}
/>
<PublishModal
isOpen={isPublishModalOpen}
onClose={closePublishModal}
onPublishSubmit={handlePublishItemSubmit}
/>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={handleConfigureModalClose}
onConfigureSubmit={handleConfigureItemSubmit}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteItemSubmit}
/>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<InternetConnectionAlert
isFailed={isInternetConnectionAlertFailed}
isQueryPending={savingStatus === RequestStatus.PENDING}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
{showErrorAlert && (
<AlertMessage
key={intl.formatMessage(messages.alertErrorTitle)}
show={showErrorAlert}
variant="danger"
icon={WarningIcon}
title={intl.formatMessage(messages.alertErrorTitle)}
aria-hidden="true"
/>
)}
</div>
</>
);
};
CourseOutline.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseOutline;

View File

@@ -0,0 +1,13 @@
@import "./header-navigations/HeaderNavigations";
@import "./status-bar/StatusBar";
@import "./section-card/SectionCard";
@import "./subsection-card/SubsectionCard";
@import "./unit-card/UnitCard";
@import "./card-header/CardHeader";
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/ConditionalSortableElement";
@import "./xblock-status/XBlockStatus";
@import "./paste-button/PasteButton";

View File

@@ -0,0 +1,1898 @@
import {
act, render, waitFor, fireEvent, within,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { cloneDeep } from 'lodash';
import {
getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl,
getCourseOutlineIndexApiUrl,
getCourseReindexApiUrl,
getXBlockApiUrl,
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getClipboardUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
import initializeStore from '../store';
import {
courseOutlineIndexMock,
courseOutlineIndexWithoutSections,
courseBestPracticesMock,
courseLaunchMock,
courseSectionMock,
courseSubsectionMock,
} from './__mocks__';
import { executeThunk } from '../utils';
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
import CourseOutline from './CourseOutline';
import messages from './messages';
import headerMessages from './header-navigations/messages';
import cardHeaderMessages from './card-header/messages';
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
import statusBarMessages from './status-bar/messages';
import configureModalMessages from './configure-modal/messages';
import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages';
import pageAlertMessages from './page-alerts/messages';
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const userId = 3;
const userPermissionsData = { permissions: [] };
window.HTMLElement.prototype.scrollIntoView = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
jest.mock('../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
visibility: 'some',
grading: 'some',
outline: 'some',
}),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseOutline courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseOutline />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexMock);
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
axiosMock
.onGet(getUserPermissionsUrl(courseId, userId))
.reply(200, userPermissionsData);
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
});
it('render CourseOutline component correctly', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
});
});
it('should render permissionDenied if incorrect permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
});
it('check reindex and render success alert is correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />);
axiosMock
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
.reply(200);
const reindexButton = await findByTestId('course-reindex');
fireEvent.click(reindexButton);
expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
});
it('check video sharing option udpates correctly', async () => {
const { findByLabelText } = render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
})
.reply(200);
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
await act(
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
}));
});
it('check video sharing option shows error on failure', async () => {
const { findByLabelText, queryByRole } = render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
})
.reply(500);
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
await act(
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
}));
const alertElement = queryByRole('alert');
expect(alertElement).toHaveTextContent(
pageAlertMessages.alertFailedGeneric.defaultMessage,
);
});
it('render error alert after failed reindex correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />);
axiosMock
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
.reply(500);
const reindexButton = await findByTestId('course-reindex');
await act(async () => fireEvent.click(reindexButton));
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
});
it('adds new section correctly', async () => {
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
let elements = await findAllByTestId('section-card');
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
top: 0,
bottom: 4000,
}));
expect(elements.length).toBe(4);
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
locator: courseSectionMock.id,
});
axiosMock
.onGet(getXBlockApiUrl(courseSectionMock.id))
.reply(200, courseSectionMock);
const newSectionButton = await findByTestId('new-section-button');
await act(async () => fireEvent.click(newSectionButton));
elements = await findAllByTestId('section-card');
expect(elements.length).toBe(5);
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
it('adds new subsection correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const [section] = await findAllByTestId('section-card');
let subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(2);
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
top: 0,
bottom: 4000,
}));
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
locator: courseSubsectionMock.id,
});
axiosMock
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
.reply(200, courseSubsectionMock);
const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
await act(async () => {
fireEvent.click(newSubsectionButton);
});
subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(3);
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
it('adds new unit correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const units = await within(subsectionElement).findAllByTestId('unit-card');
expect(units.length).toBe(1);
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
locator: 'some',
});
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
await act(async () => fireEvent.click(newUnitButton));
expect(axiosMock.history.post.length).toBe(1);
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [subsection] = section.childInfo.children;
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
parent_locator: subsection.id,
category: COURSE_BLOCK_NAMES.vertical.id,
display_name: COURSE_BLOCK_NAMES.vertical.name,
}));
});
it('render checklist value correctly', async () => {
const { getByText } = render(<RootWrapper />);
axiosMock
.onGet(getCourseBestPracticesApiUrl({
courseId, excludeGraded: true, all: true,
}))
.reply(200, courseBestPracticesMock);
axiosMock
.onGet(getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
}))
.reply(200, courseLaunchMock);
await executeThunk(fetchCourseLaunchQuery({
courseId, gradedOnly: true, validateOras: true, all: true,
}), store.dispatch);
await executeThunk(fetchCourseBestPracticesQuery({
courseId, excludeGraded: true, all: true,
}), store.dispatch);
expect(getByText('4/9 completed')).toBeInTheDocument();
});
it('check highlights are enabled after enable highlights query is successful', async () => {
const { findByTestId, findByText } = render(<RootWrapper />);
axiosMock.reset();
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
publish: 'republish',
metadata: {
highlights_enabled_for_messaging: true,
},
})
.reply(200);
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, {
...courseOutlineIndexMock,
courseStructure: {
...courseOutlineIndexMock.courseStructure,
highlightsEnabledForMessaging: true,
},
});
const enableButton = await findByTestId('highlights-enable-button');
fireEvent.click(enableButton);
const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage);
await act(async () => fireEvent.click(saveButton));
expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
});
it('should expand and collapse subsections, after click on subheader buttons', async () => {
const { queryAllByTestId, findByText } = render(<RootWrapper />);
const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
expect(collapseBtn).toBeInTheDocument();
fireEvent.click(collapseBtn);
const expandBtn = await findByText(headerMessages.expandAllButton.defaultMessage);
expect(expandBtn).toBeInTheDocument();
fireEvent.click(expandBtn);
await waitFor(() => {
const cardSubsections = queryAllByTestId('section-card__subsections');
cardSubsections.forEach(element => expect(element).toBeVisible());
fireEvent.click(collapseBtn);
cardSubsections.forEach(element => expect(element).not.toBeVisible());
});
});
it('render CourseOutline component without sections correctly', async () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexWithoutSections);
const { getByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
});
});
it('render configuration alerts and check dismiss query', async () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, {
...courseOutlineIndexMock,
notificationDismissUrl: '/some/url',
});
const { findByRole } = render(<RootWrapper />);
expect(await findByRole('alert')).toBeInTheDocument();
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
axiosMock
.onDelete('/some/url')
.reply(204);
fireEvent.click(dismissBtn);
expect(axiosMock.history.delete.length).toBe(1);
});
it('check edit title works for section, subsection and unit', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const checkEditTitle = async (section, element, item, newName, elementName) => {
axiosMock.reset();
axiosMock
.onPost(getCourseItemApiUrl(item.id, {
metadata: {
display_name: newName,
},
}))
.reply(200, { dummy: 'value' });
// mock section, subsection and unit name and check within the elements.
// this is done to avoid adding conditions to this mock.
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
display_name: newName,
childInfo: {
children: [
{
...section.childInfo.children[0],
display_name: newName,
childInfo: {
children: [
{
...section.childInfo.children[0].childInfo.children[0],
display_name: newName,
},
],
},
},
],
},
});
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
fireEvent.click(editButton);
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
fireEvent.change(editField, { target: { value: newName } });
await act(async () => fireEvent.blur(editField));
expect(
axiosMock.history.post[axiosMock.history.post.length - 1].data,
`Failed for ${elementName}!`,
).toBe(JSON.stringify({
metadata: {
display_name: newName,
},
}));
const results = await within(element).findAllByText(newName);
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
};
// check section
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
await checkEditTitle(section, sectionElement, section, 'New section name', 'section');
// check subsection
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
// check unit
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
});
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
// get section, subsection and unit
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
const checkDeleteBtn = async (item, element, elementName) => {
await waitFor(() => {
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
});
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
fireEvent.click(menu);
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
fireEvent.click(deleteButton);
const confirmButton = await findByTestId('delete-confirm-button');
await act(async () => fireEvent.click(confirmButton));
await waitFor(() => {
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
});
};
// delete unit, subsection and then section in order.
// check unit
await checkDeleteBtn(unit, unitElement, 'unit');
// check subsection
await checkDeleteBtn(subsection, subsectionElement, 'subsection');
// check section
await checkDeleteBtn(section, sectionElement, 'section');
});
it('check whether section, subsection and unit is duplicated successfully', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get section, subsection and unit
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => {
// baseline
if (parentElement) {
expect(
await within(parentElement).findAllByTestId(`${elementName}-card`),
`Failed for ${elementName}!`,
).toHaveLength(expectedLength - 1);
} else {
expect(
await findAllByTestId(`${elementName}-card`),
`Failed for ${elementName}!`,
).toHaveLength(expectedLength - 1);
}
const duplicatedItemId = item.id + elementName;
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
locator: duplicatedItemId,
});
if (elementName === 'section') {
section.id = duplicatedItemId;
} else if (elementName === 'subsection') {
section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }];
} else if (elementName === 'unit') {
subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }];
section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)];
}
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
});
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
fireEvent.click(menu);
const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`);
await act(async () => fireEvent.click(duplicateButton));
if (parentElement) {
expect(
await within(parentElement).findAllByTestId(`${elementName}-card`),
`Failed for ${elementName}!`,
).toHaveLength(expectedLength);
} else {
expect(
await findAllByTestId(`${elementName}-card`),
`Failed for ${elementName}!`,
).toHaveLength(expectedLength);
}
};
// duplicate unit, subsection and then section in order.
// check unit
await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2);
// check subsection
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 3);
// check section
await checkDuplicateBtn(section, null, sectionElement, 'section', 5);
});
it('check section, subsection & unit is published when publish button is clicked', async () => {
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
const checkPublishBtn = async (item, element, elementName) => {
expect(
(await within(element).getAllByRole('status'))[0],
`Failed for ${elementName}!`,
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
axiosMock
.onPost(getCourseItemApiUrl(item.id), {
publish: 'make_public',
})
.reply(200, { dummy: 'value' });
let mockReturnValue = {
...section,
childInfo: {
children: [
{
...section.childInfo.children[0],
published: true,
},
...section.childInfo.children.slice(1),
],
},
};
if (elementName === 'unit') {
mockReturnValue = {
...section,
childInfo: {
children: [
{
...section.childInfo.children[0],
childInfo: {
children: [
{
...section.childInfo.children[0].childInfo.children[0],
published: true,
},
...section.childInfo.children[0].childInfo.children.slice(1),
],
},
},
...section.childInfo.children.slice(1),
],
},
};
}
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, mockReturnValue);
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
fireEvent.click(menu);
const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`);
await act(async () => fireEvent.click(publishButton));
const confirmButton = await findByTestId('publish-confirm-button');
await act(async () => fireEvent.click(confirmButton));
expect(
(await within(element).getAllByRole('status'))[0],
`Failed for ${elementName}!`,
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
};
// publish unit, subsection and then section in order.
// check unit
await checkPublishBtn(unit, unitElement, 'unit');
// check subsection
await checkPublishBtn(subsection, subsectionElement, 'subsection');
// section doesn't display badges
});
it('check configure modal for section', async () => {
const { findByTestId, findAllByTestId } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const newReleaseDateIso = '2025-09-10T22:00:00Z';
const newReleaseDate = '09/10/2025';
axiosMock
.onPost(getCourseItemApiUrl(section.id), {
publish: 'republish',
metadata: {
visible_to_staff_only: true,
start: newReleaseDateIso,
},
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
start: newReleaseDateIso,
});
const [firstSection] = await findAllByTestId('section-card');
const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
await act(async () => fireEvent.click(sectionDropdownButton));
const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
await act(async () => fireEvent.click(configureBtn));
let releaseDateStack = await findByTestId('release-date-stack');
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
expect(releaseDatePicker).toHaveValue('08/10/2023');
await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } }));
expect(releaseDatePicker).toHaveValue(newReleaseDate);
const saveButton = await findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
publish: 'republish',
metadata: {
visible_to_staff_only: true,
start: newReleaseDateIso,
},
}));
await act(async () => fireEvent.click(sectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
releaseDateStack = await findByTestId('release-date-stack');
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
expect(releaseDatePicker).toHaveValue(newReleaseDate);
});
it('check configure modal for subsection', async () => {
const {
findAllByTestId,
findByTestId,
} = render(<RootWrapper />);
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
const [subsection] = section.childInfo.children;
const expectedRequestData = {
publish: 'republish',
graderType: 'Homework',
isPrereq: false,
prereqMinScore: 100,
prereqMinCompletion: 100,
metadata: {
visible_to_staff_only: null,
due: '2025-09-10T05:00:00Z',
hide_after_due: true,
show_correctness: 'always',
is_practice_exam: false,
is_time_limited: true,
is_proctored_enabled: false,
exam_review_rules: '',
default_time_limit_minutes: 3270,
is_onboarding_exam: false,
start: '2025-08-10T00:00:00Z',
},
};
axiosMock
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
.reply(200, { dummy: 'value' });
const [currentSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
subsection.start = expectedRequestData.metadata.start;
subsection.due = expectedRequestData.metadata.due;
subsection.format = expectedRequestData.graderType;
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue;
section.childInfo.children[0] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(subsectionDropdownButton);
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
fireEvent.click(configureBtn);
// update fields
let configureModal = await findByTestId('configure-modal');
expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument();
let releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } });
let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } });
let dueDateStack = await within(configureModal).findByTestId('due-date-stack');
let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } });
let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } });
let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } });
// visibility tab
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(visibilityRadioButtons[1]);
let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
let radioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(radioButtons[1]);
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
fireEvent.change(hours, { target: { value: '54:30' } });
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// verify request
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
// reopen modal and check values
await act(async () => fireEvent.click(subsectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
expect(releaseDatePicker).toHaveValue('08/10/2025');
releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
expect(releaseDateTimePicker).toHaveValue('00:00');
dueDateStack = await await within(configureModal).findByTestId('due-date-stack');
dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
expect(dueDatePicker).toHaveValue('09/10/2025');
dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
expect(dueDateTimePicker).toHaveValue('05:00');
graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType);
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
radioButtons = await within(configureModal).findAllByRole('radio');
expect(radioButtons[0]).toHaveProperty('checked', false);
expect(radioButtons[1]).toHaveProperty('checked', true);
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
expect(hours).toHaveValue('54:30');
});
it('check prereq and proctoring settings in configure modal for subsection', async () => {
const {
findAllByTestId,
findByTestId,
} = render(<RootWrapper />);
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
const [subsection, secondSubsection] = section.childInfo.children;
const expectedRequestData = {
publish: 'republish',
graderType: 'notgraded',
isPrereq: true,
prereqUsageKey: secondSubsection.id,
prereqMinScore: 80,
prereqMinCompletion: 90,
metadata: {
visible_to_staff_only: true,
due: '',
hide_after_due: false,
show_correctness: 'always',
is_practice_exam: false,
is_time_limited: true,
is_proctored_enabled: true,
exam_review_rules: 'some rules for proctored exams',
default_time_limit_minutes: 30,
is_onboarding_exam: false,
start: '1970-01-01T05:00:00Z',
},
};
axiosMock
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
.reply(200, { dummy: 'value' });
const [currentSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
subsection.isPrereq = expectedRequestData.isPrereq;
subsection.prereq = expectedRequestData.prereqUsageKey;
subsection.prereqMinScore = expectedRequestData.prereqMinScore;
subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion;
section.childInfo.children[0] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(subsectionDropdownButton);
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
fireEvent.click(configureBtn);
// update fields
let configureModal = await findByTestId('configure-modal');
let advancedTab = await within(configureModal).findByRole(
'tab',
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
);
// visibility tab
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(visibilityRadioButtons[2]);
fireEvent.click(advancedTab);
let radioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(radioButtons[2]);
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
fireEvent.change(hours, { target: { value: '00:30' } });
// select a prerequisite
const prereqSelect = await within(configureModal).findByRole('combobox');
fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } });
// update minimum score and completion percentage
let prereqMinScoreInput = await within(configureModal).findByLabelText(
configureModalMessages.minScoreLabel.defaultMessage,
);
fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } });
let prereqMinCompletionInput = await within(configureModal).findByLabelText(
configureModalMessages.minCompletionLabel.defaultMessage,
);
fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } });
// enable this subsection to be used as prerequisite by other subsections
let prereqCheckbox = await within(configureModal).findByLabelText(
configureModalMessages.prereqCheckboxLabel.defaultMessage,
);
fireEvent.click(prereqCheckbox);
// fill some rules for proctored exams
let examsRulesInput = await within(configureModal).findByLabelText(
configureModalMessages.reviewRulesLabel.defaultMessage,
);
fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } });
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// verify request
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
// reopen modal and check values
await act(async () => fireEvent.click(subsectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
advancedTab = await within(configureModal).findByRole('tab', {
name: configureModalMessages.advancedTabTitle.defaultMessage,
});
fireEvent.click(advancedTab);
radioButtons = await within(configureModal).findAllByRole('radio');
expect(radioButtons[0]).toHaveProperty('checked', false);
expect(radioButtons[1]).toHaveProperty('checked', false);
expect(radioButtons[2]).toHaveProperty('checked', true);
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
expect(hours).toHaveValue('00:30');
prereqCheckbox = await within(configureModal).findByLabelText(
configureModalMessages.prereqCheckboxLabel.defaultMessage,
);
expect(prereqCheckbox).toBeChecked();
const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true });
expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey);
examsRulesInput = await within(configureModal).findByLabelText(
configureModalMessages.reviewRulesLabel.defaultMessage,
);
expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules);
prereqMinScoreInput = await within(configureModal).findByLabelText(
configureModalMessages.minScoreLabel.defaultMessage,
);
expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`);
prereqMinCompletionInput = await within(configureModal).findByLabelText(
configureModalMessages.minCompletionLabel.defaultMessage,
);
expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`);
});
it('check practice proctoring settings in configure modal', async () => {
const {
findAllByTestId,
findByTestId,
} = render(<RootWrapper />);
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
const [subsection] = section.childInfo.children;
const expectedRequestData = {
publish: 'republish',
graderType: 'notgraded',
isPrereq: false,
prereqMinScore: 100,
prereqMinCompletion: 100,
metadata: {
visible_to_staff_only: null,
due: '',
hide_after_due: false,
show_correctness: 'never',
is_practice_exam: true,
is_time_limited: true,
is_proctored_enabled: true,
exam_review_rules: '',
default_time_limit_minutes: 30,
is_onboarding_exam: false,
start: '1970-01-01T05:00:00Z',
},
};
axiosMock
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
.reply(200, { dummy: 'value' });
const [currentSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
section.childInfo.children[0] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(subsectionDropdownButton);
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
fireEvent.click(configureBtn);
// update fields
let configureModal = await findByTestId('configure-modal');
let advancedTab = await within(configureModal).findByRole(
'tab',
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
);
// visibility tab
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(visibilityRadioButtons[4]);
// advancedTab
fireEvent.click(advancedTab);
let radioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(radioButtons[3]);
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
fireEvent.change(hours, { target: { value: '00:30' } });
// rules box should not be visible
expect(within(configureModal).queryByLabelText(
configureModalMessages.reviewRulesLabel.defaultMessage,
)).not.toBeInTheDocument();
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// verify request
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
// reopen modal and check values
await act(async () => fireEvent.click(subsectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
radioButtons = await within(configureModal).findAllByRole('radio');
expect(radioButtons[0]).toHaveProperty('checked', false);
expect(radioButtons[1]).toHaveProperty('checked', false);
expect(radioButtons[2]).toHaveProperty('checked', false);
expect(radioButtons[3]).toHaveProperty('checked', true);
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
expect(hours).toHaveValue('00:30');
});
it('check onboarding proctoring settings in configure modal', async () => {
const {
findAllByTestId,
findByTestId,
} = render(<RootWrapper />);
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
const [, subsection] = section.childInfo.children;
const expectedRequestData = {
publish: 'republish',
graderType: 'notgraded',
isPrereq: true,
prereqMinScore: 100,
prereqMinCompletion: 100,
metadata: {
visible_to_staff_only: null,
due: '',
hide_after_due: false,
show_correctness: 'past_due',
is_practice_exam: false,
is_time_limited: true,
is_proctored_enabled: true,
exam_review_rules: '',
default_time_limit_minutes: 30,
is_onboarding_exam: true,
start: '2013-02-05T05:00:00Z',
},
};
axiosMock
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
.reply(200, { dummy: 'value' });
const [currentSection] = await findAllByTestId('section-card');
const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card');
const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
section.childInfo.children[1] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(subsectionDropdownButton);
const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button');
fireEvent.click(configureBtn);
// update fields
let configureModal = await findByTestId('configure-modal');
// visibility tab
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(visibilityRadioButtons[5]);
// advancedTab
let advancedTab = await within(configureModal).findByRole(
'tab',
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
);
fireEvent.click(advancedTab);
let radioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(radioButtons[3]);
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
fireEvent.change(hours, { target: { value: '00:30' } });
// rules box should not be visible
expect(within(configureModal).queryByLabelText(
configureModalMessages.reviewRulesLabel.defaultMessage,
)).not.toBeInTheDocument();
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// verify request
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
// reopen modal and check values
await act(async () => fireEvent.click(subsectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
radioButtons = await within(configureModal).findAllByRole('radio');
expect(radioButtons[0]).toHaveProperty('checked', false);
expect(radioButtons[1]).toHaveProperty('checked', false);
expect(radioButtons[2]).toHaveProperty('checked', false);
expect(radioButtons[3]).toHaveProperty('checked', true);
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
expect(hours).toHaveValue('00:30');
});
it('check no special exam setting in configure modal', async () => {
const {
findAllByTestId,
findByTestId,
} = render(<RootWrapper />);
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]);
const [subsection] = section.childInfo.children;
const expectedRequestData = {
publish: 'republish',
graderType: 'notgraded',
prereqMinScore: 100,
prereqMinCompletion: 100,
metadata: {
visible_to_staff_only: null,
due: '',
hide_after_due: false,
show_correctness: 'always',
is_practice_exam: false,
is_time_limited: false,
is_proctored_enabled: false,
exam_review_rules: '',
default_time_limit_minutes: 0,
is_onboarding_exam: false,
start: '1970-01-01T05:00:00Z',
},
};
axiosMock
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
.reply(200, { dummy: 'value' });
const [, currentSection] = await findAllByTestId('section-card');
const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card');
const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
section.childInfo.children[0] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(subsectionDropdownButton);
const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button');
fireEvent.click(configureBtn);
// update fields
let configureModal = await findByTestId('configure-modal');
// advancedTab
let advancedTab = await within(configureModal).findByRole(
'tab',
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
);
fireEvent.click(advancedTab);
let radioButtons = await within(configureModal).findAllByRole('radio');
fireEvent.click(radioButtons[0]);
// time box should not be visible
expect(within(configureModal).queryByLabelText(
configureModalMessages.timeAllotted.defaultMessage,
)).not.toBeInTheDocument();
// rules box should not be visible
expect(within(configureModal).queryByLabelText(
configureModalMessages.reviewRulesLabel.defaultMessage,
)).not.toBeInTheDocument();
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// verify request
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
// reopen modal and check values
await act(async () => fireEvent.click(subsectionDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
radioButtons = await within(configureModal).findAllByRole('radio');
expect(radioButtons[0]).toHaveProperty('checked', true);
expect(radioButtons[1]).toHaveProperty('checked', false);
expect(radioButtons[2]).toHaveProperty('checked', false);
expect(radioButtons[3]).toHaveProperty('checked', false);
});
it('check configure modal for unit', async () => {
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const [subsection] = section.childInfo.children;
const [unit] = subsection.childInfo.children;
// Enrollment Track Groups : Audit
const newGroupAccess = { 50: [1] };
const isVisibleToStaffOnly = true;
axiosMock
.onPost(getCourseItemApiUrl(unit.id), {
publish: 'republish',
metadata: {
visible_to_staff_only: isVisibleToStaffOnly,
group_access: newGroupAccess,
},
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
const [firstSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card');
const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(subsectionExpandButton);
const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card');
const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button');
// after configuraiton response
unit.visibilityState = 'staff_only';
unit.userPartitionInfo = {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: true,
deleted: false,
},
],
},
],
selectedPartitionIndex: 0,
selectedGroupsLabel: '',
};
subsection.childInfo.children[0] = unit;
section.childInfo.children[0] = subsection;
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
fireEvent.click(unitDropdownButton);
const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button');
fireEvent.click(configureBtn);
let configureModal = await findByTestId('configure-modal');
expect(await within(configureModal).findByText(
configureModalMessages.unitVisibility.defaultMessage,
)).toBeInTheDocument();
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
await act(async () => fireEvent.click(visibilityCheckbox));
let groupeType = await within(configureModal).findByTestId('group-type-select');
fireEvent.change(groupeType, { target: { value: '0' } });
let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
fireEvent.click(checkboxes[1]);
const saveButton = await within(configureModal).findByTestId('configure-save-button');
await act(async () => fireEvent.click(saveButton));
// reopen modal and check values
await act(async () => fireEvent.click(unitDropdownButton));
await act(async () => fireEvent.click(configureBtn));
configureModal = await findByTestId('configure-modal');
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
expect(visibilityCheckbox).toBeChecked();
groupeType = await within(configureModal).findByTestId('group-type-select');
expect(groupeType).toHaveValue('0');
checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
expect(checkboxes[0]).not.toBeChecked();
expect(checkboxes[1]).toBeChecked();
});
it('check update highlights when update highlights query is successfully', async () => {
const { getByRole } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const highlights = [
'New Highlight 1',
'New Highlight 2',
'New Highlight 3',
'New Highlight 4',
'New Highlight 5',
];
axiosMock
.onPost(getCourseItemApiUrl(section.id), {
publish: 'republish',
metadata: {
highlights,
},
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
highlights,
});
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
await waitFor(() => {
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
});
});
it('check whether section move up and down options work correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get second section element
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [, sectionElement] = await findAllByTestId('section-card');
// mock api call
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(200, { dummy: 'value' });
// find menu button and click on it to open menu
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
fireEvent.click(menu);
// move second section to first position to test move up option
const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button');
await act(async () => fireEvent.click(moveUpButton));
const firstSectionId = store.getState().courseOutline.sectionsList[0].id;
expect(secondSection.id).toBe(firstSectionId);
// move first section back to second position to test move down option
const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button');
await act(async () => fireEvent.click(moveDownButton));
const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id;
expect(secondSection.id).toBe(newSecondSectionId);
});
it('check whether section move up & down option is rendered correctly based on index', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get first, second and last section element
const {
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
} = await findAllByTestId('section-card');
// find menu button and click on it to open menu in first section
const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button');
await act(async () => fireEvent.click(firstMenu));
// move down option should be enabled in first element
expect(
await within(firstSection).findByTestId('section-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
// move up option should not be enabled in first element
expect(
await within(firstSection).findByTestId('section-card-header__menu-move-up-button'),
).toHaveAttribute('aria-disabled', 'true');
// find menu button and click on it to open menu in second section
const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button');
await act(async () => fireEvent.click(secondMenu));
// both move down & up option should be enabled in second element
expect(
await within(secondSection).findByTestId('section-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
expect(
await within(secondSection).findByTestId('section-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
// find menu button and click on it to open menu in last section
const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button');
await act(async () => fireEvent.click(lastMenu));
// move down option should not be enabled in last element
expect(
await within(lastSection).findByTestId('section-card-header__menu-move-down-button'),
).toHaveAttribute('aria-disabled', 'true');
// move up option should be enabled in last element
expect(
await within(lastSection).findByTestId('section-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
});
it('check whether subsection move up and down options work correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get second section element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [, secondSubsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// mock api call
axiosMock
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id))
.reply(200, { dummy: 'value' });
// find menu button and click on it to open menu
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menu));
// move second subsection to first position to test move up option
const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button');
await act(async () => fireEvent.click(moveUpButton));
const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
expect(secondSubsection.id).toBe(firstSubsectionId);
// move first section back to second position to test move down option
const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button');
await act(async () => fireEvent.click(moveDownButton));
const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
expect(secondSubsection.id).toBe(secondSubsectionId);
});
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// using second section as second section in mock has 3 subsections
const [, sectionElement] = await findAllByTestId('section-card');
// get first, second and last subsection element
const {
0: firstSubsection,
1: secondSubsection,
length,
[length - 1]: lastSubsection,
} = await within(sectionElement).findAllByTestId('subsection-card');
// find menu button and click on it to open menu in first section
const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(firstMenu));
// move down option should be enabled in first element
expect(
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
// move up option should not be enabled in first element
expect(
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
).toHaveAttribute('aria-disabled', 'true');
// find menu button and click on it to open menu in second section
const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(secondMenu));
// both move down & up option should be enabled in second element
expect(
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
expect(
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
// find menu button and click on it to open menu in last section
const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(lastMenu));
// move down option should not be enabled in last element
expect(
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
).toHaveAttribute('aria-disabled', 'true');
// move up option should be enabled in last element
expect(
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
});
it('check whether unit move up and down options work correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get second section -> second subsection -> second unit element
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [, sectionElement] = await findAllByTestId('section-card');
const [, subsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [, secondUnit] = subsection.childInfo.children;
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
// mock api call
axiosMock
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
.reply(200, { dummy: 'value' });
// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(menu));
// move second unit to first position to test move up option
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
await act(async () => fireEvent.click(moveUpButton));
const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id;
expect(secondUnit.id).toBe(firstUnitId);
// move first unit back to second position to test move down option
const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button');
await act(async () => fireEvent.click(moveDownButton));
const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id;
expect(secondUnit.id).toBe(secondUnitId);
});
it('check whether unit move up & down option is rendered correctly based on index', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// using second section -> second subsection as it has 5 units in mock.
const [, sectionElement] = await findAllByTestId('section-card');
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
// get first, second and last unit element
const {
0: firstUnit,
1: secondUnit,
length,
[length - 1]: lastUnit,
} = await within(subsectionElement).findAllByTestId('unit-card');
// find menu button and click on it to open menu in first section
const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(firstMenu));
// move down option should be enabled in first element
expect(
await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
// move up option should not be enabled in first element
expect(
await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'),
).toHaveAttribute('aria-disabled', 'true');
// find menu button and click on it to open menu in second section
const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(secondMenu));
// both move down & up option should be enabled in second element
expect(
await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'),
).not.toHaveAttribute('aria-disabled');
expect(
await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
// find menu button and click on it to open menu in last section
const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(lastMenu));
// move down option should not be enabled in last element
expect(
await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'),
).toHaveAttribute('aria-disabled', 'true');
// move up option should be enabled in last element
expect(
await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'),
).not.toHaveAttribute('aria-disabled');
});
it('check that new section list is saved when dragged', async () => {
const { findAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[7];
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(200, { dummy: 'value' });
const section1 = store.getState().courseOutline.sectionsList[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
});
const section2 = store.getState().courseOutline.sectionsList[1].id;
expect(section1).toBe(section2);
});
it('check section list is restored to original order when API call fails', async () => {
const { findAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[6];
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(500);
const section1 = store.getState().courseOutline.sectionsList[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
});
const section1New = store.getState().courseOutline.sectionsList[0].id;
expect(section1).toBe(section1New);
});
it('check that new subsection list is saved when dragged', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(section.id))
.reply(200, { dummy: 'value' });
const subsection1 = section.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
});
const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
expect(subsection1).toBe(subsection2);
});
it('check that new subsection list is restored to original order when API call fails', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card');
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(section.id))
.reply(500);
const subsection1 = section.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
});
const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
expect(subsection1).toBe(subsection1New);
});
it('check that new unit list is saved when dragged', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(subsection.id))
.reply(200, { dummy: 'value' });
const unit1 = subsection.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
});
const unit2 = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[1].id;
expect(unit1).toBe(unit2);
});
it('check that new unit list is restored to original order when API call fails', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(subsection.id))
.reply(500);
const unit1 = subsection.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
});
const unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id;
expect(unit1).toBe(unit1New);
});
it('check that drag handle is not visible for non-draggable sections', async () => {
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, {
...courseOutlineIndexMock,
courseStructure: {
...courseOutlineIndexMock.courseStructure,
childInfo: {
...courseOutlineIndexMock.courseStructure.childInfo,
children: [
{
...courseOutlineIndexMock.courseStructure.childInfo.children[0],
actions: {
draggable: false,
childAddable: true,
deletable: true,
duplicable: true,
},
},
...courseOutlineIndexMock.courseStructure.childInfo.children.slice(1),
],
},
},
});
const { findAllByTestId } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle');
await waitFor(() => {
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
});
});
it('check whether unit copy & paste option works correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get first section -> first subsection -> first unit element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
const expectedClipboardContent = {
content: {
blockType: 'vertical',
blockTypeDisplay: 'Unit',
created: '2024-01-29T07:58:36.844249Z',
displayName: unit.displayName,
id: 15,
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx',
purpose: 'clipboard',
status: 'ready',
userId: 3,
},
sourceUsageKey: unit.id,
sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName,
sourceEditUrl: unit.studioUrl,
};
// mock api call
axiosMock
.onPost(getClipboardUrl(), {
usage_key: unit.id,
}).reply(200, expectedClipboardContent);
// check that initialUserClipboard state is empty
const { initialUserClipboard } = store.getState().courseOutline;
expect(initialUserClipboard).toBeUndefined();
// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(menu));
// move first unit back to second position to test move down option
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
await act(async () => fireEvent.click(copyButton));
// check that initialUserClipboard state is updated
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// find clipboard content label
const clipboardLabel = await within(subsectionElement).findByText(
pasteButtonMessages.clipboardContentLabel.defaultMessage,
);
await act(async () => fireEvent.mouseOver(clipboardLabel));
// find clipboard content popup link
expect(
subsectionElement.querySelector('#vertical-paste-button-overlay'),
).toHaveAttribute('href', unit.studioUrl);
// check paste button functionality
// mock api call
axiosMock
.onPost(getXBlockBaseApiUrl(), {
parent_locator: subsection.id,
staged_content: 'clipboard',
}).reply(200, { dummy: 'value' });
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
await act(async () => fireEvent.click(pasteBtn));
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
expect(lastUnitElement).toHaveTextContent(unit.displayName);
});
});

View File

@@ -0,0 +1,43 @@
module.exports = {
isSelfPaced: false,
sections: {
totalNumber: 6,
totalVisible: 4,
numberWithHighlights: 2,
highlightsActiveForCourse: true,
highlightsEnabled: true,
},
subsections: {
totalVisible: 5,
numWithOneBlockType: 2,
numBlockTypes: {
min: 0,
max: 3,
mean: 1,
median: 1,
mode: 1,
},
},
units: {
totalVisible: 9,
numBlocks: {
min: 1,
max: 2,
mean: 2,
median: 2,
mode: 2,
},
},
videos: {
totalNumber: 7,
numMobileEncoded: 0,
numWithValId: 3,
durations: {
min: null,
max: null,
mean: null,
median: null,
mode: null,
},
},
};

View File

@@ -0,0 +1,31 @@
module.exports = {
isSelfPaced: false,
dates: {
hasStartDate: true,
hasEndDate: false,
},
assignments: {
totalNumber: 11,
totalVisible: 7,
assignmentsWithDatesBeforeStart: [],
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
assignmentsWithOraDatesAfterEnd: [],
},
grades: {
hasGradingPolicy: true,
sumOfWeights: 1,
},
certificates: {
isActivated: false,
hasCertificate: false,
isEnabled: true,
},
updates: {
hasUpdate: true,
},
proctoring: {
needsProctoringEscalationEmail: false,
hasProctoringEscalationEmail: false,
},
};

View File

@@ -0,0 +1,3063 @@
module.exports = {
courseReleaseDate: 'Set Date',
courseStructure: {
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
displayName: 'Demonstration Course',
category: 'course',
hasChildren: true,
unitLevelDiscussions: false,
editedOn: 'Aug 23, 2023 at 12:35 UTC',
published: true,
publishedOn: 'Aug 23, 2023 at 11:32 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: null,
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
videoSharingEnabled: true,
videoSharingOptions: 'per-video',
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
highlightsEnabledForMessaging: false,
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
enableProctoredExams: true,
createZendeskTickets: true,
enableTimedExams: true,
childInfo: {
category: 'chapter',
displayName: 'Section',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
displayName: 'Introduction 12',
category: 'chapter',
hasChildren: true,
editedOn: 'Aug 23, 2023 at 12:35 UTC',
published: false,
publishedOn: 'Aug 23, 2023 at 12:35 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
releasedToStudents: false,
releaseDate: 'Aug 10, 2023 at 22:00 UTC',
visibilityState: 'staff_only',
hasExplicitStaffLock: true,
start: '2023-08-10T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
highlights: [
'New Highlight 1',
'New Highlight 4',
],
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
displayName: 'Demo Course Overview',
category: 'sequential',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: false,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction',
releasedToStudents: false,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
isPrereq: false,
prereqs: [{
blockDisplayName: 'Sample Subsection',
blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
}],
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: false,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: false,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
displayName: 'Introduction: Video and Sequences',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: false,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
releasedToStudents: false,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
display_name: 'Sample Subsection',
category: 'sequential',
has_children: true,
edited_on: 'Dec 05, 2023 at 10:35 UTC',
published: true,
published_on: 'Dec 05, 2023 at 10:35 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%407f75de8dcc261249250b71925f49810f',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
hide_after_due: false,
is_proctored_exam: false,
was_exam_ever_linked_with_external: false,
online_proctoring_rules: '',
is_practice_exam: false,
is_onboarding_exam: false,
is_time_limited: false,
isPrereq: true,
exam_review_rules: '',
default_time_limit_minutes: null,
proctoring_exam_configuration_link: null,
supports_onboarding: true,
show_review_rules: true,
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [],
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: true,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: true,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions',
displayName: 'Example Week 2: Get Interactive',
category: 'chapter',
hasChildren: true,
editedOn: 'Aug 16, 2023 at 11:52 UTC',
published: true,
publishedOn: 'Aug 16, 2023 at 11:52 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
highlights: [
'New',
],
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations',
displayName: "Lesson 2 - Let's Get Interactive!",
category: 'sequential',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40simulations',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: true,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: true,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
displayName: "Lesson 2 - Let's Get Interactive! ",
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
displayName: 'An Interactive Reference Table',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
displayName: 'Zooming Diagrams',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
displayName: 'Electronic Sound Experiment',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
displayName: 'New Unit',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
releasedToStudents: true,
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '1970-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations',
displayName: 'Homework - Labs and Demos',
category: 'sequential',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40graded_simulations',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: 'Homework',
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: false,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: false,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
displayName: 'Labs and Demos',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
displayName: 'Code Grader',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
displayName: 'Electric Circuit Simulator',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
displayName: 'Protein Creator',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
displayName: 'Molecule Structures',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e',
displayName: 'Homework - Essays',
category: 'sequential',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40175e76c4951144a29d46211361266e0e',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: false,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: false,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
displayName: 'Peer Assessed Essays',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7',
displayName: 'About Exams and Certificates',
category: 'chapter',
hasChildren: true,
editedOn: 'Aug 10, 2023 at 10:40 UTC',
published: true,
publishedOn: 'Aug 10, 2023 at 10:40 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7',
releasedToStudents: false,
releaseDate: 'Jan 01, 2030 at 05:00 UTC',
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '2030-01-01T05:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
highlights: [],
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow',
displayName: 'edX Exams',
category: 'sequential',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40workflow',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: 'Exam',
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: false,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: false,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
displayName: 'EdX Exams',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
displayName: 'Immediate Feedback',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
displayName: 'Getting Answers',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
displayName: 'Answering More Than Once',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
displayName: 'Limited Checks',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
displayName: 'Randomized Questions',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
displayName: 'Overall Grade Performance',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
displayName: 'Passing a Course',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
displayName: 'Getting Your edX Certificate',
category: 'vertical',
hasChildren: true,
editedOn: 'Jul 07, 2023 at 11:14 UTC',
published: true,
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
releasedToStudents: true,
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
visibilityState: 'live',
hasExplicitStaffLock: false,
start: '2013-02-05T00:00:00Z',
graded: true,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@46e11a7b395f45b9837df6c6ac609004',
displayName: 'Publish section',
category: 'chapter',
hasChildren: true,
editedOn: 'Aug 23, 2023 at 12:22 UTC',
published: true,
publishedOn: 'Aug 23, 2023 at 12:22 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%4046e11a7b395f45b9837df6c6ac609004',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
highlights: [],
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
childInfo: {
category: 'sequential',
displayName: 'Subsection',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@1945e9656cbe4abe8f2020c67e9e1f61',
displayName: 'Subsection sub',
category: 'sequential',
hasChildren: true,
editedOn: 'Aug 23, 2023 at 11:32 UTC',
published: true,
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%401945e9656cbe4abe8f2020c67e9e1f61',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
hideAfterDue: false,
isProctoredExam: false,
wasExamEverLinkedWithExternal: false,
onlineProctoringRules: '',
isPracticeExam: false,
isOnboardingExam: false,
isTimeLimited: false,
examReviewRules: '',
defaultTimeLimitMinutes: null,
proctoringExamConfigurationLink: null,
supportsOnboarding: false,
showReviewRules: true,
childInfo: {
category: 'vertical',
displayName: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
displayName: 'Unit',
category: 'vertical',
hasChildren: true,
editedOn: 'Aug 23, 2023 at 11:32 UTC',
published: true,
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
releasedToStudents: false,
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
visibilityState: 'ready',
hasExplicitStaffLock: false,
start: '2023-11-09T22:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Exam',
],
hasChanges: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
],
},
ancestorHasStaffLock: false,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
},
deprecatedBlocksInfo: {
deprecatedEnabledBlockTypes: [],
blocks: [],
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
initialState: {
expandedLocators: [
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
],
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
},
languageCode: 'en',
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
mfeProctoredExamSettingsUrl: '',
notificationDismissUrl: '',
proctoringErrors: [],
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
rerunNotificationId: 2,
};

View File

@@ -0,0 +1,25 @@
module.exports = {
courseReleaseDate: 'Set Date',
courseStructure: {},
deprecatedBlocksInfo: {
deprecatedEnabledBlockTypes: [],
blocks: [],
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
},
discussionsIncontextFeedbackUrl: '',
discussionsIncontextLearnmoreUrl: '',
initialState: {
expandedLocators: [
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
],
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
},
languageCode: 'en',
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
mfeProctoredExamSettingsUrl: '',
notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2',
proctoringErrors: [],
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
rerunNotificationId: 2,
};

View File

@@ -0,0 +1,93 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7',
display_name: 'Section',
category: 'chapter',
has_children: true,
edited_on: 'Nov 22, 2023 at 07:45 UTC',
published: true,
published_on: 'Nov 22, 2023 at 07:45 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
child_info: {
category: 'sequential',
display_name: 'Subsection',
children: [],
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
};

View File

@@ -0,0 +1,101 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@b713bc2830f34f6f87554028c3068729',
display_name: 'Subsection',
category: 'sequential',
has_children: true,
edited_on: 'Dec 05, 2023 at 10:35 UTC',
published: true,
published_on: 'Dec 05, 2023 at 10:35 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40b713bc2830f34f6f87554028c3068729',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
hide_after_due: false,
is_proctored_exam: false,
was_exam_ever_linked_with_external: false,
online_proctoring_rules: '',
is_practice_exam: false,
is_onboarding_exam: false,
is_time_limited: false,
exam_review_rules: '',
default_time_limit_minutes: null,
proctoring_exam_configuration_link: null,
supports_onboarding: false,
show_review_rules: true,
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [],
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
};

View File

@@ -0,0 +1,6 @@
export { default as courseOutlineIndexMock } from './courseOutlineIndex';
export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections';
export { default as courseBestPracticesMock } from './courseBestPractices';
export { default as courseLaunchMock } from './courseLaunch';
export { default as courseSectionMock } from './courseSection';
export { default as courseSubsectionMock } from './courseSubsection';

View File

@@ -0,0 +1,266 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
import {
Dropdown,
Form,
Hyperlink,
Icon,
IconButton,
} from '@edx/paragon';
import {
MoreVert as MoveVertIcon,
EditOutline as EditIcon,
} from '@edx/paragon/icons';
import { useEscapeClick } from '../../hooks';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
import messages from './messages';
const CardHeader = ({
title,
status,
cardId,
hasChanges,
onClickPublish,
onClickConfigure,
onClickMenuButton,
onClickEdit,
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickDuplicate,
onClickMoveUp,
onClickMoveDown,
onClickCopy,
titleComponent,
namePrefix,
actions,
enableCopyPasteUnits,
isVertical,
isSequential,
proctoringExamConfigurationLink,
discussionEnabled,
discussionsSettings,
parentInfo,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
const [titleValue, setTitleValue] = useState(title);
const cardHeaderRef = useRef(null);
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
useEffect(() => {
const locatorId = searchParams.get('show');
if (!locatorId) {
return;
}
if (cardHeaderRef.current && locatorId === cardId) {
scrollToElement(cardHeaderRef.current);
}
}, []);
const showDiscussionsEnabledBadge = (
isVertical
&& !parentInfo?.isTimeLimited
&& discussionEnabled
&& discussionsSettings?.providerType === 'openedx'
&& (
discussionsSettings?.enableGradedUnits
|| (!discussionsSettings?.enableGradedUnits && !parentInfo.graded)
)
);
useEscapeClick({
onEscape: () => {
setTitleValue(title);
closeForm();
},
dependency: title,
});
return (
<div
className="item-card-header"
data-testid={`${namePrefix}-card-header`}
ref={cardHeaderRef}
>
{isFormOpen ? (
<Form.Group className="m-0 w-75">
<Form.Control
data-testid={`${namePrefix}-edit-field`}
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label="edit field"
onBlur={() => onEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
<>
{titleComponent}
<IconButton
className="item-card-edit-icon"
data-testid={`${namePrefix}-edit-button`}
alt={intl.formatMessage(messages.altButtonEdit)}
iconAs={EditIcon}
onClick={onClickEdit}
/>
</>
)}
<div className="ml-auto d-flex">
{(isVertical || isSequential) && (
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
)}
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
<Dropdown.Toggle
className="item-card-header__menu"
id={`${namePrefix}-card-header__menu`}
data-testid={`${namePrefix}-card-header__menu-button`}
as={IconButton}
src={MoveVertIcon}
alt={`${namePrefix}-card-header__menu`}
iconAs={Icon}
/>
<Dropdown.Menu>
{isSequential && proctoringExamConfigurationLink && (
<Dropdown.Item
as={Hyperlink}
target="_blank"
destination={proctoringExamConfigurationLink}
href={proctoringExamConfigurationLink}
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
>
{intl.formatMessage(messages.menuProctoringLinkText)}
</Dropdown.Item>
)}
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-publish-button`}
disabled={isDisabledPublish}
onClick={onClickPublish}
>
{intl.formatMessage(messages.menuPublish)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
{isVertical && enableCopyPasteUnits && (
<Dropdown.Item onClick={onClickCopy}>
{intl.formatMessage(messages.menuCopy)}
</Dropdown.Item>
)}
{actions.duplicable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
onClick={onClickDuplicate}
>
{intl.formatMessage(messages.menuDuplicate)}
</Dropdown.Item>
)}
{actions.draggable && (
<>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
onClick={onClickMoveUp}
disabled={!actions.allowMoveUp}
>
{intl.formatMessage(messages.menuMoveUp)}
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
onClick={onClickMoveDown}
disabled={!actions.allowMoveDown}
>
{intl.formatMessage(messages.menuMoveDown)}
</Dropdown.Item>
</>
)}
{actions.deletable && (
<Dropdown.Item
className="border-top border-light"
data-testid={`${namePrefix}-card-header__menu-delete-button`}
onClick={onClickDelete}
>
{intl.formatMessage(messages.menuDelete)}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
);
};
CardHeader.defaultProps = {
enableCopyPasteUnits: false,
isVertical: false,
isSequential: false,
onClickCopy: null,
proctoringExamConfigurationLink: null,
discussionEnabled: false,
discussionsSettings: {},
parentInfo: {},
};
CardHeader.propTypes = {
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
cardId: PropTypes.string.isRequired,
hasChanges: PropTypes.bool.isRequired,
onClickPublish: PropTypes.func.isRequired,
onClickConfigure: PropTypes.func.isRequired,
onClickMenuButton: PropTypes.func.isRequired,
onClickEdit: PropTypes.func.isRequired,
isFormOpen: PropTypes.bool.isRequired,
onEditSubmit: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
isDisabledEditField: PropTypes.bool.isRequired,
onClickDelete: PropTypes.func.isRequired,
onClickDuplicate: PropTypes.func.isRequired,
onClickMoveUp: PropTypes.func.isRequired,
onClickMoveDown: PropTypes.func.isRequired,
onClickCopy: PropTypes.func,
titleComponent: PropTypes.node.isRequired,
namePrefix: PropTypes.string.isRequired,
proctoringExamConfigurationLink: PropTypes.string,
actions: PropTypes.shape({
deletable: PropTypes.bool.isRequired,
draggable: PropTypes.bool.isRequired,
childAddable: PropTypes.bool.isRequired,
duplicable: PropTypes.bool.isRequired,
allowMoveUp: PropTypes.bool,
allowMoveDown: PropTypes.bool,
}).isRequired,
enableCopyPasteUnits: PropTypes.bool,
isVertical: PropTypes.bool,
isSequential: PropTypes.bool,
discussionEnabled: PropTypes.bool,
discussionsSettings: PropTypes.shape({
providerType: PropTypes.string,
enableGradedUnits: PropTypes.bool,
}),
parentInfo: PropTypes.shape({
isTimeLimited: PropTypes.bool,
graded: PropTypes.bool,
}),
};
export default CardHeader;

View File

@@ -0,0 +1,29 @@
.item-card-header {
display: flex;
align-items: center;
.item-card-header__title-btn {
justify-content: flex-start;
padding: 0;
width: fit-content;
height: 1.5rem;
margin-right: .25rem;
background: transparent;
color: $black;
}
.item-card-edit-icon {
opacity: 0;
transition: opacity .3s linear;
&:focus {
opacity: 1;
}
}
&:hover {
.item-card-edit-icon {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,254 @@
import { MemoryRouter } from 'react-router-dom';
import {
act, render, fireEvent, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { ITEM_BADGE_STATUS } from '../constants';
import CardHeader from './CardHeader';
import TitleButton from './TitleButton';
import messages from './messages';
const onExpandMock = jest.fn();
const onClickMenuButtonMock = jest.fn();
const onClickPublishMock = jest.fn();
const onClickEditMock = jest.fn();
const onClickDeleteMock = jest.fn();
const onClickDuplicateMock = jest.fn();
const onClickConfigureMock = jest.fn();
const onClickMoveUpMock = jest.fn();
const onClickMoveDownMock = jest.fn();
const closeFormMock = jest.fn();
const cardHeaderProps = {
title: 'Some title',
status: ITEM_BADGE_STATUS.live,
cardId: '12345',
hasChanges: false,
onClickMenuButton: onClickMenuButtonMock,
onClickPublish: onClickPublishMock,
onClickEdit: onClickEditMock,
isFormOpen: false,
onEditSubmit: jest.fn(),
closeForm: closeFormMock,
isDisabledEditField: false,
onClickDelete: onClickDeleteMock,
onClickDuplicate: onClickDuplicateMock,
onClickConfigure: onClickConfigureMock,
onClickMoveUp: onClickMoveUpMock,
onClickMoveDown: onClickMoveDownMock,
isSequential: true,
namePrefix: 'subsection',
actions: {
draggable: true,
childAddable: true,
deletable: true,
duplicable: true,
},
};
const renderComponent = (props, entry = '/') => {
const titleComponent = (
<TitleButton
isExpanded
title={cardHeaderProps.title}
onTitleClick={onExpandMock}
namePrefix={cardHeaderProps.namePrefix}
{...props}
/>
);
return render(
<IntlProvider locale="en">
<MemoryRouter initialEntries={[entry]}>
<CardHeader
{...cardHeaderProps}
titleComponent={titleComponent}
{...props}
/>
</MemoryRouter>,
</IntlProvider>,
);
};
describe('<CardHeader />', () => {
it('render CardHeader component correctly', async () => {
const { findByText, findByTestId, queryByTestId } = renderComponent();
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument();
await waitFor(() => {
expect(queryByTestId('edit field')).not.toBeInTheDocument();
});
});
it('render status badge as live', async () => {
const { findByText } = renderComponent();
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as published_not_live', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.publishedNotLive,
});
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as staff_only', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.staffOnly,
});
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
});
it('render status badge as draft', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.draft,
});
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
});
it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.publishedNotLive,
});
const menuButton = await findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
});
it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.publishedNotLive,
hasChanges: true,
});
const menuButton = await findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
});
it('calls handleExpanded when button is clicked', async () => {
const { findByTestId } = renderComponent();
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandButton);
expect(onExpandMock).toHaveBeenCalled();
});
it('calls onClickMenuButton when menu is clicked', async () => {
const { findByTestId } = renderComponent();
const menuButton = await findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menuButton));
expect(onClickMenuButtonMock).toHaveBeenCalled();
});
it('calls onClickPublish when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
status: ITEM_BADGE_STATUS.draft,
});
const menuButton = await findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
await act(async () => fireEvent.click(publishMenuItem));
expect(onClickPublishMock).toHaveBeenCalled();
});
it('calls onClickEdit when the button is clicked', async () => {
const { findByTestId } = renderComponent();
const editButton = await findByTestId('subsection-edit-button');
await act(async () => fireEvent.click(editButton));
expect(onClickEditMock).toHaveBeenCalled();
});
it('check is field visible when isFormOpen is true', async () => {
const { findByTestId, queryByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
});
expect(await findByTestId('subsection-edit-field')).toBeInTheDocument();
waitFor(() => {
expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
});
});
it('check is field disabled when isDisabledEditField is true', async () => {
const { findByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
isDisabledEditField: true,
});
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
});
it('calls onClickDelete when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent();
const menuButton = await findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menuButton));
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
await act(async () => fireEvent.click(deleteMenuItem));
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
});
it('calls onClickDuplicate when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent();
const menuButton = await findByTestId('subsection-card-header__menu-button');
fireEvent.click(menuButton);
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
fireEvent.click(duplicateMenuItem);
await act(async () => fireEvent.click(duplicateMenuItem));
expect(onClickDuplicateMock).toHaveBeenCalled();
});
it('check if proctoringExamConfigurationLink is visible', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
proctoringExamConfigurationLink: 'https://localhost:8000/',
isSequential: true,
});
const menuButton = await findByTestId('subsection-card-header__menu-button');
await act(async () => fireEvent.click(menuButton));
expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument();
});
it('check if discussion enabled badge is visible', async () => {
const { queryByText } = renderComponent({
...cardHeaderProps,
isVertical: true,
discussionEnabled: true,
discussionsSettings: {
providerType: 'openedx',
enableGradedUnits: true,
},
parentInfo: {
isTimeLimited: false,
graded: false,
},
});
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { ITEM_BADGE_STATUS } from '../constants';
import { getItemStatusBadgeContent } from '../utils';
import messages from './messages';
import StatusBadge from './StatusBadge';
const CardStatus = ({
status,
showDiscussionsEnabledBadge,
}) => {
const intl = useIntl();
const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
return (
<>
{showDiscussionsEnabledBadge && (
<StatusBadge
text={intl.formatMessage(messages.discussionEnabledBadgeText)}
/>
)}
{badgeTitle && (
<StatusBadge
text={badgeTitle}
icon={badgeIcon}
iconClassName={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
/>
)}
</>
);
};
CardStatus.propTypes = {
status: PropTypes.string.isRequired,
showDiscussionsEnabledBadge: PropTypes.bool.isRequired,
};
export default CardStatus;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@edx/paragon';
const StatusBadge = ({
text,
icon,
iconClassName,
}) => {
if (text) {
return (
<div
className="px-2 py-1 mr-2 rounded bg-white align-self-center align-items-center d-flex border border-light-300"
role="status"
>
{icon && (
<Icon
src={icon}
size="sm"
className={iconClassName}
/>
)}
<span className="small ml-1">{text}</span>
</div>
);
}
return null;
};
StatusBadge.defaultProps = {
text: '',
icon: '',
iconClassName: '',
};
StatusBadge.propTypes = {
text: PropTypes.string,
icon: PropTypes.string,
iconClassName: PropTypes.string,
};
export default StatusBadge;

View File

@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
OverlayTrigger,
Tooltip,
Truncate,
} from '@edx/paragon';
import {
ArrowDropDown as ArrowDownIcon,
ArrowDropUp as ArrowUpIcon,
} from '@edx/paragon/icons';
import messages from './messages';
const TitleButton = ({
title,
isExpanded,
onTitleClick,
namePrefix,
}) => {
const intl = useIntl();
const titleTooltipMessage = intl.formatMessage(messages.expandTooltip);
return (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip
id={`${title}-${titleTooltipMessage}`}
>
{titleTooltipMessage}
</Tooltip>
)}
>
<Button
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
variant="tertiary"
data-testid={`${namePrefix}-card-header__expanded-btn`}
className="item-card-header__title-btn"
onClick={onTitleClick}
>
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
</Button>
</OverlayTrigger>
);
};
TitleButton.propTypes = {
title: PropTypes.string.isRequired,
isExpanded: PropTypes.bool.isRequired,
onTitleClick: PropTypes.func.isRequired,
namePrefix: PropTypes.string.isRequired,
};
export default TitleButton;

View File

@@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Button, Truncate } from '@edx/paragon';
const TitleLink = ({
title,
titleLink,
namePrefix,
}) => (
<Button
as={Link}
variant="tertiary"
data-testid={`${namePrefix}-card-header__title-link`}
className="item-card-header__title-btn"
to={titleLink}
>
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
</Button>
);
TitleLink.propTypes = {
title: PropTypes.string.isRequired,
titleLink: PropTypes.string.isRequired,
namePrefix: PropTypes.string.isRequired,
};
export default TitleLink;

View File

@@ -0,0 +1,78 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
expandTooltip: {
id: 'course-authoring.course-outline.card.expandTooltip',
defaultMessage: 'Collapse/Expand this card',
},
statusBadgeLive: {
id: 'course-authoring.course-outline.card.status-badge.live',
defaultMessage: 'Live',
},
statusBadgeGated: {
id: 'course-authoring.course-outline.card.status-badge.gated',
defaultMessage: 'Gated',
},
statusBadgePublishedNotLive: {
id: 'course-authoring.course-outline.card.status-badge.published-not-live',
defaultMessage: 'Published not live',
},
statusBadgeStaffOnly: {
id: 'course-authoring.course-outline.card.status-badge.staff-only',
defaultMessage: 'Staff only',
},
statusBadgeDraft: {
id: 'course-authoring.course-outline.card.status-badge.draft',
defaultMessage: 'Draft',
},
statusBadgeUnpublishedChanges: {
id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes',
defaultMessage: 'Draft (Unpublished changes)',
},
altButtonEdit: {
id: 'course-authoring.course-outline.card.button.edit.alt',
defaultMessage: 'Edit',
},
menuPublish: {
id: 'course-authoring.course-outline.card.menu.publish',
defaultMessage: 'Publish',
},
menuConfigure: {
id: 'course-authoring.course-outline.card.menu.configure',
defaultMessage: 'Configure',
},
menuDuplicate: {
id: 'course-authoring.course-outline.card.menu.duplicate',
defaultMessage: 'Duplicate',
},
menuMoveUp: {
id: 'course-authoring.course-outline.card.menu.moveup',
defaultMessage: 'Move up',
},
menuMoveDown: {
id: 'course-authoring.course-outline.card.menu.movedown',
defaultMessage: 'Move down',
},
menuDelete: {
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Delete',
},
menuCopy: {
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Copy to clipboard',
},
menuProctoringLinkText: {
id: 'course-authoring.course-outline.card.menu.proctoring-settings',
defaultMessage: 'Proctoring settings',
},
proctoringLinkTooltip: {
id: 'course-authoring.course-outline.card.menu.proctoring-settings-tooltip',
defaultMessage: 'Proctoring settings',
},
discussionEnabledBadgeText: {
id: 'course-authoring.course-outline.card.badge.discussionEnabled',
defaultMessage: 'Discussions enabled',
},
});
export default messages;

View File

@@ -0,0 +1,276 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { Alert, Form, Hyperlink } from '@edx/paragon';
import {
Warning as WarningIcon,
} from '@edx/paragon/icons';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import PrereqSettings from './PrereqSettings';
const AdvancedTab = ({
values,
setFieldValue,
prereqs,
releasedToStudents,
wasExamEverLinkedWithExternal,
enableProctoredExams,
supportsOnboarding,
wasProctoredExam,
showReviewRules,
onlineProctoringRules,
}) => {
const {
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
defaultTimeLimitMinutes,
examReviewRules,
} = values;
let examTypeValue = 'none';
if (isTimeLimited && isProctoredExam) {
if (isOnboardingExam) {
examTypeValue = 'onboardingExam';
} else if (isPracticeExam) {
examTypeValue = 'practiceExam';
} else {
examTypeValue = 'proctoredExam';
}
} else if (isTimeLimited) {
examTypeValue = 'timed';
}
const formatHour = (hour) => {
const hh = Math.floor(hour / 60);
const mm = hour % 60;
let hhs = `${hh}`;
let mms = `${mm}`;
if (hh < 10) {
hhs = `0${hh}`;
}
if (mm < 10) {
mms = `0${mm}`;
}
if (Number.isNaN(hh)) {
hhs = '00';
}
if (Number.isNaN(mm)) {
mms = '00';
}
return `${hhs}:${mms}`;
};
const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes));
const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam;
const handleChange = (e) => {
if (e.target.value === 'timed') {
setFieldValue('isTimeLimited', true);
setFieldValue('isOnboardingExam', false);
setFieldValue('isPracticeExam', false);
setFieldValue('isProctoredExam', false);
} else if (e.target.value === 'onboardingExam') {
setFieldValue('isOnboardingExam', true);
setFieldValue('isProctoredExam', true);
setFieldValue('isTimeLimited', true);
setFieldValue('isPracticeExam', false);
} else if (e.target.value === 'practiceExam') {
setFieldValue('isPracticeExam', true);
setFieldValue('isProctoredExam', true);
setFieldValue('isTimeLimited', true);
setFieldValue('isOnboardingExam', false);
} else if (e.target.value === 'proctoredExam') {
setFieldValue('isProctoredExam', true);
setFieldValue('isTimeLimited', true);
setFieldValue('isOnboardingExam', false);
setFieldValue('isPracticeExam', false);
} else {
setFieldValue('isTimeLimited', false);
setFieldValue('isOnboardingExam', false);
setFieldValue('isPracticeExam', false);
setFieldValue('isProctoredExam', false);
}
};
const setCurrentTimeLimit = (event) => {
const { validity: { valid } } = event.target;
let { value } = event.target;
value = value.trim();
if (value && valid) {
const minutes = moment.duration(value).asMinutes();
setFieldValue('defaultTimeLimitMinutes', minutes);
}
setTimeLimit(value);
};
const renderAlerts = () => {
const proctoredExamLockedIn = releasedToStudents && wasExamEverLinkedWithExternal;
return (
<>
{proctoredExamLockedIn && !wasProctoredExam && (
<Alert variant="warning" icon={WarningIcon}>
<FormattedMessage {...messages.proctoredExamLockedAndisNotProctoredExamAlert} />
</Alert>
)}
{proctoredExamLockedIn && wasProctoredExam && (
<Alert variant="warning" icon={WarningIcon}>
<FormattedMessage {...messages.proctoredExamLockedAndisProctoredExamAlert} />
</Alert>
)}
</>
);
};
return (
<>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.setSpecialExam} /></h5>
<hr />
<Form.RadioSet
name="specialExam"
onChange={handleChange}
value={examTypeValue}
>
{renderAlerts()}
<Form.Radio value="none">
<FormattedMessage {...messages.none} />
</Form.Radio>
<Form.Radio
value="timed"
description={<FormattedMessage {...messages.timedDescription} />}
controlClassName="mw-1-25rem"
>
<FormattedMessage {...messages.timed} />
</Form.Radio>
{enableProctoredExams && (
<>
<Form.Radio
value="proctoredExam"
description={<FormattedMessage {...messages.proctoredExamDescription} />}
controlClassName="mw-1-25rem"
>
<FormattedMessage {...messages.proctoredExam} />
</Form.Radio>
{supportsOnboarding ? (
<Form.Radio
description={<FormattedMessage {...messages.onboardingExamDescription} />}
value="onboardingExam"
controlClassName="mw-1-25rem"
>
<FormattedMessage {...messages.onboardingExam} />
</Form.Radio>
) : (
<Form.Radio
value="practiceExam"
controlClassName="mw-1-25rem"
description={<FormattedMessage {...messages.practiceExamDescription} />}
>
<FormattedMessage {...messages.practiceExam} />
</Form.Radio>
)}
</>
)}
</Form.RadioSet>
{ isTimeLimited && (
<div className="mt-3" data-testid="advanced-tab-hours-picker-wrapper">
<Form.Group>
<Form.Label>
<FormattedMessage {...messages.timeAllotted} />
</Form.Label>
<Form.Control
onChange={setCurrentTimeLimit}
value={timeLimit}
placeholder="HH:MM"
pattern="^[0-9][0-9]:[0-5][0-9]$"
/>
</Form.Group>
<Form.Text><FormattedMessage {...messages.timeLimitDescription} /></Form.Text>
</div>
)}
{ showReviewRulesDiv && (
<div className="mt-3">
<Form.Group>
<Form.Label>
<FormattedMessage {...messages.reviewRulesLabel} />
</Form.Label>
<Form.Control
onChange={(e) => setFieldValue('examReviewRules', e.target.value)}
value={examReviewRules}
as="textarea"
rows="3"
/>
</Form.Group>
<Form.Text>
{ onlineProctoringRules ? (
<FormattedMessage
{...messages.reviewRulesDescriptionWithLink}
values={{
hyperlink: (
<Hyperlink
destination={onlineProctoringRules}
target="_blank"
showLaunchIcon={false}
>
<FormattedMessage
{...messages.reviewRulesDescriptionLinkText}
/>
</Hyperlink>
),
}}
/>
) : (
<FormattedMessage {...messages.reviewRulesDescription} />
)}
</Form.Text>
</div>
)}
<PrereqSettings
values={values}
setFieldValue={setFieldValue}
prereqs={prereqs}
/>
</>
);
};
AdvancedTab.defaultProps = {
prereqs: [],
wasExamEverLinkedWithExternal: false,
enableProctoredExams: false,
supportsOnboarding: false,
wasProctoredExam: false,
showReviewRules: false,
onlineProctoringRules: '',
};
AdvancedTab.propTypes = {
values: PropTypes.shape({
isTimeLimited: PropTypes.bool.isRequired,
defaultTimeLimitMinutes: PropTypes.number,
isPrereq: PropTypes.bool,
prereqUsageKey: PropTypes.string,
prereqMinScore: PropTypes.number,
prereqMinCompletion: PropTypes.number,
isProctoredExam: PropTypes.bool,
isPracticeExam: PropTypes.bool,
isOnboardingExam: PropTypes.bool,
examReviewRules: PropTypes.string,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
prereqs: PropTypes.arrayOf(PropTypes.shape({
blockUsageKey: PropTypes.string.isRequired,
blockDisplayName: PropTypes.string.isRequired,
})),
releasedToStudents: PropTypes.bool.isRequired,
wasExamEverLinkedWithExternal: PropTypes.bool,
enableProctoredExams: PropTypes.bool,
supportsOnboarding: PropTypes.bool,
wasProctoredExam: PropTypes.bool,
showReviewRules: PropTypes.bool,
onlineProctoringRules: PropTypes.string,
};
export default injectIntl(AdvancedTab);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Stack, Form } from '@edx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control';
const BasicTab = ({
values,
setFieldValue,
courseGraders,
isSubsection,
}) => {
const intl = useIntl();
const {
releaseDate,
graderType,
dueDate,
} = values;
const onChangeGraderType = (e) => setFieldValue('graderType', e.target.value);
const createOptions = () => courseGraders.map((option) => (
<option key={option} value={option}> {option} </option>
));
return (
<>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
<hr />
<div data-testid="release-date-stack">
<Stack className="mt-3" direction="horizontal" gap={5}>
<DatepickerControl
type={DATEPICKER_TYPES.date}
value={releaseDate}
label={intl.formatMessage(messages.releaseDate)}
controlName="state-date"
onChange={(val) => setFieldValue('releaseDate', val)}
/>
<DatepickerControl
type={DATEPICKER_TYPES.time}
value={releaseDate}
label={intl.formatMessage(messages.releaseTimeUTC)}
controlName="start-time"
onChange={(val) => setFieldValue('releaseDate', val)}
/>
</Stack>
</div>
{
isSubsection && (
<div>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.grading} /></h5>
<hr />
<Form.Group>
<Form.Label><FormattedMessage {...messages.gradeAs} /></Form.Label>
<Form.Control
as="select"
defaultValue={graderType}
onChange={onChangeGraderType}
data-testid="grader-type-select"
>
<option key="notgraded" value="notgraded">
{intl.formatMessage(messages.notGradedTypeOption)}
</option>
{createOptions()}
</Form.Control>
</Form.Group>
<div data-testid="due-date-stack">
<Stack className="mt-3" direction="horizontal" gap={5}>
<DatepickerControl
type={DATEPICKER_TYPES.date}
value={dueDate}
label={intl.formatMessage(messages.dueDate)}
controlName="state-date"
onChange={(val) => setFieldValue('dueDate', val)}
data-testid="due-date-picker"
/>
<DatepickerControl
type={DATEPICKER_TYPES.time}
value={dueDate}
label={intl.formatMessage(messages.dueTimeUTC)}
controlName="start-time"
onChange={(val) => setFieldValue('dueDate', val)}
/>
</Stack>
</div>
</div>
)
}
</>
);
};
BasicTab.propTypes = {
isSubsection: PropTypes.bool.isRequired,
values: PropTypes.shape({
releaseDate: PropTypes.string.isRequired,
graderType: PropTypes.string.isRequired,
dueDate: PropTypes.string,
}).isRequired,
courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired,
setFieldValue: PropTypes.func.isRequired,
};
export default injectIntl(BasicTab);

View File

@@ -0,0 +1,303 @@
/* eslint-disable import/named */
import React from 'react';
import * as Yup from 'yup';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
Button,
ActionRow,
Form,
Tab,
Tabs,
} from '@edx/paragon';
import { useSelector } from 'react-redux';
import { Formik } from 'formik';
import { VisibilityTypes } from '../../data/constants';
import { COURSE_BLOCK_NAMES } from '../constants';
import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors';
import messages from './messages';
import BasicTab from './BasicTab';
import VisibilityTab from './VisibilityTab';
import AdvancedTab from './AdvancedTab';
import UnitTab from './UnitTab';
const ConfigureModal = ({
isOpen,
onClose,
onConfigureSubmit,
}) => {
const intl = useIntl();
const {
displayName,
start: sectionStartDate,
visibilityState,
due,
isTimeLimited,
defaultTimeLimitMinutes,
hideAfterDue,
showCorrectness,
courseGraders,
category,
format,
userPartitionInfo,
ancestorHasStaffLock,
isPrereq,
prereqs,
prereq,
prereqMinScore,
prereqMinCompletion,
releasedToStudents,
wasExamEverLinkedWithExternal,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
supportsOnboarding,
showReviewRules,
onlineProctoringRules,
} = useSelector(getCurrentItem);
const enableProctoredExams = useSelector(getProctoredExamsFlag);
const getSelectedGroups = () => {
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex]
?.groups
.filter(({ selected }) => selected)
.map(({ id }) => `${id}`)
|| [];
}
return [];
};
const defaultPrereqScore = (val) => {
if (val === null || val === undefined) {
return 100;
}
return parseFloat(val);
};
const initialValues = {
releaseDate: sectionStartDate,
isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY,
saveButtonDisabled: true,
graderType: format == null ? 'notgraded' : format,
dueDate: due == null ? '' : due,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
defaultTimeLimitMinutes,
hideAfterDue: hideAfterDue === undefined ? false : hideAfterDue,
showCorrectness,
isPrereq,
prereqUsageKey: prereq,
prereqMinScore: defaultPrereqScore(prereqMinScore),
prereqMinCompletion: defaultPrereqScore(prereqMinCompletion),
// by default it is -1 i.e. accessible to all learners & staff
selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex,
selectedGroups: getSelectedGroups(),
};
const validationSchema = Yup.object().shape({
isTimeLimited: Yup.boolean(),
isProctoredExam: Yup.boolean(),
isPracticeExam: Yup.boolean(),
isOnboardingExam: Yup.boolean(),
examReviewRules: Yup.string(),
defaultTimeLimitMinutes: Yup.number().nullable(true),
hideAfterDueState: Yup.boolean(),
showCorrectness: Yup.string().required(),
isPrereq: Yup.boolean(),
prereqUsageKey: Yup.string().nullable(true),
prereqMinScore: Yup.number().min(
0,
intl.formatMessage(messages.minScoreError),
).max(
100,
intl.formatMessage(messages.minScoreError),
).nullable(true),
prereqMinCompletion: Yup.number().min(
0,
intl.formatMessage(messages.minScoreError),
).max(
100,
intl.formatMessage(messages.minScoreError),
).nullable(true),
selectedPartitionIndex: Yup.number().integer(),
selectedGroups: Yup.array().of(Yup.string()),
});
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
const handleSave = (data) => {
const groupAccess = {};
switch (category) {
case COURSE_BLOCK_NAMES.chapter.id:
onConfigureSubmit(data.isVisibleToStaffOnly, data.releaseDate);
break;
case COURSE_BLOCK_NAMES.sequential.id:
onConfigureSubmit(
data.isVisibleToStaffOnly,
data.releaseDate,
data.graderType,
data.dueDate,
data.isTimeLimited,
data.isProctoredExam,
data.isOnboardingExam,
data.isPracticeExam,
data.examReviewRules,
data.isTimeLimited ? data.defaultTimeLimitMinutes : 0,
data.hideAfterDue,
data.showCorrectness,
data.isPrereq,
data.prereqUsageKey,
data.prereqMinScore,
data.prereqMinCompletion,
);
break;
case COURSE_BLOCK_NAMES.vertical.id:
// groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1
if (data.selectedPartitionIndex >= 0) {
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
}
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess);
break;
default:
break;
}
};
const renderModalBody = (values, setFieldValue) => {
switch (category) {
case COURSE_BLOCK_NAMES.chapter.id:
return (
<Tabs>
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
<BasicTab
values={values}
setFieldValue={setFieldValue}
isSubsection={isSubsection}
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
/>
</Tab>
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
<VisibilityTab
values={values}
setFieldValue={setFieldValue}
category={category}
isSubsection={isSubsection}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
/>
</Tab>
</Tabs>
);
case COURSE_BLOCK_NAMES.sequential.id:
return (
<Tabs>
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
<BasicTab
values={values}
setFieldValue={setFieldValue}
isSubsection={isSubsection}
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
/>
</Tab>
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
<VisibilityTab
values={values}
setFieldValue={setFieldValue}
category={category}
isSubsection={isSubsection}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
/>
</Tab>
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
<AdvancedTab
values={values}
setFieldValue={setFieldValue}
prereqs={prereqs}
releasedToStudents={releasedToStudents}
wasExamEverLinkedWithExternal={wasExamEverLinkedWithExternal}
enableProctoredExams={enableProctoredExams}
supportsOnboarding={supportsOnboarding}
showReviewRules={showReviewRules}
wasProctoredExam={isProctoredExam}
onlineProctoringRules={onlineProctoringRules}
/>
</Tab>
</Tabs>
);
case COURSE_BLOCK_NAMES.vertical.id:
return (
<UnitTab
values={values}
setFieldValue={setFieldValue}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY && !ancestorHasStaffLock}
userPartitionInfo={userPartitionInfo}
/>
);
default:
return null;
}
};
return (
<ModalDialog
className="configure-modal"
size="lg"
isOpen={isOpen}
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
>
<div data-testid="configure-modal">
<ModalDialog.Header className="configure-modal__header">
<ModalDialog.Title>
{intl.formatMessage(messages.title, { title: displayName })}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik
initialValues={initialValues}
onSubmit={handleSave}
validationSchema={validationSchema}
validateOnBlur
validateOnChange
>
{({
values, handleSubmit, dirty, isValid, setFieldValue,
}) => (
<>
<ModalDialog.Body className="configure-modal__body">
<Form.Group size="sm" className="form-field">
{renderModalBody(values, setFieldValue)}
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer className="pt-1">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button data-testid="configure-save-button" onClick={handleSubmit} disabled={!(dirty && isValid)}>
{intl.formatMessage(messages.saveButton)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</>
)}
</Formik>
</div>
</ModalDialog>
);
};
ConfigureModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfigureSubmit: PropTypes.func.isRequired,
};
export default ConfigureModal;

View File

@@ -0,0 +1,14 @@
.configure-modal {
.configure-modal__header {
padding-top: 1.5rem;
position: static;
}
.w-7rem {
width: 7.2rem;
}
.mw-1-25rem {
min-width: 1.25rem;
}
}

View File

@@ -0,0 +1,419 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import ConfigureModal from './ConfigureModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const currentSectionMock = {
displayName: 'Section1',
category: 'chapter',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
format: 'Not Graded',
childInfo: {
displayName: 'Subsection',
children: [
{
displayName: 'Subsection 1',
id: 1,
category: 'sequential',
due: '',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
defaultTimeLimitMinutes: null,
hideAfterDue: false,
showCorrectness: false,
format: 'Homework',
courseGraders: ['Homework', 'Exam'],
childInfo: {
displayName: 'Unit',
children: [
{
id: 11,
displayName: 'Subsection_1 Unit 1',
},
],
},
},
{
displayName: 'Subsection 2',
id: 2,
category: 'sequential',
due: '',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
defaultTimeLimitMinutes: null,
hideAfterDue: false,
showCorrectness: false,
format: 'Homework',
courseGraders: ['Homework', 'Exam'],
childInfo: {
displayName: 'Unit',
children: [
{
id: 21,
displayName: 'Subsection_2 Unit 1',
},
],
},
},
{
displayName: 'Subsection 3',
id: 3,
category: 'sequential',
due: '',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
defaultTimeLimitMinutes: null,
hideAfterDue: false,
showCorrectness: false,
format: 'Homework',
courseGraders: ['Homework', 'Exam'],
childInfo: {
children: [],
},
},
],
},
};
const onCloseMock = jest.fn();
const onConfigureSubmitMock = jest.fn();
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<ConfigureModal /> for Section', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentSectionMock);
});
it('renders ConfigureModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument();
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
it('switches to the Visibility tab and renders correctly', () => {
const { getByRole, getByText } = renderComponent();
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
expect(getByText('Section Visibility')).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
});
it('disables the Save button and enables it if there is a change', () => {
const { getByRole, getByPlaceholderText, getByTestId } = renderComponent();
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
expect(saveButton).toBeDisabled();
const input = getByPlaceholderText('MM/DD/YYYY');
fireEvent.change(input, { target: { value: '12/15/2023' } });
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const checkbox = getByTestId('visibility-checkbox');
fireEvent.click(checkbox);
expect(saveButton).not.toBeDisabled();
});
});
const currentSubsectionMock = {
displayName: 'Subsection 1',
id: 1,
category: 'sequential',
due: '',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
defaultTimeLimitMinutes: null,
hideAfterDue: false,
showCorrectness: false,
format: 'Homework',
courseGraders: ['Homework', 'Exam'],
childInfo: {
displayName: 'Unit',
children: [
{
id: 11,
displayName: 'Subsection_1 Unit 1',
},
{
id: 12,
displayName: 'Subsection_1 Unit 2',
},
],
},
};
const renderSubsectionComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<ConfigureModal /> for Subsection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentSubsectionMock);
});
it('renders subsection ConfigureModal component correctly', () => {
const { getByText, getByRole } = renderSubsectionComponent();
expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument();
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.grading.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.gradeAs.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.dueDate.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.dueTimeUTC.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
it('switches to the subsection Visibility tab and renders correctly', () => {
const { getByRole, getByText } = renderSubsectionComponent();
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
expect(getByText('Subsection Visibility')).toBeInTheDocument();
expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideContentAfterDueDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideEntireSubsection.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.assessmentResultsVisibility.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alwaysShowAssessmentResults.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alwaysShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.neverShowAssessmentResults.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.neverShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.showAssessmentResultsPastDue.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.showAssessmentResultsPastDueDescription.defaultMessage)).toBeInTheDocument();
});
it('switches to the subsection Advanced tab and renders correctly', () => {
const { getByRole, getByText } = renderSubsectionComponent();
const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage });
fireEvent.click(advancedTab);
expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.none.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument();
});
it('disables the Save button and enables it if there is a change', () => {
const { getByRole, getByTestId } = renderSubsectionComponent();
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
expect(saveButton).toBeDisabled();
const input = getByTestId('grader-type-select');
fireEvent.change(input, { target: { value: 'Exam' } });
expect(saveButton).not.toBeDisabled();
});
});
const currentUnitMock = {
displayName: 'Unit 1',
id: 1,
category: 'vertical',
due: '',
start: '2025-08-10T10:00:00Z',
visibilityState: true,
defaultTimeLimitMinutes: null,
hideAfterDue: false,
showCorrectness: false,
userPartitionInfo: {
selectablePartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 6,
name: 'Honor',
selected: false,
deleted: false,
},
{
id: 2,
name: 'Verified',
selected: false,
deleted: false,
},
],
},
{
id: 1508065533,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
id: 1224170703,
name: 'Content Group 1',
selected: false,
deleted: false,
},
],
},
],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
};
const renderUnitComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<ConfigureModal /> for Unit', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentUnitMock);
});
it('renders unit ConfigureModal component correctly', () => {
const {
getByText, queryByText, getByRole, getByTestId,
} = renderUnitComponent();
expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument();
expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument();
const input = getByTestId('group-type-select');
[0, 1].forEach(groupeTypeIndex => {
fireEvent.change(input, { target: { value: groupeTypeIndex } });
expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument();
currentUnitMock
.userPartitionInfo
.selectablePartitions[groupeTypeIndex].groups
.forEach(g => expect(getByText(g.name)).toBeInTheDocument());
});
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
it('disables the Save button and enables it if there is a change', () => {
useSelector.mockReturnValue(
{
...currentUnitMock,
userPartitionInfo: {
...currentUnitMock.userPartitionInfo,
selectedPartitionIndex: 0,
},
},
);
const { getByRole, getByTestId } = renderUnitComponent();
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
expect(saveButton).toBeDisabled();
const input = getByTestId('group-type-select');
// unrestrict access
fireEvent.change(input, { target: { value: -1 } });
expect(saveButton).not.toBeDisabled();
fireEvent.change(input, { target: { value: 0 } });
expect(saveButton).toBeDisabled();
const checkbox = getByTestId('unit-visibility-checkbox');
fireEvent.click(checkbox);
expect(saveButton).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,117 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
const PrereqSettings = ({
values,
setFieldValue,
prereqs,
}) => {
const intl = useIntl();
const {
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
} = values;
if (isPrereq === null || isPrereq === undefined) {
return null;
}
const handleSelectChange = (e) => {
setFieldValue('prereqUsageKey', e.target.value);
};
const prereqSelectionForm = () => (
<>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.limitAccessTitle} /></h5>
<hr />
<Form>
<Form.Text><FormattedMessage {...messages.limitAccessDescription} /></Form.Text>
<Form.Group controlId="prereqForm.select">
<Form.Label>
{intl.formatMessage(messages.prerequisiteSelectLabel)}
</Form.Label>
<Form.Control
as="select"
defaultValue={prereqUsageKey}
onChange={handleSelectChange}
role="combobox"
>
<option value="">
{intl.formatMessage(messages.noPrerequisiteOption)}
</option>
{prereqs.map((prereqOption) => (
<option
key={prereqOption.blockUsageKey}
value={prereqOption.blockUsageKey}
>
{prereqOption.blockDisplayName}
</option>
))}
</Form.Control>
</Form.Group>
{prereqUsageKey && (
<>
<FormikControl
name="prereqMinScore"
value={prereqMinScore}
label={<Form.Label>{intl.formatMessage(messages.minScoreLabel)}</Form.Label>}
controlClassName="text-right"
controlClasses="w-7rem"
type="number"
trailingElement="%"
/>
<FormikControl
name="prereqMinCompletion"
value={prereqMinCompletion}
label={<Form.Label>{intl.formatMessage(messages.minCompletionLabel)}</Form.Label>}
controlClassName="text-right"
controlClasses="w-7rem"
type="number"
trailingElement="%"
/>
</>
)}
</Form>
</>
);
const handleCheckboxChange = e => setFieldValue('isPrereq', e.target.checked);
return (
<>
{prereqs.length > 0 && prereqSelectionForm()}
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.prereqTitle} /></h5>
<hr />
<Form.Checkbox checked={isPrereq} onChange={handleCheckboxChange}>
<FormattedMessage {...messages.prereqCheckboxLabel} />
</Form.Checkbox>
</>
);
};
PrereqSettings.defaultProps = {
prereqs: [],
};
PrereqSettings.propTypes = {
values: PropTypes.shape({
isPrereq: PropTypes.bool,
prereqUsageKey: PropTypes.string,
prereqMinScore: PropTypes.number,
prereqMinCompletion: PropTypes.number,
}).isRequired,
prereqs: PropTypes.arrayOf(PropTypes.shape({
blockUsageKey: PropTypes.string.isRequired,
blockDisplayName: PropTypes.string.isRequired,
})),
setFieldValue: PropTypes.func.isRequired,
};
export default injectIntl(PrereqSettings);

View File

@@ -0,0 +1,133 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Form } from '@edx/paragon';
import {
FormattedMessage, injectIntl, useIntl,
} from '@edx/frontend-platform/i18n';
import { Field } from 'formik';
import messages from './messages';
const UnitTab = ({
values,
setFieldValue,
showWarning,
userPartitionInfo,
}) => {
const intl = useIntl();
const {
isVisibleToStaffOnly,
selectedPartitionIndex,
} = values;
const handleChange = (e) => {
setFieldValue('isVisibleToStaffOnly', e.target.checked);
};
const handleSelect = (e) => {
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
};
return (
<>
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="unit-visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.unitVisibilityWarning} />
</Alert>
)}
<hr />
<Form.Group controlId="groupSelect">
<Form.Label as="legend" className="font-weight-bold">
<FormattedMessage {...messages.restrictAccessTo} />
</Form.Label>
<Form.Control
as="select"
name="groupSelect"
value={selectedPartitionIndex}
onChange={handleSelect}
data-testid="group-type-select"
>
<option value="-1" key="-1">
{userPartitionInfo.selectedPartitionIndex === -1
? intl.formatMessage(messages.unitSelectGroupType)
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
</option>
{userPartitionInfo.selectablePartitions.map((partition, index) => (
<option
key={partition.id}
value={index}
>
{partition.name}
</option>
))}
</Form.Control>
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
<Form.Group controlId="select-groups-checkboxes">
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
<div
role="group"
className="d-flex flex-column"
data-testid="group-checkboxes"
aria-labelledby="select-groups-checkboxes"
>
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
<Form.Group
key={group.id}
className="pgn__form-checkbox"
>
<Field
as={Form.Control}
className="flex-grow-0 mr-1"
controlClassName="pgn__form-checkbox-input mr-1"
type="checkbox"
value={`${group.id}`}
name="selectedGroups"
/>
<Form.Label isInline>
{group.name}
</Form.Label>
</Form.Group>
))}
</div>
</Form.Group>
)}
</Form.Group>
</>
);
};
UnitTab.propTypes = {
values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired,
selectedPartitionIndex: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
showWarning: PropTypes.bool.isRequired,
userPartitionInfo: PropTypes.shape({
selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
groups: PropTypes.arrayOf(PropTypes.shape({
deleted: PropTypes.bool.isRequired,
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
}).isRequired).isRequired,
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
scheme: PropTypes.string.isRequired,
}).isRequired).isRequired,
selectedGroupsLabel: PropTypes.string,
selectedPartitionIndex: PropTypes.number.isRequired,
}).isRequired,
};
export default injectIntl(UnitTab);

View File

@@ -0,0 +1,135 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Form } from '@edx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { COURSE_BLOCK_NAMES } from '../constants';
const VisibilityTab = ({
values,
setFieldValue,
category,
showWarning,
isSubsection,
}) => {
const intl = useIntl();
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
const {
isVisibleToStaffOnly,
hideAfterDue,
showCorrectness,
} = values;
const handleChange = (e) => {
setFieldValue('isVisibleToStaffOnly', e.target.checked);
};
const getVisibilityValue = () => {
if (isVisibleToStaffOnly) {
return 'hide';
}
if (hideAfterDue) {
return 'hideDue';
}
return 'show';
};
const visibilityChanged = (e) => {
const selected = e.target.value;
if (selected === 'hide') {
setFieldValue('isVisibleToStaffOnly', true);
setFieldValue('hideAfterDue', false);
} else if (selected === 'hideDue') {
setFieldValue('isVisibleToStaffOnly', false);
setFieldValue('hideAfterDue', true);
} else {
setFieldValue('isVisibleToStaffOnly', false);
setFieldValue('hideAfterDue', false);
}
};
const correctnessChanged = (e) => {
setFieldValue('showCorrectness', e.target.value);
};
return (
<>
<h5 className="mt-4 text-gray-700">
{intl.formatMessage(messages.visibilitySectionTitle, { visibilityTitle })}
</h5>
<hr />
{
isSubsection ? (
<>
<Form.RadioSet
name="subsectionVisibility"
onChange={visibilityChanged}
value={getVisibilityValue()}
>
<Form.Radio value="show">
<FormattedMessage {...messages.showEntireSubsection} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
<Form.Radio value="hideDue">
<FormattedMessage {...messages.hideContentAfterDue} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
<Form.Radio value="hide">
<FormattedMessage {...messages.hideEntireSubsection} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.hideEntireSubsectionDescription} /></Form.Text>
</Form.RadioSet>
{showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.subsectionVisibilityWarning} />
</Alert>
)}
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.assessmentResultsVisibility} /></h5>
<Form.RadioSet
name="assessmentResultsVisibility"
onChange={correctnessChanged}
value={showCorrectness}
>
<Form.Radio value="always">
<FormattedMessage {...messages.alwaysShowAssessmentResults} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.alwaysShowAssessmentResultsDescription} /></Form.Text>
<Form.Radio value="never">
<FormattedMessage {...messages.neverShowAssessmentResults} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.neverShowAssessmentResultsDescription} /></Form.Text>
<Form.Radio value="past_due">
<FormattedMessage {...messages.showAssessmentResultsPastDue} />
</Form.Radio>
<Form.Text><FormattedMessage {...messages.showAssessmentResultsPastDueDescription} /></Form.Text>
</Form.RadioSet>
</>
) : (
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
)
}
{showWarning && !isSubsection && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.sectionVisibilityWarning} />
</Alert>
)}
</>
);
};
VisibilityTab.propTypes = {
values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired,
hideAfterDue: PropTypes.bool.isRequired,
showCorrectness: PropTypes.string.isRequired,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
category: PropTypes.string.isRequired,
showWarning: PropTypes.bool.isRequired,
isSubsection: PropTypes.bool.isRequired,
};
export default injectIntl(VisibilityTab);

View File

@@ -0,0 +1,270 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.configure-modal.title',
defaultMessage: '{title} Settings',
},
basicTabTitle: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.title',
defaultMessage: 'Basic',
},
notGradedTypeOption: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.notGradedTypeOption',
defaultMessage: 'Not Graded',
},
releaseDateAndTime: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time',
defaultMessage: 'Release Date and Time',
},
releaseDate: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date',
defaultMessage: 'Release Date:',
},
releaseTimeUTC: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC',
defaultMessage: 'Release Time in UTC:',
},
visibilityTabTitle: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.title',
defaultMessage: 'Visibility',
},
visibilitySectionTitle: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility',
defaultMessage: '{visibilityTitle} Visibility',
},
unitVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility',
defaultMessage: 'Unit Visibility',
},
hideFromLearners: {
id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners',
defaultMessage: 'Hide from learners',
},
restrictAccessTo: {
id: 'course-authoring.course-outline.configure-modal.visibility.restrict-access-to',
defaultMessage: 'Restrict access to',
},
sectionVisibilityWarning: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility-warning',
defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the section. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.',
},
unitVisibilityWarning: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-visibility-warning',
defaultMessage: 'If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.',
},
subsectionVisibilityWarning: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.subsection-visibility-warning',
defaultMessage: 'If you select an option other than "Hide entire subsection", published units in this subsection will become available to learners unless they are explicitly hidden.',
},
unitSelectGroup: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group',
defaultMessage: 'Select one or more groups:',
},
unitSelectGroupType: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type',
defaultMessage: 'Select a group type',
},
unitAllLearnersAndStaff: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff',
defaultMessage: 'All Learners and Staff',
},
cancelButton: {
id: 'course-authoring.course-outline.configure-modal.button.cancel',
defaultMessage: 'Cancel',
},
saveButton: {
id: 'course-authoring.course-outline.configure-modal.button.label',
defaultMessage: 'Save',
},
grading: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.grading',
defaultMessage: 'Grading',
},
gradeAs: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.grade-as',
defaultMessage: 'Grade as:',
},
dueDate: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date',
defaultMessage: 'Due Date:',
},
dueTimeUTC: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC',
defaultMessage: 'Due Time in UTC:',
},
subsectionVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility',
defaultMessage: 'Subsection Visibility',
},
showEntireSubsection: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection',
defaultMessage: 'Show entire subsection',
},
showEntireSubsectionDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection-description',
defaultMessage: 'Learners see the published subsection and can access its content',
},
hideContentAfterDue: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due',
defaultMessage: 'Hide content after due date',
},
hideContentAfterDueDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
},
hideEntireSubsection: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
defaultMessage: 'Hide entire subsection',
},
hideEntireSubsectionDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection-description',
defaultMessage: 'Learners do not see the subsection in the course outline. The subsection is not included in grade calculations.',
},
assessmentResultsVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.assessment-results-visibility',
defaultMessage: 'Assessment Results Visibility',
},
alwaysShowAssessmentResults: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results',
defaultMessage: 'Always show assessment results',
},
alwaysShowAssessmentResultsDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results-description',
defaultMessage: 'When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.',
},
neverShowAssessmentResults: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results',
defaultMessage: 'Never show assessment results',
},
neverShowAssessmentResultsDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results-description',
defaultMessage: 'Learners never see whether their answers to assessments are correct or incorrect, nor the score received.',
},
showAssessmentResultsPastDue: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due',
defaultMessage: 'Show assessment results when subsection is past due',
},
showAssessmentResultsPastDueDescription: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due-description',
defaultMessage: 'Learners do not see whether their answer to assessments were correct or incorrect, nor the score received, until after the due date for the subsection has passed. If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.',
},
setSpecialExam: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam',
defaultMessage: 'Set as a Special Exam',
},
none: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.none',
defaultMessage: 'None',
},
timed: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed',
defaultMessage: 'Timed',
},
timedDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.',
},
proctoredExam: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExam',
defaultMessage: 'Proctored',
},
proctoredExamDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
defaultMessage: 'Proctored exams are timed and they record video of each learner taking the exam. The videos are then reviewed to ensure that learners follow all examination rules. Please note that setting this exam as proctored will change the visibility settings to "Hide content after due date."',
},
onboardingExam: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.onboardingExam',
defaultMessage: 'Onboarding',
},
onboardingExamDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
defaultMessage: 'Use Onboarding to introduce learners to proctoring, verify their identity, and create an onboarding profile. Learners must complete the onboarding profile step prior to taking a proctored exam. Profile reviews take 2+ business days.',
},
practiceExam: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.practiceExam',
defaultMessage: 'Practice proctored',
},
practiceExamDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
defaultMessage: 'Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of a practice exam do not affect a learner\'s grade.',
},
advancedTabTitle: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.title',
defaultMessage: 'Advanced',
},
timeAllotted: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted',
defaultMessage: 'Time Allotted (HH:MM):',
},
timeLimitDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description',
defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.',
},
prereqTitle: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqTitle',
defaultMessage: 'Use as a Prerequisite',
},
prereqCheckboxLabel: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqCheckboxLabel',
defaultMessage: 'Make this subsection available as a prerequisite to other content',
},
limitAccessTitle: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessTitle',
defaultMessage: 'Limit access',
},
limitAccessDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessDescription',
defaultMessage: 'Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100',
},
noPrerequisiteOption: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.noPrerequisiteOption',
defaultMessage: 'No prerequisite',
},
prerequisiteSelectLabel: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prerequisiteSelectLabel',
defaultMessage: 'Prerequisite:',
},
minScoreLabel: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreLabel',
defaultMessage: 'Minimum score:',
},
minCompletionLabel: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionLabel',
defaultMessage: 'Minimum completion:',
},
minScoreError: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreError',
defaultMessage: 'The minimum score percentage must be a whole number between 0 and 100.',
},
minCompletionError: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionError',
defaultMessage: 'The minimum completion percentage must be a whole number between 0 and 100.',
},
proctoredExamLockedAndisNotProctoredExamAlert: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisNotProctoredExamAlert',
defaultMessage: 'This subsection was released to learners as a proctored exam, but was reverted back to a basic or timed exam. You may not configure it as a proctored exam now. Contact edX Support for assistance.',
},
proctoredExamLockedAndisProctoredExamAlert: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisProctoredExamAlert',
defaultMessage: 'This proctored exam has been released to learners. You may not convert it to another type of special exam. You may revert this subsection back to being a basic exam by selecting \'None\', or a timed exam, but you will NOT be able to configure it as a proctored exam in the future.',
},
reviewRulesLabel: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesLabel',
defaultMessage: 'Review rules',
},
reviewRulesDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescription',
defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam.',
},
reviewRulesDescriptionWithLink: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionWithLink',
defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam, along with the {hyperlink}.',
},
reviewRulesDescriptionLinkText: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionLinkText',
defaultMessage: 'general proctored exam rules',
},
});
export default messages;

View File

@@ -0,0 +1,82 @@
export const ITEM_BADGE_STATUS = /** @type {const} */ ({
live: 'live',
gated: 'gated',
publishedNotLive: 'published_not_live',
unpublishedChanges: 'unpublished_changes',
staffOnly: 'staff_only',
draft: 'draft',
});
export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
export const CHECKLIST_FILTERS = /** @type {const} */ ({
ALL: 'ALL',
SELF_PACED: 'SELF_PACED',
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
});
export const COURSE_BLOCK_NAMES = /** @type {const} */ ({
chapter: { id: 'chapter', name: 'Section' },
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
});
export const LAUNCH_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'welcomeMessage',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'gradingPolicy',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'certificate',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'courseDates',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'assignmentDeadlines',
pacingTypeFilter: CHECKLIST_FILTERS.INSTRUCTOR_PACED,
},
{
id: 'proctoringEmail',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
});
export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'videoDuration',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'mobileFriendlyVideo',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'diverseSequences',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
{
id: 'weeklyHighlights',
pacingTypeFilter: CHECKLIST_FILTERS.SELF_PACED,
},
{
id: 'unitDepth',
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
});
export const VIDEO_SHARING_OPTIONS = /** @type {const} */ ({
perVideo: 'per-video',
allOn: 'all-on',
allOff: 'all-off',
});

View File

@@ -0,0 +1,474 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
export const getCourseLaunchApiUrl = ({
courseId,
gradedOnly,
validateOras,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
export const getCourseBlockApiUrl = (courseId) => {
const formattedCourseId = courseId.split('course-v1:')[1];
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
};
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
/**
* @typedef {Object} courseOutline
* @property {string} courseReleaseDate
* @property {Object} courseStructure
* @property {Object} deprecatedBlocksInfo
* @property {string} discussionsIncontextFeedbackUrl
* @property {string} discussionsIncontextLearnmoreUrl
* @property {Object} initialState
* @property {Object} initialUserClipboard
* @property {string} languageCode
* @property {string} lmsLink
* @property {string} mfeProctoredExamSettingsUrl
* @property {string} notificationDismissUrl
* @property {string[]} proctoringErrors
* @property {string} reindexLink
* @property {null} rerunNotificationId
*/
/**
* Get course outline index.
* @param {string} courseId
* @returns {Promise<courseOutline>}
*/
export async function getCourseOutlineIndex(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseOutlineIndexApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Get course best practices.
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
*/
export async function getCourseBestPractices({
courseId,
excludeGraded,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
return camelCaseObject(data);
}
/** @typedef {object} courseLaunchData
* @property {boolean} isSelfPaced
* @property {object} dates
* @property {object} assignments
* @property {object} grades
* @property {number} grades.sum_of_weights
* @property {object} certificates
* @property {object} updates
* @property {object} proctoring
*/
/**
* Get course launch.
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
* @returns {Promise<courseLaunchData>}
*/
export async function getCourseLaunch({
courseId,
gradedOnly,
validateOras,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}
/**
* Enable course highlights emails
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function enableCourseHighlightsEmails(courseId) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseBlockApiUrl(courseId), {
publish: 'republish',
metadata: {
highlights_enabled_for_messaging: true,
},
});
return data;
}
/**
* Restart reindex course
* @param {string} reindexLink
* @returns {Promise<Object>}
*/
export async function restartIndexingOnCourse(reindexLink) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseReindexApiUrl(reindexLink));
return camelCaseObject(data);
}
/**
* @typedef {Object} section
* @property {string} id
* @property {string} displayName
* @property {string} category
* @property {boolean} hasChildren
* @property {string} editedOn
* @property {boolean} published
* @property {string} publishedOn
* @property {string} studioUrl
* @property {boolean} releasedToStudents
* @property {string} releaseDate
* @property {string} visibilityState
* @property {boolean} hasExplicitStaffLock
* @property {string} start
* @property {boolean} graded
* @property {string} dueDate
* @property {null} due
* @property {null} relativeWeeksDue
* @property {null} format
* @property {string[]} courseGraders
* @property {boolean} hasChanges
* @property {object} actions
* @property {null} explanatoryMessage
* @property {object[]} userPartitions
* @property {string} showCorrectness
* @property {string[]} highlights
* @property {boolean} highlightsEnabled
* @property {boolean} highlightsPreviewOnly
* @property {string} highlightsDocUrl
* @property {object} childInfo
* @property {boolean} ancestorHasStaffLock
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
* @property {object} userPartitionInfo
* @property {boolean} enableCopyPasteUnits
*/
/**
* Get course section
* @param {string} itemId
* @returns {Promise<section>}
*/
export async function getCourseItem(itemId) {
const { data } = await getAuthenticatedHttpClient()
.get(getXBlockApiUrl(itemId));
return camelCaseObject(data);
}
/**
* Update course section highlights
* @param {string} sectionId
* @param {Array<string>} highlights
* @returns {Promise<Object>}
*/
export async function updateCourseSectionHighlights(sectionId, highlights) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(sectionId), {
publish: 'republish',
metadata: {
highlights,
},
});
return data;
}
/**
* Publish course section
* @param {string} sectionId
* @returns {Promise<Object>}
*/
export async function publishCourseSection(sectionId) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(sectionId), {
publish: 'make_public',
});
return data;
}
/**
* Configure course section
* @param {string} sectionId
* @param {boolean} isVisibleToStaffOnly
* @param {string} startDatetime
* @returns {Promise<Object>}
*/
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(sectionId), {
publish: 'republish',
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
start: startDatetime,
},
});
return data;
}
/**
* Configure course section
* @param {string} itemId
* @param {string} isVisibleToStaffOnly
* @param {string} releaseDate
* @param {string} graderType
* @param {string} dueDate
* @param {boolean} isProctoredExam,
* @param {boolean} isOnboardingExam,
* @param {boolean} isPracticeExam,
* @param {string} examReviewRules,
* @param {boolean} isTimeLimited
* @param {number} defaultTimeLimitMin
* @param {string} hideAfterDue
* @param {string} showCorrectness
* @param {boolean} isPrereq,
* @param {string} prereqUsageKey,
* @param {number} prereqMinScore,
* @param {number} prereqMinCompletion,
* @returns {Promise<Object>}
*/
export async function configureCourseSubsection(
itemId,
isVisibleToStaffOnly,
releaseDate,
graderType,
dueDate,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
defaultTimeLimitMin,
hideAfterDue,
showCorrectness,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(itemId), {
publish: 'republish',
graderType,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
due: dueDate,
hide_after_due: hideAfterDue,
show_correctness: showCorrectness,
is_practice_exam: isPracticeExam,
is_time_limited: isTimeLimited,
is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam,
exam_review_rules: examReviewRules,
default_time_limit_minutes: defaultTimeLimitMin,
is_onboarding_exam: isOnboardingExam,
start: releaseDate,
},
});
return data;
}
/**
* Configure course unit
* @param {string} unitId
* @param {boolean} isVisibleToStaffOnly
* @param {object} groupAccess
* @returns {Promise<Object>}
*/
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(unitId), {
publish: 'republish',
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
group_access: groupAccess,
},
});
return data;
}
/**
* Edit course section
* @param {string} itemId
* @param {string} displayName
* @returns {Promise<Object>}
*/
export async function editItemDisplayName(itemId, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(itemId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Delete course section
* @param {string} itemId
* @returns {Promise<Object>}
*/
export async function deleteCourseItem(itemId) {
const { data } = await getAuthenticatedHttpClient()
.delete(getCourseItemApiUrl(itemId));
return data;
}
/**
* Duplicate course section
* @param {string} itemId
* @param {string} parentId
* @returns {Promise<Object>}
*/
export async function duplicateCourseItem(itemId, parentId) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
duplicate_source_locator: itemId,
parent_locator: parentId,
});
return data;
}
/**
* Add new course item like section, subsection or unit.
* @param {string} parentLocator
* @param {string} category
* @param {string} displayName
* @returns {Promise<Object>}
*/
export async function addNewCourseItem(parentLocator, category, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
category,
display_name: displayName,
});
return data;
}
/**
* Set order for the list of the sections
* @param {string} courseId
* @param {Array<string>} children list of sections id's
* @returns {Promise<Object>}
*/
export async function setSectionOrderList(courseId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getCourseBlockApiUrl(courseId), {
children,
});
return data;
}
/**
* Set order for the list of the subsections
* @param {string} itemId Subsection or unit ID
* @param {Array<string>} children list of sections id's
* @returns {Promise<Object>}
*/
export async function setCourseItemOrderList(itemId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getCourseItemApiUrl(itemId), {
children,
});
return data;
}
/**
* Set video sharing setting
* @param {string} courseId
* @param {string} videoSharingOption
* @returns {Promise<Object>}
*/
export async function setVideoSharingOption(courseId, videoSharingOption) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: videoSharingOption,
},
});
return data;
}
/**
* Copy block to clipboard
* @param {string} usageKey
* @returns {Promise<Object>}
*/
export async function copyBlockToClipboard(usageKey) {
const { data } = await getAuthenticatedHttpClient()
.post(getClipboardUrl(), {
usage_key: usageKey,
});
return camelCaseObject(data);
}
/**
* Paste block to clipboard
* @param {string} parentLocator
* @returns {Promise<Object>}
*/
export async function pasteBlock(parentLocator) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
staged_content: 'clipboard',
});
return data;
}
/**
* Dismiss notification
* @param {string} url
* @returns void
*/
export async function dismissNotification(url) {
await getAuthenticatedHttpClient()
.delete(url);
}

View File

@@ -0,0 +1,12 @@
export const getOutlineIndexData = (state) => state.courseOutline.outlineIndexData;
export const getLoadingStatus = (state) => state.courseOutline.loadingStatus;
export const getStatusBarData = (state) => state.courseOutline.statusBarData;
export const getSavingStatus = (state) => state.courseOutline.savingStatus;
export const getSectionsList = (state) => state.courseOutline.sectionsList;
export const getCurrentItem = (state) => state.courseOutline.currentItem;
export const getCurrentSection = (state) => state.courseOutline.currentSection;
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard;
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;

View File

@@ -0,0 +1,225 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { VIDEO_SHARING_OPTIONS } from '../constants';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseOutline',
initialState: {
loadingStatus: {
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
},
outlineIndexData: {},
savingStatus: '',
statusBarData: {
courseReleaseDate: '',
highlightsEnabledForMessaging: false,
isSelfPaced: false,
checklist: {
totalCourseLaunchChecks: 0,
completedCourseLaunchChecks: 0,
totalCourseBestPracticesChecks: 0,
completedCourseBestPracticesChecks: 0,
},
videoSharingEnabled: false,
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
},
sectionsList: [],
isCustomRelativeDatesActive: false,
currentSection: {},
currentSubsection: {},
currentItem: {},
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
initialUserClipboard: {
content: {},
sourceUsageKey: null,
sourceContexttitle: null,
sourceEditUrl: null,
},
enableProctoredExams: false,
},
reducers: {
fetchOutlineIndexSuccess: (state, { payload }) => {
state.outlineIndexData = payload;
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
state.initialUserClipboard = payload.initialUserClipboard;
state.enableProctoredExams = payload.courseStructure?.enableProctoredExams;
},
updateOutlineIndexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
outlineIndexLoadingStatus: payload.status,
};
},
updateReindexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
reIndexLoadingStatus: payload.status,
};
},
updateFetchSectionLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchSectionLoadingStatus: payload.status,
};
},
updateStatusBar: (state, { payload }) => {
state.statusBarData = {
...state.statusBarData,
...payload,
};
},
updateClipboardContent: (state, { payload }) => {
state.initialUserClipboard = payload;
},
updateCourseActions: (state, { payload }) => {
state.actions = {
...state.actions,
...payload,
};
},
fetchStatusBarChecklistSuccess: (state, { payload }) => {
state.statusBarData.checklist = {
...state.statusBarData.checklist,
...payload,
};
},
fetchStatusBarSelPacedSuccess: (state, { payload }) => {
state.statusBarData.isSelfPaced = payload.isSelfPaced;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
updateSectionList: (state, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => (section.id === payload.id ? payload : section));
},
setCurrentItem: (state, { payload }) => {
state.currentItem = payload;
},
reorderSectionList: (state, { payload }) => {
const sectionsList = [...state.sectionsList];
sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id));
state.sectionsList = [...sectionsList];
},
reorderSubsectionList: (state, { payload }) => {
const { sectionId, subsectionListIds } = payload;
const sections = [...state.sectionsList];
const i = sections.findIndex(section => section.id === sectionId);
sections[i].childInfo.children.sort((a, b) => subsectionListIds.indexOf(a.id) - subsectionListIds.indexOf(b.id));
state.sectionsList = [...sections];
},
reorderUnitList: (state, { payload }) => {
const { sectionId, subsectionId, unitListIds } = payload;
const sections = [...state.sectionsList];
const i = sections.findIndex(section => section.id === sectionId);
const j = sections[i].childInfo.children.findIndex(subsection => subsection.id === subsectionId);
const subsection = sections[i].childInfo.children[j];
subsection.childInfo.children.sort((a, b) => unitListIds.indexOf(a.id) - unitListIds.indexOf(b.id));
state.sectionsList = [...sections];
},
setCurrentSection: (state, { payload }) => {
state.currentSection = payload;
},
setCurrentSubsection: (state, { payload }) => {
state.currentSubsection = payload;
},
addSection: (state, { payload }) => {
state.sectionsList = [
...state.sectionsList,
payload,
];
},
addSubsection: (state, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
if (section.id === payload.parentLocator) {
section.childInfo.children = [
...section.childInfo.children,
payload.data,
];
}
return section;
});
},
deleteSection: (state, { payload }) => {
state.sectionsList = state.sectionsList.filter(
({ id }) => id !== payload.itemId,
);
},
deleteSubsection: (state, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
if (section.id !== payload.sectionId) {
return section;
}
section.childInfo.children = section.childInfo.children.filter(
({ id }) => id !== payload.itemId,
);
return section;
});
},
deleteUnit: (state, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => {
if (section.id !== payload.sectionId) {
return section;
}
section.childInfo.children = section.childInfo.children.map((subsection) => {
if (subsection.id !== payload.subsectionId) {
return subsection;
}
subsection.childInfo.children = subsection.childInfo.children.filter(
({ id }) => id !== payload.itemId,
);
return subsection;
});
return section;
});
},
duplicateSection: (state, { payload }) => {
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
if (currentValue.id === payload.id) {
return [...result, currentValue, payload.duplicatedItem];
}
return [...result, currentValue];
}, []);
},
},
});
export const {
addSection,
addSubsection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,
updateStatusBar,
updateCourseActions,
fetchStatusBarChecklistSuccess,
fetchStatusBarSelPacedSuccess,
updateFetchSectionLoadingStatus,
updateSavingStatus,
updateSectionList,
setCurrentItem,
setCurrentSection,
setCurrentSubsection,
deleteSection,
deleteSubsection,
deleteUnit,
duplicateSection,
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
updateClipboardContent,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,640 @@
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { COURSE_BLOCK_NAMES } from '../constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import {
getCourseBestPracticesChecklist,
getCourseLaunchChecklist,
} from '../utils/getChecklistForStatusBar';
import {
addNewCourseItem,
deleteCourseItem,
duplicateCourseItem,
editItemDisplayName,
enableCourseHighlightsEmails,
getCourseBestPractices,
getCourseLaunch,
getCourseOutlineIndex,
getCourseItem,
publishCourseSection,
configureCourseSection,
configureCourseSubsection,
configureCourseUnit,
restartIndexingOnCourse,
updateCourseSectionHighlights,
setSectionOrderList,
setVideoSharingOption,
setCourseItemOrderList,
copyBlockToClipboard,
pasteBlock,
dismissNotification,
} from './api';
import {
addSection,
addSubsection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,
updateStatusBar,
updateCourseActions,
fetchStatusBarChecklistSuccess,
fetchStatusBarSelPacedSuccess,
updateSavingStatus,
updateSectionList,
updateFetchSectionLoadingStatus,
deleteSection,
deleteSubsection,
deleteUnit,
duplicateSection,
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
updateClipboardContent,
} from './slice';
export function fetchCourseOutlineIndexQuery(courseId) {
return async (dispatch) => {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const outlineIndex = await getCourseOutlineIndex(courseId);
const {
courseReleaseDate,
courseStructure: {
highlightsEnabledForMessaging,
videoSharingEnabled,
videoSharingOptions,
actions,
},
} = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateStatusBar({
courseReleaseDate,
highlightsEnabledForMessaging,
videoSharingOptions,
videoSharingEnabled,
}));
dispatch(updateCourseActions(actions));
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function fetchCourseLaunchQuery({
courseId,
gradedOnly = true,
validateOras = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseLaunch({
courseId, gradedOnly, validateOras, all,
});
dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced }));
dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data)));
return true;
} catch (error) {
return false;
}
};
}
export function fetchCourseBestPracticesQuery({
courseId,
excludeGraded = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
return true;
} catch (error) {
return false;
}
};
}
export function enableCourseHighlightsEmailsQuery(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await enableCourseHighlightsEmails(courseId);
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function setVideoSharingOptionQuery(courseId, option) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setVideoSharingOption(courseId, option);
dispatch(updateStatusBar({ videoSharingOptions: option }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
dispatch(hideProcessingNotification());
}
};
}
export function fetchCourseReindexQuery(courseId, reindexLink) {
return async (dispatch) => {
dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await restartIndexingOnCourse(reindexLink);
dispatch(updateReindexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateReindexLoadingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function fetchCourseSectionQuery(sectionId, shouldScroll = false) {
return async (dispatch) => {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const data = await getCourseItem(sectionId);
data.shouldScroll = shouldScroll;
dispatch(updateSectionList(data));
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function publishCourseItemQuery(itemId, sectionId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await publishCourseSection(itemId).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function configureCourseItemQuery(sectionId, configureFn) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await configureFn().then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime),
));
};
}
export function configureCourseSubsectionQuery(
itemId,
sectionId,
isVisibleToStaffOnly,
releaseDate,
graderType,
dueDate,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
defaultTimeLimitMin,
hideAfterDue,
showCorrectness,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseSubsection(
itemId,
isVisibleToStaffOnly,
releaseDate,
graderType,
dueDate,
isTimeLimited,
isProctoredExam,
isOnboardingExam,
isPracticeExam,
examReviewRules,
defaultTimeLimitMin,
hideAfterDue,
showCorrectness,
isPrereq,
prereqUsageKey,
prereqMinScore,
prereqMinCompletion,
),
));
};
}
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess),
));
};
}
export function editCourseItemQuery(itemId, sectionId, displayName) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await editItemDisplayName(itemId, displayName).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
/**
* Generic function to delete course item, see below wrapper funcs for specific implementations.
* @param {string} itemId
* @param {() => {}} deleteItemFn
* @returns {}
*/
function deleteCourseItemQuery(itemId, deleteItemFn) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
try {
await deleteCourseItem(itemId);
dispatch(deleteItemFn());
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function deleteCourseSectionQuery(sectionId) {
return async (dispatch) => {
dispatch(deleteCourseItemQuery(
sectionId,
() => deleteSection({ itemId: sectionId }),
));
};
}
export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
return async (dispatch) => {
dispatch(deleteCourseItemQuery(
subsectionId,
() => deleteSubsection({ itemId: subsectionId, sectionId }),
));
};
}
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
return async (dispatch) => {
dispatch(deleteCourseItemQuery(
unitId,
() => deleteUnit({ itemId: unitId, subsectionId, sectionId }),
));
};
}
/**
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
* @param {string} itemId
* @param {string} parentLocator
* @param {(locator) => Promise<any>} duplicateFn
* @returns {}
*/
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
try {
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
if (result) {
await duplicateFn(result.locator);
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function duplicateSectionQuery(sectionId, courseBlockId) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
sectionId,
courseBlockId,
async (locator) => {
const duplicatedItem = await getCourseItem(locator);
// Page should scroll to newly duplicated item.
duplicatedItem.shouldScroll = true;
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
},
));
};
}
export function duplicateSubsectionQuery(subsectionId, sectionId) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
subsectionId,
sectionId,
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
));
};
}
export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
return async (dispatch) => {
dispatch(duplicateCourseItemQuery(
unitId,
subsectionId,
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
));
};
}
/**
* Generic function to add any course item. See wrapper functions below for specific implementations.
* @param {string} parentLocator
* @param {string} category
* @param {string} displayName
* @param {(data) => {}} addItemFn
* @returns {}
*/
function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await addNewCourseItem(
parentLocator,
category,
displayName,
).then(async (result) => {
if (result) {
await addItemFn(result);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function addNewSectionQuery(parentLocator) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.chapter.id,
COURSE_BLOCK_NAMES.chapter.name,
async (result) => {
const data = await getCourseItem(result.locator);
// Page should scroll to newly created section.
data.shouldScroll = true;
dispatch(addSection(data));
},
));
};
}
export function addNewSubsectionQuery(parentLocator) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.sequential.id,
COURSE_BLOCK_NAMES.sequential.name,
async (result) => {
const data = await getCourseItem(result.locator);
// Page should scroll to newly created subsection.
data.shouldScroll = true;
dispatch(addSubsection({ parentLocator, data }));
},
));
};
}
export function addNewUnitQuery(parentLocator, callback) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.vertical.id,
COURSE_BLOCK_NAMES.vertical.name,
async (result) => callback(result.locator),
));
};
}
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setSectionOrderList(courseId, sectionListIds).then(async (result) => {
if (result) {
dispatch(reorderSectionList(sectionListIds));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setCourseItemOrderList(sectionId, subsectionListIds).then(async (result) => {
if (result) {
dispatch(reorderSubsectionList({ sectionId, subsectionListIds }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setCourseItemOrderList(subsectionId, unitListIds).then(async (result) => {
if (result) {
dispatch(reorderUnitList({ sectionId, subsectionId, unitListIds }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function setClipboardContent(usageKey, broadcastClipboard) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
try {
await copyBlockToClipboard(usageKey).then(async (result) => {
const status = result?.content?.status;
if (status === 'ready') {
dispatch(updateClipboardContent(result));
broadcastClipboard(result);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} else {
throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function pasteClipboardContent(parentLocator, sectionId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
try {
await pasteBlock(parentLocator).then(async (result) => {
if (result) {
dispatch(fetchCourseSectionQuery(sectionId, true));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function dismissNotificationQuery(url) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
await dismissNotification(url).then(async () => {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
});
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
AlertModal,
} from '@edx/paragon';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { COURSE_BLOCK_NAMES } from '../constants';
import { getCurrentItem } from '../data/selectors';
import messages from './messages';
const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
const intl = useIntl();
let { category } = useSelector(getCurrentItem);
category = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
return (
<AlertModal
title={intl.formatMessage(messages.title, { category })}
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={close}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
data-testid="delete-confirm-button"
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();
}}
>
{intl.formatMessage(messages.deleteButton, { category })}
</Button>
</ActionRow>
)}
>
<p>{intl.formatMessage(messages.description, { category })}</p>
</AlertModal>
);
};
DeleteModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
onDeleteSubmit: PropTypes.func.isRequired,
};
export default DeleteModal;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import DeleteModal from './DeleteModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const onDeleteSubmitMock = jest.fn();
const closeMock = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));
const currentItemMock = {
displayName: 'Delete',
};
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<DeleteModal
isOpen
close={closeMock}
onDeleteSubmit={onDeleteSubmitMock}
{...props}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<DeleteModal />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentItemMock);
});
it('render DeleteModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('calls onDeleteSubmit function when the "Delete" button is clicked', () => {
const { getByRole } = renderComponent();
const okButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(okButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});
it('calls the close function when the "Cancel" button is clicked', () => {
const { getByRole } = renderComponent();
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.delete-modal.title',
defaultMessage: 'Delete this {category}?',
},
description: {
id: 'course-authoring.course-outline.delete-modal.description',
defaultMessage: 'Deleting this {category} is permanent and cannot be undone.',
},
deleteButton: {
id: 'course-authoring.course-outline.delete-modal.button.delete',
defaultMessage: 'Delete',
},
cancelButton: {
id: 'course-authoring.course-outline.delete-modal.button.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

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