From ef93e95dd76e3dba0b513b174558500e81dccfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Mon, 26 Jan 2026 12:52:50 -0500 Subject: [PATCH] feat: New header in course unit page [FC-0114] (#2751) - `ENABLE_UNIT_PAGE_NEW_DESIGN` flag created - New Status Bard implemented in the header of the course unit page. - New buttons added in the header of the course unit page. - Which user roles will this change impact? "Course Author". --- .env | 1 + .env.development | 1 + .env.test | 1 + src/course-unit/CourseUnit.scss | 16 ++ src/course-unit/CourseUnit.test.jsx | 144 ++++++++++++++ .../{CourseUnit.jsx => CourseUnit.tsx} | 177 +++++++++++++++--- .../{constants.js => constants.ts} | 0 .../header-navigations/HeaderNavigations.jsx | 54 ------ ...ns.test.jsx => HeaderNavigations.test.tsx} | 0 .../header-navigations/HeaderNavigations.tsx | 98 ++++++++++ .../header-navigations/messages.ts | 25 +++ src/course-unit/header-title/HeaderTitle.jsx | 113 ----------- src/course-unit/header-title/HeaderTitle.scss | 18 ++ .../header-title/HeaderTitle.test.jsx | 170 ----------------- .../header-title/HeaderTitle.test.tsx | 113 +++++++++++ src/course-unit/header-title/HeaderTitle.tsx | 121 ++++++++++++ src/course-unit/header-title/messages.ts | 5 - src/course-unit/hooks.jsx | 2 +- src/course-unit/messages.ts | 40 ++++ src/course-unit/utils.ts | 6 + src/generic/alert-message/index.tsx | 4 +- src/index.jsx | 1 + 22 files changed, 743 insertions(+), 367 deletions(-) rename src/course-unit/{CourseUnit.jsx => CourseUnit.tsx} (64%) rename src/course-unit/{constants.js => constants.ts} (100%) delete mode 100644 src/course-unit/header-navigations/HeaderNavigations.jsx rename src/course-unit/header-navigations/{HeaderNavigations.test.jsx => HeaderNavigations.test.tsx} (100%) create mode 100644 src/course-unit/header-navigations/HeaderNavigations.tsx delete mode 100644 src/course-unit/header-title/HeaderTitle.jsx delete mode 100644 src/course-unit/header-title/HeaderTitle.test.jsx create mode 100644 src/course-unit/header-title/HeaderTitle.test.tsx create mode 100644 src/course-unit/header-title/HeaderTitle.tsx diff --git a/.env b/.env index 76e0d0285..22852cf81 100644 --- a/.env +++ b/.env @@ -37,6 +37,7 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=false +ENABLE_UNIT_PAGE_NEW_DESIGN=false ENABLE_COURSE_OUTLINE_NEW_DESIGN=false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' diff --git a/.env.development b/.env.development index 33aacbdb2..e1c7709be 100644 --- a/.env.development +++ b/.env.development @@ -41,6 +41,7 @@ ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_COURSE_OUTLINE_NEW_DESIGN=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_TAGGING_TAXONOMY_PAGES=true +ENABLE_UNIT_PAGE_NEW_DESIGN=true BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.test b/.env.test index 9ecfd073e..9757e8faf 100644 --- a/.env.test +++ b/.env.test @@ -36,6 +36,7 @@ ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true ENABLE_COURSE_OUTLINE_NEW_DESIGN=false ENABLE_TAGGING_TAXONOMY_PAGES=true +ENABLE_UNIT_PAGE_NEW_DESIGN=true BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_CHECKLIST_QUALITY=true diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index bd0806454..b2fbc8502 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -9,8 +9,24 @@ .course-unit { min-width: 900px; + + .sub-header { + // To clean the blank space in the bottom of the sub header + margin-bottom: -35px; + } } .course-unit__alert { margin-bottom: 1.75rem; } + +.unit-header-status-bar { + .draft-badge { + background-color: #B4610E; + color: white; + } + + .badge-label { + margin-top: 2px; + } +} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index df211b6c0..b83238178 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -2346,4 +2346,148 @@ describe('', () => { // Does not render the "Add Components" section expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument(); }); + + it('displays the live state in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + currently_visible_to_students: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Live')).toBeInTheDocument(); + }); + + it('displays the staff only state in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + currently_visible_to_students: false, + visibility_state: 'staff_only', + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Staff Only')).toBeInTheDocument(); + }); + + it('displays the scheduled state in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + currently_visible_to_students: false, + published: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Scheduled')).toBeInTheDocument(); + }); + + it('displays the draft changes state in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + published: true, + has_changes: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Unpublished changes')).toBeInTheDocument(); + }); + + it('displays discussions enabled label in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + discussion_enabled: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Discussions Enabled')).toBeInTheDocument(); + }); + + it('displays group access with one group in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + user_partition_info: { + ...courseSectionVerticalMock.xblock_info.user_partition_info, + selected_partition_index: 1, + selected_groups_label: 'Visibility group 1', + }, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Access: Visibility group 1')).toBeInTheDocument(); + }); + + it('displays group access with multiple groups in the status bar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + user_partition_info: { + ...courseSectionVerticalMock.xblock_info.user_partition_info, + selected_partition_index: 1, + selected_groups_label: 'Visibility group 1, Visibility group 2, Visibility group 3', + }, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + render(); + expect(await screen.findByText('Access: 3 Groups')).toBeInTheDocument(); + }); }); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.tsx similarity index 64% rename from src/course-unit/CourseUnit.jsx rename to src/course-unit/CourseUnit.tsx index ab6855abf..938b43c94 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.tsx @@ -1,15 +1,26 @@ import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; +import type { MessageDescriptor } from 'react-intl'; import { Alert, Container, Layout, Button, TransitionReplace, + Stack, + Badge, + Icon, + OverlayTrigger, + Tooltip, } from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Warning as WarningIcon, CheckCircle as CheckCircleIcon, + Lock, + AccessTimeFilled, + Groups, + QuestionAnswer, } from '@openedx/paragon/icons'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import DraftIcon from '@src/generic/DraftIcon'; import { CourseAuthoringUnitSidebarSlot } from '../plugin-slots/CourseAuthoringUnitSidebarSlot'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -33,6 +44,119 @@ import XBlockContainerIframe from './xblock-container-iframe'; import MoveModal from './move-modal'; import IframePreviewLibraryXBlockChanges from './preview-changes'; import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot'; +import { UNIT_VISIBILITY_STATES } from './constants'; +import { isUnitPageNewDesignEnabled } from './utils'; + +const StatusBar = ({ courseUnit }: { courseUnit: any }) => { + const { selectedPartitionIndex, selectedGroupsLabel } = courseUnit.userPartitionInfo ?? {}; + const hasGroups = selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel; + let groupsCount = 0; + if (hasGroups) { + groupsCount = selectedGroupsLabel.split(',').length; + } + + let visibilityChipData = { + variant: 'warning', + className: 'draft-badge', + text: messages.statusBarDraftNeverPublished, + icon: DraftIcon, + } as { + variant: string, + className?: string, + text: MessageDescriptor, + icon: React.ComponentType, + }; + + if (courseUnit.currentlyVisibleToStudents) { + visibilityChipData = { + variant: 'success', + text: messages.statusBarLiveBadge, + icon: CheckCircleIcon, + }; + } else if (courseUnit.visibilityState === UNIT_VISIBILITY_STATES.staffOnly) { + visibilityChipData = { + variant: 'secondary', + text: messages.statusBarStaffOnly, + icon: Lock, + }; + } else if (courseUnit.published) { + visibilityChipData = { + variant: 'info', + text: messages.statusBarScheduledBadge, + icon: AccessTimeFilled, + }; + } + + return ( + + + + + + + + + + {courseUnit.published && courseUnit.hasChanges && ( + + + + + + + + + )} + {groupsCount === 1 && ( + + + + + + + )} + {groupsCount > 1 && ( + + {selectedGroupsLabel} + + )} + > + + + + + + + + )} + {courseUnit.discussionEnabled && ( + + + + + )} + + ); +}; const CourseUnit = () => { const { blockId } = useParams(); @@ -74,6 +198,7 @@ const CourseUnit = () => { handleNavigateToTargetUnit, addComponentTemplateData, } = useCourseUnit({ courseId, blockId }); + const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); const readOnly = !!courseUnit.readOnly; @@ -121,7 +246,7 @@ const CourseUnit = () => { : intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })} aria-hidden={movedXBlockParams.isSuccess} dismissible - actions={movedXBlockParams.isUndo ? null : [ + actions={movedXBlockParams.isUndo ? undefined : [ - - - )} - {[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.splitTest.id].includes(category) && ( - - )} - - ); -}; - -HeaderNavigations.propTypes = { - headerNavigationsActions: PropTypes.shape({ - handleViewLive: PropTypes.func.isRequired, - handlePreview: PropTypes.func.isRequired, - handleEdit: PropTypes.func.isRequired, - }).isRequired, - category: PropTypes.string.isRequired, -}; - -export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/HeaderNavigations.test.jsx b/src/course-unit/header-navigations/HeaderNavigations.test.tsx similarity index 100% rename from src/course-unit/header-navigations/HeaderNavigations.test.jsx rename to src/course-unit/header-navigations/HeaderNavigations.test.tsx diff --git a/src/course-unit/header-navigations/HeaderNavigations.tsx b/src/course-unit/header-navigations/HeaderNavigations.tsx new file mode 100644 index 000000000..17c726c4e --- /dev/null +++ b/src/course-unit/header-navigations/HeaderNavigations.tsx @@ -0,0 +1,98 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, ButtonGroup, Stack, +} from '@openedx/paragon'; +import { + Add, Edit as EditIcon, FindInPage, InfoOutline, +} from '@openedx/paragon/icons'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; + +import messages from './messages'; +import { isUnitPageNewDesignEnabled } from '../utils'; + +type HeaderNavigationActions = { + handleViewLive: () => void; + handlePreview: () => void; + handleEdit: () => void; +}; + +type HeaderNavigationsProps = { + headerNavigationsActions: HeaderNavigationActions; + category: string; +}; + +/** + * Generic header navigations to be used in this pages: + * - Unit page + * - Legacy library content page + * - Split test page + */ +const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigationsProps) => { + const intl = useIntl(); + const { + handleViewLive, + handlePreview, + handleEdit, + } = headerNavigationsActions; + + const showNewDesignButtons = isUnitPageNewDesignEnabled(); + + return ( + + ); +}; + +export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/messages.ts b/src/course-unit/header-navigations/messages.ts index 53239434a..332896608 100644 --- a/src/course-unit/header-navigations/messages.ts +++ b/src/course-unit/header-navigations/messages.ts @@ -16,6 +16,31 @@ const messages = defineMessages({ defaultMessage: 'Edit', description: 'The unit edit button text', }, + addButton: { + id: 'course-authoring.course-unit.button.add', + defaultMessage: 'Add', + description: 'The unit add button text', + }, + moreActionsButtonAriaLabel: { + id: 'course-authoring.course-unit.button.more-actions', + defaultMessage: 'More actions', + description: 'The unit more actions button aria-label', + }, + analyticsMenu: { + id: 'course-authoring.course-unit.button.analytics', + defaultMessage: 'Analytics', + description: 'The unit analytics menu text', + }, + alignMenu: { + id: 'course-authoring.course-unit.button.align', + defaultMessage: 'Align', + description: 'The unit align menu text', + }, + infoButton: { + id: 'course-authoring.course-unit.button.unit-info', + defaultMessage: 'Unit Info', + description: 'The unit info button text', + }, }); export default messages; diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx deleted file mode 100644 index 1bcb8a5c9..000000000 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Form, IconButton, useToggle } from '@openedx/paragon'; -import { - EditOutline as EditIcon, - Settings as SettingsIcon, -} from '@openedx/paragon/icons'; - -import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; -import { COURSE_BLOCK_NAMES } from '../../constants'; -import { getCourseUnitData } from '../data/selectors'; -import { updateQueryPendingStatus } from '../data/slice'; -import messages from './messages'; - -const HeaderTitle = ({ - unitTitle, - isTitleEditFormOpen, - handleTitleEdit, - handleTitleEditSubmit, - handleConfigureSubmit, -}) => { - const intl = useIntl(); - const dispatch = useDispatch(); - const [titleValue, setTitleValue] = useState(unitTitle); - const currentItemData = useSelector(getCourseUnitData); - const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo ?? {}; - - const isXBlockComponent = [ - COURSE_BLOCK_NAMES.libraryContent.id, - COURSE_BLOCK_NAMES.splitTest.id, - COURSE_BLOCK_NAMES.component.id, - ].includes(currentItemData.category); - - const onConfigureSubmit = (...arg) => { - handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); - }; - - const getVisibilityMessage = () => { - let message; - - if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel) { - message = intl.formatMessage(messages.definedVisibilityMessage, { selectedGroupsLabel }); - } else if (currentItemData.hasPartitionGroupComponents) { - message = intl.formatMessage(messages.commonVisibilityMessage); - } - - return message ? (

{message}

) : null; - }; - - useEffect(() => { - setTitleValue(unitTitle); - dispatch(updateQueryPendingStatus(true)); - }, [unitTitle]); - - return ( - <> -
- {isTitleEditFormOpen ? ( - - e && e.focus()} - value={titleValue} - name="displayName" - onChange={(e) => setTitleValue(e.target.value)} - aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)} - onBlur={() => handleTitleEditSubmit(titleValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleTitleEditSubmit(titleValue); - } - }} - /> - - ) : unitTitle} - - - -
- {getVisibilityMessage()} - - ); -}; - -export default HeaderTitle; - -HeaderTitle.propTypes = { - unitTitle: PropTypes.string.isRequired, - isTitleEditFormOpen: PropTypes.bool.isRequired, - handleTitleEdit: PropTypes.func.isRequired, - handleTitleEditSubmit: PropTypes.func.isRequired, - handleConfigureSubmit: PropTypes.func.isRequired, -}; diff --git a/src/course-unit/header-title/HeaderTitle.scss b/src/course-unit/header-title/HeaderTitle.scss index d191b1cfe..9727b99eb 100644 --- a/src/course-unit/header-title/HeaderTitle.scss +++ b/src/course-unit/header-title/HeaderTitle.scss @@ -2,3 +2,21 @@ font-size: var(--pgn-typography-font-size-sm); font-weight: var(--pgn-typography-font-weight-normal); } + +.unit-header-title { + .edit-button { + opacity: 0; + transition: opacity .3s linear; + margin-right: .5rem; + + &:focus { + opacity: 1; + } + } + + &:hover { + .edit-button { + opacity: 1; + } + } +} diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx deleted file mode 100644 index f48d919c1..000000000 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ /dev/null @@ -1,170 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { initializeMockApp } from '@edx/frontend-platform'; - -import initializeStore from '../../store'; -import { executeThunk } from '../../utils'; -import { getCourseSectionVerticalApiUrl } from '../data/api'; -import { fetchCourseSectionVerticalData } from '../data/thunk'; -import { courseSectionVerticalMock } from '../__mocks__'; -import HeaderTitle from './HeaderTitle'; -import messages from './messages'; - -const blockId = '123'; -const unitTitle = 'Getting Started'; -const isTitleEditFormOpen = false; -const handleTitleEdit = jest.fn(); -const handleTitleEditSubmit = jest.fn(); -const handleConfigureSubmit = jest.fn(); -let store; -let axiosMock; - -const renderComponent = (props) => render( - - - - - , -); - -describe('', () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, courseSectionVerticalMock); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - }); - - it('render HeaderTitle component correctly', () => { - const { getByText, getByRole } = renderComponent(); - - expect(getByText(unitTitle)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - }); - - it('render HeaderTitle with open edit form', () => { - const { getByRole } = renderComponent({ - isTitleEditFormOpen: true, - }); - - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); - }); - - it('Units sourced from upstream show a enabled edit button', async () => { - // Override mock unit with one sourced from an upstream library - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - xblock_info: { - ...courseSectionVerticalMock.xblock_info, - upstreamInfo: { - ...courseSectionVerticalMock.xblock_info.upstreamInfo, - upstreamRef: 'lct:org:lib:unit:unit-1', - }, - }, - }); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - - const { getByRole } = renderComponent(); - - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); - }); - - it('calls toggle edit title form by clicking on Edit button', async () => { - const user = userEvent.setup(); - const { getByRole } = renderComponent(); - - const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); - await user.click(editTitleButton); - expect(handleTitleEdit).toHaveBeenCalledTimes(1); - }); - - it('calls saving title by clicking outside or press Enter key', async () => { - const user = userEvent.setup(); - const { getByRole } = renderComponent({ - isTitleEditFormOpen: true, - }); - - const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); - await user.type(titleField, ' 1'); - expect(titleField).toHaveValue(`${unitTitle} 1`); - await user.click(document.body); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); - - await user.click(titleField); - await user.type(titleField, ' 2[Enter]'); - expect(titleField).toHaveValue(`${unitTitle} 1 2`); - expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); - }); - - it('displays a visibility message with the selected groups for the unit', async () => { - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - xblock_info: { - ...courseSectionVerticalMock.xblock_info, - user_partition_info: { - ...courseSectionVerticalMock.xblock_info.user_partition_info, - selected_partition_index: 1, - selected_groups_label: 'Visibility group 1', - }, - }, - }); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const { getByText } = renderComponent(); - const visibilityMessage = messages.definedVisibilityMessage.defaultMessage - .replace('{selectedGroupsLabel}', 'Visibility group 1'); - - await waitFor(() => { - expect(getByText(visibilityMessage)).toBeInTheDocument(); - }); - }); - - it('displays a visibility message with the selected groups for some of xblock', async () => { - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - xblock_info: { - ...courseSectionVerticalMock.xblock_info, - has_partition_group_components: true, - }, - }); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const { getByText } = renderComponent(); - - await waitFor(() => { - expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument(); - }); - }); -}); diff --git a/src/course-unit/header-title/HeaderTitle.test.tsx b/src/course-unit/header-title/HeaderTitle.test.tsx new file mode 100644 index 000000000..3ec4852f0 --- /dev/null +++ b/src/course-unit/header-title/HeaderTitle.test.tsx @@ -0,0 +1,113 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMocks, render, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; +import { executeThunk } from '@src/utils'; + +import { getCourseSectionVerticalApiUrl } from '../data/api'; +import { fetchCourseSectionVerticalData } from '../data/thunk'; +import { courseSectionVerticalMock } from '../__mocks__'; +import HeaderTitle from './HeaderTitle'; +import messages from './messages'; + +const blockId = '123'; +const unitTitle = 'Getting Started'; +const isTitleEditFormOpen = false; +const handleTitleEdit = jest.fn(); +const handleTitleEditSubmit = jest.fn(); +const handleConfigureSubmit = jest.fn(); +let store; +let axiosMock; + +const renderComponent = (props?: any) => render( + , +); + +describe('', () => { + beforeEach(async () => { + const mocks = initializeMocks(); + + store = mocks.reduxStore; + axiosMock = mocks.axiosMock; + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + }); + + it('render HeaderTitle component correctly', () => { + renderComponent(); + + expect(screen.getByText(unitTitle)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + }); + + it('render HeaderTitle with open edit form', () => { + renderComponent({ + isTitleEditFormOpen: true, + }); + + expect(screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); + expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); + expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); + }); + + it('Units sourced from upstream show a enabled edit button', async () => { + // Override mock unit with one sourced from an upstream library + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + upstreamInfo: { + ...courseSectionVerticalMock.xblock_info.upstreamInfo, + upstreamRef: 'lct:org:lib:unit:unit-1', + }, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + renderComponent(); + + expect(screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); + expect(screen.getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); + }); + + it('calls toggle edit title form by clicking on Edit button', async () => { + const user = userEvent.setup(); + renderComponent(); + + const editTitleButton = screen.getByRole('button', { name: messages.altButtonEdit.defaultMessage }); + await user.click(editTitleButton); + expect(handleTitleEdit).toHaveBeenCalledTimes(1); + }); + + it('calls saving title by clicking outside or press Enter key', async () => { + const user = userEvent.setup(); + renderComponent({ + isTitleEditFormOpen: true, + }); + + const titleField = screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); + await user.type(titleField, ' 1'); + expect(titleField).toHaveValue(`${unitTitle} 1`); + await user.click(document.body); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(1); + + await user.click(titleField); + await user.type(titleField, ' 2[Enter]'); + expect(titleField).toHaveValue(`${unitTitle} 1 2`); + expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/course-unit/header-title/HeaderTitle.tsx b/src/course-unit/header-title/HeaderTitle.tsx new file mode 100644 index 000000000..623c099e9 --- /dev/null +++ b/src/course-unit/header-title/HeaderTitle.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + Form, IconButton, useToggle, +} from '@openedx/paragon'; +import { + EditOutline as EditIcon, + Settings as SettingsIcon, +} from '@openedx/paragon/icons'; + +import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getCourseUnitData } from '../data/selectors'; +import { updateQueryPendingStatus } from '../data/slice'; +import messages from './messages'; +import { isUnitPageNewDesignEnabled } from '../utils'; + +type HeaderTitleProps = { + unitTitle: string; + isTitleEditFormOpen: boolean; + handleTitleEdit: () => void; + handleTitleEditSubmit: (title: string) => void; + handleConfigureSubmit: ( + id: string, + isVisible: boolean, + groupAccess: boolean, + isDiscussionEnabled: boolean, + closeModalFn: (value: boolean) => void + ) => void; +}; + +/** + * Component that renders the title and extra action buttons: + * - Edit button: Hidden, It appears when you hover over it. + * The title becomes a text form. + * - Settings button: Shown only in the legacy unit page. + * Opens a settings modal. + */ +const HeaderTitle = ({ + unitTitle, + isTitleEditFormOpen, + handleTitleEdit, + handleTitleEditSubmit, + handleConfigureSubmit, +}: HeaderTitleProps) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const [titleValue, setTitleValue] = useState(unitTitle); + const currentItemData = useSelector(getCourseUnitData); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + + const isXBlockComponent = [ + COURSE_BLOCK_NAMES.libraryContent.id, + COURSE_BLOCK_NAMES.splitTest.id, + COURSE_BLOCK_NAMES.component.id, + ].includes(currentItemData.category); + + const onConfigureSubmit = (...arg) => { + handleConfigureSubmit( + currentItemData.id, + arg[0], + arg[1], + arg[2], + closeConfigureModal, + ); + }; + + useEffect(() => { + setTitleValue(unitTitle); + dispatch(updateQueryPendingStatus(true)); + }, [unitTitle]); + + return ( +
+ {isTitleEditFormOpen ? ( + + e && e.focus()} + value={titleValue} + name="displayName" + onChange={(e) => setTitleValue(e.target.value)} + aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)} + onBlur={() => handleTitleEditSubmit(titleValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleTitleEditSubmit(titleValue); + } + }} + /> + + ) : unitTitle} + + {!isUnitPageNewDesignEnabled() && ( + <> + + + + )} +
+ ); +}; + +export default HeaderTitle; diff --git a/src/course-unit/header-title/messages.ts b/src/course-unit/header-title/messages.ts index 036e9ddef..334436f17 100644 --- a/src/course-unit/header-title/messages.ts +++ b/src/course-unit/header-title/messages.ts @@ -21,11 +21,6 @@ const messages = defineMessages({ defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}', description: 'Group visibility accessibility text for Unit', }, - commonVisibilityMessage: { - id: 'course-authoring.course-unit.heading.visibility.common.message', - defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners.', - description: 'The label text of some content restriction in this unit', - }, }); export default messages; diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index b987599ee..14d7403e4 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -49,7 +49,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); const [searchParams] = useSearchParams(); const { sendMessageToIframe } = useIframe(); - const [addComponentTemplateData, setAddComponentTemplateData] = useState({}); + const [addComponentTemplateData, setAddComponentTemplateData] = useState(undefined); const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false); const courseUnit = useSelector(getCourseUnitData); diff --git a/src/course-unit/messages.ts b/src/course-unit/messages.ts index bbda3588e..334becd2e 100644 --- a/src/course-unit/messages.ts +++ b/src/course-unit/messages.ts @@ -53,6 +53,46 @@ const messages = defineMessages({ defaultMessage: 'library', description: 'Text of the link in the alert when the unit is read only because is a library unit', }, + statusBarDraftChangesBadge: { + id: 'course-authoring.course-unit.status-bar.publish-status.draft-changes', + defaultMessage: 'Unpublished changes', + description: 'Text for the Draft Changes Badge in the status bar.', + }, + statusBarDiscussionsEnabled: { + id: 'course-authoring.course-unit.status-bar.discussions-enabled', + defaultMessage: 'Discussions Enabled', + description: 'Text for the Discussions enabled Badge in the status bar.', + }, + statusBarDraftNeverPublished: { + id: 'course-authoring.course-unit.status-bar.visibility.draft', + defaultMessage: 'Draft (Never Published)', + description: 'Text for the Discussions enabled Badge in the status bar.', + }, + statusBarGroupAccessOneGroup: { + id: 'course-authoring.course-unit.status-bar.access.one-group', + defaultMessage: 'Access: {groupName}', + description: 'Text in the status bar when the access for the unit is for one group', + }, + statusBarGroupAccessMultipleGroup: { + id: 'course-authoring.course-unit.status-bar.access.multiple-group', + defaultMessage: 'Access: {groupsCount} Groups', + description: 'Text in the status bar when the access for the unit is for one group', + }, + statusBarLiveBadge: { + id: 'course-authoring.course-unit.status-bar.visibility.chip', + defaultMessage: 'Live', + description: 'Text for the Live Badge in the status bar.', + }, + statusBarStaffOnly: { + id: 'course-authoring.course-unit.status-bar.visibility.staff-only', + defaultMessage: 'Staff Only', + description: 'Text for the Staff Only Badge in the status bar.', + }, + statusBarScheduledBadge: { + id: 'course-authoring.course-unit.status-bar.visibility.scheduled', + defaultMessage: 'Scheduled', + description: 'Text for the Upcoming Badge in the status bar.', + }, }); export default messages; diff --git a/src/course-unit/utils.ts b/src/course-unit/utils.ts index c912c94d7..26532036a 100644 --- a/src/course-unit/utils.ts +++ b/src/course-unit/utils.ts @@ -1,3 +1,5 @@ +import { getConfig } from '@edx/frontend-platform'; + /** * Adapts API URL paths to the application's internal URL format based on predefined conditions. * @@ -43,3 +45,7 @@ export const subsectionFirstUnitEditUrl = ( const url = `/course/${courseId}/subsection/${subsectionId}`; return url; }; + +export const isUnitPageNewDesignEnabled = () => ( + getConfig().ENABLE_UNIT_PAGE_NEW_DESIGN?.toString().toLowerCase() === 'true' +); diff --git a/src/generic/alert-message/index.tsx b/src/generic/alert-message/index.tsx index 244778ecf..16f84e93a 100644 --- a/src/generic/alert-message/index.tsx +++ b/src/generic/alert-message/index.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Alert } from '@openedx/paragon'; -interface Props extends React.ComponentPropsWithoutRef { - title?: string; +interface Props extends Omit, 'title'> { + title?: string | React.ReactNode; description?: string | React.ReactNode; } diff --git a/src/index.jsx b/src/index.jsx index 516695cc6..671d627f5 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -174,6 +174,7 @@ initialize({ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false', ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false', ENABLE_COURSE_IMPORT_IN_LIBRARY: process.env.ENABLE_COURSE_IMPORT_IN_LIBRARY || 'false', + ENABLE_UNIT_PAGE_NEW_DESIGN: process.env.ENABLE_UNIT_PAGE_NEW_DESIGN || 'false', ENABLE_COURSE_OUTLINE_NEW_DESIGN: process.env.ENABLE_COURSE_OUTLINE_NEW_DESIGN || 'false', ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',