From a837a0d419cb18c58ae41b65b38d393c983a3a89 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 11 Dec 2025 19:39:58 -0500 Subject: [PATCH 1/7] feat: New header un course unit page --- .../{CourseUnit.jsx => CourseUnit.tsx} | 46 ++++---- .../{constants.js => constants.ts} | 0 src/course-unit/header-title/HeaderTitle.jsx | 101 ++++++++++++++++-- src/course-unit/header-title/messages.js | 40 +++++++ src/course-unit/hooks.jsx | 2 +- src/course-unit/{messages.js => messages.ts} | 0 src/generic/alert-message/index.tsx | 4 +- 7 files changed, 163 insertions(+), 30 deletions(-) rename src/course-unit/{CourseUnit.jsx => CourseUnit.tsx} (89%) rename src/course-unit/{constants.js => constants.ts} (100%) rename src/course-unit/{messages.js => messages.ts} (100%) diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.tsx similarity index 89% rename from src/course-unit/CourseUnit.jsx rename to src/course-unit/CourseUnit.tsx index ab6855abf3..9ccc29f06d 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.tsx @@ -74,6 +74,7 @@ const CourseUnit = () => { handleNavigateToTargetUnit, addComponentTemplateData, } = useCourseUnit({ courseId, blockId }); + const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); const readOnly = !!courseUnit.readOnly; @@ -121,7 +122,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 0000000000..acf2addf23 --- /dev/null +++ b/src/course-unit/header-navigations/HeaderNavigations.tsx @@ -0,0 +1,100 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { + Button, Dropdown, Icon, Stack, +} from '@openedx/paragon'; +import { + Add, AutoGraph, Edit as EditIcon, Tag, ViewSidebar, +} from '@openedx/paragon/icons'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; + +import messages from './messages'; + +type HeaderNavigationActions = { + handleViewLive: () => void; + handlePreview: () => void; + handleEdit: () => void; +}; + +type HeaderNavigationsProps = { + headerNavigationsActions: HeaderNavigationActions; + category: string; +}; + +const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigationsProps) => { + const intl = useIntl(); + const { + handleViewLive, + handlePreview, + handleEdit, + } = headerNavigationsActions; + + const showNewDesignButtons = getConfig().ENABLE_UNIT_PAGE_NEW_DESIGN === 'true'; + + return ( + + ); +}; + +export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/messages.ts b/src/course-unit/header-navigations/messages.ts index 53239434ac..b1b2e92b89 100644 --- a/src/course-unit/header-navigations/messages.ts +++ b/src/course-unit/header-navigations/messages.ts @@ -16,6 +16,26 @@ 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', + }, }); export default messages; diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.tsx similarity index 100% rename from src/course-unit/header-title/HeaderTitle.test.jsx rename to src/course-unit/header-title/HeaderTitle.test.tsx diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.tsx similarity index 84% rename from src/course-unit/header-title/HeaderTitle.jsx rename to src/course-unit/header-title/HeaderTitle.tsx index 9c27c87d8c..c6c29e043c 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Badge, Form, Icon, IconButton, Stack, useToggle, @@ -13,14 +12,14 @@ import { Settings as SettingsIcon, } from '@openedx/paragon/icons'; -import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; -import { COURSE_BLOCK_NAMES } from '../../constants'; +import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; import messages from './messages'; import { UNIT_VISIBILITY_STATES } from '../constants'; -const StatusBar = ({ courseUnit }) => { +const StatusBar = ({ courseUnit }: { courseUnit: any }) => { const { selectedPartitionIndex, selectedGroupsLabel } = courseUnit.userPartitionInfo ?? {}; const hasGroups = selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel; @@ -103,13 +102,27 @@ const StatusBar = ({ courseUnit }) => { ); }; +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; +}; + const HeaderTitle = ({ unitTitle, isTitleEditFormOpen, handleTitleEdit, handleTitleEditSubmit, handleConfigureSubmit, -}) => { +}: HeaderTitleProps) => { const intl = useIntl(); const dispatch = useDispatch(); const [titleValue, setTitleValue] = useState(unitTitle); @@ -123,17 +136,13 @@ const HeaderTitle = ({ ].includes(currentItemData.category); const onConfigureSubmit = (...arg) => { - handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); - }; - - const getVisibilityMessage = () => { - let message; - - if (currentItemData.hasPartitionGroupComponents) { - message = intl.formatMessage(messages.commonVisibilityMessage); - } - - return message ? (

{message}

) : null; + handleConfigureSubmit( + currentItemData.id, + arg[0], + arg[1], + arg[2], + closeConfigureModal, + ); }; useEffect(() => { @@ -180,23 +189,13 @@ const HeaderTitle = ({ currentItemData={currentItemData} isSelfPaced={false} isXBlockComponent={isXBlockComponent} - userPartitionInfo={currentItemData?.userPartitionInfo || {}} /> -
+
- {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/messages.js b/src/course-unit/header-title/messages.js index 94011ac581..1d8d7b4196 100644 --- a/src/course-unit/header-title/messages.js +++ b/src/course-unit/header-title/messages.js @@ -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', - }, statusBarLiveBadge: { id: 'course-authoring.course-unit.status-bar.visibility.chip', defaultMessage: 'Live', diff --git a/src/index.jsx b/src/index.jsx index 77b00c3e56..fbc6aa0455 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_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_GRADING_METHOD_IN_PROBLEMS: process.env.ENABLE_GRADING_METHOD_IN_PROBLEMS === 'true', From dc68383d0a51b7591d8fcf5ec57cb3b2c23e3993 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 15 Dec 2025 18:59:30 -0500 Subject: [PATCH 3/7] test: Adding test for the Status bar --- .../header-title/HeaderTitle.test.tsx | 194 ++++++++++++------ 1 file changed, 136 insertions(+), 58 deletions(-) diff --git a/src/course-unit/header-title/HeaderTitle.test.tsx b/src/course-unit/header-title/HeaderTitle.test.tsx index f48d919c1e..c28f1ca457 100644 --- a/src/course-unit/header-title/HeaderTitle.test.tsx +++ b/src/course-unit/header-title/HeaderTitle.test.tsx @@ -1,13 +1,9 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { render, waitFor } from '@testing-library/react'; +import { initializeMocks, render, screen } from '@src/testUtils'; 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 { executeThunk } from '@src/utils'; -import initializeStore from '../../store'; -import { executeThunk } from '../../utils'; import { getCourseSectionVerticalApiUrl } from '../data/api'; import { fetchCourseSectionVerticalData } from '../data/thunk'; import { courseSectionVerticalMock } from '../__mocks__'; @@ -23,34 +19,23 @@ const handleConfigureSubmit = jest.fn(); let store; let axiosMock; -const renderComponent = (props) => render( - - - - - , +const renderComponent = (props?: any) => render( + , ); describe('', () => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); + const mocks = initializeMocks(); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + store = mocks.reduxStore; + axiosMock = mocks.axiosMock; axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); @@ -58,22 +43,22 @@ describe('', () => { }); it('render HeaderTitle component correctly', () => { - const { getByText, getByRole } = renderComponent(); + renderComponent(); - expect(getByText(unitTitle)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + 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', () => { - const { getByRole } = renderComponent({ + 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(); + 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 () => { @@ -93,28 +78,28 @@ describe('', () => { }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const { getByRole } = renderComponent(); + renderComponent(); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); + 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(); - const { getByRole } = renderComponent(); + renderComponent(); - const editTitleButton = getByRole('button', { name: messages.altButtonEdit.defaultMessage }); + 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(); - const { getByRole } = renderComponent({ + renderComponent({ isTitleEditFormOpen: true, }); - const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); + const titleField = screen.getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage }); await user.type(titleField, ' 1'); expect(titleField).toHaveValue(`${unitTitle} 1`); await user.click(document.body); @@ -126,7 +111,7 @@ describe('', () => { expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); }); - it('displays a visibility message with the selected groups for the unit', async () => { + it('displays the live state in the status bar', async () => { axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, { @@ -138,33 +123,126 @@ describe('', () => { selected_partition_index: 1, selected_groups_label: 'Visibility group 1', }, + currently_visible_to_students: true, }, }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const { getByText } = renderComponent(); - const visibilityMessage = messages.definedVisibilityMessage.defaultMessage - .replace('{selectedGroupsLabel}', 'Visibility group 1'); + renderComponent(); + expect(await screen.findByText('Live')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(getByText(visibilityMessage)).toBeInTheDocument(); - }); + it('displays the ready state in the status bar', 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', + }, + currently_visible_to_students: false, + visibility_state: 'ready', + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + expect(await screen.findByText('Ready')).toBeInTheDocument(); }); - it('displays a visibility message with the selected groups for some of xblock', async () => { + it('displays the unpublished state in the status bar', async () => { axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, { ...courseSectionVerticalMock, xblock_info: { ...courseSectionVerticalMock.xblock_info, - has_partition_group_components: true, + user_partition_info: { + ...courseSectionVerticalMock.xblock_info.user_partition_info, + selected_partition_index: 1, + selected_groups_label: 'Visibility group 1', + }, + visibility_state: 'staff_only', + discussion_enabled: true, + published: false, }, }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const { getByText } = renderComponent(); + renderComponent(); + expect(await screen.findByText('Unpublished')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument(); - }); + it('displays the published state in the status bar', 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', + }, + visibility_state: 'staff_only', + discussion_enabled: true, + published: true, + has_changes: false, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + expect(await screen.findByText('Published')).toBeInTheDocument(); + }); + + it('displays the draft changes state in the status bar', 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', + }, + visibility_state: 'staff_only', + discussion_enabled: true, + published: true, + has_changes: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + expect(await screen.findByText('Draft Changes')).toBeInTheDocument(); + }); + + it('displays extra setting labels in the status bar', 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', + }, + visibility_state: 'staff_only', + discussion_enabled: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + // Staff visibility label + expect(await screen.findByText('Visible to Staff-Only')).toBeInTheDocument(); + // Group visibility names + expect(await screen.findByText('Visibility group 1')).toBeInTheDocument(); + // Discussions setting label + expect(await screen.findByText('Discussions Enabled')).toBeInTheDocument(); }); }); From b768cb342fe5cf12968985838f4c79cf5c83c347 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 16 Jan 2026 12:13:15 -0500 Subject: [PATCH 4/7] feat: Add new requirements to the header --- .../header-navigations/HeaderNavigations.tsx | 75 ++++------ .../header-navigations/messages.ts | 5 + src/course-unit/header-title/HeaderTitle.scss | 25 ++++ src/course-unit/header-title/HeaderTitle.tsx | 130 ++++++++++-------- .../header-title/{messages.js => messages.ts} | 40 +++--- src/course-unit/utils.ts | 6 + 6 files changed, 160 insertions(+), 121 deletions(-) rename src/course-unit/header-title/{messages.js => messages.ts} (65%) diff --git a/src/course-unit/header-navigations/HeaderNavigations.tsx b/src/course-unit/header-navigations/HeaderNavigations.tsx index acf2addf23..55cc99209a 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.tsx +++ b/src/course-unit/header-navigations/HeaderNavigations.tsx @@ -1,14 +1,14 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; import { - Button, Dropdown, Icon, Stack, + Button, ButtonGroup, Stack, } from '@openedx/paragon'; import { - Add, AutoGraph, Edit as EditIcon, Tag, ViewSidebar, + 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; @@ -29,58 +29,43 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat handleEdit, } = headerNavigationsActions; - const showNewDesignButtons = getConfig().ENABLE_UNIT_PAGE_NEW_DESIGN === 'true'; + const showNewDesignButtons = isUnitPageNewDesignEnabled(); return (
); };