diff --git a/.env b/.env index 3237da4c31..72cde40cd3 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 BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.development b/.env.development index 970902cff7..abc1fd8555 100644 --- a/.env.development +++ b/.env.development @@ -40,6 +40,7 @@ ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=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 0e1e83d0cd..677f5e3d17 100644 --- a/.env.test +++ b/.env.test @@ -35,6 +35,7 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_CERTIFICATE_PAGE=true ENABLE_COURSE_IMPORT_IN_LIBRARY=true 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.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.jsx b/src/course-unit/header-title/HeaderTitle.jsx deleted file mode 100644 index 1bcb8a5c94..0000000000 --- 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.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx deleted file mode 100644 index f48d919c1e..0000000000 --- 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 0000000000..c28f1ca457 --- /dev/null +++ b/src/course-unit/header-title/HeaderTitle.test.tsx @@ -0,0 +1,248 @@ +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); + }); + + it('displays the live 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: true, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + expect(await screen.findByText('Live')).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 the unpublished 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: false, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + renderComponent(); + expect(await screen.findByText('Unpublished')).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(); + }); +}); diff --git a/src/course-unit/header-title/HeaderTitle.tsx b/src/course-unit/header-title/HeaderTitle.tsx new file mode 100644 index 0000000000..c6c29e043c --- /dev/null +++ b/src/course-unit/header-title/HeaderTitle.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Badge, Form, Icon, IconButton, Stack, useToggle, +} from '@openedx/paragon'; +import { + EditOutline as EditIcon, + Groups, + Lock, + QuestionAnswer, + Settings as SettingsIcon, +} from '@openedx/paragon/icons'; + +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 }: { courseUnit: any }) => { + const { selectedPartitionIndex, selectedGroupsLabel } = courseUnit.userPartitionInfo ?? {}; + const hasGroups = selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel; + + let visibilityChipData = { + variant: 'success', + className: 'bg-white text-success-400 border border-success-500', + text: messages.statusBarUpcomingBadge, + }; + let publishStatusChipData = { + variant: 'light', + className: '', + text: messages.statusBarUnpublishedBadge, + }; + + if (courseUnit.currentlyVisibleToStudents) { + visibilityChipData = { + variant: 'success', + className: '', + text: messages.statusBarLiveBadge, + }; + } else if (courseUnit.visibilityState === UNIT_VISIBILITY_STATES.ready) { + visibilityChipData = { + variant: 'success', + className: 'bg-white text-success-400 border border-success-500', + text: messages.statusBarReadyBadge, + }; + } + + if (courseUnit.published) { + if (courseUnit.hasChanges) { + publishStatusChipData = { + variant: 'warning', + className: 'bg-warning-500 text-primary-700 border border-warning-300', + text: messages.statusBarDraftChangesBadge, + }; + } else { + publishStatusChipData = { + variant: 'success', + className: '', + text: messages.statusBarPublishedBadge, + }; + } + } + + return ( + + + + + + + + {courseUnit.visibilityState === UNIT_VISIBILITY_STATES.staffOnly && ( + + + + + )} + {hasGroups && ( + + + + {selectedGroupsLabel} + + + )} + {courseUnit.discussionEnabled && ( + + + + + )} + + ); +}; + +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); + 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} + + + +
+
+ +
+ + ); +}; + +export default HeaderTitle; diff --git a/src/course-unit/header-title/messages.js b/src/course-unit/header-title/messages.js index 036e9ddef8..1d8d7b4196 100644 --- a/src/course-unit/header-title/messages.js +++ b/src/course-unit/header-title/messages.js @@ -21,10 +21,45 @@ 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', + description: 'Text for the Live Badge in the status bar.', + }, + statusBarReadyBadge: { + id: 'course-authoring.course-unit.status-bar.visibility.ready', + defaultMessage: 'Ready', + description: 'Text for the Ready Badge in the status bar.', + }, + statusBarStaffOnly: { + id: 'course-authoring.course-unit.status-bar.visibility.staff-only', + defaultMessage: 'Visible to Staff-Only', + description: 'Text for the Staff Only Badge in the status bar.', + }, + statusBarUpcomingBadge: { + id: 'course-authoring.course-unit.status-bar.visibility.Upcoming', + defaultMessage: 'Upcoming', + description: 'Text for the Upcoming Badge in the status bar.', + }, + statusBarUnpublishedBadge: { + id: 'course-authoring.course-unit.status-bar.publish-status.unpublished', + defaultMessage: 'Unpublished', + description: 'Text for the Unpublished Badge in the status bar.', + }, + statusBarDraftChangesBadge: { + id: 'course-authoring.course-unit.status-bar.publish-status.draft-changes', + defaultMessage: 'Draft Changes', + description: 'Text for the Draft Changes Badge in the status bar.', + }, + statusBarPublishedBadge: { + id: 'course-authoring.course-unit.status-bar.publish-status.published', + defaultMessage: 'Published', + description: 'Text for the Published 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.', }, }); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index b987599eea..14d7403e41 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.js b/src/course-unit/messages.ts similarity index 100% rename from src/course-unit/messages.js rename to src/course-unit/messages.ts diff --git a/src/generic/alert-message/index.tsx b/src/generic/alert-message/index.tsx index 244778ecf8..16f84e93a3 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 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',