Compare commits

..

323 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
Kristin Aoki
e50b8c7407 feat: add file size and usage metrics (#573) 2023-08-31 12:21:37 -04:00
vladislavkeblysh
ffae3bd868 feat: Created Course updates page (#581) 2023-08-31 10:56:45 -04:00
Kristin Aoki
181f9c7a5f feat: add sort function and modal (#577)
* feat: add sort modal and function

* fix: dateAdded typo

* chore: update mock api data
2023-08-25 10:08:15 -04:00
Jhon Vente
1d95af5a31 [DOCS] Readme updated according OEP-55 (#526) 2023-08-24 09:16:27 -04:00
Kristin Aoki
d7a4b5b45b fix: add word break style for long words (#574) 2023-08-23 15:17:26 -04:00
vladislavkeblysh
2e8eed7504 feat: Created Course Team (#564) 2023-08-23 09:21:43 -04:00
German
d768bfc97a fix: xpert unit sumamries settings ui fixes (#576)
1. https://jira.2u.com/browse/ACADEMIC-16289
2. https://jira.2u.com/browse/ACADEMIC-16422
2023-08-22 15:43:17 -03:00
Jesper Hodge
9c997ab845 fix: Pass correct prop to TinyMceWidget and update FLCC (#575)
* fix: Pass correct prop to TinyMceWidget

* chore: update flcc

* fix: lockfile
2023-08-22 13:28:30 -04:00
Kristin Aoki
c1976ce4d3 feat: add delete confirmation modal (#570) 2023-08-21 17:28:49 -04:00
Kristin Aoki
be74de2b22 fix: file info bugs (#571) 2023-08-21 16:47:52 -04:00
German
fda1208660 feat: add xpert summaries configuration by default for units (#567)
* feat: add xpert summaries configuration by default for units
2023-08-21 16:14:39 -03:00
Kristin Aoki
b65f4f2b74 feat: bump frontend-lib-content-components (#569) 2023-08-17 11:26:39 -04:00
David Nuon
530c355787 fix: Add enabled badge to xpert settings tile (#566)
* feat: Add "Enabled" badge to xpert settings tile

* fix: Update model with state instead of non-existent prop from response
2023-08-15 09:17:58 -07:00
sundasnoreen12
fc21e22afb test: added test cases of discussion restriction (#556)
* test: added test cases of discussion restriction

* refactor: added null default value for dataTestId

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-15 14:38:45 +05:00
Peter Kulko
f9bc5c4927 feat: created Grading page (#557) 2023-08-14 14:44:01 -04:00
Kristin Aoki
484b141328 fix: overflow-y scroll behavior (#565) 2023-08-14 12:10:53 -04:00
Kristin Aoki
dc0762312e feat: bump frontend-lib-content-components (#562) 2023-08-11 14:29:57 -04:00
Raymond Zhou
33f46be993 feat: flcc to 1.168.0 (#561) 2023-08-11 13:05:02 -04:00
Kristin Aoki
d1c176cfc8 fix: width and height of asset preview (#558) 2023-08-10 16:54:51 -04:00
David Nuon
17d14968fa fix: Change wording to not crowd xpert tile in preferences page (#560) 2023-08-09 12:40:14 -07:00
Kristin Aoki
df51130fce feat: bump frontend-lib-content-components (#559) 2023-08-09 15:22:41 -04:00
Kristin Aoki
bc05d2c01e feat: upgrade frontend-lib-content-components (#554) 2023-08-08 13:03:08 -04:00
ruzniaievdm
a0e37c0357 feat: Added Schedule and Details MFE page (#547) 2023-08-08 09:49:53 -04:00
sundasnoreen12
a218e7e5f8 test: added test cases for hide discussion tab (#552)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-08 14:11:41 +05:00
David Nuon
f2a4386892 Update verbiage for Xpert Settings (#550)
* chore: Update verbiage for Xpert configuration screen

* fix: Change "generate" to "display" in xpert modal text

* fix: Updated learn more link

* fix: Change link and add targets
2023-08-07 07:17:40 -07:00
Kristin Aoki
c9b111a022 fix: remove env variable for files and uploads (#549) 2023-08-04 14:25:05 -04:00
Kristin Aoki
b9feb50a2c feat: add files and uploads page (#541) 2023-08-04 11:57:44 -04:00
Zachary Hancock
7fdf8da8ed fix: load up-to-date config on studio fetch (#548) 2023-08-01 15:37:13 -04:00
David Nuon
1dba6208a5 feat: configuration for xpert unit summaries (#540)
Adds setting modal for Xpert unit summaries

Includes hiding the config section for xpert summary - 
this is done based on a flag from 3d113d267c
2023-08-01 09:07:08 -04:00
Kristin Aoki
9f4422d1b9 fix: ui bugs (#542) 2023-07-31 17:23:07 -04:00
Omar Al-Ithawi
8bfc3f2945 feat: include paragon in atlas pull (#538)
This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-25 11:16:29 -04:00
Kristin Aoki
0e1a7e2603 feat: make placeholder depend on api response (#537) 2023-07-25 10:32:31 -04:00
sundasnoreen12
cc7fc6a9e1 chore: add paragon messages (#530) (#534)
Co-authored-by: Mashal Malik <107556986+Mashal-m@users.noreply.github.com>
2023-07-24 19:13:20 +05:00
Mashal Malik
da1e7a0277 chore: add paragon messages (#530) 2023-07-21 11:08:39 +05:00
Peter Kulko
87ead24e20 feat: added Advanced settings page (#521)
Co-authored-by: sendr <sendr84@gmail.com>
Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
2023-07-19 10:45:50 -04:00
Kristin Aoki
e05e6325c9 fix: marketing base url typo (#533) 2023-07-17 11:35:39 -04:00
Leangseu Kim
b090c8c153 feat: add open responses card to page and resources 2023-07-13 11:07:58 -04:00
Kristin Aoki
3c3dfeb325 feat: use new studio footer (#532) 2023-07-11 13:05:10 -04:00
kenclary
7ee8cc7fb1 feat: update flcc to 1.62.0 (#528) 2023-06-30 15:41:54 -04:00
Raymond Zhou
912fff9b0f feat: update flcc to 1.61.0 (#527) 2023-06-30 11:59:46 -04:00
Kristin Aoki
2c71385ce7 fix: env custom pages conditional render (#525) 2023-06-29 10:45:56 -04:00
Kristin Aoki
139457087b feat: add custom pages (#510) 2023-06-27 16:26:35 -04:00
Raymond Zhou
3a26285bd1 feat: update flcc to 1.60.0 (#523) 2023-06-27 13:19:26 -04:00
Raymond Zhou
e2c1deaeb3 feat: flcc to 1.157.0 (#520) 2023-06-14 14:07:42 -04:00
Kristin Aoki
61baf1a886 chore: bump frontend-lib-content-components (#518) 2023-06-13 16:51:29 -04:00
Maria Grimaldi
51e5e7126c fix: cast progress graph configuration to string (#495) 2023-06-12 11:10:59 -04:00
Jenkins
a53a93ccee chore(i18n): update translations 2023-06-11 17:32:51 -04:00
Dmytro
e980f1f20e fix: disable invalid link Video Uploads (#511) 2023-06-08 11:06:11 -04:00
Kristin Aoki
fac9eab496 feat: bump frontend-lib-content-components (#509) 2023-06-06 11:38:57 -04:00
ayesha waris
1b1afcf195 feat: integrated backend discussions restriction with UI (#507)
* feat: integrated backend discussions restriction with UI

* refactor: code refactoring

* test: fixes test cases

* refactor: discussion restriction component

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-06-06 14:55:19 +05:00
Kristin Aoki
788f671626 feat: bump frontend-lib-content-components (#506) 2023-05-31 14:27:04 -04:00
Jenkins
ac7b4c9fcf chore(i18n): update translations 2023-05-28 17:32:49 -04:00
Mashal Malik
9a4af8ff2e feat: upgraded to node v18, added .nvmrc and updated workflows (#464)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* feat: upfate validate workflow

* feat: update validate workflow

* fix: update lock file

* refactor: update validate file

* build: update pkg

* refactor: updated packages

* build: updated frontend-build, frontend-platform, component-footer & component-header packages

* refactor: updated workflow

* refactor: updated workflow

* refactor: updated workflow

* build: update commit file

* build: update lock file

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* build: update pkg

* build: update pkgs

* build: update lock file

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-05-25 13:49:24 +05:00
kenclary
9cfd8013d2 feat: update frontend-lib-content-components to v1.151.2 (#503) 2023-05-22 11:06:42 -04:00
Jenkins
74f5a0e8ee chore(i18n): update translations 2023-05-21 17:32:46 -04:00
sundasnoreen12
0d67c2588d feat: implemented discussion restriction UI (#494)
* feat: implemented discussion restriction UI

* refactor: fixed UI figma design issues

* refactor: fixed 2nd review points

* refactor: fixed review issues regarding confirmation popup

* refactor: changed tab component to button group

* perf: performance improvement changes

* refactor: fixed memorization issues

* refactor: fixed memo issues

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-05-19 17:16:48 +05:00
Kristin Aoki
738f501cf9 feat: add new header and page routes (#501) 2023-05-18 17:08:38 -04:00
Chris Chávez
ff6a5d99d6 [FAL-3383] Implement new video UX flow on new video editor (#498)
* feat: Video Gallery URL updated to match to the new flow needs

* chore: Video Gallery Url updated and blockId added
2023-05-18 09:55:33 -04:00
Raymond Zhou
a46a34412c feat: update flcc to 1.150.1 (#500) 2023-05-17 15:43:48 -04:00
Kristin Aoki
db6c3172de feat: bump frontend-lib-content-components (#499) 2023-05-11 10:31:23 -04:00
Omar Al-Ithawi
0d38279950 feat: use atlas in make pull_translations (#490)
Changes
-------
 - Bump frontend-platform to bring `intl-imports.js` script
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-09 10:13:50 -04:00
Kristin Aoki
3dd28082ea chore: bump frontend-lib-content-components (#497) 2023-05-05 11:01:36 -04:00
Kristin Aoki
767283cbc6 chore: bump frontend-lib-content-components (#496) 2023-05-03 10:56:28 -04:00
Jenkins
0066902127 chore(i18n): update translations 2023-04-30 17:32:45 -04:00
Raymond Zhou
9a567b875e feat: update flcc to 1.45.0 (#492) 2023-04-27 15:13:29 -04:00
Kristin Aoki
a7f877caf5 feat: bump flcc and paragon (#491) 2023-04-26 10:23:03 -04:00
Raymond Zhou
e75928a774 feat: update flcc to 1.142.0 (#489) 2023-04-25 16:31:58 -04:00
Chris Chávez
4b7f46852b [FAL-3375] Feat: Adding video selection gallery page to the routes (#461)
* feat: Adding video selection gallery page to the routes

* test: CourseAuthoringRoutes.test.jsx added
2023-04-25 12:45:00 -04:00
Pooja Kulkarni
1e0c128ad6 fix: make blockid parameter optional (#455) 2023-04-25 11:36:58 -04:00
Yoiber
e3887129fc chore(i18n): add more languages (#450)
* chore(i18n): add more languages

* chore(i18n): Pylint fixes

* chore(i18n): Typo to named the imports
2023-04-25 08:58:37 -04:00
ayesha waris
2eaf882734 fix: only global staff can see 2 edx discussion providers in settings (#477)
* fix: only global staff can see 2 edx discussion providers in settings

* test: adds and updated test cases for app list

* refactor: memoized showoneedxprovider constant

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
2023-04-25 15:00:35 +05:00
Jenkins
284c402a49 chore(i18n): update translations 2023-04-23 17:32:44 -04:00
connorhaugh
d08eb0e3a9 feat: upgrade flcc (#487) 2023-04-21 10:41:37 -04:00
Kristin Aoki
76b7623cb0 feat: bump frontend-lib-content-components 1.137.0 (#486) 2023-04-20 13:41:47 -04:00
connorhaugh
1e25091698 feat: add hotjar tracking (#485) 2023-04-19 16:56:18 -04:00
Jenkins
1289f7d4e2 chore(i18n): update translations 2023-04-16 17:27:42 -04:00
Kristin Aoki
eb1b2eb883 feat: bump frontend-lib-content-components 1.135.1 (#483) 2023-04-14 16:44:30 -04:00
Kristin Aoki
74e45139bf feat: bump frontend-lib-content-components 1.135.0 (#482) 2023-04-14 12:20:52 -04:00
Raymond Zhou
f9a240ade4 feat: update FLCC to 1.134.0 (#481) 2023-04-13 17:59:54 -04:00
Raymond Zhou
b09e7f3683 feat: update FLCC to ver1.133.0 (#480) 2023-04-13 13:12:20 -04:00
sundasnoreen12
b19d52555f refactor: removed all extra messages files (#475)
Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-13 17:13:49 +05:00
Raymond Zhou
ab4dd9a4a8 feat: update FLCC to 1.132.0 (#476) 2023-04-11 14:17:58 -04:00
connorhaugh
a94942a36e Feat update flcc for cat 2 part deux (#473)
* feat: update flcc for cat-2

* feat: updte flcc for cat-2 part deux
2023-04-10 15:55:59 -04:00
connorhaugh
ab7c51994c feat: update flcc for cat-2 (#472) 2023-04-10 10:58:59 -04:00
Raymond Zhou
67967a92cf feat: update FLCC to 1.129.0 (#471) 2023-04-06 14:12:09 -04:00
connorhaugh
6efa8c5356 feat: upgrade flcc to 125 (#470) 2023-04-05 10:24:15 -04:00
Kristin Aoki
c28669f5b2 feat: upgrade flcc package to 1.126.1 (#469) 2023-04-04 09:54:05 -04:00
kenclary
270f4a8a12 chore: upgrade frontend-lib-content-components to 1.124.0. (#468) 2023-03-27 13:57:44 -04:00
Kristin Aoki
641a169e6f feat: upgrade flcc to 1.11.8.0 (#466) 2023-03-22 14:52:22 -04:00
kenclary
25e254bbfb chore: update frontend-lib-content-components to 1.117.1. (#465) 2023-03-16 14:46:56 -04:00
Jenkins
af0ddf532a chore(i18n): update translations 2023-03-12 17:32:39 -04:00
kenclary
eaf76c8dee chore: update frontend-lib-content-components to 1.113.0. (#459) 2023-03-10 10:51:50 -05:00
Ihor Romaniuk
5c0ca7b706 feat: replace hardcoded edx string with site_name from configs (#425)
* feat: replace hardcoded edx string with site_name from configs

* feat: add ability to obtain site name dynamically

* fix: localize overriding createPortal method
2023-03-09 13:38:22 -05:00
Mashal Malik
530b247c33 refactor: remove unused tranisfex v2 url (#457) 2023-03-06 12:18:02 +05:00
Ihor Romaniuk
a5bc86e948 feat: replace hardcoded logo with logo from configs (#426) 2023-03-03 08:29:12 -05:00
connorhaugh
9910937269 feat: flcc 1.109.2 (#456) 2023-03-02 10:48:29 -05:00
connorhaugh
1344c289df feat: update flcc (#453) 2023-02-24 11:41:11 -05:00
Feanil Patel
7f4111c12c Update standard workflow files. (#452)
* build: Creating a missing workflow file `self-assign-issue.yml`.

The .github/workflows/self-assign-issue.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.

* build: Creating a missing workflow file `add-remove-label-on-comment.yml`.

The .github/workflows/add-remove-label-on-comment.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.

* build: Updating a missing workflow file `add-depr-ticket-to-depr-board.yml`.

The .github/workflows/add-depr-ticket-to-depr-board.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-02-24 10:44:48 -05:00
connorhaugh
105fdea8ef feat: upgrade FLCC to 1.101.3 (#451) 2023-02-15 08:41:05 -05:00
connorhaugh
9d91e3f242 feat: update flcc to 1.99.3 (#449) 2023-02-13 12:58:00 -05:00
connorhaugh
fdcb3a5e7f feat: update FLCC to 1.99.0 (#447) 2023-02-10 10:22:48 -05:00
Bilal Qamar
86974b76a9 Fixed broken "Pages and Resources" page (#446)
* fix: updated AppHeader call

* fix: AppHeader function signature changed to react component
2023-02-09 06:45:37 -05:00
Kristin Aoki
835915750c feat: update frontend-lib-content-component 1.95.0 (#445) 2023-02-07 16:13:51 -05:00
Jenkins
fe8a125d1a chore(i18n): update translations 2023-02-05 16:32:36 -05:00
Kristin Aoki
f82e572ad2 feat: update frontend-lib-content-components (#439) 2023-02-01 15:46:13 -05:00
Kristin Aoki
8aa03496fb feat: update frontend-content-components & paragon (#438) 2023-01-31 15:21:13 -05:00
Muhammad Abdullah Waheed
3c2c347bb9 Automate Browserlist DB Update (#357)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2023-01-31 17:40:26 +05:00
Jenkins
0d166288cc chore(i18n): update translations 2023-01-29 16:32:35 -05:00
Bilal Qamar
f8954ef870 refactor: upgraded frontend-build version to v12
PR #322
2023-01-26 09:02:14 -03:00
Muhammad Adeel Tajamul
66afd4ddac fix: updated openedx discussion provider help text (#431)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-01-25 07:42:29 +05:00
Kristin Aoki
a99eb8a44a feat: upgrade frontend-lib-content-components (#436) 2023-01-24 13:51:06 -05:00
kenclary
b2981318b0 chore: update version of frontend-lib-content-components to 1.85.0. (#435) 2023-01-24 10:16:03 -05:00
Jenkins
5142f3afd4 chore(i18n): update translations 2023-01-22 16:32:35 -05:00
Raymond Zhou
b7b3601337 feat: update FLCC to 1.81.0 (#432) 2023-01-20 11:55:58 -05:00
Kristin Aoki
50e5ca86c6 feat: upgrade paragon and lib-content-components (#430) 2023-01-17 16:26:11 -05:00
connorhaugh
fe9a9a37e7 feat: upgrade to 1.76.0 (#429) 2023-01-12 15:43:22 -05:00
connorhaugh
1c5ab42ea6 feat: update flcc to 1.74.0 (#428) 2023-01-12 10:53:46 -05:00
Kristin Aoki
74fcbe426d feat: upgrade lib-content-components to 1.73.0 (#419) 2023-01-06 10:41:27 -05:00
938 changed files with 95131 additions and 35146 deletions

12
.env
View File

@@ -16,15 +16,27 @@ LOGO_URL=''
LOGO_WHITE_URL=''
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=''
TERMS_OF_SERVICE_URL=''
PRIVACY_POLICY_URL=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
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

@@ -1,6 +1,6 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=
@@ -16,17 +16,29 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
TERMS_OF_SERVICE_URL=
PRIVACY_POLICY_URL=
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2001
PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
SITE_NAME='Your Plaform Name Here'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_EMAIL='support@example.com'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
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

@@ -1,5 +1,5 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -22,10 +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_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"

View File

@@ -1,6 +1,8 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint',
module.exports = createConfig(
'eslint',
{
rules: {
'jsx-a11y/label-has-associated-control': [2, {
@@ -8,6 +10,8 @@ module.exports = createConfig('eslint',
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: 'off',
indent: ['error', 2],
'no-restricted-exports': 'off',
},
});
},
);

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -0,0 +1,12 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

View File

@@ -9,14 +9,13 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

3
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
v16
18

34
.stylelintrc.json Normal file
View File

@@ -0,0 +1,34 @@
{
"extends": ["@edx/stylelint-config-edx"],
"rules": {
"selector-pseudo-class-no-unknown": [true, {
"ignorePseudoClasses": ["export"]
}],
"unit-no-unknown": [true, {
"ignoreUnits": ["\\.5"]
}],
"property-no-vendor-prefix": [true, {
"ignoreProperties": ["animation", "filter", "transform", "transition"]
}],
"value-no-vendor-prefix": [true, {
"ignoreValues": ["fill-available"]
}],
"function-no-unknown": null,
"number-leading-zero": "never",
"no-descending-specificity": null,
"selector-class-pattern": null,
"scss/no-global-function-names": null,
"color-hex-case": "upper",
"color-hex-length": "long",
"scss/dollar-variable-empty-line-before": null,
"scss/dollar-variable-colon-space-after": "at-least-one-space",
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,
"scss/at-import-partial-extension": null,
"scss/comment-no-empty": null,
"property-no-unknown": [true, {
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
}],
"alpha-value-notation": "number"
}
}

View File

@@ -1,22 +1,21 @@
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"
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
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# 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...
@@ -45,9 +44,26 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Pulls translations using atlas.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& 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) 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.
validate-no-uncommitted-package-lock-changes:
@@ -59,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

@@ -1,20 +1,70 @@
|Build Status| |Codecov| |license|
#############################
frontend-app-course-authoring
#############################
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|license-badge| |status-badge| |codecov-badge|
************
Introduction
************
Purpose
*******
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
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
************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _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
===================
1. Clone the repo:
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm use`_.
3. Install npm dependencies:
``cd frontend-app-course-authoring && npm install``
4. Start the dev server:
``npm start``
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
Features
********
@@ -23,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:
@@ -79,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
@@ -99,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
-------------------
@@ -113,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:
@@ -144,34 +191,94 @@ 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
**********
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
Installation and Startup
========================
1. Clone the repo:
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Install npm dependencies:
``cd frontend-app-course-authoring && npm install``
3. Start the dev server:
``npm start``
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
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
========================
@@ -182,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
*********
@@ -197,3 +304,92 @@ The production build is created with ``npm run build``.
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
:target: @edx/frontend-app-course-authoring
Internationalization
====================
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-course-authoring/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
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
****************************
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
******
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
Reporting Security Issues
*************************
Please do not report security issues in public, and email security@openedx.org instead.
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |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

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',
],
});

43614
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,15 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"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": "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": {
@@ -33,51 +36,70 @@
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-component-footer": "11.1.1",
"@edx/frontend-lib-content-components": "^1.72.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "^20.21.0",
"@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-dom": "16.14.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-transition-group": "4.4.1",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-helmet": "^6.1.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.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": "^11.0.0",
"@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",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",
"@edx/stylelint-config-edx": "^2.3.0",
"@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

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course Authoring | edX</title>
<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

@@ -1,24 +1,55 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Footer from '@edx/frontend-component-footer';
import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
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, getLoadingStatus } from './pages-and-resources/data/selectors';
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';
export default function CourseAuthoringPage({ courseId, children }) {
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
}) => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
AppHeader.propTypes = {
courseId: PropTypes.string.isRequired,
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
};
AppHeader.defaultProps = {
courseNumber: null,
courseOrg: null,
};
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);
@@ -27,41 +58,42 @@ export default function CourseAuthoringPage({ courseId, children }) {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
const courseDetailStatus = useSelector(state => state.courseDetail.status);
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
return (
<NotFoundAlert />
);
}
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
);
}
const AppHeader = () => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
const AppFooter = () => (
<div className="mt-6">
<Footer />
</div>
);
return (
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* While V2 Editors are tempoarily served from thier own pages
{/* While V2 Editors are temporarily served from their own pages
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 ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
{inProgress ? !isEditor && <Loading />
: (!isEditor && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)
)}
{children}
{!inProgress && <AppFooter />}
{!inProgress && !isEditor && <StudioFooter />}
</div>
);
}
};
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
@@ -71,3 +103,5 @@ CourseAuthoringPage.propTypes = {
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { queryByTestId, render } from '@testing-library/react';
import { render } from '@testing-library/react';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -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/';
@@ -23,50 +24,18 @@ jest.mock('react-router-dom', () => ({
}));
let axiosMock;
let store;
let container;
function renderComponent() {
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
container = wrapper.container;
}
const mockStore = async () => {
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
response: { status: 403 },
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
describe('DiscussionsSettings', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('renders permission error in case of 403', async () => {
await mockStore();
renderComponent();
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('Editor Pages Load no header', () => {
@@ -78,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();
@@ -121,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,11 +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 {
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 { 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:
@@ -23,30 +37,81 @@ import EditorContainer from './editors/EditorContainer';
* 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.
*/
export default function CourseAuthoringRoutes({ courseId }) {
const { path } = useRouteMatch();
const CourseAuthoringRoutes = () => {
const { courseId } = useParams();
return (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
)}
</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

@@ -0,0 +1,116 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
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>,
Footer: () => <div>Footer</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
mockComponentFn(props);
return pagesAndResourcesMockText;
});
jest.mock('./editors/EditorContainer', () => (props) => {
mockComponentFn(props);
return editorContainerMockText;
});
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
mockComponentFn(props);
return videoSelectorContainerMockText;
});
jest.mock('./custom-pages/CustomPages', () => (props) => {
mockComponentFn(props);
return customPagesMockText;
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/pages-and-resources']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the EditorContainer component when the course editor route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/video/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});

View File

@@ -0,0 +1,301 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@edx/paragon';
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
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';
import SubHeader from '../generic/sub-header/SubHeader';
import AlertMessage from '../generic/alert-message';
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import SettingCard from './setting-card/SettingCard';
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import getPageHeadTitle from '../generic/utils';
import { 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);
const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
const { 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 {
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]);
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-content-center m-6">
<Placeholder />
</div>
);
}
const handleResetSettingsValues = () => {
setIsEditableState(false);
showErrorModal(false);
setEditedSettings({});
showSaveSettingsPrompt(false);
};
const handleSettingBlur = () => {
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
};
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setIsQueryPending(true);
} else {
showSaveSettingsPrompt(false);
showErrorModal(!errorModal);
}
};
const handleInternetConnectionFailed = () => {
setInternetConnectionError(true);
showSaveSettingsPrompt(false);
setShowSuccessAlert(false);
};
const handleQueryProcessing = () => {
setShowSuccessAlert(false);
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
};
const handleManuallyChangeClick = (setToState) => {
showErrorModal(setToState);
showSaveSettingsPrompt(true);
};
return (
<>
<Container size="xl" className="advanced-settings px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
<AlertProctoringError
icon={Info}
proctoringErrorsData={proctoringErrors}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
/>
)}
<TransitionReplace>
{showSuccessAlert ? (
<AlertMessage
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
show={showSuccessAlert}
variant="success"
icon={CheckCircle}
title={intl.formatMessage(messages.alertSuccess)}
description={intl.formatMessage(messages.alertSuccessDescriptions)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
/>
) : null}
</TransitionReplace>
</div>
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
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">
<div className="small">
<FormattedMessage
id="course-authoring.advanced-settings.policies.description"
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
values={{ notice: <strong>Warning: </strong> }}
/>
</div>
<div className="setting-items-deprecated-setting">
<Button
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
onClick={() => setShowDeprecated(!showDeprecated)}
size="sm"
>
<FormattedMessage
id="course-authoring.advanced-settings.deprecated.button.text"
defaultMessage="{visibility} deprecated settings"
values={{
visibility:
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
}}
/>
</Button>
</div>
<ul className="setting-items-list p-0">
{Object.keys(advancedSettingsData).map((settingName) => {
const settingData = advancedSettingsData[settingName];
if (settingData.deprecated && !showDeprecated) {
return null;
}
return (
<SettingCard
key={settingName}
settingData={settingData}
name={settingName}
showSaveSettingsPrompt={showSaveSettingsPrompt}
saveSettingsPrompt={saveSettingsPrompt}
setEdited={setEditedSettings}
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disableForm={viewOnly}
/>
);
})}
</ul>
</section>
</div>
</article>
</Layout.Element>
<Layout.Element>
<SettingsSidebar
courseId={courseId}
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
/>
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
<AlertMessage
show={saveSettingsPrompt}
aria-hidden={saveSettingsPrompt}
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
role="dialog"
actions={[
!isQueryPending && (
<Button variant="tertiary" onClick={handleResetSettingsValues}>
{intl.formatMessage(messages.buttonCancelText)}
</Button>
),
<StatefulButton
key="statefulBtn"
onClick={handleUpdateAdvancedSettingsData}
state={isQueryPending ? RequestStatus.PENDING : 'default'}
{...updateSettingsButtonState}
/>,
].filter(Boolean)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}
description={intl.formatMessage(messages.alertWarningDescriptions)}
/>
</div>
<ModalError
isError={errorModal}
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
handleUndoChanges={handleResetSettingsValues}
settingsData={advancedSettingsData}
errorList={errorFields.length > 0 ? errorFields : []}
/>
</>
);
};
AdvancedSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(AdvancedSettings);

View File

@@ -0,0 +1,211 @@
import React from 'react';
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 MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
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) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<AdvancedSettings intl={injectIntl} courseId={courseId} />
</IntlProvider>
</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,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(advancedSettingsElement).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea.value).toBe('[1, 2, 3]');
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
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

@@ -0,0 +1,16 @@
module.exports = {
advancedModules: {
deprecated: false,
displayName: 'Advanced Module List',
help: 'Enter the names of the advanced modules to use in your course.',
hideOnEnabledPublisher: false,
value: [],
},
certHtmlViewEnabled: {
deprecated: true,
display_name: 'Certificate web/html view enabled',
help: 'If true, certificate Web/HTML views are enabled for the course.',
hide_on_enabled_publisher: false,
value: true,
},
};

View File

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

View File

@@ -0,0 +1,41 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
/**
* Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
return camelCaseObject(data);
}
/**
* Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
return camelCaseObject(data);
}
/**
* Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
return camelCaseObject(data);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headingTitle: {
id: 'course-authoring.advanced-settings.heading.title',
defaultMessage: 'Advanced settings',
},
headingSubtitle: {
id: 'course-authoring.advanced-settings.heading.subtitle',
defaultMessage: 'Settings',
},
policy: {
id: 'course-authoring.advanced-settings.policies.title',
defaultMessage: 'Manual policy definition',
},
alertWarning: {
id: 'course-authoring.advanced-settings.alert.warning',
defaultMessage: "You've made some changes",
},
alertWarningDescriptions: {
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
},
alertSuccess: {
id: 'course-authoring.advanced-settings.alert.success',
defaultMessage: 'Your policy changes have been saved.',
},
alertSuccessDescriptions: {
id: 'course-authoring.advanced-settings.alert.success.descriptions',
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
},
alertProctoringError: {
id: 'course-authoring.advanced-settings.alert.proctoring.error',
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
},
alertProctoringErrorDescriptions: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
},
buttonSaveText: {
id: 'course-authoring.advanced-settings.alert.button.save',
defaultMessage: 'Save changes',
},
buttonSavingText: {
id: 'course-authoring.advanced-settings.alert.button.saving',
defaultMessage: 'Saving',
},
buttonCancelText: {
id: 'course-authoring.advanced-settings.alert.button.cancel',
defaultMessage: 'Cancel',
},
deprecatedButtonShowText: {
id: 'course-authoring.advanced-settings.deprecated.button.show',
defaultMessage: 'Show',
},
deprecatedButtonHideText: {
id: 'course-authoring.advanced-settings.deprecated.button.hide',
defaultMessage: 'Hide',
},
alertWarningAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
defaultMessage: 'notification-warning-title',
},
alertWarningAriaDescribedby: {
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
defaultMessage: 'notification-warning-description',
},
alertSuccessAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
defaultMessage: 'alert-confirmation-title',
},
alertSuccessAriaDescribedby: {
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
defaultMessage: 'alert-confirmation-description',
},
alertProctoringAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
defaultMessage: 'alert-danger-title',
},
alertProctoringDescribedby: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
defaultMessage: 'alert-danger-description',
},
});
export default messages;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
import messages from './messages';
const ModalError = ({
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
)}
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
Please check the following validation feedbacks and reflect them in your course settings:"
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
))}
</ul>
</AlertModal>
);
ModalError.propTypes = {
intl: intlShape.isRequired,
isError: PropTypes.bool.isRequired,
handleUndoChanges: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired,
errorList: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
message: PropTypes.string,
})).isRequired,
settingsData: PropTypes.shape({}).isRequired,
};
export default injectIntl(ModalError);

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ModalError from './ModalError';
import messages from './messages';
const handleUndoChangesMock = jest.fn();
const showErrorModalMock = jest.fn();
const errorList = [
{ key: 'setting1', message: 'Error 1' },
{ key: 'setting2', message: 'Error 2' },
];
const settingsData = {
setting1: 'value1',
setting2: 'value2',
};
const RootWrapper = () => (
<IntlProvider locale="en">
<ModalError
isError
handleUndoChanges={handleUndoChangesMock}
showErrorModal={showErrorModalMock}
errorList={errorList}
settingsData={settingsData}
/>
</IntlProvider>
);
describe('<ModalError />', () => {
it('calls handleUndoChanges when "Undo changes" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const undoChangesButton = getByText(messages.modalErrorButtonUndoChanges.defaultMessage);
fireEvent.click(undoChangesButton);
expect(handleUndoChangesMock).toHaveBeenCalledTimes(1);
});
it('calls showErrorModal when "Change manually" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const changeManuallyButton = getByText(messages.modalErrorButtonChangeManually.defaultMessage);
fireEvent.click(changeManuallyButton);
expect(showErrorModalMock).toHaveBeenCalledTimes(1);
});
it('renders error message with correct values', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(/There was/i)).toBeInTheDocument();
expect(getByText(/2 validation error/i)).toBeInTheDocument();
expect(getByText(/while trying to save the course settings in the database. Please check the following validation feedbacks and reflect them in your course settings:/i)).toBeInTheDocument();
expect(getByText(messages.modalErrorTitle.defaultMessage)).toBeInTheDocument();
});
it('renders correct number of errors', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Error 1')).toBeInTheDocument();
expect(getByText('Error 2')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Icon } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { capitalize } from 'lodash';
import { transformKeysToCamelCase } from '../../utils';
const ModalErrorListItem = ({ settingName, settingsData }) => {
const { displayName } = settingsData[transformKeysToCamelCase(settingName)];
return (
<li className="modal-error-item">
<Alert variant="danger">
<h4 className="modal-error-item-title">
<Icon src={Error} />{capitalize(displayName)}:
</h4>
<p className="m-0">{settingName.message}</p>
</Alert>
</li>
);
};
ModalErrorListItem.propTypes = {
settingName: PropTypes.shape({
key: PropTypes.string,
message: PropTypes.string,
}).isRequired,
settingsData: PropTypes.shape({}).isRequired,
};
export default ModalErrorListItem;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
const settingName = {
key: 'exampleKey',
message: 'Error message',
};
const settingsData = {
exampleKey: {
displayName: 'Error field',
},
};
const RootWrapper = () => (
<IntlProvider locale="en">
<ModalErrorListItem settingName={settingName} settingsData={settingsData} />
</IntlProvider>
);
describe('<ModalErrorListItem />', () => {
it('renders the display name and error message', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Error field:')).toBeInTheDocument();
expect(getByText('Error message')).toBeInTheDocument();
});
it('renders the alert with variant "danger"', () => {
const { getByRole } = render(<RootWrapper />);
expect(getByRole('alert')).toHaveClass('alert-danger');
});
});

View File

@@ -0,0 +1,18 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
modalErrorTitle: {
id: 'course-authoring.advanced-settings.modal.error.title',
defaultMessage: 'Validation error while saving',
},
modalErrorButtonChangeManually: {
id: 'course-authoring.advanced-settings.modal.error.btn.change-manually',
defaultMessage: 'Change manually',
},
modalErrorButtonUndoChanges: {
id: 'course-authoring.advanced-settings.modal.error.btn.undo-changes',
defaultMessage: 'Undo changes',
},
});
export default messages;

View File

@@ -0,0 +1,124 @@
@import "variables";
.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;
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
}
}
}
.alert-toast {
position: fixed;
bottom: 0;
width: 100%;
padding: 0 .625rem;
z-index: $zindex-modal;
}
.alert-proctoring-error {
list-style: none;
}
.setting-items-list {
li {
list-style: none;
}
.form-control {
min-height: 2.75rem;
flex-grow: 1;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
}
.pgn__card-status {
padding: .625rem;
}
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
}
}
.setting-sidebar-supplementary {
.setting-sidebar-supplementary-about {
.setting-sidebar-supplementary-about-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
margin-bottom: 1.25rem;
}
.setting-sidebar-supplementary-about-descriptions {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
color: $text-color-base;
}
}
.setting-sidebar-supplementary-other-links ul {
list-style: none;
.setting-sidebar-supplementary-other-link {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
line-height: 1.5rem;
color: $info-500;
margin-bottom: .5rem;
}
}
.setting-sidebar-supplementary-other-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
margin-bottom: 1.25rem;
}
}
.modal-error-item {
list-style: none;
.pgn__icon {
display: inline-block;
margin-right: 5px;
margin-bottom: 5px;
color: $danger;
}
.modal-error-item-title {
display: flex;
align-items: center;
}
}
.modal-popup-content {
max-width: 200px;
color: $white;
background-color: $black;
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
font-weight: 400;
}
.pgn__modal-popup__arrow::after {
border-top-color: $black;
}

View File

@@ -0,0 +1 @@
$text-color-base: $gray-700;

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import {
ActionRow,
Card,
Form,
Icon,
IconButton,
ModalPopup,
useToggle,
} from '@edx/paragon';
import { InfoOutline, Warning } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';
import messages from './messages';
const SettingCard = ({
name,
settingData,
handleBlur,
setEdited,
showSaveSettingsPrompt,
saveSettingsPrompt,
isEditableState,
setIsEditableState,
// injected
intl,
disableForm,
}) => {
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
const { value } = e.target;
setNewValue(e.target.value);
if (value !== initialValue) {
if (!saveSettingsPrompt) {
showSaveSettingsPrompt(true);
}
if (!isEditableState) {
setIsEditableState(true);
}
}
};
const handleCardBlur = () => {
setEdited((prevEditedSettings) => ({
...prevEditedSettings,
[name]: newValue,
}));
handleBlur();
};
return (
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex row m-0 align-items-center">
<Card.Header
className="col-6"
title={(
<ActionRow>
{capitalize(displayName)}
<IconButton
ref={setTarget}
onClick={open}
src={InfoOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.helpButtonText)}
variant="primary"
className=" ml-1 mr-2"
/>
<ModalPopup
hasArrow
placement="right"
positionRef={target}
isOpen={isOpen}
onClose={close}
className="pgn__modal-popup__arrow"
>
<div
className="p-2 x-small rounded modal-popup-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
<ActionRow.Spacer />
</ActionRow>
)}
/>
<Card.Section className="col-6 flex-grow-1">
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}
value={isEditableState ? newValue : initialValue}
name={name}
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disableForm}
/>
</Form.Group>
</Card.Section>
</Card.Body>
{deprecated && (
<Card.Status icon={Warning} variant="danger">
{intl.formatMessage(messages.deprecated)}
</Card.Status>
)}
</Card>
</li>
);
};
SettingCard.propTypes = {
intl: intlShape.isRequired,
settingData: PropTypes.shape({
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.object,
PropTypes.array,
]),
}).isRequired,
setEdited: PropTypes.func.isRequired,
showSaveSettingsPrompt: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
handleBlur: PropTypes.func.isRequired,
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
disableForm: PropTypes.bool.isRequired,
};
export default injectIntl(SettingCard);

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import SettingCard from './SettingCard';
import messages from './messages';
const setEdited = jest.fn();
const showSaveSettingsPrompt = jest.fn();
const setIsEditableState = jest.fn();
const handleBlur = jest.fn();
const settingData = {
deprecated: false,
help: 'This is a help message',
displayName: 'Setting Name',
value: 'Setting Value',
};
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const RootWrapper = () => (
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={settingData}
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
/>
</IntlProvider>
);
describe('<SettingCard />', () => {
afterEach(() => jest.clearAllMocks());
it('renders the setting card with the provided data', () => {
const { getByText, getByLabelText } = render(<RootWrapper />);
const cardTitle = getByText(/Setting Name/i);
const input = getByLabelText(/Setting Name/i);
expect(cardTitle).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
});
it('displays the deprecated status when the setting is deprecated', () => {
const deprecatedSettingData = { ...settingData, deprecated: true };
const { getByText } = render(
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={deprecatedSettingData}
handleBlur={handleBlur}
isEditable={false}
saveSettingsPrompt
/>
</IntlProvider>,
);
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
expect(deprecatedStatus).toBeInTheDocument();
});
it('does not display the deprecated status when the setting is not deprecated', () => {
const { queryByText } = render(<RootWrapper />);
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
});
it('calls setEdited on blur', async () => {
const { getByLabelText } = render(<RootWrapper />);
const inputBox = getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
userEvent.clear(inputBox);
userEvent.type(inputBox, '3, 2, 1');
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deprecated: {
id: 'course-authoring.advanced-settings.button.deprecated',
defaultMessage: 'Deprecated',
},
helpButtonText: {
id: 'course-authoring.advanced-settings.button.help',
defaultMessage: 'Show help text',
},
});
export default messages;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
<HelpSidebar
courseId={courseId}
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
showOtherSettings
>
<h4 className="help-sidebar-about-title">
{intl.formatMessage(messages.about)}
</h4>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.aboutDescription1)}
</p>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.aboutDescription2)}
</p>
<p className="help-sidebar-about-descriptions">
<FormattedMessage
id="course-authoring.advanced-settings.about.description-3"
defaultMessage="{notice} When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks ()."
values={{ notice: <strong>Note:</strong> }}
/>
</p>
</HelpSidebar>
);
SettingsSidebar.defaultProps = {
proctoredExamSettingsUrl: '',
};
SettingsSidebar.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
proctoredExamSettingsUrl: PropTypes.string,
};
export default injectIntl(SettingsSidebar);

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import SettingsSidebar from './SettingsSidebar';
import messages from './messages';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<SettingsSidebar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders about and other sidebar titles correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
});
it('renders about descriptions correctly', () => {
const { getByText } = render(<RootWrapper />);
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks ().');
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
expect(aboutThirtyDescription).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
about: {
id: 'course-authoring.advanced-settings.sidebar.about.title',
defaultMessage: 'What do advanced settings do?',
},
aboutDescription1: {
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
},
aboutDescription2: {
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
defaultMessage: 'Any policies you modify here override all other information youve defined elsewhere in Studio. Do not edit policies unless you are familiar with both their purpose and syntax.',
},
other: {
id: 'course-authoring.advanced-settings.sidebar.other.title',
defaultMessage: 'Other course settings',
},
otherCourseSettingsLinkToScheduleAndDetails: {
id: 'course-authoring.advanced-settings.sidebar.links.schedule-and-details',
defaultMessage: 'Details & schedule',
description: 'Link to Studio Details & schedule page',
},
otherCourseSettingsLinkToGrading: {
id: 'course-authoring.advanced-settings.sidebar.links.grading',
defaultMessage: 'Grading',
description: 'Link to Studio Grading page',
},
otherCourseSettingsLinkToCourseTeam: {
id: 'course-authoring.advanced-settings.sidebar.links.course-team',
defaultMessage: 'Course team',
description: 'Link to Studio Course team page',
},
otherCourseSettingsLinkToGroupConfigurations: {
id: 'course-authoring.advanced-settings.sidebar.links.group-configurations',
defaultMessage: 'Group configurations',
description: 'Link to Studio Group configurations page',
},
otherCourseSettingsLinkToProctoredExamSettings: {
id: 'course-authoring.advanced-settings.sidebar.links.proctored-exam-settings',
defaultMessage: 'Proctored exam settings',
description: 'Link to Proctored exam settings page',
},
});
export default messages;

View File

@@ -0,0 +1,48 @@
/**
* Validates advanced settings data by checking if the provided settings are correctly formatted JSON.
* It performs validation on a given object of settings, detects incorrectly formatted settings,
* and sets error fields accordingly using the setErrorFields function.
*
* @param {object} settingObj - The object containing the settings to validate.
* @param {function} setErrorFields - The function to set error fields.
* @returns {boolean} - `true` if the data is valid, otherwise `false`.
*/
export default function validateAdvancedSettingsData(settingObj, setErrorFields, setEditedSettings) {
const fieldsWithErrors = [];
const pushDataToErrorArray = (settingName) => {
fieldsWithErrors.push({ key: settingName, message: 'Incorrectly formatted JSON' });
};
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
try {
JSON.parse(settingValue);
} catch (e) {
let targetSettingValue = settingValue;
const firstNonWhite = settingValue.substring(0, 1);
const isValid = !['{', '[', "'"].includes(firstNonWhite);
if (isValid) {
try {
targetSettingValue = `"${ targetSettingValue.trim() }"`;
JSON.parse(targetSettingValue);
setEditedSettings((prevEditedSettings) => ({
...prevEditedSettings,
[settingName]: targetSettingValue,
}));
} catch (quotedE) { /* empty */ }
}
pushDataToErrorArray(settingName);
}
});
setErrorFields((prevState) => {
if (JSON.stringify(prevState) !== JSON.stringify(fieldsWithErrors)) {
return fieldsWithErrors;
}
return prevState;
});
return fieldsWithErrors.length === 0;
}

View File

@@ -0,0 +1,29 @@
import validateAdvancedSettingsData from './utils';
describe('validateAdvancedSettingsData', () => {
it('should validate correctly formatted settings and return true', () => {
const settingObj = {
setting1: '{ "key": "value" }',
setting2: '{ "key": "value" }',
};
const setErrorFieldsMock = jest.fn();
const setEditedSettingsMock = jest.fn();
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
expect(isValid).toBe(true);
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
expect(setEditedSettingsMock).toHaveBeenCalledTimes(0);
});
it('should validate incorrectly formatted settings and set error fields', () => {
const settingObj = {
setting1: '{ "key": "value" }',
setting2: 'incorrectJSON',
setting3: '{ "key": "value" }',
};
const setErrorFieldsMock = jest.fn();
const setEditedSettingsMock = jest.fn();
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
expect(isValid).toBe(false);
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
expect(setEditedSettingsMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,9 @@
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,81 @@
.form-group-custom {
.pgn__form-label {
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
color: $gray-500;
margin-bottom: .5rem;
}
.pgn__form-control-description,
.pgn__form-text {
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
color: $gray-500;
margin-top: .5rem;
}
.dropdown-toggle {
width: 100%;
justify-content: space-between;
}
.form-group-custom_isInvalid {
input {
border-color: $form-feedback-invalid-color;
}
}
.feedback-error {
color: $form-feedback-invalid-color;
}
}
.datepicker-custom {
margin: 0;
.datepicker-custom-control {
display: block;
width: 100%;
font-size: $input-font-size;
font-weight: $input-font-weight;
line-height: $input-line-height;
background: $input-bg;
border-color: $input-border-color;
border-width: $input-border-width;
box-shadow: $input-box-shadow;
border-radius: $input-border-radius;
color: $input-color;
padding: $input-padding-y $input-padding-x;
height: $input-height;
resize: none;
&:focus,
:focus-visible {
color: $input-focus-color;
background-color: $input-bg;
border-color: $input-focus-border-color;
box-shadow: $input-focus-box-shadow;
outline: 0;
}
&::placeholder {
color: $input-placeholder-color;
}
}
.datepicker-custom-control_readonly {
border-color: transparent;
background: $input-disabled-bg;
}
.datepicker-custom-control_isInvalid {
border-color: $form-feedback-invalid-color;
}
.datepicker-custom-control-icon {
position: absolute;
z-index: 2;
right: 1.188rem;
top: 50%;
transform: translateY(-50%);
color: $black;
}
}

View File

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

View File

@@ -0,0 +1,2 @@
$text-color-base: $gray-700;
$text-color-weak: #3E3E3C;

47
src/constants.js Normal file
View File

@@ -0,0 +1,47 @@
export const DATE_FORMAT = 'MM/dd/yyyy';
export const TIME_FORMAT = 'HH:mm';
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 = {
default: 'default',
pending: 'pending',
error: 'error',
};
export const USER_ROLES = {
admin: 'instructor',
staff: 'staff',
};
export const BADGE_STATES = {
danger: 'danger',
secondary: 'secondary',
};
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,
};

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